maesttro 0.1.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 ADDED
@@ -0,0 +1,155 @@
1
+ # maesttro
2
+
3
+ Isolated Docker sandbox for local coding agents — `claude`, `opencode`, `codex`,
4
+ and others — running under a dedicated bot GitHub identity, never your personal
5
+ `~/.ssh` or `~/.config/gh`. Install once, then `maesttro` just works from any
6
+ project.
7
+
8
+ Companion piece to Maesttro's orchestration side: this is the local, single-
9
+ machine entry point — a curated, versioned way to run any supported client
10
+ safely, with sane defaults baked in.
11
+
12
+ ---
13
+
14
+ ## Install
15
+
16
+ **Global install (recommended for regular use — instant, offline):**
17
+ ```bash
18
+ npm i -g maesttro
19
+ ```
20
+
21
+ **Zero-install, try it once (npx/bunx resolve the package by this exact name):**
22
+ ```bash
23
+ npx maesttro
24
+ bunx maesttro
25
+ ```
26
+ Note: `npx`/`bunx` check the registry for the latest version on every
27
+ invocation (small network hit + latency each time), and typically run
28
+ whatever's tagged `latest` unless you pin a version. Good for a first try or
29
+ demoing to someone else; `npm i -g` is the better daily-driver since all real
30
+ state lives in Docker anyway — the CLI itself is stateless either way.
31
+
32
+ **From GitHub Packages** — map the scope in `~/.npmrc` first, so `maesttro update`
33
+ resolves against the same registry:
34
+ ```ini
35
+ @esatto:registry=https://npm.pkg.github.com
36
+ //npm.pkg.github.com/:_authToken=<GitHub token with read:packages>
37
+ ```
38
+ ```bash
39
+ npm i -g @esatto/maesttro
40
+ ```
41
+
42
+ **From source** (the package lives in the `stagehand/` directory of the Maesttro monorepo):
43
+ ```bash
44
+ npm i -g ./stagehand
45
+ ```
46
+
47
+ ---
48
+
49
+ ## Usage
50
+
51
+ ```bash
52
+ maesttro setup /path/to/bot-token.txt # one-time: build image, create volume, auth bot
53
+ maesttro list # see available clients
54
+ maesttro doctor # health check anytime
55
+
56
+ maesttro # launch the default client (claude)
57
+ maesttro opencode # launch opencode instead
58
+ maesttro codex # launch codex instead
59
+ maesttro bash # plain shell inside the sandbox, for debugging
60
+
61
+ maesttro update # bump the package + rebuild image with refreshed configs
62
+ ```
63
+
64
+ ---
65
+
66
+ ## Curated clients
67
+
68
+ Defined in `src/clients.js`, installed in `docker/Dockerfile`, configured via
69
+ `docker/configs/<client>/`:
70
+
71
+ | Key | Client | Package |
72
+ |---|---|---|
73
+ | `claude` (default) | Claude Code | `@anthropic-ai/claude-code` |
74
+ | `opencode` | opencode | `opencode-ai` |
75
+ | `codex` | OpenAI Codex CLI | `@openai/codex` |
76
+
77
+ Adding a new client: add an entry to `src/clients.js`, an install line in
78
+ `docker/Dockerfile`, and a `docker/configs/<key>/` directory with any default
79
+ config — then `maesttro update` rebuilds the image with it included.
80
+
81
+ ---
82
+
83
+ ## How `maesttro update` keeps configs current
84
+
85
+ `docker/configs/<client>/` files are baked into the image at build time —
86
+ they become each client's out-of-the-box defaults inside the sandbox. When you
87
+ publish a new version of `maesttro` with updated curated configs:
88
+
89
+ ```bash
90
+ maesttro update
91
+ ```
92
+
93
+ does two things, in order:
94
+ 1. `npm i -g maesttro@latest` — refreshes the installed package files
95
+ (including the bundled `docker/` directory) in place
96
+ 2. Rebuilds the sandbox image from that same, now-refreshed, `docker/`
97
+ directory
98
+
99
+ Because the Dockerfile path is resolved relative to the running script's own
100
+ install location, step 2 automatically picks up whatever step 1 just wrote —
101
+ no extra plumbing needed to keep the two in sync.
102
+
103
+ Your bot identity (in the `maesttro-home` volume) and your project files are
104
+ untouched by an update — only the image (client versions + curated configs)
105
+ gets refreshed.
106
+
107
+ ---
108
+
109
+ ## What's mounted, and why
110
+
111
+ | Mount | Source | Purpose |
112
+ |---|---|---|
113
+ | `/home/agent` | `maesttro-home` named volume | Bot's persistent identity (`gh` auth, per-client session state). Survives across runs, lives only in Docker. |
114
+ | `/workspace` | current host directory | The project being worked on — repo-level config comes along automatically. |
115
+ | `/home/agent/.claude/skills` (read-only) | `~/agent-shared/skills` on host, if present | Reusable skills/house-style files shared across sessions. Read-only. |
116
+
117
+ Nothing from your personal `~/.ssh` or `~/.config/gh` is ever mounted.
118
+
119
+ ---
120
+
121
+ ## Package layout
122
+
123
+ ```
124
+ maesttro/
125
+ ├── package.json
126
+ ├── bin/maesttro.js # entry point, dispatches subcommands
127
+ ├── src/
128
+ │ ├── clients.js # curated client registry
129
+ │ ├── setup.js # `maesttro setup`
130
+ │ ├── update.js # `maesttro update`
131
+ │ ├── doctor.js # `maesttro doctor`
132
+ │ ├── launch.js # `maesttro` / `maesttro <client>` / `maesttro list`
133
+ │ └── paths.js # locates bundled Dockerfile wherever npm installs the package
134
+ └── docker/
135
+ ├── Dockerfile
136
+ └── configs/
137
+ ├── claude/
138
+ ├── opencode/
139
+ └── codex/
140
+ ```
141
+
142
+ ## Publishing
143
+
144
+ Released by CI, not by hand: bumping `version` in `package.json` and merging
145
+ to `main` publishes `maesttro` to npmjs and `@esatto/maesttro` to GitHub
146
+ Packages. `maesttro update` always pulls whatever is tagged `latest` on the
147
+ registry it was installed from.
148
+
149
+ ## Open items
150
+
151
+ - Fill in real curated defaults in `docker/configs/*` (currently placeholders)
152
+ - Pin exact client CLI versions in the Dockerfile once stable
153
+ - Decide whether each client gets its own bot token/volume for finer audit
154
+ trail, or shares one (current setup: shared)
155
+ - Homebrew tap as an alternative install path for non-Node users — optional
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { setup } = require("../src/setup")
4
+ const { doctor } = require("../src/doctor")
5
+ const { launch, list } = require("../src/launch")
6
+ const { update } = require("../src/update")
7
+
8
+ const [, , cmd, ...rest] = process.argv
9
+
10
+ const HELP = `maesttro — isolated sandbox for local coding agents
11
+
12
+ Usage:
13
+ maesttro Launch the default client (claude) in the sandbox
14
+ maesttro <client> Launch a specific client — see \`maesttro list\`
15
+ maesttro list Show available clients
16
+ maesttro setup [token] One-time: build image, create volume, authenticate bot identity
17
+ maesttro update Update the package and rebuild the image with refreshed client configs
18
+ maesttro doctor Health check — Docker, image, volume, bot auth, isolation, clients
19
+ maesttro help Show this message
20
+ `
21
+
22
+ async function main() {
23
+ switch (cmd) {
24
+ case undefined:
25
+ launch([])
26
+ break
27
+ case "setup":
28
+ await setup(rest)
29
+ break
30
+ case "update":
31
+ update()
32
+ break
33
+ case "doctor":
34
+ doctor()
35
+ break
36
+ case "list":
37
+ list()
38
+ break
39
+ case "help":
40
+ case "--help":
41
+ case "-h":
42
+ console.log(HELP)
43
+ break
44
+ default:
45
+ launch([cmd, ...rest])
46
+ }
47
+ }
48
+
49
+ main()
@@ -0,0 +1,25 @@
1
+ FROM node:22-slim
2
+
3
+ RUN apt-get update && apt-get install -y \
4
+ git curl gh ca-certificates \
5
+ && rm -rf /var/lib/apt/lists/*
6
+
7
+ # Curated client CLIs — keep in sync with src/clients.js
8
+ RUN npm install -g \
9
+ @anthropic-ai/claude-code \
10
+ opencode-ai \
11
+ @openai/codex
12
+
13
+ RUN useradd -m -s /bin/bash agent
14
+ USER agent
15
+ WORKDIR /workspace
16
+
17
+ # Bake curated default configs into each client's config path.
18
+ # These become each client's out-of-the-box defaults inside the sandbox;
19
+ # `maesttro update` refreshes them by rebuilding the image.
20
+ COPY --chown=agent:agent configs/claude/ /home/agent/.claude/
21
+ COPY --chown=agent:agent configs/opencode/ /home/agent/.config/opencode/
22
+ COPY --chown=agent:agent configs/codex/ /home/agent/.codex/
23
+
24
+ ENTRYPOINT ["/bin/bash", "-lc"]
25
+ CMD ["claude"]
@@ -0,0 +1,3 @@
1
+ {
2
+ "_comment": "Curated default settings for Claude Code inside the maesttro sandbox. Replace with your actual house defaults."
3
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "_comment": "Curated default settings for OpenAI Codex CLI inside the maesttro sandbox. Replace with your actual house defaults."
3
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "_comment": "Curated default settings for opencode inside the maesttro sandbox. Replace with your actual house defaults."
3
+ }
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "maesttro",
3
+ "version": "0.1.0",
4
+ "description": "Isolated Docker sandbox for running local coding agents (claude, opencode, codex, ...) under a dedicated bot GitHub identity, with curated configs baked in per client.",
5
+ "bin": {
6
+ "maesttro": "./bin/maesttro.js"
7
+ },
8
+ "files": [
9
+ "bin",
10
+ "src",
11
+ "docker"
12
+ ],
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/esatto/maesttro.git",
16
+ "directory": "stagehand"
17
+ },
18
+ "engines": {
19
+ "node": ">=18"
20
+ },
21
+ "license": "UNLICENSED",
22
+ "private": false
23
+ }
package/src/clients.js ADDED
@@ -0,0 +1,26 @@
1
+ // Curated list of supported agent clients. Adding a new one here:
2
+ // 1. add the entry below
3
+ // 2. add `RUN npm install -g <npmPackage>` to docker/Dockerfile
4
+ // 3. create docker/configs/<key>/ with any default config to bake in
5
+ // then `maesttro update` (or `agent setup` during dev) picks it up.
6
+
7
+ module.exports = {
8
+ claude: {
9
+ label: "Claude Code",
10
+ npmPackage: "@anthropic-ai/claude-code",
11
+ binary: "claude",
12
+ configDir: "/home/agent/.claude",
13
+ },
14
+ opencode: {
15
+ label: "opencode",
16
+ npmPackage: "opencode-ai",
17
+ binary: "opencode",
18
+ configDir: "/home/agent/.config/opencode",
19
+ },
20
+ codex: {
21
+ label: "OpenAI Codex CLI",
22
+ npmPackage: "@openai/codex",
23
+ binary: "codex",
24
+ configDir: "/home/agent/.codex",
25
+ },
26
+ }
package/src/doctor.js ADDED
@@ -0,0 +1,78 @@
1
+ const { execSync } = require("child_process")
2
+ const { IMAGE, VOLUME } = require("./paths")
3
+ const clients = require("./clients")
4
+
5
+ function check(label, fn) {
6
+ try {
7
+ const detail = fn()
8
+ console.log(` [ok] ${label}${detail ? " — " + detail : ""}`)
9
+ return true
10
+ } catch (e) {
11
+ console.log(` [fail] ${label}`)
12
+ return false
13
+ }
14
+ }
15
+
16
+ function doctor() {
17
+ console.log("== maesttro doctor ==\n")
18
+ let allOk = true
19
+
20
+ allOk &= check("Docker daemon reachable", () => {
21
+ execSync("docker info", { stdio: "ignore" })
22
+ })
23
+
24
+ allOk &= check("Sandbox image built", () => {
25
+ const out = execSync(
26
+ `docker image inspect ${IMAGE} --format "{{.Created}}"`,
27
+ )
28
+ .toString()
29
+ .trim()
30
+ return `built ${out}`
31
+ })
32
+
33
+ allOk &= check("Persistent volume exists", () => {
34
+ execSync(`docker volume inspect ${VOLUME}`, { stdio: "ignore" })
35
+ })
36
+
37
+ allOk &= check("Bot identity authenticated", () => {
38
+ const out = execSync(
39
+ `docker run --rm -v ${VOLUME}:/home/agent ${IMAGE} "gh auth status 2>&1"`,
40
+ ).toString()
41
+ const match = out.match(/Logged in to github\.com account (\S+)/)
42
+ return match ? match[1] : "authenticated"
43
+ })
44
+
45
+ allOk &= check(
46
+ "Personal SSH key NOT visible inside sandbox (isolation intact)",
47
+ () => {
48
+ const out = execSync(
49
+ `docker run --rm -v ${VOLUME}:/home/agent ${IMAGE} "cat ~/.ssh/id_rsa 2>&1 || echo MISSING"`,
50
+ ).toString()
51
+ if (!out.includes("MISSING") && !out.includes("No such file")) {
52
+ throw new Error(
53
+ "SSH key is reachable inside the sandbox — isolation broken",
54
+ )
55
+ }
56
+ },
57
+ )
58
+
59
+ for (const [key, c] of Object.entries(clients)) {
60
+ allOk &= check(`Client available: ${c.label}`, () => {
61
+ execSync(`docker run --rm ${IMAGE} "which ${c.binary}"`, {
62
+ stdio: "ignore",
63
+ })
64
+ })
65
+ }
66
+
67
+ console.log()
68
+ if (allOk) {
69
+ console.log("All checks passed.")
70
+ } else {
71
+ console.log(
72
+ "Some checks failed — run `maesttro setup` or `maesttro update` to fix, or check the failing item above.",
73
+ )
74
+ process.exit(1)
75
+ }
76
+ }
77
+
78
+ module.exports = { doctor }
package/src/launch.js ADDED
@@ -0,0 +1,68 @@
1
+ const { spawnSync, execSync } = require("child_process")
2
+ const fs = require("fs")
3
+ const path = require("path")
4
+ const os = require("os")
5
+ const { IMAGE, VOLUME } = require("./paths")
6
+ const clients = require("./clients")
7
+
8
+ const DEFAULT_CLIENT = "claude"
9
+
10
+ function ensureReady() {
11
+ try {
12
+ execSync(`docker image inspect ${IMAGE}`, { stdio: "ignore" })
13
+ execSync(`docker volume inspect ${VOLUME}`, { stdio: "ignore" })
14
+ } catch {
15
+ console.error("Sandbox isn't set up yet. Run `maesttro setup` first.")
16
+ process.exit(1)
17
+ }
18
+ }
19
+
20
+ function list() {
21
+ console.log("Available clients:\n")
22
+ for (const [key, c] of Object.entries(clients)) {
23
+ console.log(` ${key.padEnd(10)} ${c.label} (${c.npmPackage})`)
24
+ }
25
+ console.log(
26
+ `\nDefault: ${DEFAULT_CLIENT}. Run \`maesttro <client>\` to pick one, or \`maesttro <client> -- <args>\` to pass args through.`,
27
+ )
28
+ }
29
+
30
+ function launch(cmdArgs) {
31
+ ensureReady()
32
+
33
+ const cwd = process.cwd()
34
+ const sharedSkills =
35
+ process.env.AGENT_SHARED_SKILLS ||
36
+ path.join(os.homedir(), "agent-shared", "skills")
37
+
38
+ let clientKey = DEFAULT_CLIENT
39
+ let extraArgs = []
40
+
41
+ if (cmdArgs.length > 0 && clients[cmdArgs[0]]) {
42
+ clientKey = cmdArgs[0]
43
+ extraArgs = cmdArgs.slice(1)
44
+ } else if (cmdArgs.length > 0) {
45
+ // not a known client — treat the whole thing as a raw command, e.g. `maesttro bash`
46
+ return runRaw(cmdArgs.join(" "), cwd, sharedSkills)
47
+ }
48
+
49
+ const client = clients[clientKey]
50
+ const command = [client.binary, ...extraArgs].join(" ")
51
+ runRaw(command, cwd, sharedSkills)
52
+ }
53
+
54
+ function runRaw(command, cwd, sharedSkills) {
55
+ const mounts = ["-v", `${VOLUME}:/home/agent`, "-v", `${cwd}:/workspace`]
56
+ if (fs.existsSync(sharedSkills)) {
57
+ mounts.push("-v", `${sharedSkills}:/home/agent/.claude/skills:ro`)
58
+ }
59
+
60
+ const result = spawnSync(
61
+ "docker",
62
+ ["run", "-it", "--rm", ...mounts, "-w", "/workspace", IMAGE, command],
63
+ { stdio: "inherit" },
64
+ )
65
+ process.exit(result.status ?? 0)
66
+ }
67
+
68
+ module.exports = { launch, list }
package/src/paths.js ADDED
@@ -0,0 +1,12 @@
1
+ const path = require("path")
2
+
3
+ // __dirname here is <install>/src — package root is one level up.
4
+ // This works identically whether installed via `npm link`, `npm i -g .`,
5
+ // or from the registry — npm always keeps the package's own files together.
6
+ const PKG_ROOT = path.resolve(__dirname, "..")
7
+ const DOCKERFILE_DIR = path.join(PKG_ROOT, "docker")
8
+
9
+ const IMAGE = "maesttro-runner:latest"
10
+ const VOLUME = "maesttro-home"
11
+
12
+ module.exports = { PKG_ROOT, DOCKERFILE_DIR, IMAGE, VOLUME }
package/src/setup.js ADDED
@@ -0,0 +1,101 @@
1
+ const { execSync, spawnSync } = require("child_process")
2
+ const readline = require("readline")
3
+ const fs = require("fs")
4
+ const { DOCKERFILE_DIR, IMAGE, VOLUME } = require("./paths")
5
+
6
+ function run(cmd, opts = {}) {
7
+ return execSync(cmd, { stdio: "inherit", ...opts })
8
+ }
9
+
10
+ function ask(question) {
11
+ const rl = readline.createInterface({
12
+ input: process.stdin,
13
+ output: process.stdout,
14
+ })
15
+ return new Promise((resolve) =>
16
+ rl.question(question, (answer) => {
17
+ rl.close()
18
+ resolve(answer.trim())
19
+ }),
20
+ )
21
+ }
22
+
23
+ async function setup(args) {
24
+ console.log("== maesttro setup ==\n")
25
+
26
+ // 1. Docker present?
27
+ try {
28
+ execSync("docker info", { stdio: "ignore" })
29
+ } catch {
30
+ console.error(
31
+ "Docker doesn't seem to be running. Start Docker Desktop (or the docker daemon) and try again.",
32
+ )
33
+ process.exit(1)
34
+ }
35
+
36
+ // 2. Build image from the Dockerfile bundled inside this package
37
+ console.log(
38
+ "-> Building sandbox image (this pulls node:22-slim + installs CLIs, first run only)...",
39
+ )
40
+ run(`docker build -t ${IMAGE} "${DOCKERFILE_DIR}"`)
41
+
42
+ // 3. Create persistent volume
43
+ console.log("\n-> Ensuring persistent volume exists...")
44
+ execSync(`docker volume create ${VOLUME}`, { stdio: "ignore" })
45
+
46
+ // 4. Get bot token — file path arg, or interactive paste
47
+ const tokenPath = args[0]
48
+ let token
49
+ if (tokenPath && fs.existsSync(tokenPath)) {
50
+ token = fs.readFileSync(tokenPath, "utf8").trim()
51
+ } else {
52
+ console.log(
53
+ "\nPaste the bot account's fine-grained GitHub token (input hidden not supported in plain readline — clear your scrollback after):",
54
+ )
55
+ token = await ask("Token: ")
56
+ }
57
+ if (!token) {
58
+ console.error(
59
+ "No token provided. Run `maesttro setup /path/to/token.txt` or paste one when prompted.",
60
+ )
61
+ process.exit(1)
62
+ }
63
+
64
+ // 5. Authenticate the bot identity into the volume
65
+ console.log("\n-> Authenticating bot identity into the sandbox volume...")
66
+ const auth = spawnSync(
67
+ "docker",
68
+ [
69
+ "run",
70
+ "-i",
71
+ "--rm",
72
+ "-v",
73
+ `${VOLUME}:/home/agent`,
74
+ IMAGE,
75
+ "gh auth login --with-token && gh auth setup-git",
76
+ ],
77
+ { input: token, stdio: ["pipe", "inherit", "inherit"] },
78
+ )
79
+ if (auth.status !== 0) {
80
+ console.error(
81
+ "\nToken authentication failed — check the token is valid and has repo access.",
82
+ )
83
+ process.exit(1)
84
+ }
85
+
86
+ // 6. Verify isolation
87
+ console.log("\n-> Verifying isolation...")
88
+ console.log(" gh identity inside sandbox:")
89
+ run(`docker run --rm -v ${VOLUME}:/home/agent ${IMAGE} "gh auth status"`)
90
+ console.log(" personal SSH key should NOT be visible:")
91
+ run(
92
+ `docker run --rm -v ${VOLUME}:/home/agent ${IMAGE} "cat ~/.ssh/id_rsa 2>&1 || true"`,
93
+ )
94
+
95
+ console.log(
96
+ "\nSetup complete. Run `maesttro` from any project directory to start a session,",
97
+ )
98
+ console.log("or `maesttro list` to see available clients.")
99
+ }
100
+
101
+ module.exports = { setup }
package/src/update.js ADDED
@@ -0,0 +1,22 @@
1
+ const { execSync } = require("child_process")
2
+ const { DOCKERFILE_DIR, IMAGE, PKG_ROOT } = require("./paths")
3
+ const { name: packageName } = require(`${PKG_ROOT}/package.json`)
4
+
5
+ function update() {
6
+ console.log("== maesttro update ==\n")
7
+
8
+ console.log(`-> Updating ${packageName} package...`)
9
+ execSync(`npm i -g ${packageName}@latest`, { stdio: "inherit" })
10
+
11
+ console.log("\n-> Rebuilding sandbox image with refreshed client configs...")
12
+ execSync(`docker build -t ${IMAGE} "${DOCKERFILE_DIR}"`, { stdio: "inherit" })
13
+
14
+ console.log(
15
+ "\nUpdate complete. Bot identity and your project files are untouched —",
16
+ )
17
+ console.log(
18
+ "only the image (client versions + curated configs) was refreshed.",
19
+ )
20
+ }
21
+
22
+ module.exports = { update }