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.
@@ -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
@@ -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