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 +7 -0
- data/README.md +334 -0
- data/exe/gdkbox +11 -0
- data/lib/gdkbox/box.rb +293 -0
- data/lib/gdkbox/cli.rb +410 -0
- data/lib/gdkbox/completion.rb +125 -0
- data/lib/gdkbox/config.rb +150 -0
- data/lib/gdkbox/docker.rb +89 -0
- data/lib/gdkbox/harness.rb +136 -0
- data/lib/gdkbox/provisioner.rb +183 -0
- data/lib/gdkbox/shell.rb +57 -0
- data/lib/gdkbox/skills.rb +127 -0
- data/lib/gdkbox/ssh_config.rb +53 -0
- data/lib/gdkbox/ssh_key.rb +36 -0
- data/lib/gdkbox/store.rb +47 -0
- data/lib/gdkbox/version.rb +5 -0
- data/lib/gdkbox/vscode.rb +26 -0
- data/lib/gdkbox.rb +20 -0
- data/skills/gdkbox-fleet/SKILL.md +170 -0
- metadata +95 -0
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
|