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
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GDKBox
|
|
4
|
+
# A small, focused wrapper around the `docker` CLI.
|
|
5
|
+
#
|
|
6
|
+
# Only the verbs gdkbox needs are exposed. Every container created by gdkbox
|
|
7
|
+
# is tagged with the `gdkbox=true` label plus a `gdkbox.name` label so they
|
|
8
|
+
# can be discovered later without relying on local metadata alone.
|
|
9
|
+
class Docker
|
|
10
|
+
def initialize(shell: Shell.new)
|
|
11
|
+
@shell = shell
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def available?
|
|
15
|
+
!@shell.which("docker").nil?
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def pull(image)
|
|
19
|
+
@shell.run!("docker", "pull", image)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Start a detached container, returning its id.
|
|
23
|
+
def run_container(name:, image:, publish: [], labels: {}, env: {}, args: [])
|
|
24
|
+
cmd = ["docker", "run", "-d", "--name", name]
|
|
25
|
+
labels.each { |key, value| cmd.push("--label", "#{key}=#{value}") }
|
|
26
|
+
env.each { |key, value| cmd.push("-e", "#{key}=#{value}") }
|
|
27
|
+
publish.each { |mapping| cmd.push("-p", mapping) }
|
|
28
|
+
cmd << image
|
|
29
|
+
cmd.concat(args)
|
|
30
|
+
@shell.run!(*cmd).stdout.strip
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Run a bash script inside a running container. The script is passed to
|
|
34
|
+
# `bash -lc` as a single argument; values that vary (keys, usernames) are
|
|
35
|
+
# passed through the environment to avoid quoting pitfalls.
|
|
36
|
+
#
|
|
37
|
+
# When check is true (the default) a non-zero exit raises; pass check:
|
|
38
|
+
# false to capture output and exit status without raising, which is what
|
|
39
|
+
# agent runs want so the caller can surface the agent's output either way.
|
|
40
|
+
def exec(name, script, user: nil, workdir: nil, env: {}, input: nil, check: true)
|
|
41
|
+
cmd = ["docker", "exec", "-i"]
|
|
42
|
+
cmd.push("-u", user) if user
|
|
43
|
+
cmd.push("-w", workdir) if workdir
|
|
44
|
+
env.each { |key, value| cmd.push("-e", "#{key}=#{value}") }
|
|
45
|
+
cmd.push(name, "bash", "-lc", script)
|
|
46
|
+
check ? @shell.run!(*cmd, input: input) : @shell.run(*cmd, input: input)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Copy a host path into a container at dest (a full destination path).
|
|
50
|
+
def cp_into(name, src, dest)
|
|
51
|
+
@shell.run!("docker", "cp", src, "#{name}:#{dest}")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def start(name)
|
|
55
|
+
@shell.run!("docker", "start", name)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def stop(name)
|
|
59
|
+
@shell.run!("docker", "stop", name)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def rm(name, force: true)
|
|
63
|
+
cmd = ["docker", "rm"]
|
|
64
|
+
cmd << "-f" if force
|
|
65
|
+
cmd << name
|
|
66
|
+
@shell.run(*cmd)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The container's lifecycle state, or :absent if it does not exist.
|
|
70
|
+
def state(name)
|
|
71
|
+
result = @shell.run("docker", "inspect", "-f", "{{.State.Status}}", name)
|
|
72
|
+
return :absent unless result.success?
|
|
73
|
+
|
|
74
|
+
result.stdout.strip.to_sym
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Names of all gdkbox-managed containers, derived from labels.
|
|
78
|
+
def list_names
|
|
79
|
+
result = @shell.run(
|
|
80
|
+
"docker", "ps", "-a",
|
|
81
|
+
"--filter", "label=gdkbox=true",
|
|
82
|
+
"--format", '{{index .Labels "gdkbox.name"}}'
|
|
83
|
+
)
|
|
84
|
+
return [] unless result.success?
|
|
85
|
+
|
|
86
|
+
result.stdout.split("\n").map(&:strip).reject(&:empty?)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GDKBox
|
|
4
|
+
# An agent CLI ("harness") that can be installed into a box and dispatched to
|
|
5
|
+
# run tasks headlessly. gdkbox ships a small registry of harnesses; each box
|
|
6
|
+
# runs exactly one, chosen at `gdkbox up --harness`.
|
|
7
|
+
#
|
|
8
|
+
# The npm-installable harnesses reuse the same mise-aware install path as
|
|
9
|
+
# Claude Code (install into ~/.local, symlink the binary + node into
|
|
10
|
+
# /usr/local/bin). A harness declares the env var its provider reads for
|
|
11
|
+
# credentials and the config.yml key gdkbox looks under for a default.
|
|
12
|
+
class Harness
|
|
13
|
+
@registry = {}
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
def register(**kwargs)
|
|
17
|
+
harness = new(**kwargs)
|
|
18
|
+
@registry[harness.id] = harness
|
|
19
|
+
harness
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def [](id)
|
|
23
|
+
@registry.fetch(id.to_s) do
|
|
24
|
+
raise Error, "Unknown harness '#{id}'. Available: #{ids.join(', ')}."
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def all
|
|
29
|
+
@registry.values
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def ids
|
|
33
|
+
@registry.keys
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The harness used when none is specified.
|
|
37
|
+
def default
|
|
38
|
+
"claude"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
attr_reader :id, :npm_package, :npm_flags, :bin, :key_env, :config_key,
|
|
43
|
+
:summary, :skills_subdir
|
|
44
|
+
|
|
45
|
+
def initialize(id:, bin:, key_env:, config_key:, summary:, run:, skills_subdir:,
|
|
46
|
+
npm_package: nil, npm_flags: nil)
|
|
47
|
+
@id = id
|
|
48
|
+
@npm_package = npm_package
|
|
49
|
+
@npm_flags = npm_flags
|
|
50
|
+
@bin = bin
|
|
51
|
+
@key_env = key_env
|
|
52
|
+
@config_key = config_key
|
|
53
|
+
@summary = summary
|
|
54
|
+
@run = run
|
|
55
|
+
@skills_subdir = skills_subdir
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# True when gdkbox knows how to install this harness automatically.
|
|
59
|
+
def installable?
|
|
60
|
+
!@npm_package.nil?
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The in-box shell snippet that runs the agent against $GDKBOX_TASK. The
|
|
64
|
+
# caller wraps it with a timeout and sources the seeded API key.
|
|
65
|
+
def agent_command(json:, yolo:)
|
|
66
|
+
@run.call(json: json, yolo: yolo)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
register(
|
|
70
|
+
id: "claude",
|
|
71
|
+
npm_package: "@anthropic-ai/claude-code",
|
|
72
|
+
bin: "claude",
|
|
73
|
+
key_env: "ANTHROPIC_API_KEY",
|
|
74
|
+
config_key: "anthropic_api_key",
|
|
75
|
+
summary: "Anthropic Claude Code",
|
|
76
|
+
skills_subdir: ".claude/skills",
|
|
77
|
+
run: lambda do |json:, yolo:|
|
|
78
|
+
cmd = +%(claude -p "$GDKBOX_TASK")
|
|
79
|
+
cmd << " --output-format json" if json
|
|
80
|
+
cmd << " --dangerously-skip-permissions" if yolo
|
|
81
|
+
cmd
|
|
82
|
+
end
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
register(
|
|
86
|
+
id: "codex",
|
|
87
|
+
npm_package: "@openai/codex",
|
|
88
|
+
bin: "codex",
|
|
89
|
+
key_env: "OPENAI_API_KEY",
|
|
90
|
+
config_key: "openai_api_key",
|
|
91
|
+
summary: "OpenAI Codex CLI",
|
|
92
|
+
skills_subdir: ".codex/skills",
|
|
93
|
+
run: lambda do |json:, yolo:|
|
|
94
|
+
cmd = +%(codex exec "$GDKBOX_TASK")
|
|
95
|
+
cmd << " --json" if json
|
|
96
|
+
# The box is disposable, so let the agent write to the workspace.
|
|
97
|
+
cmd << " --sandbox workspace-write" if yolo
|
|
98
|
+
cmd
|
|
99
|
+
end
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
register(
|
|
103
|
+
id: "opencode",
|
|
104
|
+
npm_package: "opencode-ai",
|
|
105
|
+
bin: "opencode",
|
|
106
|
+
# opencode reads provider keys from the environment; default to OpenAI.
|
|
107
|
+
key_env: "OPENAI_API_KEY",
|
|
108
|
+
config_key: "openai_api_key",
|
|
109
|
+
summary: "opencode (sst)",
|
|
110
|
+
skills_subdir: ".config/opencode/skills",
|
|
111
|
+
run: lambda do |json:, yolo:|
|
|
112
|
+
# `opencode run` is non-interactive and has no skip-permissions flag.
|
|
113
|
+
+%(opencode run "$GDKBOX_TASK")
|
|
114
|
+
end
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
register(
|
|
118
|
+
id: "pi",
|
|
119
|
+
npm_package: "@earendil-works/pi-coding-agent",
|
|
120
|
+
# pi's npm package runs postinstall scripts that need network/build; the
|
|
121
|
+
# docs recommend installing with --ignore-scripts.
|
|
122
|
+
npm_flags: "--ignore-scripts",
|
|
123
|
+
bin: "pi",
|
|
124
|
+
# pi is multi-provider; default the seeded key to Anthropic.
|
|
125
|
+
key_env: "ANTHROPIC_API_KEY",
|
|
126
|
+
config_key: "anthropic_api_key",
|
|
127
|
+
summary: "pi (earendil-works)",
|
|
128
|
+
skills_subdir: ".pi/skills",
|
|
129
|
+
run: lambda do |json:, yolo:|
|
|
130
|
+
cmd = +%(pi -p "$GDKBOX_TASK")
|
|
131
|
+
cmd << " --mode json" if json
|
|
132
|
+
cmd
|
|
133
|
+
end
|
|
134
|
+
)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GDKBox
|
|
4
|
+
# Runs the in-container setup steps: enabling SSH access and (optionally)
|
|
5
|
+
# installing Claude Code. All steps are idempotent so they can be re-run
|
|
6
|
+
# safely, for example after a `gdkbox start`.
|
|
7
|
+
class Provisioner
|
|
8
|
+
# Installs and starts the OpenSSH server, then authorizes the gdkbox public
|
|
9
|
+
# key for the GDK user. Values are passed via the environment to dodge
|
|
10
|
+
# shell quoting.
|
|
11
|
+
#
|
|
12
|
+
# The official GDK image ships an sshd_config that listens on a non-default
|
|
13
|
+
# port (2022) and runs GitLab's own `gitlab-sshd` for git-over-SSH, so we
|
|
14
|
+
# cannot assume a vanilla sshd on port 22. We drop in our own config
|
|
15
|
+
# (loaded via the `Include` near the top of sshd_config, so it overrides
|
|
16
|
+
# the image's later `Port` line) pinning the listen port to the container
|
|
17
|
+
# port gdkbox publishes, ensure host keys exist, and restart sshd so it
|
|
18
|
+
# rebinds. This is idempotent and also re-run by `gdkbox start`.
|
|
19
|
+
SSH_SETUP = <<~'BASH'
|
|
20
|
+
set -e
|
|
21
|
+
if [ ! -x /usr/sbin/sshd ] && ! command -v sshd >/dev/null 2>&1; then
|
|
22
|
+
apt-get update -qq
|
|
23
|
+
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq openssh-server >/dev/null
|
|
24
|
+
fi
|
|
25
|
+
mkdir -p /run/sshd
|
|
26
|
+
ssh-keygen -A >/dev/null 2>&1 || true
|
|
27
|
+
mkdir -p /etc/ssh/sshd_config.d
|
|
28
|
+
printf 'Port %s\nPasswordAuthentication no\n' "$GDKBOX_SSH_PORT" \
|
|
29
|
+
> /etc/ssh/sshd_config.d/00-gdkbox.conf
|
|
30
|
+
# The stock ~/.bash_logout runs `clear_console -q`, which fails (no TTY)
|
|
31
|
+
# under `docker exec ... bash -lc` and clobbers the command's exit
|
|
32
|
+
# status with 1. We run agents through a login shell, so neutralize it
|
|
33
|
+
# to keep `gdkbox dispatch` exit codes (and the rest) honest.
|
|
34
|
+
: > "/home/$GDKBOX_USER/.bash_logout" 2>/dev/null || true
|
|
35
|
+
# The GDK image manages its toolchain (ruby, node, bundle, ...) with mise
|
|
36
|
+
# but only activates it in ~/.bashrc (interactive shells). `gdkbox
|
|
37
|
+
# dispatch` and `gdkbox ssh <box> <cmd>` use non-interactive login shells,
|
|
38
|
+
# which source ~/.profile, not ~/.bashrc. Put mise's shims on PATH there
|
|
39
|
+
# so dispatched agents can actually run the GDK toolchain. Shims resolve
|
|
40
|
+
# the correct tool version from the directory's mise/.tool-versions config.
|
|
41
|
+
profile="/home/$GDKBOX_USER/.profile"
|
|
42
|
+
touch "$profile"
|
|
43
|
+
if ! grep -q 'gdkbox:mise-shims' "$profile" 2>/dev/null; then
|
|
44
|
+
{
|
|
45
|
+
echo '# gdkbox:mise-shims'
|
|
46
|
+
echo 'if [ -d "$HOME/.local/share/mise/shims" ]; then PATH="$HOME/.local/share/mise/shims:$PATH"; fi'
|
|
47
|
+
} >> "$profile"
|
|
48
|
+
fi
|
|
49
|
+
chown "$GDKBOX_USER:$GDKBOX_USER" "$profile"
|
|
50
|
+
install -d -m 700 -o "$GDKBOX_USER" -g "$GDKBOX_USER" "/home/$GDKBOX_USER/.ssh"
|
|
51
|
+
printf '%s\n' "$GDKBOX_PUBKEY" > "/home/$GDKBOX_USER/.ssh/authorized_keys"
|
|
52
|
+
chown "$GDKBOX_USER:$GDKBOX_USER" "/home/$GDKBOX_USER/.ssh/authorized_keys"
|
|
53
|
+
chmod 600 "/home/$GDKBOX_USER/.ssh/authorized_keys"
|
|
54
|
+
/usr/sbin/sshd -t
|
|
55
|
+
# Restart our sshd so it binds the configured port (gitlab-sshd and any
|
|
56
|
+
# pre-existing sshd may be on other ports). Match only the OpenSSH daemon.
|
|
57
|
+
pkill -x sshd >/dev/null 2>&1 || true
|
|
58
|
+
sleep 1
|
|
59
|
+
/usr/sbin/sshd
|
|
60
|
+
BASH
|
|
61
|
+
|
|
62
|
+
# Installs an agent harness CLI (an npm package) for the GDK user.
|
|
63
|
+
#
|
|
64
|
+
# The official GDK image manages Node with `mise`, so `node`/`npm` are not
|
|
65
|
+
# on PATH in a plain shell. We install into `~/.local` (which is on PATH
|
|
66
|
+
# for every shell) using mise's npm when a direct npm is unavailable, and
|
|
67
|
+
# symlink the mise-managed node so the agent's `env node` shebang can find
|
|
68
|
+
# an interpreter. Both `npm` and the plain-npm case are handled so this
|
|
69
|
+
# works on the GDK image and on simpler images alike. The binary name and
|
|
70
|
+
# package come from the harness via the environment.
|
|
71
|
+
AGENT_SETUP = <<~'BASH'
|
|
72
|
+
set -e
|
|
73
|
+
mkdir -p "$HOME/.local/bin"
|
|
74
|
+
# Pin a concrete node: the agent CLIs are node scripts with `env node`
|
|
75
|
+
# shebangs, and the mise *shim* for node errors outside a directory that
|
|
76
|
+
# configures a node version. Symlinking the real mise install makes node
|
|
77
|
+
# resolvable everywhere (and gets linked into /usr/local/bin afterwards).
|
|
78
|
+
# Done before the early-exit so re-provisioning fixes the PATH too.
|
|
79
|
+
if command -v mise >/dev/null 2>&1 && mise which node >/dev/null 2>&1; then
|
|
80
|
+
ln -sf "$(mise which node)" "$HOME/.local/bin/node"
|
|
81
|
+
fi
|
|
82
|
+
if command -v "$GDKBOX_AGENT_BIN" >/dev/null 2>&1; then
|
|
83
|
+
echo "$GDKBOX_AGENT_BIN already installed: $("$GDKBOX_AGENT_BIN" --version 2>/dev/null || echo unknown)"
|
|
84
|
+
exit 0
|
|
85
|
+
fi
|
|
86
|
+
if command -v npm >/dev/null 2>&1; then
|
|
87
|
+
npm install -g --prefix "$HOME/.local" $GDKBOX_NPM_FLAGS "$GDKBOX_NPM_PKG"
|
|
88
|
+
elif command -v mise >/dev/null 2>&1 && mise which npm >/dev/null 2>&1; then
|
|
89
|
+
mise exec -- npm install -g --prefix "$HOME/.local" $GDKBOX_NPM_FLAGS "$GDKBOX_NPM_PKG"
|
|
90
|
+
else
|
|
91
|
+
echo "Neither npm nor mise-managed node found in box; cannot install $GDKBOX_AGENT_BIN" >&2
|
|
92
|
+
exit 1
|
|
93
|
+
fi
|
|
94
|
+
"$HOME/.local/bin/$GDKBOX_AGENT_BIN" --version || true
|
|
95
|
+
BASH
|
|
96
|
+
|
|
97
|
+
# Symlinks the user-installed agent binary (and the mise-managed `node` it
|
|
98
|
+
# needs) into /usr/local/bin so they are on PATH for every shell, not just
|
|
99
|
+
# login shells. Without this, `ssh box <agent>` (a non-login shell) would
|
|
100
|
+
# not find the CLI even though `gdkbox dispatch` (a login shell) does.
|
|
101
|
+
AGENT_LINK = <<~'BASH'
|
|
102
|
+
set -e
|
|
103
|
+
home="/home/$GDKBOX_USER"
|
|
104
|
+
if [ -e "$home/.local/bin/$GDKBOX_AGENT_BIN" ]; then
|
|
105
|
+
ln -sf "$home/.local/bin/$GDKBOX_AGENT_BIN" "/usr/local/bin/$GDKBOX_AGENT_BIN"
|
|
106
|
+
fi
|
|
107
|
+
if [ -e "$home/.local/bin/node" ]; then
|
|
108
|
+
ln -sf "$home/.local/bin/node" /usr/local/bin/node
|
|
109
|
+
fi
|
|
110
|
+
BASH
|
|
111
|
+
|
|
112
|
+
# Writes the harness's API key into the box so dispatched agents can
|
|
113
|
+
# authenticate without a human. The key is exported under the env var the
|
|
114
|
+
# harness's provider reads (e.g. ANTHROPIC_API_KEY, OPENAI_API_KEY). It is
|
|
115
|
+
# stored only inside the container, at a 0600 file owned by the GDK user,
|
|
116
|
+
# sourced explicitly by `gdkbox dispatch` and wired into interactive
|
|
117
|
+
# shells. The key never touches host-side metadata.
|
|
118
|
+
API_KEY_SETUP = <<~'BASH'
|
|
119
|
+
set -e
|
|
120
|
+
home="/home/$GDKBOX_USER"
|
|
121
|
+
install -d -m 700 -o "$GDKBOX_USER" -g "$GDKBOX_USER" "$home/.gdkbox"
|
|
122
|
+
umask 077
|
|
123
|
+
printf "export %s='%s'\n" "$GDKBOX_API_KEY_ENV" "$GDKBOX_API_KEY" > "$home/.gdkbox/env"
|
|
124
|
+
chown "$GDKBOX_USER:$GDKBOX_USER" "$home/.gdkbox/env"
|
|
125
|
+
chmod 600 "$home/.gdkbox/env"
|
|
126
|
+
line='[ -f "$HOME/.gdkbox/env" ] && . "$HOME/.gdkbox/env"'
|
|
127
|
+
for f in "$home/.bashrc" "$home/.profile"; do
|
|
128
|
+
touch "$f"
|
|
129
|
+
grep -qF "$line" "$f" || printf '%s\n' "$line" >> "$f"
|
|
130
|
+
chown "$GDKBOX_USER:$GDKBOX_USER" "$f"
|
|
131
|
+
done
|
|
132
|
+
BASH
|
|
133
|
+
|
|
134
|
+
def initialize(docker:, config:)
|
|
135
|
+
@docker = docker
|
|
136
|
+
@config = config
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def setup_ssh(container_name, public_key)
|
|
140
|
+
@docker.exec(
|
|
141
|
+
container_name, SSH_SETUP,
|
|
142
|
+
user: "root",
|
|
143
|
+
env: {
|
|
144
|
+
"GDKBOX_USER" => @config.ssh_user,
|
|
145
|
+
"GDKBOX_PUBKEY" => public_key,
|
|
146
|
+
"GDKBOX_SSH_PORT" => Config::SSH_CONTAINER_PORT.to_s
|
|
147
|
+
}
|
|
148
|
+
)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Install the given harness (an npm-installable agent CLI) into the box.
|
|
152
|
+
def setup_agent(container_name, harness)
|
|
153
|
+
unless harness.installable?
|
|
154
|
+
raise Error, "gdkbox cannot auto-install the '#{harness.id}' harness yet."
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
@docker.exec(
|
|
158
|
+
container_name, AGENT_SETUP, user: @config.ssh_user,
|
|
159
|
+
env: {
|
|
160
|
+
"GDKBOX_AGENT_BIN" => harness.bin,
|
|
161
|
+
"GDKBOX_NPM_PKG" => harness.npm_package,
|
|
162
|
+
"GDKBOX_NPM_FLAGS" => harness.npm_flags.to_s
|
|
163
|
+
}
|
|
164
|
+
)
|
|
165
|
+
@docker.exec(
|
|
166
|
+
container_name, AGENT_LINK, user: "root",
|
|
167
|
+
env: { "GDKBOX_USER" => @config.ssh_user, "GDKBOX_AGENT_BIN" => harness.bin }
|
|
168
|
+
)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def setup_api_key(container_name, api_key, key_env)
|
|
172
|
+
@docker.exec(
|
|
173
|
+
container_name, API_KEY_SETUP,
|
|
174
|
+
user: "root",
|
|
175
|
+
env: {
|
|
176
|
+
"GDKBOX_USER" => @config.ssh_user,
|
|
177
|
+
"GDKBOX_API_KEY" => api_key,
|
|
178
|
+
"GDKBOX_API_KEY_ENV" => key_env
|
|
179
|
+
}
|
|
180
|
+
)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
data/lib/gdkbox/shell.rb
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module GDKBox
|
|
6
|
+
# Raised when a shelled-out command exits non-zero.
|
|
7
|
+
class CommandError < Error
|
|
8
|
+
attr_reader :command, :status, :stderr
|
|
9
|
+
|
|
10
|
+
def initialize(command, status, stderr)
|
|
11
|
+
@command = Array(command)
|
|
12
|
+
@status = status
|
|
13
|
+
@stderr = stderr
|
|
14
|
+
super("Command failed (exit #{status}): #{@command.join(' ')}\n#{stderr}".strip)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Thin wrapper around shelling out to external commands.
|
|
19
|
+
#
|
|
20
|
+
# All arguments are passed as an explicit argv array (never a single
|
|
21
|
+
# interpolated string) so user-supplied values cannot be interpreted by a
|
|
22
|
+
# shell. Tests inject a fake that records the commands instead of running
|
|
23
|
+
# them.
|
|
24
|
+
class Shell
|
|
25
|
+
Result = Struct.new(:stdout, :stderr, :status) do
|
|
26
|
+
def success?
|
|
27
|
+
status.zero?
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Run a command, returning a Result. Never raises on a non-zero exit.
|
|
32
|
+
def run(*args, input: nil)
|
|
33
|
+
argv = args.map(&:to_s)
|
|
34
|
+
stdout, stderr, status = Open3.capture3(*argv, stdin_data: input)
|
|
35
|
+
Result.new(stdout, stderr, status.exitstatus || 1)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Run a command, raising CommandError on a non-zero exit.
|
|
39
|
+
def run!(*args, input: nil)
|
|
40
|
+
result = run(*args, input: input)
|
|
41
|
+
raise CommandError.new(args, result.status, result.stderr) unless result.success?
|
|
42
|
+
|
|
43
|
+
result
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Run a command and return its stdout, raising on failure.
|
|
47
|
+
def capture(*args, input: nil)
|
|
48
|
+
run!(*args, input: input).stdout
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Resolve an executable on PATH, returning its path or nil.
|
|
52
|
+
def which(command)
|
|
53
|
+
result = run("sh", "-c", "command -v #{command}")
|
|
54
|
+
result.success? ? result.stdout.strip : nil
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module GDKBox
|
|
7
|
+
# Discovers and installs agent skills.
|
|
8
|
+
#
|
|
9
|
+
# A skill is a directory containing a `SKILL.md` file. gdkbox knows about
|
|
10
|
+
# skills from three roots: the ones bundled in this repo's top-level
|
|
11
|
+
# `skills/` directory, the user-wide `~/.claude/skills`, and the current
|
|
12
|
+
# project's `./.claude/skills`. They can be installed into a host Claude
|
|
13
|
+
# Code skills directory or pushed into a box for dispatched agents to use.
|
|
14
|
+
class Skills
|
|
15
|
+
# A discovered skill: where it lives and a one-line summary for listings.
|
|
16
|
+
Skill = Struct.new(:name, :path, :origin, :description)
|
|
17
|
+
|
|
18
|
+
# Where the bundled skills live inside the gem/repo: <root>/skills.
|
|
19
|
+
SOURCE_DIR = File.expand_path("../../skills", __dir__)
|
|
20
|
+
|
|
21
|
+
# User-wide Claude Code skills directory.
|
|
22
|
+
GLOBAL_DEST = File.expand_path("~/.claude/skills")
|
|
23
|
+
|
|
24
|
+
# Project-local Claude Code skills directory, relative to a working dir.
|
|
25
|
+
def self.project_dest(dir = Dir.pwd)
|
|
26
|
+
File.join(dir, ".claude", "skills")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The skill roots searched by `discover`, most specific first so that a
|
|
30
|
+
# project or user skill shadows a bundled one with the same name.
|
|
31
|
+
def self.roots(project_dir: Dir.pwd)
|
|
32
|
+
[
|
|
33
|
+
[:project, project_dest(project_dir)],
|
|
34
|
+
[:global, GLOBAL_DEST],
|
|
35
|
+
[:bundled, SOURCE_DIR]
|
|
36
|
+
]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Every skill gdkbox can see, across all roots, deduped by name (the
|
|
40
|
+
# first/most-specific root wins). Returns Skill structs sorted by name.
|
|
41
|
+
def self.discover(project_dir: Dir.pwd)
|
|
42
|
+
found = {}
|
|
43
|
+
roots(project_dir: project_dir).each do |origin, dir|
|
|
44
|
+
next unless File.directory?(dir)
|
|
45
|
+
|
|
46
|
+
Dir.children(dir).sort.each do |entry|
|
|
47
|
+
path = File.join(dir, entry)
|
|
48
|
+
next if found.key?(entry)
|
|
49
|
+
next unless File.file?(File.join(path, "SKILL.md"))
|
|
50
|
+
|
|
51
|
+
found[entry] = Skill.new(entry, path, origin, description_for(path))
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
found.values.sort_by(&:name)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Resolve a token (a discovered skill name, or a path to a skill
|
|
58
|
+
# directory) to a Skill. Raises Error when it cannot be found.
|
|
59
|
+
def self.resolve(token, project_dir: Dir.pwd)
|
|
60
|
+
maybe_path = File.expand_path(token)
|
|
61
|
+
if token.match?(%r{[/\\]}) || File.directory?(maybe_path)
|
|
62
|
+
unless File.file?(File.join(maybe_path, "SKILL.md"))
|
|
63
|
+
raise Error, "'#{token}' is not a skill directory (no SKILL.md)."
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
return Skill.new(File.basename(maybe_path), maybe_path, :path, description_for(maybe_path))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
skill = discover(project_dir: project_dir).find { |s| s.name == token }
|
|
70
|
+
unless skill
|
|
71
|
+
names = discover(project_dir: project_dir).map(&:name)
|
|
72
|
+
hint = names.empty? ? "" : " Available: #{names.join(', ')}."
|
|
73
|
+
raise Error, "No skill named '#{token}'.#{hint}"
|
|
74
|
+
end
|
|
75
|
+
skill
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Best-effort one-line summary pulled from a skill's SKILL.md frontmatter.
|
|
79
|
+
def self.description_for(path)
|
|
80
|
+
text = File.read(File.join(path, "SKILL.md"))
|
|
81
|
+
front = text[/\A---\s*\n(.*?)\n---\s*\n/m, 1]
|
|
82
|
+
return nil unless front
|
|
83
|
+
|
|
84
|
+
data = begin
|
|
85
|
+
YAML.safe_load(front)
|
|
86
|
+
rescue StandardError
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
desc = data.is_a?(Hash) ? data["description"] : nil
|
|
90
|
+
desc&.to_s&.gsub(/\s+/, " ")&.strip
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Names of the skills shipped with this repo, sorted.
|
|
94
|
+
def self.available
|
|
95
|
+
return [] unless File.directory?(SOURCE_DIR)
|
|
96
|
+
|
|
97
|
+
Dir.children(SOURCE_DIR)
|
|
98
|
+
.select { |entry| File.directory?(File.join(SOURCE_DIR, entry)) }
|
|
99
|
+
.sort
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def initialize(dest: GLOBAL_DEST)
|
|
103
|
+
@dest = dest
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
attr_reader :dest
|
|
107
|
+
|
|
108
|
+
# Copy a single named skill into the destination directory.
|
|
109
|
+
#
|
|
110
|
+
# Raises Error if the skill is unknown, or if it is already installed and
|
|
111
|
+
# `force` is false. Returns the path it was installed to.
|
|
112
|
+
def install(name, force: false)
|
|
113
|
+
source = File.join(SOURCE_DIR, name)
|
|
114
|
+
raise Error, "No skill named '#{name}' in this repo." unless File.directory?(source)
|
|
115
|
+
|
|
116
|
+
target = File.join(@dest, name)
|
|
117
|
+
if File.exist?(target) && !force
|
|
118
|
+
raise Error, "Skill '#{name}' is already installed at #{target}. Use --force to overwrite."
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
FileUtils.mkdir_p(@dest)
|
|
122
|
+
FileUtils.rm_rf(target)
|
|
123
|
+
FileUtils.cp_r(source, target)
|
|
124
|
+
target
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module GDKBox
|
|
6
|
+
# Generates an SSH config fragment describing every box, and wires it into
|
|
7
|
+
# the user's ~/.ssh/config via an Include directive.
|
|
8
|
+
#
|
|
9
|
+
# The fragment is regenerated wholesale from the current set of boxes on
|
|
10
|
+
# every change, which keeps it consistent and easy to reason about. Each box
|
|
11
|
+
# becomes a `Host gdkbox-<name>` entry that VS Code Remote-SSH and a plain
|
|
12
|
+
# `ssh gdkbox-<name>` can both use.
|
|
13
|
+
class SSHConfig
|
|
14
|
+
HEADER = "# Managed by gdkbox - generated file, do not edit by hand"
|
|
15
|
+
|
|
16
|
+
def initialize(config:)
|
|
17
|
+
@config = config
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def write(boxes)
|
|
21
|
+
@config.ensure_dirs!
|
|
22
|
+
File.write(@config.ssh_config_path, render(boxes))
|
|
23
|
+
ensure_include!
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def render(boxes)
|
|
27
|
+
lines = [HEADER, ""]
|
|
28
|
+
boxes.sort_by { |box| box["name"] }.each do |box|
|
|
29
|
+
lines << "Host #{@config.ssh_host_alias(box['name'])}"
|
|
30
|
+
lines << " HostName 127.0.0.1"
|
|
31
|
+
lines << " Port #{box['ssh_port']}"
|
|
32
|
+
lines << " User #{box['ssh_user']}"
|
|
33
|
+
lines << " IdentityFile #{@config.private_key_path}"
|
|
34
|
+
lines << " IdentitiesOnly yes"
|
|
35
|
+
lines << " StrictHostKeyChecking no"
|
|
36
|
+
lines << " UserKnownHostsFile /dev/null"
|
|
37
|
+
lines << ""
|
|
38
|
+
end
|
|
39
|
+
"#{lines.join("\n").rstrip}\n"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Prepend an `Include` directive to ~/.ssh/config if it is not present.
|
|
43
|
+
def ensure_include!
|
|
44
|
+
path = @config.user_ssh_config
|
|
45
|
+
include_line = "Include #{@config.ssh_config_path}"
|
|
46
|
+
existing = File.exist?(path) ? File.read(path) : ""
|
|
47
|
+
return if existing.include?(include_line)
|
|
48
|
+
|
|
49
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
50
|
+
File.write(path, "#{include_line}\n#{existing}")
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GDKBox
|
|
4
|
+
# Manages a dedicated ed25519 keypair used to authenticate into boxes.
|
|
5
|
+
#
|
|
6
|
+
# gdkbox keeps its own key under the gdkbox home rather than touching the
|
|
7
|
+
# user's personal keys. The public half is installed into each box; the
|
|
8
|
+
# private half is referenced from the generated SSH config.
|
|
9
|
+
class SSHKey
|
|
10
|
+
def initialize(config:, shell: Shell.new)
|
|
11
|
+
@config = config
|
|
12
|
+
@shell = shell
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Generate the keypair if it does not yet exist, returning the public key.
|
|
16
|
+
def ensure!
|
|
17
|
+
return public_key if File.exist?(@config.public_key_path)
|
|
18
|
+
|
|
19
|
+
@config.ensure_dirs!
|
|
20
|
+
unless @shell.which("ssh-keygen")
|
|
21
|
+
raise Error, "ssh-keygen not found on PATH; cannot generate an SSH key"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
@shell.run!(
|
|
25
|
+
"ssh-keygen", "-t", "ed25519",
|
|
26
|
+
"-N", "", "-C", "gdkbox",
|
|
27
|
+
"-f", @config.private_key_path
|
|
28
|
+
)
|
|
29
|
+
public_key
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def public_key
|
|
33
|
+
File.read(@config.public_key_path).strip
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|