Class: GDKBox::CLI

Inherits:
Thor
  • Object
show all
Defined in:
lib/gdkbox/cli.rb

Overview

The gdkbox command-line interface.

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.exit_on_failure?Boolean

Returns:

  • (Boolean)


10
11
12
# File 'lib/gdkbox/cli.rb', line 10

def self.exit_on_failure?
  true
end

Instance Method Details

#add_skill(box_name, *skill_tokens) ⇒ Object

Raises:



501
502
503
504
505
506
507
508
509
510
511
# File 'lib/gdkbox/cli.rb', line 501

def add_skill(box_name, *skill_tokens)
  box = load_box!(box_name)
  skill_tokens = prompt_for_skills if skill_tokens.empty?
  raise Error, "No skills selected." if skill_tokens.empty?

  skill_tokens.each do |token|
    skill = box.install_skill(token, force: options[:force])
    say "Installed skill '#{skill.name}' into box '#{box_name}'.", :green
  end
  say "\nDispatched agents in '#{box_name}' can now use these skills.", :cyan
end

#claim(name = nil) ⇒ Object



310
311
312
313
314
315
316
317
318
319
320
321
322
323
# File 'lib/gdkbox/cli.rb', line 310

def claim(name = nil)
  box = if name
    load_box!(name).claim!(owner: options[:owner], ttl: options[:ttl])
  else
    Box.claim_any(config: config, owner: options[:owner], ttl: options[:ttl])
  end

  if options[:json]
    puts JSON.generate(box.summary)
  else
    expiry = options[:ttl] ? " until #{box.data['claim_expires_at']}" : ""
    say "Claimed '#{box.name}' for '#{options[:owner]}'#{expiry}.", :green
  end
end

#code(name) ⇒ Object



188
189
190
191
192
193
194
195
196
197
198
199
200
# File 'lib/gdkbox/cli.rb', line 188

def code(name)
  box = load_box!(name)
  rewrite_ssh_config
  vscode = VSCode.new
  unless vscode.available?
    say "The `code` CLI was not found on PATH.", :yellow
    say "Open VS Code manually and connect to host: #{box.ssh_host_alias}"
    say "Or run: #{vscode.open_command(box).join(' ')}"
    return
  end
  say "Opening #{box.name} in VS Code (#{box.ssh_host_alias})...", :green
  vscode.open(box)
end

#completion(shell) ⇒ Object



523
524
525
# File 'lib/gdkbox/cli.rb', line 523

def completion(shell)
  puts Completion.new(self.class).script(shell)
end

#dispatch(name) ⇒ Object

Raises:



164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
# File 'lib/gdkbox/cli.rb', line 164

def dispatch(name)
  box = load_box!(name)
  task = options[:task]
  task = File.read(options[:task_file]) if options[:task_file]
  raise Error, "Provide a task with --task or --task-file." if task.nil? || task.strip.empty?

  result = box.run_agent(
    task: task,
    json: options[:json],
    yolo: options[:yolo],
    timeout: options[:timeout]
  )
  $stdout.print(result.stdout)
  $stderr.print(result.stderr) unless result.stderr.to_s.empty?
  exit(result.status)
end

#harnessesObject



212
213
214
215
216
217
# File 'lib/gdkbox/cli.rb', line 212

def harnesses
  Harness.all.each do |h|
    say format("%-10s ", h.id), :green, false
    say "#{h.summary} (key: $#{h.key_env})"
  end
end

#hydrate(name) ⇒ Object



357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
# File 'lib/gdkbox/cli.rb', line 357

def hydrate(name)
  box = load_box!(name)
  unless options[:force]
    estimate = options[:trees] ? "a sizeable download" : "a large download, likely several GB"
    return unless yes?("Hydrate '#{name}' (#{estimate})? [y/N]")
  end

  say "Hydrating '#{name}' (#{options[:trees] ? 'trees only' : 'full'})...", :green
  tty = $stderr.tty?
  box.hydrate!(trees: options[:trees]) do |line|
    if tty
      $stderr.print("\r\e[K#{line}")
    elsif !line.include?("%") # skip per-percent spam when piped
      $stderr.puts(line)
    end
  end
  $stderr.print("\n") if tty
  say "Done. '#{name}' can now work across the full history.", :green
end

#install_agent(name) ⇒ Object



203
204
205
206
207
208
# File 'lib/gdkbox/cli.rb', line 203

def install_agent(name)
  box = load_box!(name)
  say "Installing #{box.harness.summary} in '#{name}'...", :green
  box.install_agent!
  say "Done. SSH in and run `#{box.harness.bin}` to start an agent.", :green
