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.
data/lib/gdkbox/cli.rb ADDED
@@ -0,0 +1,410 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "thor"
5
+ require "gdkbox"
6
+
7
+ module GDKBox
8
+ # The `gdkbox` command-line interface.
9
+ class CLI < Thor
10
+ def self.exit_on_failure?
11
+ true
12
+ end
13
+
14
+ class_option :verbose, type: :boolean, default: false,
15
+ desc: "Print extra diagnostic output"
16
+
17
+ desc "up NAME", "Spin up a new GDK box and wire it for SSH + VS Code"
18
+ long_desc <<~DESC
19
+ Pulls the official GDK-in-a-box image, starts a container named after
20
+ NAME, enables SSH access using a dedicated gdkbox key, installs the
21
+ chosen agent harness (--harness, default claude) inside the box, and
22
+ registers a `Host gdkbox-NAME` entry so VS Code Remote-SSH can connect.
23
+ DESC
24
+ option :image, type: :string, desc: "Override the GDK image to use"
25
+ option :ssh_port, type: :numeric, desc: "Host port to publish SSH on"
26
+ option :web_port, type: :numeric, desc: "Host port to publish the GDK web UI on"
27
+ option :harness, type: :string,
28
+ desc: "Agent harness to install (see `gdkbox harnesses`; default from config.yml or claude)"
29
+ option :agent, type: :boolean, default: true,
30
+ desc: "Install the agent harness inside the box"
31
+ option :json, type: :boolean, default: false,
32
+ desc: "Print the box descriptor as JSON (for orchestrators)"
33
+ option :api_key, type: :string,
34
+ desc: "Seed the harness API key for unattended dispatch (defaults to the provider env var)"
35
+ option :anthropic_api_key, type: :string, desc: "Alias of --api-key (kept for compatibility)"
36
+ option :skill, type: :array, default: [],
37
+ desc: "Skill name(s) or path(s) to install into the box for dispatched agents"
38
+ option :default_skills, type: :boolean, default: true,
39
+ desc: "Also install the default skills from ~/.gdkbox/config.yml"
40
+ def up(name)
41
+ ensure_docker!
42
+ box = build_box(name)
43
+ raise Error, "Box '#{name}' already exists. Use `gdkbox rm #{name}` first." if box.exists?
44
+ harness = Harness[options[:harness] || config.default_harness]
45
+ api_key = resolve_api_key(harness)
46
+
47
+ say "Spinning up GDK box '#{name}' (#{harness.summary}; first run pulls a large image)...", :green unless options[:json]
48
+ box.create!(
49
+ image: options[:image],
50
+ ssh_port: options[:ssh_port],
51
+ web_port: options[:web_port],
52
+ harness: harness.id,
53
+ install_agent: options[:agent],
54
+ api_key: api_key
55
+ )
56
+ rewrite_ssh_config
57
+ seed_skills(box)
58
+
59
+ if options[:json]
60
+ puts JSON.generate(box.summary)
61
+ return
62
+ end
63
+
64
+ say "\nBox '#{name}' is up.", :green
65
+ print_connection_details(box)
66
+ unless api_key
67
+ say "\n No #{harness.key_env} seeded. Before unattended dispatch, run:", :yellow
68
+ say " gdkbox set-key #{name} (uses $#{harness.key_env})", :yellow
69
+ end
70
+ end
71
+
72
+ desc "ls", "List all GDK boxes and their status"
73
+ option :json, type: :boolean, default: false,
74
+ desc: "Print the fleet as a JSON array (for orchestrators)"
75
+ def ls
76
+ boxes = Box.all(config: config).sort_by(&:name)
77
+ docker = Docker.new
78
+
79
+ if options[:json]
80
+ summaries = boxes.map { |box| box.summary(state: docker.available? ? nil : "unknown") }
81
+ puts JSON.generate(summaries)
82
+ return
83
+ end
84
+
85
+ if boxes.empty?
86
+ say "No GDK boxes yet. Create one with `gdkbox up <name>`."
87
+ return
88
+ end
89
+
90
+ boxes.each do |box|
91
+ status = docker.available? ? box.state : "unknown"
92
+ say format("%-20s %-10s ssh:%-6s web:%-6s %s",
93
+ box.name, status, box.ssh_port, box.web_port, box.web_url)
94
+ end
95
+ end
96
+
97
+ desc "status NAME", "Show detailed status and connection info for a box"
98
+ option :json, type: :boolean, default: false, desc: "Print the box descriptor as JSON"
99
+ def status(name)
100
+ box = load_box!(name)
101
+ docker = Docker.new
102
+
103
+ if options[:json]
104
+ puts JSON.generate(box.summary(state: docker.available? ? nil : "unknown"))
105
+ return
106
+ end
107
+
108
+ say "Box: #{box.name}"
109
+ say "Container: #{box.container_name}"
110
+ say "State: #{docker.available? ? box.state : 'unknown'}"
111
+ print_connection_details(box)
112
+ end
113
+
114
+ desc "dispatch NAME", "Dispatch an agent task headlessly into the box"
115
+ long_desc <<~DESC
116
+ Runs the box's agent harness non-interactively inside the GDK checkout
117
+ and streams the agent's output. This is the primitive an orchestrator
118
+ uses to hand a task to a box in the pool. Provide the task with --task or
119
+ --task-file. Exit status mirrors the agent's.
120
+ DESC
121
+ option :task, type: :string, desc: "The task/prompt to give the agent"
122
+ option :task_file, type: :string, desc: "Read the task from a local file"
123
+ option :json, type: :boolean, default: false,
124
+ desc: "Return the agent's structured JSON output"
125
+ option :timeout, type: :numeric, desc: "Abort the agent after N seconds"
126
+ option :yolo, type: :boolean, default: true,
127
+ desc: "Skip permission prompts (safe in an isolated box)"
128
+ def dispatch(name)
129
+ box = load_box!(name)
130
+ task = options[:task]
131
+ task = File.read(options[:task_file]) if options[:task_file]
132
+ raise Error, "Provide a task with --task or --task-file." if task.nil? || task.strip.empty?
133
+
134
+ result = box.run_agent(
135
+ task: task,
136
+ json: options[:json],
137
+ yolo: options[:yolo],
138
+ timeout: options[:timeout]
139
+ )
140
+ $stdout.print(result.stdout)
141
+ $stderr.print(result.stderr) unless result.stderr.to_s.empty?
142
+ exit(result.status)
143
+ end
144
+
145
+ desc "ssh NAME", "Open an interactive SSH session into the box"
146
+ def ssh(name)
147
+ box = load_box!(name)
148
+ exec(*box.ssh_command)
149
+ end
150
+
151
+ desc "code NAME", "Open the box in VS Code via Remote-SSH"
152
+ def code(name)
153
+ box = load_box!(name)
154
+ rewrite_ssh_config
155
+ vscode = VSCode.new
156
+ unless vscode.available?
157
+ say "The `code` CLI was not found on PATH.", :yellow
158
+ say "Open VS Code manually and connect to host: #{box.ssh_host_alias}"
159
+ say "Or run: #{vscode.open_command(box).join(' ')}"
160
+ return
161
+ end
162
+ say "Opening #{box.name} in VS Code (#{box.ssh_host_alias})...", :green
163
+ vscode.open(box)
164
+ end
165
+
166
+ desc "install-agent NAME", "(Re)install the box's agent harness inside it"
167
+ def install_agent(name)
168
+ box = load_box!(name)
169
+ say "Installing #{box.harness.summary} in '#{name}'...", :green
170
+ box.install_agent!
171
+ say "Done. SSH in and run `#{box.harness.bin}` to start an agent.", :green
172
+ end
173
+ map "install-agent" => :install_agent
174
+
175
+ desc "harnesses", "List the agent harnesses gdkbox can install"
176
+ def harnesses
177
+ Harness.all.each do |h|
178
+ say format("%-10s ", h.id), :green, false
179
+ say "#{h.summary} (key: $#{h.key_env})"
180
+ end
181
+ end
182
+
183
+ desc "set-key NAME", "Seed or rotate the agent API key inside the box"
184
+ long_desc <<~DESC
185
+ Stores the API key for the box's harness inside the box so dispatched
186
+ agents can authenticate without a human. The key is exported under the
187
+ provider's env var (e.g. ANTHROPIC_API_KEY, OPENAI_API_KEY), kept only
188
+ inside the container (a 0600 file owned by the GDK user) and never in
189
+ host-side metadata. Prefer the provider env var or --api-key over a flag
190
+ visible in shell history.
191
+ DESC
192
+ option :api_key, type: :string, desc: "API key to seed (defaults to the provider env var)"
193
+ option :anthropic_api_key, type: :string, desc: "Alias of --api-key (kept for compatibility)"
194
+ def set_key(name)
195
+ box = load_box!(name)
196
+ api_key = resolve_api_key(box.harness)
197
+ unless api_key
198
+ raise Error, "No API key. Pass --api-key, set $#{box.harness.key_env}, " \
199
+ "or add `#{box.harness.config_key}:` to ~/.gdkbox/config.yml."
200
+ end
201
+
202
+ say "Seeding #{box.harness.key_env} into '#{name}'...", :green
203
+ box.set_api_key!(api_key)
204
+ say "Done. Unattended `gdkbox dispatch #{name}` is ready.", :green
205
+ end
206
+ map "set-key" => :set_key
207
+
208
+ desc "start NAME", "Start a stopped box (and re-enable SSH)"
209
+ def start(name)
210
+ box = load_box!(name)
211
+ say "Starting '#{name}'...", :green
212
+ box.start!
213
+ print_connection_details(box)
214
+ end
215
+
216
+ desc "stop NAME", "Stop a running box"
217
+ def stop(name)
218
+ box = load_box!(name)
219
+ say "Stopping '#{name}'...", :green
220
+ box.stop!
221
+ end
222
+
223
+ desc "rm NAME", "Remove a box: its container, metadata, and SSH entry"
224
+ option :force, type: :boolean, default: false, desc: "Skip confirmation"
225
+ def rm(name)
226
+ box = load_box!(name)
227
+ unless options[:force]
228
+ return unless yes?("Remove box '#{name}' and its container? [y/N]")
229
+ end
230
+ box.destroy!
231
+ rewrite_ssh_config
232
+ say "Removed '#{name}'.", :green
233
+ end
234
+
235
+ desc "install-skill [NAME]", "Install the agent skill(s) bundled in this repo into Claude Code"
236
+ long_desc <<~DESC
237
+ Copies the agent skills shipped with this repo into a Claude Code skills
238
+ directory so anyone who has this repo can use them. With no NAME, every
239
+ bundled skill is installed; pass a NAME to install just one.
240
+
241
+ Installs into ~/.claude/skills (available everywhere) by default. Pass
242
+ --project to install into ./.claude/skills for the current project only.
243
+ Use --force to overwrite a skill that is already installed.
244
+ DESC
245
+ option :project, type: :boolean, default: false,
246
+ desc: "Install into ./.claude/skills instead of ~/.claude/skills"
247
+ option :force, type: :boolean, default: false,
248
+ desc: "Overwrite a skill that is already installed"
249
+ def install_skill(name = nil)
250
+ available = Skills.available
251
+ raise Error, "This repo ships no skills." if available.empty?
252
+
253
+ if name && !available.include?(name)
254
+ raise Error, "No skill named '#{name}'. Available: #{available.join(', ')}."
255
+ end
256
+
257
+ dest = options[:project] ? Skills.project_dest : Skills::GLOBAL_DEST
258
+ skills = Skills.new(dest: dest)
259
+
260
+ (name ? [name] : available).each do |skill_name|
261
+ target = skills.install(skill_name, force: options[:force])
262
+ say "Installed skill '#{skill_name}' -> #{target}", :green
263
+ end
264
+ say "\nStart a new Claude Code session to pick up the skill.", :cyan
265
+ end
266
+ map "install-skill" => :install_skill
267
+
268
+ desc "skills", "List agent skills available to install (bundled, ~/.claude, ./.claude)"
269
+ def skills
270
+ found = Skills.discover
271
+ if found.empty?
272
+ say "No skills found in this repo, ~/.claude/skills, or ./.claude/skills."
273
+ return
274
+ end
275
+ found.each do |skill|
276
+ say format("%-28s ", skill.name), :green, false
277
+ say "[#{skill.origin}]", :cyan
278
+ say " #{skill.description}" if skill.description && !skill.description.empty?
279
+ end
280
+ end
281
+
282
+ desc "add-skill BOX [SKILL...]", "Install agent skill(s) into a box for dispatched agents"
283
+ long_desc <<~DESC
284
+ Copies skills into the box's harness skills directory so agents
285
+ dispatched into it (`gdkbox dispatch`) can use them. Each SKILL is a
286
+ discovered skill name (see `gdkbox skills`) or a path to a skill
287
+ directory. With no SKILL given, lists the available skills and lets you
288
+ pick interactively. Use --force to overwrite a skill already in the box.
289
+ DESC
290
+ option :force, type: :boolean, default: false,
291
+ desc: "Overwrite a skill already installed in the box"
292
+ def add_skill(box_name, *skill_tokens)
293
+ box = load_box!(box_name)
294
+ skill_tokens = prompt_for_skills if skill_tokens.empty?
295
+ raise Error, "No skills selected." if skill_tokens.empty?
296
+
297
+ skill_tokens.each do |token|
298
+ skill = box.install_skill(token, force: options[:force])
299
+ say "Installed skill '#{skill.name}' into box '#{box_name}'.", :green
300
+ end
301
+ say "\nDispatched agents in '#{box_name}' can now use these skills.", :cyan
302
+ end
303
+ map "add-skill" => :add_skill
304
+
305
+ desc "completion SHELL", "Print a shell completion script (bash or zsh)"
306
+ long_desc <<~DESC
307
+ Outputs a completion script for SHELL. It completes subcommands, flags,
308
+ and — dynamically — live box names and skill names.
309
+
310
+ Bash: echo 'eval "$(gdkbox completion bash)"' >> ~/.bashrc
311
+ Zsh: echo 'eval "$(gdkbox completion zsh)"' >> ~/.zshrc
312
+ (run after `compinit`)
313
+ DESC
314
+ def completion(shell)
315
+ puts Completion.new(self.class).script(shell)
316
+ end
317
+
318
+ desc "version", "Print the gdkbox version"
319
+ def version
320
+ say GDKBox::VERSION
321
+ end
322
+
323
+ private
324
+
325
+ # Seed skills into a freshly created box: the config.yml defaults (unless
326
+ # --no-default-skills) plus any --skill. All harnesses support SKILL.md
327
+ # skills; the box's harness decides the destination directory. Failures are
328
+ # non-fatal: the box is already up, and a stale config entry should not
329
+ # abort `gdkbox up`. Progress is muted when emitting JSON.
330
+ def seed_skills(box)
331
+ tokens = []
332
+ tokens.concat(config.default_skills) if options[:default_skills]
333
+ tokens.concat(options[:skill])
334
+ tokens.uniq!
335
+
336
+ tokens.each do |token|
337
+ skill = box.install_skill(token)
338
+ say "Seeded skill '#{skill.name}' into '#{box.name}'.", :green unless options[:json]
339
+ rescue Error => e
340
+ say "Skipped skill '#{token}': #{e.message}", :yellow unless options[:json]
341
+ end
342
+ end
343
+
344
+ # Show the discovered skills and let the user pick by number (or "all").
345
+ def prompt_for_skills
346
+ found = Skills.discover
347
+ raise Error, "No skills available to install." if found.empty?
348
+
349
+ say "Available skills:"
350
+ found.each_with_index do |skill, i|
351
+ say format(" %2d) %-26s [%s]", i + 1, skill.name, skill.origin)
352
+ end
353
+ answer = ask("Select skills (comma-separated numbers, or 'all'):").to_s.strip
354
+ return found.map(&:name) if answer.casecmp("all").zero?
355
+
356
+ answer.split(/[,\s]+/).reject(&:empty?).map do |n|
357
+ idx = Integer(n, exception: false)
358
+ raise Error, "Invalid selection: '#{n}'." unless idx && found[idx - 1]
359
+
360
+ found[idx - 1].name
361
+ end
362
+ end
363
+
364
+ def config
365
+ @config ||= Config.new
366
+ end
367
+
368
+ def build_box(name)
369
+ Box.new(name, config: config)
370
+ end
371
+
372
+ def load_box!(name)
373
+ box = build_box(name)
374
+ raise Error, "No box named '#{name}'. Run `gdkbox ls` to see boxes." unless box.exists?
375
+
376
+ box
377
+ end
378
+
379
+ def ensure_docker!
380
+ return if Docker.new.available?
381
+
382
+ raise Error, "Docker does not appear to be installed or on PATH."
383
+ end
384
+
385
+ # The API key to seed for the given harness, resolved in priority order:
386
+ # --api-key (or its --anthropic-api-key alias), then the provider env var,
387
+ # then config.yml. Returns nil when none is set. config.yml is last because
388
+ # it is a plaintext file.
389
+ def resolve_api_key(harness)
390
+ [
391
+ options[:api_key],
392
+ options[:anthropic_api_key],
393
+ ENV[harness.key_env],
394
+ config.api_key(harness.config_key)
395
+ ].find { |key| key && !key.to_s.strip.empty? }&.to_s
396
+ end
397
+
398
+ def rewrite_ssh_config
399
+ SSHConfig.new(config: config).write(Store.new(config: config).all)
400
+ end
401
+
402
+ def print_connection_details(box)
403
+ say ""
404
+ say " SSH: ssh #{box.ssh_host_alias}", :cyan
405
+ say " VS Code: gdkbox code #{box.name}", :cyan
406
+ say " Web UI: #{box.web_url}", :cyan
407
+ say " Agent: ssh #{box.ssh_host_alias} -t #{box.harness.bin}", :cyan
408
+ end
409
+ end
410
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GDKBox
4
+ # Generates shell completion scripts for the `gdkbox` CLI.
5
+ #
6
+ # The list of subcommands and their flags is introspected from the Thor CLI
7
+ # so it stays in sync automatically. Box names and skill names are completed
8
+ # dynamically at completion time by shelling back out to `gdkbox ls` and
9
+ # `gdkbox skills`.
10
+ class Completion
11
+ SHELLS = %w[bash zsh].freeze
12
+
13
+ # Subcommands whose first positional argument is an existing box.
14
+ BOX_COMMANDS = %w[status dispatch ssh code install-agent set-key start stop rm add-skill].freeze
15
+
16
+ def initialize(cli_class = CLI)
17
+ @cli = cli_class
18
+ end
19
+
20
+ # The completion script for the given shell.
21
+ def script(shell)
22
+ unless SHELLS.include?(shell)
23
+ raise Error, "Unsupported shell '#{shell}'. Supported: #{SHELLS.join(', ')}."
24
+ end
25
+
26
+ body = function + "\ncomplete -F _gdkbox gdkbox\n"
27
+ return body if shell == "bash"
28
+
29
+ # zsh can run bash-style completion functions via bashcompinit.
30
+ "autoload -U +X bashcompinit && bashcompinit\n#{body}"
31
+ end
32
+
33
+ private
34
+
35
+ # User-facing subcommand names (dashed), sorted.
36
+ def commands
37
+ (@cli.commands.keys + ["help"]).uniq.map { |c| c.tr("_", "-") }.sort
38
+ end
39
+
40
+ # The flag switches accepted by a single command, including global options.
41
+ def flags_for(command)
42
+ opts = command.options.values + @cli.class_options.values
43
+ opts.flat_map do |opt|
44
+ base = "--#{opt.name.tr('_', '-')}"
45
+ opt.type == :boolean ? [base, "--no-#{opt.name.tr('_', '-')}"] : [base]
46
+ end.uniq.sort
47
+ end
48
+
49
+ # `case` arms mapping each command to its flags, for the bash function.
50
+ def flag_cases
51
+ @cli.commands.map do |name, command|
52
+ " #{name.tr('_', '-')}) flags=\"#{flags_for(command).join(' ')}\";;"
53
+ end.join("\n")
54
+ end
55
+
56
+ def function
57
+ <<~BASH
58
+ _gdkbox() {
59
+ local cur prev cmd i
60
+ cur="${COMP_WORDS[COMP_CWORD]}"
61
+ prev="${COMP_WORDS[COMP_CWORD-1]}"
62
+ COMPREPLY=()
63
+
64
+ local commands="#{commands.join(' ')}"
65
+
66
+ # Find the subcommand: the first non-flag word after "gdkbox".
67
+ cmd=""
68
+ for ((i=1; i<COMP_CWORD; i++)); do
69
+ case "${COMP_WORDS[i]}" in
70
+ -*) ;;
71
+ *) cmd="${COMP_WORDS[i]}"; break;;
72
+ esac
73
+ done
74
+
75
+ if [[ -z "$cmd" ]]; then
76
+ COMPREPLY=( $(compgen -W "$commands" -- "$cur") )
77
+ return
78
+ fi
79
+
80
+ # Complete flags when the current word looks like one.
81
+ if [[ "$cur" == -* ]]; then
82
+ local flags=""
83
+ case "$cmd" in
84
+ #{flag_cases}
85
+ *) flags="--verbose";;
86
+ esac
87
+ COMPREPLY=( $(compgen -W "$flags" -- "$cur") )
88
+ return
89
+ fi
90
+
91
+ # Complete the value an option expects.
92
+ case "$prev" in
93
+ --skill) COMPREPLY=( $(compgen -W "$(__gdkbox_skills)" -- "$cur") ); return;;
94
+ --harness) COMPREPLY=( $(compgen -W "#{@cli ? GDKBox::Harness.ids.join(' ') : ''}" -- "$cur") ); return;;
95
+ --task-file) COMPREPLY=( $(compgen -f -- "$cur") ); return;;
96
+ esac
97
+
98
+ # Positional-argument completions.
99
+ case "$cmd" in
100
+ #{(BOX_COMMANDS - ['add-skill']).join('|')})
101
+ COMPREPLY=( $(compgen -W "$(__gdkbox_boxes)" -- "$cur") );;
102
+ add-skill)
103
+ if [[ "$prev" == "add-skill" ]]; then
104
+ COMPREPLY=( $(compgen -W "$(__gdkbox_boxes)" -- "$cur") )
105
+ else
106
+ COMPREPLY=( $(compgen -W "$(__gdkbox_skills)" -- "$cur") )
107
+ fi;;
108
+ install-skill)
109
+ COMPREPLY=( $(compgen -W "$(__gdkbox_skills)" -- "$cur") );;
110
+ completion)
111
+ COMPREPLY=( $(compgen -W "bash zsh" -- "$cur") );;
112
+ esac
113
+ }
114
+
115
+ __gdkbox_boxes() {
116
+ gdkbox ls --json 2>/dev/null | grep -oE '"name":"[^"]+"' | sed -E 's/.*:"([^"]+)"/\\1/'
117
+ }
118
+
119
+ __gdkbox_skills() {
120
+ gdkbox skills 2>/dev/null | awk '/^[^[:space:]]/{print $1}'
121
+ }
122
+ BASH
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "yaml"
5
+
6
+ module GDKBox
7
+ # Central configuration: filesystem paths, defaults, and naming conventions.
8
+ #
9
+ # The home directory can be overridden with the GDKBOX_HOME environment
10
+ # variable (useful for tests and for running multiple isolated setups).
11
+ #
12
+ # User-tunable defaults can be set in `<home>/config.yml` (by default
13
+ # `~/.gdkbox/config.yml`), for example:
14
+ #
15
+ # # ~/.gdkbox/config.yml
16
+ # image: registry.example.com/my-gdk:latest # default image for `up`
17
+ # skills: # transferred into every box
18
+ # - gdkbox-fleet
19
+ # - code-history
20
+ class Config
21
+ # The official "GDK in a box" container image published by GitLab.
22
+ DEFAULT_IMAGE =
23
+ "registry.gitlab.com/gitlab-org/gitlab-development-kit/gitlab-gdk-in-a-box:main"
24
+
25
+ CONTAINER_PREFIX = "gdkbox-"
26
+ HOST_ALIAS_PREFIX = "gdkbox-"
27
+
28
+ # Inside the official image GDK lives under the `gdk` user.
29
+ SSH_USER = "gdk"
30
+ REMOTE_PATH = "/home/gdk/gitlab-development-kit"
31
+
32
+ # Ports as seen from inside the container.
33
+ SSH_CONTAINER_PORT = 22
34
+ GDK_WEB_CONTAINER_PORT = 3000
35
+
36
+ # Starting points for the host-side published ports. Each new box claims
37
+ # the next free port at or above these bases.
38
+ SSH_PORT_BASE = 2222
39
+ WEB_PORT_BASE = 3000
40
+
41
+ attr_reader :home
42
+
43
+ def initialize(home: nil)
44
+ @home = File.expand_path(
45
+ home || ENV["GDKBOX_HOME"] || File.join(Dir.home, ".gdkbox")
46
+ )
47
+ end
48
+
49
+ def boxes_dir
50
+ File.join(home, "boxes")
51
+ end
52
+
53
+ def keys_dir
54
+ File.join(home, "keys")
55
+ end
56
+
57
+ def ssh_config_path
58
+ File.join(home, "ssh_config")
59
+ end
60
+
61
+ # Lock file guarding host-port allocation against concurrent `gdkbox up`.
62
+ def lock_path
63
+ File.join(home, "create.lock")
64
+ end
65
+
66
+ # Path to the optional user config file.
67
+ def config_file
68
+ File.join(home, "config.yml")
69
+ end
70
+
71
+ # Parsed contents of config.yml, or an empty hash when it is absent or
72
+ # unreadable. Memoized for the life of the Config instance.
73
+ def file_settings
74
+ @file_settings ||= begin
75
+ if File.file?(config_file)
76
+ data = begin
77
+ YAML.safe_load(File.read(config_file))
78
+ rescue StandardError
79
+ nil
80
+ end
81
+ data.is_a?(Hash) ? data : {}
82
+ else
83
+ {}
84
+ end
85
+ end
86
+ end
87
+
88
+ # Skill names/paths to transfer into every box by default, from config.yml.
89
+ def default_skills
90
+ Array(file_settings["skills"]).map(&:to_s).reject(&:empty?)
91
+ end
92
+
93
+ # The default agent harness for `gdkbox up`, from config.yml.
94
+ def default_harness
95
+ key = file_settings["harness"].to_s
96
+ key.empty? ? Harness.default : key
97
+ end
98
+
99
+ # An API key read from config.yml under `config_key` (e.g.
100
+ # "anthropic_api_key", "openai_api_key"), or nil. This is a plaintext
101
+ # secret on disk, so it is the lowest-priority source (a flag or the
102
+ # provider's env var wins); keep config.yml mode 600 if you use it.
103
+ def api_key(config_key)
104
+ key = file_settings[config_key.to_s]
105
+ key.to_s.strip.empty? ? nil : key.to_s
106
+ end
107
+
108
+ # Back-compat convenience for the Anthropic key.
109
+ def anthropic_api_key
110
+ api_key("anthropic_api_key")
111
+ end
112
+
113
+ def private_key_path
114
+ File.join(keys_dir, "id_ed25519")
115
+ end
116
+
117
+ def public_key_path
118
+ "#{private_key_path}.pub"
119
+ end
120
+
121
+ def user_ssh_config
122
+ File.join(Dir.home, ".ssh", "config")
123
+ end
124
+
125
+ def ensure_dirs!
126
+ FileUtils.mkdir_p(boxes_dir)
127
+ FileUtils.mkdir_p(keys_dir)
128
+ end
129
+
130
+ def container_name(name)
131
+ "#{CONTAINER_PREFIX}#{name}"
132
+ end
133
+
134
+ def ssh_host_alias(name)
135
+ "#{HOST_ALIAS_PREFIX}#{name}"
136
+ end
137
+
138
+ def ssh_user
139
+ SSH_USER
140
+ end
141
+
142
+ def remote_path
143
+ REMOTE_PATH
144
+ end
145
+
146
+ def default_image
147
+ ENV["GDKBOX_IMAGE"] || file_settings["image"] || DEFAULT_IMAGE
148
+ end
149
+ end
150
+ end