gdkbox 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e21ef2a459e26b8fa85bc98435127d9b32c9659a529c4a6892dedd7085a0e855
4
+ data.tar.gz: 506f31bc04b24adb46b25e122e9ae320caab6d68c7a80e4c3b93f431ac8fda80
5
+ SHA512:
6
+ metadata.gz: 2e4a771f534a6c6abfb283dde4b162aa2ea594ef2638f74b5c4eaa49c233ba6eb0a690a7e879b42cfd8c065ff70b202f91bf30df120229af8c5aa2e92fe592ca
7
+ data.tar.gz: 1fb3efe4175029720b3272eebab8f5b2f083de8262a66655a055267234cc473019712f9a13a8894718c9329d7e20bdf4bdbcd6450e70a6143e9d92459e29f699
data/README.md ADDED
@@ -0,0 +1,334 @@
1
+ # gdkbox
2
+
3
+ Spin up a [GitLab Development Kit (GDK)](https://gitlab.com/gitlab-org/gitlab-development-kit)
4
+ "in a box" with a single command, then connect to it from **VS Code** and run
5
+ **Claude Code** agents against a real GitLab development environment.
6
+
7
+ `gdkbox` runs GitLab's official GDK image in a Docker container, enables SSH so
8
+ VS Code Remote-SSH can attach, and installs Claude Code inside the box for you.
9
+
10
+ ```
11
+ gdkbox up myenv # pull GDK image, start container, wire SSH + Claude Code
12
+ gdkbox code myenv # open the box in VS Code (Remote-SSH)
13
+ gdkbox ssh myenv # drop into a shell inside the box
14
+ gdkbox ls # list your boxes and their status
15
+ ```
16
+
17
+ ## How it works
18
+
19
+ ```
20
+ gdkbox up demo
21
+
22
+ ┌──────────────┼─────────────────────────────────────────────┐
23
+ │ ▼ │
24
+ │ 1. docker pull <official GDK image> │
25
+ │ 2. docker run -p 127.0.0.1:2222:22 -p 127.0.0.1:3000:3000
26
+ │ 3. provision install + start sshd, authorize gdkbox key │
27
+ │ 4. provision npm install -g @anthropic-ai/claude-code │
28
+ │ 5. register Host gdkbox-demo -> ~/.gdkbox/ssh_config │
29
+ └──────────────────────────────────────────────────────────────┘
30
+
31
+ ┌───────────┴────────────┐
32
+ ▼ ▼
33
+ VS Code Remote-SSH ssh gdkbox-demo -t claude
34
+ (ssh-remote+gdkbox-demo) (run agents in the box)
35
+ ```
36
+
37
+ - **Infra:** a Docker container per box, ports published to `127.0.0.1`.
38
+ - **Image:** GitLab's official GDK-in-a-box image (overridable).
39
+ - **Editor:** VS Code Remote-SSH, via a generated `~/.gdkbox/ssh_config`
40
+ that is `Include`d from your `~/.ssh/config`.
41
+ - **Agents:** Claude Code is installed inside each box.
42
+
43
+ ## Requirements
44
+
45
+ - Ruby >= 3.0
46
+ - [Docker](https://docs.docker.com/get-docker/) installed and running
47
+ - `ssh` and `ssh-keygen` (standard on macOS/Linux)
48
+ - [VS Code](https://code.visualstudio.com/) with the **Remote - SSH**
49
+ extension, and the `code` command on your `PATH` (optional, only for
50
+ `gdkbox code`)
51
+
52
+ ## Install
53
+
54
+ From this directory:
55
+
56
+ ```sh
57
+ gem build gdkbox.gemspec
58
+ gem install ./gdkbox-0.1.0.gem
59
+ ```
60
+
61
+ Or run it straight from a checkout without installing:
62
+
63
+ ```sh
64
+ ./bin/gdkbox up myenv
65
+ ```
66
+
67
+ ## Commands
68
+
69
+ | Command | Description |
70
+ | --- | --- |
71
+ | `gdkbox up NAME` | Pull the GDK image, start a box, enable SSH, install Claude Code, register VS Code host. |
72
+ | `gdkbox ls` | List boxes and their container status (`--json` for orchestrators). |
73
+ | `gdkbox status NAME` | Show container state and connection details (`--json`). |
74
+ | `gdkbox dispatch NAME` | Run a Claude Code agent task headlessly in the box (`--task`/`--task-file`). |
75
+ | `gdkbox code NAME` | Open the box in VS Code via Remote-SSH. |
76
+ | `gdkbox ssh NAME` | Open an interactive SSH session into the box. |
77
+ | `gdkbox claude NAME` | Install Claude Code inside the box. |
78
+ | `gdkbox set-key NAME` | Seed/rotate the Anthropic API key in the box for unattended dispatch. |
79
+ | `gdkbox start NAME` | Start a stopped box (and re-enable SSH). |
80
+ | `gdkbox stop NAME` | Stop a running box. |
81
+ | `gdkbox rm NAME` | Remove a box: container, metadata, and SSH entry. |
82
+ | `gdkbox install-skill [NAME]` | Install the agent skill(s) bundled in this repo into your **host** Claude Code (`--project`, `--force`). |
83
+ | `gdkbox skills` | List agent skills available to install (bundled + `~/.claude/skills` + `./.claude/skills`). |
84
+ | `gdkbox add-skill BOX [SKILL...]` | Install skill(s) into a **box** so dispatched agents can use them; interactive picker if no SKILL (`--force`). |
85
+ | `gdkbox completion SHELL` | Print a bash or zsh completion script (completes subcommands, flags, live box and skill names). |
86
+ | `gdkbox harnesses` | List the agent harnesses gdkbox can install (claude, codex, opencode, pi). |
87
+ | `gdkbox install-agent NAME` | (Re)install the box's agent harness inside it. |
88
+
89
+ ### `gdkbox up` options
90
+
91
+ | Option | Default | Description |
92
+ | --- | --- | --- |
93
+ | `--image` | official GDK image | Override the container image. |
94
+ | `--ssh-port` | next free from 2222 | Host port to publish SSH on. |
95
+ | `--web-port` | next free from 3000 | Host port to publish the GDK web UI on. |
96
+ | `--harness ID` | `claude` (or config.yml) | Agent harness to install: claude, codex, opencode, pi. |
97
+ | `--no-agent` | (agent installed) | Skip installing the agent harness. |
98
+ | `--skill NAME [NAME...]` | (none) | Seed skill(s) into the box at creation for dispatched agents. |
99
+
100
+ ## Typical workflow
101
+
102
+ ```sh
103
+ # 1. Create a box (first run pulls a large image, so give it a few minutes)
104
+ gdkbox up demo
105
+
106
+ # 2. Edit the code with VS Code Remote-SSH
107
+ gdkbox code demo
108
+
109
+ # 3. Inside the box, start GDK and run an agent
110
+ gdkbox ssh demo
111
+ # gdk start # boot the GitLab services
112
+ # claude # launch a Claude Code agent against the repo
113
+
114
+ # 4. The GitLab web UI is published locally
115
+ open http://127.0.0.1:3000
116
+
117
+ # 5. Stop or remove the box when you're done
118
+ gdkbox stop demo
119
+ gdkbox rm demo
120
+ ```
121
+
122
+ ## Orchestrating a fleet of agents
123
+
124
+ The end goal of `gdkbox` is to back an **orchestrator** that runs a pool of
125
+ reusable boxes and dispatches a Claude Code agent into each to execute tasks in
126
+ parallel. Two primitives make this possible:
127
+
128
+ - **Headless agent runs:** `gdkbox dispatch NAME --task "..."` runs `claude -p`
129
+ non-interactively inside the box and streams the agent's output. Exit status
130
+ mirrors the agent's. Add `--json` for structured output and `--timeout N` to
131
+ bound a run.
132
+ - **Machine-readable state:** `gdkbox ls --json` / `gdkbox status NAME --json`
133
+ emit JSON descriptors so an orchestrator can see which boxes exist and their
134
+ state.
135
+
136
+ For **unattended** dispatch the agents need Anthropic credentials. Seed an API
137
+ key into each box (kept only inside the container, in a `0600` file owned by the
138
+ GDK user — never in host-side metadata). `gdkbox up` and `gdkbox set-key` both
139
+ default to the `ANTHROPIC_API_KEY` environment variable:
140
+
141
+ ```sh
142
+ export ANTHROPIC_API_KEY=sk-ant-...
143
+
144
+ # Warm a pool of 3 boxes (in parallel; first run pulls a large image).
145
+ # The key is seeded automatically because ANTHROPIC_API_KEY is set.
146
+ for i in 1 2 3; do gdkbox up "pool-$i" --json & done; wait
147
+
148
+ # Or seed/rotate the key on existing boxes:
149
+ gdkbox set-key pool-1
150
+
151
+ # Fan three tasks out, one per box. Agents authenticate with the seeded key.
152
+ gdkbox dispatch pool-1 --task "Run the test suite and fix the first failure" --json &
153
+ gdkbox dispatch pool-2 --task "Update the README install section" --json &
154
+ gdkbox dispatch pool-3 --task "Add a changelog entry" --json &
155
+ wait
156
+ ```
157
+
158
+ `gdkbox ls --json` reports `"api_key_set": true|false` per box so an
159
+ orchestrator can tell which boxes are ready for unattended work. Prefer the
160
+ `ANTHROPIC_API_KEY` env var over `--anthropic-api-key`, which can leak into
161
+ shell history.
162
+
163
+ In this repo the orchestrator is meant to be **another Claude Code session**,
164
+ guided by the bundled **`gdkbox-fleet` skill** at
165
+ [`skills/gdkbox-fleet/SKILL.md`](skills/gdkbox-fleet/SKILL.md),
166
+ which documents the pool model, dispatch loop, state-reset-between-tasks
167
+ caveat, and guardrails.
168
+
169
+ ### Installing the skill
170
+
171
+ The skills shipped with this repo live in the top-level
172
+ [`skills/`](skills/) directory. Install them into Claude Code with:
173
+
174
+ ```sh
175
+ # Install every bundled skill into ~/.claude/skills (available everywhere)
176
+ gdkbox install-skill
177
+
178
+ # Install one skill, into the current project only
179
+ gdkbox install-skill gdkbox-fleet --project
180
+
181
+ # Re-install over an existing copy
182
+ gdkbox install-skill gdkbox-fleet --force
183
+ ```
184
+
185
+ This copies each skill verbatim into `~/.claude/skills/<name>` (or
186
+ `./.claude/skills/<name>` with `--project`). Start a new Claude Code session
187
+ afterwards to pick it up.
188
+
189
+ ### Giving skills to agents in a box
190
+
191
+ `install-skill` targets your **host** Claude Code. Agents dispatched into a box
192
+ (`gdkbox dispatch`) run *inside* the container and read the box's own
193
+ `~/.claude/skills`, so they need the skill installed there. Skills can come
194
+ from this repo (`skills/`), your host (`~/.claude/skills`), your project
195
+ (`./.claude/skills`), or any directory path.
196
+
197
+ ```sh
198
+ # See everything gdkbox can install (bundled + host + project), with summaries
199
+ gdkbox skills
200
+
201
+ # Install skill(s) into a running box; agents dispatched there can then use them
202
+ gdkbox add-skill pool-1 gdkbox-fleet
203
+ gdkbox add-skill pool-1 ./path/to/my-skill --force
204
+
205
+ # No skill given → list and pick interactively
206
+ gdkbox add-skill pool-1
207
+
208
+ # Or seed skills when the box is first created
209
+ gdkbox up pool-1 --skill gdkbox-fleet --skill code-history
210
+ ```
211
+
212
+ Each skill is copied to `~/.claude/skills/<name>` inside the box and owned by
213
+ the GDK user, so a dispatched `claude` agent discovers it automatically.
214
+
215
+ ## Agent harnesses
216
+
217
+ Each box runs one agent **harness**, chosen at `gdkbox up --harness` (default
218
+ `claude`, or the `harness:` in config.yml). `gdkbox dispatch` then runs whatever
219
+ harness the box was created with.
220
+
221
+ | Harness | CLI | Headless dispatch | Default key env | Skills dir (in box) |
222
+ | --- | --- | --- | --- | --- |
223
+ | `claude` | Claude Code | `claude -p` | `ANTHROPIC_API_KEY` | `~/.claude/skills` |
224
+ | `codex` | OpenAI Codex | `codex exec` | `OPENAI_API_KEY` | `~/.codex/skills` |
225
+ | `opencode` | opencode (sst) | `opencode run` | `OPENAI_API_KEY` | `~/.config/opencode/skills` |
226
+ | `pi` | pi (earendil-works) | `pi -p` | `ANTHROPIC_API_KEY` | `~/.pi/skills` |
227
+
228
+ ```sh
229
+ gdkbox up reviewer --harness codex # a Codex box
230
+ gdkbox set-key reviewer # seeds $OPENAI_API_KEY
231
+ gdkbox add-skill reviewer my-skill # installs into ~/.codex/skills
232
+ gdkbox dispatch reviewer --task "review the diff"
233
+ gdkbox harnesses # list available harnesses
234
+ ```
235
+
236
+ Skills use the same `SKILL.md` format across all four, so a skill installed by
237
+ `add-skill`/`--skill` works regardless of harness. The key env / provider for
238
+ `opencode` and `pi` are configurable defaults — adjust per your provider.
239
+
240
+ ## Shell completion
241
+
242
+ `gdkbox completion <shell>` prints a completion script for `bash` or `zsh`. It
243
+ completes subcommands and flags, and — dynamically, by calling back into
244
+ `gdkbox` — live **box names** (for `ssh`, `dispatch`, `rm`, …) and **skill
245
+ names** (for `add-skill`, `install-skill`, and `--skill`).
246
+
247
+ ```sh
248
+ # bash — add to ~/.bashrc
249
+ eval "$(gdkbox completion bash)"
250
+
251
+ # zsh — add to ~/.zshrc (after `compinit` has run)
252
+ eval "$(gdkbox completion zsh)"
253
+ ```
254
+
255
+ Or write it to your completions directory, e.g.
256
+ `gdkbox completion bash > $(brew --prefix)/etc/bash_completion.d/gdkbox`.
257
+
258
+ ## Configuration
259
+
260
+ | Environment variable | Purpose |
261
+ | --- | --- |
262
+ | `GDKBOX_HOME` | Where gdkbox stores keys, box metadata, and the SSH config (default `~/.gdkbox`). |
263
+ | `GDKBOX_IMAGE` | Default image used by `gdkbox up` when `--image` is not given. |
264
+
265
+ ### `config.yml`
266
+
267
+ Defaults for `gdkbox up` can be set in `~/.gdkbox/config.yml` (or
268
+ `$GDKBOX_HOME/config.yml`). Most usefully, list skills to transfer into every
269
+ new box so dispatched agents always have them:
270
+
271
+ ```yaml
272
+ # ~/.gdkbox/config.yml
273
+
274
+ # Default image for `gdkbox up` (overridden by --image or $GDKBOX_IMAGE).
275
+ # image: registry.gitlab.com/gitlab-org/gitlab-development-kit/gitlab-gdk-in-a-box:main
276
+
277
+ # Default agent harness for `gdkbox up` (overridden by --harness).
278
+ # harness: claude
279
+
280
+ # Skills transferred into every new box. Each entry is a discovered skill
281
+ # name (see `gdkbox skills`) or a path to a skill directory.
282
+ skills:
283
+ - gdkbox-fleet
284
+ # - code-history
285
+ # - ./path/to/a/local/skill
286
+
287
+ # API keys seeded into boxes for unattended `gdkbox dispatch`, per provider.
288
+ # anthropic_api_key: sk-ant-... # used by the claude / pi harnesses
289
+ # openai_api_key: sk-... # used by the codex / opencode harnesses
290
+ ```
291
+
292
+ `gdkbox up` installs the skills on top of any `--skill` you pass; use
293
+ `--no-default-skills` to skip them for one run. Precedence for the image is
294
+ `--image` > `$GDKBOX_IMAGE` > `config.yml` > built-in default; for the API key
295
+ it is `--api-key` > the harness's provider env var (e.g. `$ANTHROPIC_API_KEY`,
296
+ `$OPENAI_API_KEY`) > `config.yml`.
297
+
298
+ > **API keys in `config.yml` are plaintext secrets on disk.** Prefer the
299
+ > provider env var; if you do put a key in `config.yml`, keep the file mode
300
+ > `600` (`chmod 600 ~/.gdkbox/config.yml`).
301
+
302
+ State on disk:
303
+
304
+ ```
305
+ ~/.gdkbox/
306
+ ├── config.yml # optional; defaults for `gdkbox up`
307
+ ├── boxes/<name>.json # metadata for each box
308
+ ├── keys/id_ed25519(.pub) # dedicated keypair authorized into every box
309
+ └── ssh_config # generated; Included from ~/.ssh/config
310
+ ```
311
+
312
+ `gdkbox` never touches your personal SSH keys: it creates and uses its own
313
+ `id_ed25519` under `~/.gdkbox/keys`.
314
+
315
+ ## Development
316
+
317
+ ```sh
318
+ bundle install
319
+ bundle exec rspec # or: ruby -e "require 'rspec/core'; RSpec::Core::Runner.run(['spec'])"
320
+ ```
321
+
322
+ The Docker, SSH, and filesystem boundaries are isolated behind small wrapper
323
+ classes (`Docker`, `Shell`, `Store`, `SSHConfig`, `SSHKey`, `Provisioner`,
324
+ `VSCode`), so the orchestration logic in `Box` is fully unit-tested without
325
+ needing a running Docker daemon.
326
+
327
+ ## Notes & limitations
328
+
329
+ - Ports are published to `127.0.0.1` only, so boxes are reachable from your
330
+ machine but not the wider network.
331
+ - Processes started via `docker exec` (like `sshd`) do not survive a container
332
+ restart, so `gdkbox start` re-runs the SSH provisioning step to bring it back.
333
+ - Claude Code is installed in the box but still needs your Anthropic
334
+ credentials at runtime — sign in the first time you run `claude` inside it.
data/exe/gdkbox ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "gdkbox/cli"
5
+
6
+ begin
7
+ GDKBox::CLI.start(ARGV)
8
+ rescue GDKBox::Error => e
9
+ warn e.message
10
+ exit 1
11
+ end
data/lib/gdkbox/box.rb ADDED
@@ -0,0 +1,293 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module GDKBox
6
+ # A single GDK-in-a-box instance: a Docker container plus its persisted
7
+ # metadata. This is the orchestration layer the CLI talks to.
8
+ class Box
9
+ # Outcome of a headless agent run inside a box.
10
+ AgentResult = Struct.new(:stdout, :stderr, :status) do
11
+ def success?
12
+ status.zero?
13
+ end
14
+ end
15
+
16
+ attr_reader :name, :config
17
+
18
+ def initialize(name, config:, shell: Shell.new, docker: nil, store: nil, ssh_key: nil)
19
+ @name = name
20
+ @config = config
21
+ @shell = shell
22
+ @docker = docker || Docker.new(shell: shell)
23
+ @store = store || Store.new(config: config)
24
+ @ssh_key = ssh_key || SSHKey.new(config: config, shell: shell)
25
+ end
26
+
27
+ def self.all(config:, store: nil, **kwargs)
28
+ store ||= Store.new(config: config)
29
+ store.all.map { |data| from_data(data, config: config, store: store, **kwargs) }
30
+ end
31
+
32
+ def self.from_data(data, config:, **kwargs)
33
+ box = new(data["name"], config: config, **kwargs)
34
+ box.instance_variable_set(:@data, data)
35
+ box
36
+ end
37
+
38
+ def data
39
+ @data ||= @store.load(name)
40
+ end
41
+
42
+ def exists?
43
+ @store.exists?(name)
44
+ end
45
+
46
+ def container_name
47
+ data ? data["container_name"] : @config.container_name(name)
48
+ end
49
+
50
+ def ssh_port
51
+ data && data["ssh_port"]
52
+ end
53
+
54
+ def web_port
55
+ data && data["web_port"]
56
+ end
57
+
58
+ def web_url
59
+ "http://127.0.0.1:#{web_port}"
60
+ end
61
+
62
+ def ssh_host_alias
63
+ @config.ssh_host_alias(name)
64
+ end
65
+
66
+ def remote_path
67
+ data ? data["remote_path"] : @config.remote_path
68
+ end
69
+
70
+ # The agent harness this box runs (defaults to Claude for boxes created
71
+ # before harness support existed).
72
+ def harness
73
+ Harness[(data && data["harness"]) || Harness.default]
74
+ end
75
+
76
+ def state
77
+ @docker.state(container_name)
78
+ end
79
+
80
+ # Provision a brand new box end to end: pull image, run container, enable
81
+ # SSH, optionally install the agent harness, then persist metadata.
82
+ def create!(image: nil, ssh_port: nil, web_port: nil, harness: Harness.default,
83
+ install_agent: true, api_key: nil)
84
+ raise Error, "Box '#{name}' already exists" if exists?
85
+
86
+ harness = Harness[harness]
87
+ image ||= @config.default_image
88
+ public_key = @ssh_key.ensure!
89
+ cname = @config.container_name(name)
90
+
91
+ @docker.pull(image)
92
+
93
+ # Reserve the host ports atomically. `next_port` reads the store, so
94
+ # parallel `gdkbox up` runs would otherwise all pick the same "next free"
95
+ # port and collide at `docker run`. Holding an exclusive lock while we
96
+ # choose ports *and* persist a preliminary record makes each sibling see
97
+ # the others' reservations.
98
+ ssh_port, web_port = reserve_ports!(cname, ssh_port, web_port)
99
+
100
+ begin
101
+ @docker.run_container(
102
+ name: cname,
103
+ image: image,
104
+ publish: [
105
+ "127.0.0.1:#{ssh_port}:#{Config::SSH_CONTAINER_PORT}",
106
+ "127.0.0.1:#{web_port}:#{Config::GDK_WEB_CONTAINER_PORT}"
107
+ ],
108
+ labels: { "gdkbox" => "true", "gdkbox.name" => name }
109
+ )
110
+ rescue StandardError
111
+ # The container never started, so release the reserved ports rather
112
+ # than stranding a record that points at nothing.
113
+ @store.delete(name)
114
+ raise
115
+ end
116
+
117
+ provisioner = Provisioner.new(docker: @docker, config: @config)
118
+ provisioner.setup_ssh(cname, public_key)
119
+
120
+ # Enrich the reserved record now that the box is reachable over SSH,
121
+ # before the optional Claude/API-key steps. That way a failure in those
122
+ # steps leaves a box that `gdkbox ls`/`rm` can still see and clean up,
123
+ # rather than an orphan container with no record.
124
+ @data = @data.merge(
125
+ "image" => image,
126
+ "ssh_user" => @config.ssh_user,
127
+ "remote_path" => @config.remote_path,
128
+ "harness" => harness.id,
129
+ "created_at" => Time.now.utc.iso8601
130
+ )
131
+ @store.save(@data)
132
+
133
+ provisioner.setup_agent(cname, harness) if install_agent
134
+ @data["agent_installed"] = install_agent
135
+
136
+ if api_key && !api_key.strip.empty?
137
+ provisioner.setup_api_key(cname, api_key, harness.key_env)
138
+ @data["api_key_set"] = true
139
+ end
140
+
141
+ @store.save(@data)
142
+ @data
143
+ end
144
+
145
+ # Start a stopped box and make sure sshd is running again (processes
146
+ # started via `docker exec` do not survive a container restart).
147
+ def start!
148
+ @docker.start(container_name)
149
+ Provisioner.new(docker: @docker, config: @config)
150
+ .setup_ssh(container_name, @ssh_key.ensure!)
151
+ end
152
+
153
+ def stop!
154
+ @docker.stop(container_name)
155
+ end
156
+
157
+ def destroy!
158
+ @docker.rm(container_name, force: true)
159
+ @store.delete(name)
160
+ end
161
+
162
+ # (Re)install this box's agent harness inside the container.
163
+ def install_agent!
164
+ Provisioner.new(docker: @docker, config: @config).setup_agent(container_name, harness)
165
+ return unless data
166
+
167
+ @data = data.merge("agent_installed" => true)
168
+ @store.save(@data)
169
+ end
170
+
171
+ # Install an agent skill into this box's harness skills directory so agents
172
+ # dispatched into the box can use it. `token` is a discovered skill name
173
+ # (bundled, ~/.claude/skills, or ./.claude/skills) or a path to a skill
174
+ # directory. Skills use the same SKILL.md format across harnesses; only the
175
+ # destination directory differs. Returns the resolved Skill.
176
+ def install_skill(token, project_dir: Dir.pwd, force: false)
177
+ raise Error, "Box '#{name}' does not exist" unless exists?
178
+
179
+ skill = Skills.resolve(token, project_dir: project_dir)
180
+ skills_dir = "/home/#{@config.ssh_user}/#{harness.skills_subdir}"
181
+ target = "#{skills_dir}/#{skill.name}"
182
+
183
+ @docker.exec(container_name, 'mkdir -p "$GDKBOX_SKILLS_DIR"',
184
+ user: @config.ssh_user, env: { "GDKBOX_SKILLS_DIR" => skills_dir })
185
+
186
+ present = @docker.exec(
187
+ container_name, '[ -e "$GDKBOX_TARGET" ] && echo yes || echo no',
188
+ user: @config.ssh_user, env: { "GDKBOX_TARGET" => target }, check: false
189
+ ).stdout.strip == "yes"
190
+ raise Error, "Skill '#{skill.name}' is already in box '#{name}'. Use --force to overwrite." if present && !force
191
+
192
+ @docker.exec(container_name, 'rm -rf "$GDKBOX_TARGET"',
193
+ user: @config.ssh_user, env: { "GDKBOX_TARGET" => target }) if present
194
+
195
+ @docker.cp_into(container_name, skill.path, target)
196
+ @docker.exec(container_name, 'chown -R "$GDKBOX_USER:$GDKBOX_USER" "$GDKBOX_TARGET"',
197
+ user: "root", env: { "GDKBOX_USER" => @config.ssh_user, "GDKBOX_TARGET" => target })
198
+
199
+ skill
200
+ end
201
+
202
+ # Seed or rotate the agent API key inside an existing box so dispatched
203
+ # agents can authenticate unattended. The key is exported under the env
204
+ # var this box's harness reads.
205
+ def set_api_key!(api_key)
206
+ raise Error, "Box '#{name}' does not exist" unless exists?
207
+ raise Error, "An API key is required" if api_key.nil? || api_key.strip.empty?
208
+
209
+ Provisioner.new(docker: @docker, config: @config)
210
+ .setup_api_key(container_name, api_key, harness.key_env)
211
+ @data = data.merge("api_key_set" => true)
212
+ @store.save(@data)
213
+ end
214
+
215
+ # Run this box's agent harness non-interactively and capture its output.
216
+ # This is the primitive an orchestrator uses to dispatch a task to a box.
217
+ # The task text is passed through the environment so arbitrary prompts
218
+ # cannot break out of the shell command.
219
+ def run_agent(task:, json: false, yolo: true, timeout: nil)
220
+ raise Error, "Box '#{name}' does not exist" unless exists?
221
+
222
+ agent = harness.agent_command(json: json, yolo: yolo)
223
+ agent = "timeout #{Integer(timeout)} #{agent}" if timeout
224
+ # Source the seeded API key (if any) so the agent authenticates unattended.
225
+ command = %([ -f "$HOME/.gdkbox/env" ] && . "$HOME/.gdkbox/env"; #{agent})
226
+
227
+ result = @docker.exec(
228
+ container_name, command,
229
+ user: @config.ssh_user,
230
+ workdir: remote_path,
231
+ env: { "GDKBOX_TASK" => task },
232
+ check: false
233
+ )
234
+ AgentResult.new(result.stdout, result.stderr, result.status)
235
+ end
236
+
237
+ # A machine-readable summary for orchestrators (`gdkbox ls --json`).
238
+ def summary(state: nil)
239
+ {
240
+ "name" => name,
241
+ "state" => (state || self.state).to_s,
242
+ "container_name" => container_name,
243
+ "ssh_host" => ssh_host_alias,
244
+ "ssh_port" => ssh_port,
245
+ "web_port" => web_port,
246
+ "web_url" => web_url,
247
+ "remote_path" => remote_path,
248
+ "harness" => harness.id,
249
+ "agent_installed" => (data && data["agent_installed"]) || false,
250
+ # Back-compat: orchestrators predating multi-harness check this field.
251
+ "claude_installed" => (harness.id == "claude" && (data && data["agent_installed"])) || false,
252
+ "api_key_set" => (data && data["api_key_set"]) || false
253
+ }
254
+ end
255
+
256
+ # argv to open an interactive SSH session using the generated config.
257
+ def ssh_command
258
+ ["ssh", ssh_host_alias]
259
+ end
260
+
261
+ private
262
+
263
+ # Choose free host ports and persist a preliminary record claiming them,
264
+ # all while holding an exclusive lock so concurrent `create!` calls in
265
+ # separate processes serialize and cannot pick the same ports. Returns the
266
+ # chosen [ssh_port, web_port]. Caller-supplied ports are honored as-is.
267
+ def reserve_ports!(cname, ssh_port, web_port)
268
+ @config.ensure_dirs!
269
+ File.open(@config.lock_path, File::RDWR | File::CREAT, 0o644) do |lock|
270
+ lock.flock(File::LOCK_EX)
271
+ ssh_port ||= next_port(Config::SSH_PORT_BASE)
272
+ web_port ||= next_port(Config::WEB_PORT_BASE, exclude: [ssh_port])
273
+ @data = {
274
+ "name" => name,
275
+ "container_name" => cname,
276
+ "ssh_port" => ssh_port,
277
+ "web_port" => web_port,
278
+ "agent_installed" => false,
279
+ "api_key_set" => false
280
+ }
281
+ @store.save(@data)
282
+ [ssh_port, web_port]
283
+ end
284
+ end
285
+
286
+ def next_port(base, exclude: [])
287
+ used = @store.used_ports + exclude
288
+ port = base
289
+ port += 1 while used.include?(port)
290
+ port
291
+ end
292
+ end
293
+ end