end

#install_skill(name = nil) ⇒ Object

Raises:



458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
# File 'lib/gdkbox/cli.rb', line 458

def install_skill(name = nil)
  available = Skills.available
  raise Error, "This repo ships no skills." if available.empty?

  if name && !available.include?(name)
    raise Error, "No skill named '#{name}'. Available: #{available.join(', ')}."
  end

  dest = options[:project] ? Skills.project_dest : Skills::GLOBAL_DEST
  skills = Skills.new(dest: dest)

  (name ? [name] : available).each do |skill_name|
    target = skills.install(skill_name, force: options[:force])
    say "Installed skill '#{skill_name}' -> #{target}", :green
  end
  say "\nStart a new Claude Code session to pick up the skill.", :cyan
end

#lsObject



110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
# File 'lib/gdkbox/cli.rb', line 110

def ls
  boxes = Box.all(config: config).sort_by(&:name)
  docker = Docker.new

  if options[:json]
    summaries = boxes.map { |box| box.summary(state: docker.available? ? nil : "unknown") }
    puts JSON.generate(summaries)
    return
  end

  if boxes.empty?
    say "No GDK boxes yet. Create one with `gdkbox up <name>`."
    return
  end

  boxes.each do |box|
    status = docker.available? ? box.state : "unknown"
    claim = box.claimed_by ? " claimed:#{box.claimed_by}" : ""
    say format("%-20s %-10s ssh:%-6s web:%-6s %s%s",
      box.name, status, box.ssh_port, box.web_port, box.web_url, claim)
  end
end

#release(name) ⇒ Object



329
330
331
332
333
334
335
336
337
338
339
# File 'lib/gdkbox/cli.rb', line 329

def release(name)
  box = load_box!(name)
  if !options[:force] && options[:owner].to_s.strip.empty?
    raise Error, "Pass --owner <id> (or --force to override another owner's claim)."
  end

  case box.release!(owner: options[:owner], force: options[:force])
  when :released then say "Released '#{name}'.", :green
  when :unclaimed then say "Box '#{name}' was not claimed. Nothing to do."
  end
end

#rm(name) ⇒ Object



434
435
436
437
438
439
440
441
442
# File 'lib/gdkbox/cli.rb', line 434

def rm(name)
  box = load_box!(name)
  unless options[:force]
    return unless yes?("Remove box '#{name}' and its container? [y/N]")
  end
  box.destroy!
  rewrite_ssh_config
  say "Removed '#{name}'.", :green
end

#set_git(name) ⇒ Object



258
259
260
261
262
263
264
# File 'lib/gdkbox/cli.rb', line 258

def set_git(name)
  box = load_box!(name)
  git_name, git_email = box.set_git_identity!(
    git_name: options[:name], git_email: options[:email]
  )
  say "Seeded git identity into '#{name}': #{[git_name, git_email].compact.join(' <')}#{'>' if git_email}", :green
end

#set_hostObject



391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
# File 'lib/gdkbox/cli.rb', line 391

def set_host
  hosts = HostsFile.new
  if options[:remove]
    case hosts.remove
    when :absent
      say "No gdkbox-managed '#{HostsFile::HOSTNAME}' entry in #{hosts.path}. Nothing to do."
    when :removed
      say "Removed the '#{HostsFile::HOSTNAME}' entry from #{hosts.path}.", :green
    when :manual
      say "Could not write #{hosts.path}. Remove the line tagged " \
        "'#{HostsFile::MARKER}' manually (e.g. with `sudoedit #{hosts.path}`).", :yellow
    end
  else
    case hosts.add
    when :present
      say "'#{HostsFile::HOSTNAME}' already resolves via #{hosts.path}. Nothing to do."
    when :added
      say "Added '#{HostsFile::ENTRY}' to #{hosts.path}.", :green
    when :manual
      say "Could not write #{hosts.path}. Add the entry manually:", :yellow
      say "  echo '#{HostsFile::ENTRY}' | sudo tee -a #{hosts.path}", :yellow
    end
  end
end

#set_key(name) ⇒ Object



230
231
232
233
234
235
236
237
238
239
240
241
# File 'lib/gdkbox/cli.rb', line 230

def set_key(name)
  box = load_box!(name)
  api_key = resolve_api_key(box.harness)
  unless api_key
    raise Error, "No API key. Pass --api-key, set $#{box.harness.key_env}, " \
      "or add `#{box.harness.config_key}:` to ~/.gdkbox/config.yml."
  end

  say "Seeding #{box.harness.key_env} into '#{name}'...", :green
  box.set_api_key!(api_key)
  say "Done. Unattended `gdkbox dispatch #{name}` is ready.", :green
