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:



528
529
530
531
532
533
534
535
536
537
538
# File 'lib/gdkbox/cli.rb', line 528

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



337
338
339
340
341
342
343
344
345
346
347
348
349
350
# File 'lib/gdkbox/cli.rb', line 337

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



209
210
211
212
213
214
215
216
217
218
219
220
221
# File 'lib/gdkbox/cli.rb', line 209

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



558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
# File 'lib/gdkbox/cli.rb', line 558

def completion(shell)
  completer = Completion.new(self.class)
  unless options[:install]
    puts completer.script(shell)
    return
  end

  script_path, rc, rc_changed = completer.install(shell)
  say "Wrote #{script_path}.", :green
  if rc_changed
    say "Wired #{rc} to source it. Open a new #{shell} (or `source #{rc}`) to activate.", :green
  else
    say "#{rc} already sources it. New #{shell} shells pick up the refreshed script.", :green
  end
end

#dispatch(name) ⇒ Object

Raises:



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

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



233
234
235
236
237
238
# File 'lib/gdkbox/cli.rb', line 233

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



384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
# File 'lib/gdkbox/cli.rb', line 384

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



224
225
226
227
228
229
# File 'lib/gdkbox/cli.rb', line 224

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:



485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
# File 'lib/gdkbox/cli.rb', line 485

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



130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
# File 'lib/gdkbox/cli.rb', line 130

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 vite:%-6s %s%s",
      box.name, status, box.ssh_port, box.web_port, box.vite_port || "-",
      box.web_url, claim)
  end
end

#release(name) ⇒ Object



356
357
358
359
360
361
362
363
364
365
366
# File 'lib/gdkbox/cli.rb', line 356

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



461
462
463
464
465
466
467
468
469
# File 'lib/gdkbox/cli.rb', line 461

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



279
280
281
282
283
284
285
# File 'lib/gdkbox/cli.rb', line 279

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



418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
# File 'lib/gdkbox/cli.rb', line 418

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



251
252
253
254
255
256
257
258
259
260
261
262
# File 'lib/gdkbox/cli.rb', line 251

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



299
300
301
302
303
304
305
306
307
308
309
310
# File 'lib/gdkbox/cli.rb', line 299

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



505
506
507
508
509
510
511
512
513
514
515
516
# File 'lib/gdkbox/cli.rb', line 505

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



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

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

#start(name) ⇒ Object



445
446
447
448
449
450
# File 'lib/gdkbox/cli.rb', line 445

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

#status(name) ⇒ Object



156
157
158
159
160
161
162
163
164
165
166
167
168
169
# File 'lib/gdkbox/cli.rb', line 156

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



453
454
455
456
457
# File 'lib/gdkbox/cli.rb', line 453

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

#up(name) ⇒ Object

Raises:



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
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
# File 'lib/gdkbox/cli.rb', line 51

def up(name)
  if options[:ttl] && options[:owner].to_s.strip.empty?
    raise Error, "--ttl only makes sense with --owner (it is the claim's lease)."
  end
  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],
    vite_port: options[:vite_port],
    harness: harness.id,
    install_agent: options[:agent],
    api_key: api_key,
    claim_owner: options[:owner],
    claim_ttl: options[:ttl]
  )
  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
  unless box.data["vite_port_configured"]
    say "\n  Could not align the box's vite port with the published " \
      "#{box.vite_port} — browser asset loading may fail.", :yellow
    say "  Retry inside the box: gdk config set vite.port #{box.vite_port} " \
      "&& gdk reconfigure && gdk restart vite", :yellow
  end
  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



109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
# File 'lib/gdkbox/cli.rb', line 109

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



575
576
577
# File 'lib/gdkbox/cli.rb', line 575

def version
  say GDKBox::VERSION
end