Sitelet https://github.com/NodeOps-app/createos-cli/commit/8d986ee0f833f155446a3e7cf27e3ec469a0e2d8
Skip to content

Commit 8d986ee

Browse files
committed
feat(sandbox): add setup commands for every documented integration
1 parent 202b2cb commit 8d986ee

7 files changed

Lines changed: 1008 additions & 21 deletions

File tree

‎README.md‎

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -341,25 +341,54 @@ asks for a few more characters rather than guessing.
341341
| `--ingress` | Give the sandbox a public HTTPS URL |
342342
| `--auto-pause` | Auto-pause after inactivity (e.g. `10m`, `1h`). Omit to keep running. |
343343

344-
**`sandbox setup` — run an editor's workspaces on sandboxes:**
345-
346-
`createos sandbox setup orca` connects [Orca](https://orca.dev) so that each of
347-
its workspaces runs on its own disposable sandbox instead of your laptop.
344+
**`sandbox setup` — run a coding harness on sandboxes:**
345+
346+
One subcommand per host on the
347+
[Integrations](https://createos.sh/docs/Sandbox/Integrations) page. Each one
348+
installs the CreateOS plugin for that host, so its work runs in a disposable
349+
sandbox instead of on your laptop.
350+
351+
| Command | Host | Needs |
352+
| -------------------------------------------- | ---------------- | --------------- |
353+
| `createos sandbox setup claude-code` | Claude Code | `claude` |
354+
| `createos sandbox setup codex` | Codex | `codex` |
355+
| `createos sandbox setup deepseek` | DeepSeek Harness | `dsh`, `node` |
356+
| `createos sandbox setup herdr` | Herdr | `herdr`, `bun` |
357+
| `createos sandbox setup opencode` | OpenCode | `opencode`, `bun` |
358+
| `createos sandbox setup orca` | Orca | `git`, `ssh` |
359+
| `createos sandbox setup pi` | Pi | `pi` |
360+
361+
Every subcommand takes `--doctor`, which checks the prerequisites and reports
362+
without changing anything. Running one twice is safe — an install that is
363+
already in place is left alone.
348364

349365
```bash
350-
createos sandbox setup orca --doctor # check prerequisites, change nothing
351-
createos sandbox setup orca # print the plugin install steps
366+
createos sandbox setup claude-code --doctor # check prerequisites, change nothing
367+
createos sandbox setup claude-code # add the marketplace + install the plugin
352368
```
353369

370+
`opencode` and `deepseek` have no installer of their own, so setup
371+
clones the plugins into `~/.config/createos/plugins` and refreshes that clone
372+
on each run. Pass `--local <path>` to use your own checkout instead. The
373+
OpenCode setup also adds the plugin to your OpenCode config, backing the file
374+
up to `<config>.before-createos` first; `--mode remote` moves OpenCode's own
375+
shell and file tools into the sandbox as well.
376+
377+
The DeepSeek Harness plugin reads its CreateOS credentials from
378+
`CREATEOS_SANDBOX_API_KEY` and `CREATEOS_SANDBOX_SHAPE`. Setup reports whether
379+
they are set but never reads or prints a key — export them yourself.
380+
381+
**Orca**
382+
354383
The workspace checkout is pushed into the sandbox rather than cloned, so no git
355384
token ever reaches the box and private repositories work with no extra setup.
356385
Set `CREATEOS_AGENTS` to install coding agents at create time, for example
357386
`CREATEOS_AGENTS=claude,codex`.
358387

359388
Orca calls this command itself for each lifecycle phase once its plugin is
360-
installed. The plugin lives in
361-
[NodeOps-app/createos-plugins](https://github.com/NodeOps-app/createos-plugins)
362-
under `packages/orca-plugin`.
389+
installed. The plugins live in
390+
[NodeOps-app/createos-plugin](https://github.com/NodeOps-app/createos-plugin)
391+
under `packages/`.
363392

364393
**When to use `exec`, `shell`, `process`, and PTY:**
365394

‎cmd/sandbox/orca.go‎

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -121,18 +121,6 @@ type orcaLifecyclePayload struct {
121121
} `json:"recipeResult"`
122122
}
123123

124-
// newSetupCommand returns `createos sandbox setup`, the harness integration
125-
// group.
126-
func newSetupCommand() *cli.Command {
127-
return &cli.Command{
128-
Name: "setup",
129-
Usage: "Connect a coding harness to CreateOS Sandbox",
130-
Description: "Each subcommand wires one harness to CreateOS Sandbox, so a\n" +
131-
"workspace runs on a disposable microVM instead of your laptop.",
132-
Subcommands: []*cli.Command{newSetupHerdrCommand(), newSetupOrcaCommand()},
133-
}
134-
}
135-
136124
func newSetupOrcaCommand() *cli.Command {
137125
return &cli.Command{
138126
Name: "orca",

‎cmd/sandbox/setup.go‎

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
package sandbox
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"os/exec"
8+
"path/filepath"
9+
"strings"
10+
11+
"github.com/urfave/cli/v2"
12+
13+
"github.com/NodeOps-app/createos-cli/internal/api"
14+
)
15+
16+
// The integrations monorepo. Every host below installs some part of it, and
17+
// the two that have no installer of their own (OpenCode, DeepSeek Harness)
18+
// need a checkout on disk, which setupPluginCheckout manages.
19+
const (
20+
setupPluginRepo = "NodeOps-app/createos-plugin"
21+
setupPluginRepoURL = "https://github.com/" + setupPluginRepo
22+
// The Claude Code marketplace declares itself under this name, so
23+
// `plugin@marketplace` ids resolve to it regardless of how the user
24+
// named the source when adding it.
25+
setupMarketplaceName = "createos"
26+
)
27+
28+
// newSetupCommand returns `createos sandbox setup`, the harness integration
29+
// group. One subcommand per host on
30+
// https://createos.sh/docs/Sandbox/Integrations.
31+
func newSetupCommand() *cli.Command {
32+
return &cli.Command{
33+
Name: "setup",
34+
Usage: "Connect a coding harness to CreateOS Sandbox",
35+
Description: "Each subcommand wires one harness to CreateOS Sandbox, so a\n" +
36+
"workspace runs on a disposable microVM instead of your laptop.\n\n" +
37+
"Every subcommand takes --doctor, which checks the prerequisites and\n" +
38+
"reports without changing anything.",
39+
Subcommands: []*cli.Command{
40+
newSetupClaudeCodeCommand(),
41+
newSetupCodexCommand(),
42+
newSetupDeepSeekCommand(),
43+
newSetupHerdrCommand(),
44+
newSetupOpenCodeCommand(),
45+
newSetupOrcaCommand(),
46+
newSetupPiCommand(),
47+
},
48+
}
49+
}
50+
51+
// setupSignedIn confirms the session actually works before a host integration
52+
// is wired to it. A plugin installed against a dead session fails later, in
53+
// the host's UI, where the reason is much harder to see.
54+
func setupSignedIn(c *cli.Context) error {
55+
client, ok := c.App.Metadata[api.SandboxClientKey].(*api.SandboxClient)
56+
if !ok {
57+
return fmt.Errorf("you're not signed in — run 'createos login' first")
58+
}
59+
if _, _, err := client.ListSandboxes(c.Context, api.ListSandboxesOpts{}); err != nil {
60+
return fmt.Errorf("your session is not usable — run 'createos login' again: %w", err)
61+
}
62+
fmt.Println("signed in to CreateOS")
63+
return nil
64+
}
65+
66+
// setupRequireBin resolves a host binary, turning a bare exec.LookPath miss
67+
// into the install hint the user actually needs.
68+
func setupRequireBin(name, hint string) (string, error) {
69+
bin, err := exec.LookPath(name)
70+
if err != nil {
71+
return "", fmt.Errorf("%s is not on PATH — %s", name, hint)
72+
}
73+
fmt.Printf("%s found at %s\n", name, bin)
74+
return bin, nil
75+
}
76+
77+
// setupRun runs a host CLI and returns its combined output, which callers
78+
// attach to any error: these tools explain their own failures far better than
79+
// an exit status does.
80+
func setupRun(ctx context.Context, bin string, args ...string) (string, error) {
81+
// #nosec G204 -- bin is an exec.LookPath result and every arg is either a
82+
// literal from this package or a path the user named on the command line;
83+
// it is one argv element, never a shell string.
84+
out, err := exec.CommandContext(ctx, bin, args...).CombinedOutput()
85+
return string(out), err
86+
}
87+
88+
// setupAlreadyDone reports whether a host CLI refused because the thing was
89+
// already installed. Those tools exit non-zero for it, so a plain error check
90+
// would make a second `setup` run fail on a box that is correctly set up.
91+
func setupAlreadyDone(out string) bool {
92+
s := strings.ToLower(out)
93+
for _, phrase := range []string{
94+
"already exists",
95+
"already added",
96+
"already installed",
97+
"already registered",
98+
"already configured",
99+
} {
100+
if strings.Contains(s, phrase) {
101+
return true
102+
}
103+
}
104+
return false
105+
}
106+
107+
// setupCheckoutDir is where setup keeps its own clone of the integrations
108+
// monorepo. Beside the per-sandbox keys and ssh mux sockets the CLI already
109+
// owns, so nothing of the user's is involved.
110+
func setupCheckoutDir() (string, error) {
111+
home, err := os.UserHomeDir()
112+
if err != nil {
113+
return "", fmt.Errorf("resolve $HOME: %w", err)
114+
}
115+
return filepath.Join(home, ".config", "createos", "plugins", "createos-plugin"), nil
116+
}
117+
118+
// setupPluginCheckout returns a path to the integrations monorepo.
119+
//
120+
// A --local path wins and is used as-is, so plugin developers can point the
121+
// setup at their own working tree. Otherwise setup owns a clone under
122+
// ~/.config/createos and refreshes it on every run, because the host reads
123+
// these files directly — a stale checkout silently pins the user to whatever
124+
// the plugin looked like the day they first ran setup.
125+
func setupPluginCheckout(ctx context.Context, local string) (string, error) {
126+
if local = strings.TrimSpace(local); local != "" {
127+
dir, err := filepath.Abs(local)
128+
if err != nil {
129+
return "", fmt.Errorf("could not resolve %q: %w", local, err)
130+
}
131+
if _, err := os.Stat(filepath.Join(dir, "packages")); err != nil {
132+
return "", fmt.Errorf("%s does not look like the integrations repo: no packages/ directory", dir)
133+
}
134+
fmt.Printf("using your checkout at %s\n", dir)
135+
return dir, nil
136+
}
137+
138+
if _, err := setupRequireBin("git", "install it from https://git-scm.com"); err != nil {
139+
return "", err
140+
}
141+
dir, err := setupCheckoutDir()
142+
if err != nil {
143+
return "", err
144+
}
145+
if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
146+
fmt.Printf("updating %s\n", dir)
147+
if out, pullErr := setupRun(ctx, "git", "-C", dir, "pull", "--ff-only", "--quiet"); pullErr != nil {
148+
// A diverged or dirty checkout is the user's, not ours to reset.
149+
// The stale copy still works, so warn and carry on.
150+
fmt.Printf("could not update the checkout, using it as-is: %s\n", strings.TrimSpace(out))
151+
}
152+
return dir, nil
153+
}
154+
if err := os.MkdirAll(filepath.Dir(dir), 0o750); err != nil {
155+
return "", fmt.Errorf("could not create %s: %w", filepath.Dir(dir), err)
156+
}
157+
fmt.Printf("cloning %s into %s\n", setupPluginRepoURL, dir)
158+
if out, err := setupRun(ctx, "git", "clone", "--depth", "1", setupPluginRepoURL, dir); err != nil {
159+
return "", fmt.Errorf("could not clone the integrations repo: %w\n%s", err, out)
160+
}
161+
return dir, nil
162+
}
163+
164+
// setupPackageDir resolves one package inside the checkout and fails loudly
165+
// when it is missing, which means the checkout is not what we think it is.
166+
func setupPackageDir(checkout, pkg string) (string, error) {
167+
dir := filepath.Join(checkout, "packages", pkg)
168+
if _, err := os.Stat(dir); err != nil {
169+
return "", fmt.Errorf("%s is missing from the checkout at %s", pkg, checkout)
170+
}
171+
return dir, nil
172+
}
173+
174+
// setupDoctorFlag is the flag every subcommand shares.
175+
func setupDoctorFlag() cli.Flag {
176+
return &cli.BoolFlag{
177+
Name: "doctor",
178+
Usage: "Check the prerequisites and report, without changing anything",
179+
}
180+
}
181+
182+
// setupLocalFlag is shared by the hosts that need a checkout on disk.
183+
func setupLocalFlag() cli.Flag {
184+
return &cli.StringFlag{
185+
Name: "local",
186+
Usage: "Use this local checkout of " + setupPluginRepo + " instead of cloning it",
187+
}
188+
}
189+
190+
// setupDoctorDone prints the line that ends a --doctor run.
191+
func setupDoctorDone() {
192+
fmt.Println("\nEverything the plugin needs is present. Run this again without --doctor to install it.")
193+
}

0 commit comments

Comments
 (0)