end

#set_remote(name, remote = nil) ⇒ Object



278
279
280
281
282
283
284
285
286
287
288
289
# File 'lib/gdkbox/cli.rb', line 278

def set_remote(name, remote = nil)
  box = load_box!(name)
  remote ||= config.default_gitlab_remote
  unless remote
    raise Error, "No remote. Pass REMOTE (URL or namespace/project) " \
      "or set `gitlab_remote:` in ~/.gdkbox/config.yml."
  end

  say "Pointing '#{name}' GitLab checkout at #{Box.expand_remote(remote)} (fetching)...", :green
  url = box.set_gitlab_remote!(remote)
  say "origin now #{url}.", :green
end

#skillsObject



478
479
480
481
482
483
484
485
486
487
488
489
# File 'lib/gdkbox/cli.rb', line 478

def skills
  found = Skills.discover
  if found.empty?
    say "No skills found in this repo, ~/.claude/skills, or ./.claude/skills."
    return
  end
  found.each do |skill|
    say format("%-28s ", skill.name), :green, false
    say "[#{skill.origin}]", :cyan
    say "  #{skill.description}" if skill.description && !skill.description.empty?
  end
end

#ssh(name) ⇒ Object



182
183
184
185
# File 'lib/gdkbox/cli.rb', line 182

def ssh(name)
  box = load_box!(name)
  exec(*box.ssh_command)
end

#start(name) ⇒ Object



418
419
420
421
422
423
# File 'lib/gdkbox/cli.rb', line 418

def start(name)
  box = load_box!(name)
  say "Starting '#{name}'...", :green
  box.start!
  print_connection_details(box)
end

#status(name) ⇒ Object



135
136
137
138
139
140
141
142
143
144
145
146
147
148
# File 'lib/gdkbox/cli.rb', line 135

def status(name)
  box = load_box!(name)
  docker = Docker.new

  if options[:json]
    puts JSON.generate(box.summary(state: docker.available? ? nil : "unknown"))
    return
  end

  say "Box:        #{box.name}"
  say "Container:  #{box.container_name}"
  say "State:      #{docker.available? ? box.state : 'unknown'}"
  print_connection_details(box)
end

#stop(name) ⇒ Object



426
427
428
429
430
# File 'lib/gdkbox/cli.rb', line 426

def stop(name)
  box = load_box!(name)
  say "Stopping '#{name}'...", :green
  box.stop!
end

#up(name) ⇒ Object

Raises:



43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
# File 'lib/gdkbox/cli.rb', line 43

def up(name)
  ensure_docker!
  box = build_box(name)
  raise Error, "Box '#{name}' already exists. Use `gdkbox rm #{name}` first." if box.exists?
  harness = Harness[options[:harness] || config.default_harness]
  api_key = resolve_api_key(harness)

  say "Spinning up GDK box '#{name}' (#{harness.summary}; first run pulls a large image)...", :green unless options[:json]
  box.create!(
    image: options[:image],
    ssh_port: options[:ssh_port],
    web_port: options[:web_port],
    harness: harness.id,
    install_agent: options[:agent],
    api_key: api_key
  )
  rewrite_ssh_config
  seed_skills(box)
  repoint_gitlab_remote(box)

  if options[:json]
    puts JSON.generate(box.summary)
    return
  end

  setup_host

  say "\nBox '#{name}' is up.", :green
  print_connection_details(box)
  unless api_key
    say "\n  No #{harness.key_env} seeded. Before unattended dispatch, run:", :yellow
    say "    gdkbox set-key #{name}   (uses $#{harness.key_env})", :yellow
  end
end

#update_imageObject



89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
# File 'lib/gdkbox/cli.rb', line 89

def update_image
  ensure_docker!
  image = options[:image] || config.default_image
  docker = Docker.new

  say "Pulling #{image} ...", :green
  bar = ProgressBar.new
  docker.pull(image) do |progress|
    label = "#{progress.completed}/#{progress.total} layers"
    label += " ยท #{progress.human_speed}" if progress.human_speed
    bar.update(progress.fraction, label)
  end
  bar.finish

  say "Updated. New boxes will use this image; recreate existing boxes to adopt it.", :green
end

#versionObject



528
529
530
# File 'lib/gdkbox/cli.rb', line 528

def version
  say GDKBox::VERSION
end