llm-experiment 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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +40 -0
  3. data/CODE_OF_CONDUCT.md +74 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +190 -0
  6. data/exe/llmx +6 -0
  7. data/lib/llm-experiment.rb +3 -0
  8. data/lib/llm_experiment/auth.rb +143 -0
  9. data/lib/llm_experiment/cleaner.rb +183 -0
  10. data/lib/llm_experiment/cli/build_command.rb +37 -0
  11. data/lib/llm_experiment/cli/clean_command.rb +42 -0
  12. data/lib/llm_experiment/cli/doctor_command.rb +30 -0
  13. data/lib/llm_experiment/cli/login_command.rb +25 -0
  14. data/lib/llm_experiment/cli/metrics_command.rb +32 -0
  15. data/lib/llm_experiment/cli/new_command.rb +21 -0
  16. data/lib/llm_experiment/cli/parse_command.rb +77 -0
  17. data/lib/llm_experiment/cli/run_command.rb +74 -0
  18. data/lib/llm_experiment/cli/sanitize_command.rb +34 -0
  19. data/lib/llm_experiment/cli/shell_command.rb +35 -0
  20. data/lib/llm_experiment/cli/status_command.rb +69 -0
  21. data/lib/llm_experiment/cli/version_command.rb +19 -0
  22. data/lib/llm_experiment/cli.rb +120 -0
  23. data/lib/llm_experiment/container.rb +178 -0
  24. data/lib/llm_experiment/doctor.rb +126 -0
  25. data/lib/llm_experiment/experiment.rb +163 -0
  26. data/lib/llm_experiment/grid.rb +117 -0
  27. data/lib/llm_experiment/image_builder/app.rb +267 -0
  28. data/lib/llm_experiment/image_builder/base.rb +64 -0
  29. data/lib/llm_experiment/metrics_report.rb +138 -0
  30. data/lib/llm_experiment/pins.rb +22 -0
  31. data/lib/llm_experiment/sanitizer.rb +144 -0
  32. data/lib/llm_experiment/scaffold.rb +43 -0
  33. data/lib/llm_experiment/shell.rb +60 -0
  34. data/lib/llm_experiment/stats.rb +69 -0
  35. data/lib/llm_experiment/transcript/claude.rb +104 -0
  36. data/lib/llm_experiment/transcript/codex.rb +96 -0
  37. data/lib/llm_experiment/transcript/hermeticity.rb +35 -0
  38. data/lib/llm_experiment/transcript/parser.rb +105 -0
  39. data/lib/llm_experiment/transcript.rb +27 -0
  40. data/lib/llm_experiment/trial.rb +204 -0
  41. data/lib/llm_experiment/version.rb +5 -0
  42. data/lib/llm_experiment.rb +70 -0
  43. data/templates/README.md.erb +26 -0
  44. data/templates/base.Containerfile +120 -0
  45. data/templates/experiment.yml.erb +30 -0
  46. data/templates/gitignore +3 -0
  47. data/templates/runner.rb +305 -0
  48. metadata +92 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6bb9441528decd9413786b8a2da04d71bafe0b515183d26fbf90e0c36d5e7cb5
4
+ data.tar.gz: 7bf9c1e2ad2be746c967bf6ebd010c034d9707379f4d1a2fc2a9ae33e462e416
5
+ SHA512:
6
+ metadata.gz: 85312ed37cbbe34223c90ced1b91805fdf7e80bdec0e252ac1a7962dd4a46b1ef3efd18ac8bfe01796d8bde3943bdf4afd7c95309a137d84388a6650ae262c43
7
+ data.tar.gz: '039e17b7b29ec5728c8f2c0b6f07ab7de956cbfbc06b0aed6a4a9380e50f113b017d6c28b228d9b8402598393ac69d24a164e93127367bfd7d055b220fafb798'
data/CHANGELOG.md ADDED
@@ -0,0 +1,40 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2026-08-30
9
+
10
+ ### Added
11
+
12
+ - `llmx new` — scaffold an experiment directory.
13
+ - `llmx doctor` — preflight the `container` CLI, apiserver, builder envelope, disk, auth store, images, and prompts.
14
+ - `llmx build base` — build and smoke-check the base image from pinned toolchains.
15
+ - `llmx build app KEY` — build a per-app image from a git bundle, with history flattened.
16
+ - `llmx login` — interactive plan-subscription login into the host auth store.
17
+ - `llmx run` — one trial, or the whole grid with `--all`.
18
+ - `llmx status` — grid completion per task, agent, and condition.
19
+ - `llmx shell [APP]` — interactive shell in a trial-shaped container.
20
+ - `llmx parse` — transcripts to `events.jsonl` and `metrics.json`, with hermeticity warnings.
21
+ - `llmx metrics` — medians and exact Mann-Whitney U, never pooled across agent or app.
22
+ - `llmx sanitize` — the gate from `results-raw/` to the committed `results/`.
23
+ - `llmx clean` — disk reclamation verified by observation, with the kill-the-VM recovery documented in its output.
24
+ - `llmx version` — gem version and default toolchain pins.
25
+
26
+ ### Notable properties
27
+
28
+ - Zero runtime dependencies. Stdlib only.
29
+ - Every `container` state change is verified by observation, never by exit code.
30
+ - Image builds refuse to start without room: 10 GB for the base, `LLMX_MIN_FREE_GB` (25 by default) for an app. Measured against a real Rails build that consumed about 24 GB, most of it the builder VM.
31
+ - The builder is provisioned with 6 CPUs and 8 GB, because a starved builder dies mid-build with no error. An existing builder is reused only when it meets both dimensions; CPUs alone accepted a builder with 6 CPUs and 2 GB.
32
+ - Credentials never enter an image. Each trial copies them out of the mount and repoints the CLI at the copy.
33
+ - Prompts cross into a container base64-encoded, never through shell interpolation.
34
+ - App images carry only orphan `trial/*` branches; the build fails if any other commit message survives.
35
+ - A trial aborts (exit 3) unless the suite ran and failed before the agent started.
36
+ - Trials run sequentially, blocked by task, condition order rotated, resumable from `meta.json`.
37
+ - Every trial records requested model, reported model, CLI versions, and gem version.
38
+ - `llmx sanitize` refuses rather than redacts, and publishes raw transcripts only for apps marked `publish_transcripts: true`.
39
+
40
+ [0.1.0]: https://github.com/lucianghinda/llm-experiment/releases/tag/v0.1.0
@@ -0,0 +1,74 @@
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ In the interest of fostering an open and welcoming environment, we as
6
+ contributors and maintainers pledge to making participation in our project and
7
+ our community a harassment-free experience for everyone, regardless of age, body
8
+ size, disability, ethnicity, gender identity and expression, level of experience,
9
+ nationality, personal appearance, race, religion, or sexual identity and
10
+ orientation.
11
+
12
+ ## Our Standards
13
+
14
+ Examples of behavior that contributes to creating a positive environment
15
+ include:
16
+
17
+ * Using welcoming and inclusive language
18
+ * Being respectful of differing viewpoints and experiences
19
+ * Gracefully accepting constructive criticism
20
+ * Focusing on what is best for the community
21
+ * Showing empathy towards other community members
22
+
23
+ Examples of unacceptable behavior by participants include:
24
+
25
+ * The use of sexualized language or imagery and unwelcome sexual attention or
26
+ advances
27
+ * Trolling, insulting/derogatory comments, and personal or political attacks
28
+ * Public or private harassment
29
+ * Publishing others' private information, such as a physical or electronic
30
+ address, without explicit permission
31
+ * Other conduct which could reasonably be considered inappropriate in a
32
+ professional setting
33
+
34
+ ## Our Responsibilities
35
+
36
+ Project maintainers are responsible for clarifying the standards of acceptable
37
+ behavior and are expected to take appropriate and fair corrective action in
38
+ response to any instances of unacceptable behavior.
39
+
40
+ Project maintainers have the right and responsibility to remove, edit, or
41
+ reject comments, commits, code, wiki edits, issues, and other contributions
42
+ that are not aligned to this Code of Conduct, or to ban temporarily or
43
+ permanently any contributor for other behaviors that they deem inappropriate,
44
+ threatening, offensive, or harmful.
45
+
46
+ ## Scope
47
+
48
+ This Code of Conduct applies both within project spaces and in public spaces
49
+ when an individual is representing the project or its community. Examples of
50
+ representing a project or community include using an official project e-mail
51
+ address, posting via an official social media account, or acting as an appointed
52
+ representative at an online or offline event. Representation of a project may be
53
+ further defined and clarified by project maintainers.
54
+
55
+ ## Enforcement
56
+
57
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be
58
+ reported by contacting the project team at dev@ghinda.com. All
59
+ complaints will be reviewed and investigated and will result in a response that
60
+ is deemed necessary and appropriate to the circumstances. The project team is
61
+ obligated to maintain confidentiality with regard to the reporter of an incident.
62
+ Further details of specific enforcement policies may be posted separately.
63
+
64
+ Project maintainers who do not follow or enforce the Code of Conduct in good
65
+ faith may face temporary or permanent repercussions as determined by other
66
+ members of the project's leadership.
67
+
68
+ ## Attribution
69
+
70
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71
+ available at [http://contributor-covenant.org/version/1/4][version]
72
+
73
+ [homepage]: http://contributor-covenant.org
74
+ [version]: http://contributor-covenant.org/version/1/4/
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Lucian Ghinda
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,190 @@
1
+ # llm-experiment
2
+
3
+ Run controlled, reproducible experiments on coding agents
4
+
5
+ Each trial runs in a fresh container with no host state. The host only orchestrates.
6
+
7
+ Supports Claude Code and Codex CLI.
8
+
9
+ ## How it works
10
+
11
+ `llmx` runs repeatable coding-agent experiments inside disposable containers. Each trial starts from the same code and task. It records the agent's work, reruns the tests, and collects comparable results.
12
+
13
+ 1. Define tasks, agents, and conditions in `experiment.yml`.
14
+ 2. Build pinned container images for reproducibility.
15
+ 3. Generate the task × agent × condition trial matrix.
16
+ 4. Run every trial in a clean, isolated container.
17
+ 5. Capture transcripts, diffs, test results, and metrics.
18
+ 6. Compare conditions and export sanitized, publishable results.
19
+
20
+ ## Requirements
21
+
22
+ - macOS on Apple silicon, using Apple's container runtime
23
+ - The `container` CLI installed and available on your `PATH` (verified with 0.5.0)
24
+ - Ruby 3.4+
25
+
26
+ The gem has no runtime dependencies.
27
+
28
+ ## Installation
29
+
30
+ Run:
31
+
32
+ ```sh
33
+ gem install llm-experiment
34
+ ```
35
+
36
+ This installs the `llmx` command.
37
+
38
+ ## Quick start
39
+
40
+ ```sh
41
+ llmx new my-question
42
+ cd my-question
43
+ $EDITOR experiment.yml
44
+ export LLMX_APP_CAMPFIRE=/path/to/checkout
45
+ llmx doctor
46
+ llmx build base
47
+ llmx login
48
+ llmx build app campfire
49
+ llmx run --all --dry-run
50
+ llmx run --all
51
+ llmx parse --all
52
+ llmx metrics
53
+ llmx sanitize
54
+ ```
55
+
56
+ Write your prompts to `prompts/<app>/<task-id>/<condition>.txt` before running.
57
+
58
+ The gem runs trials. It does not write your prompts or plant your tasks.
59
+
60
+ ## Commands
61
+
62
+ Every command takes `--help`, `--experiment DIR`, and `--verbose`. Use `--verbose` to echo each container command; it is off by default because a grid runs hundreds of them.
63
+
64
+ Command | Description
65
+ --- | ---
66
+ `llmx new NAME` | Scaffold an experiment directory
67
+ `llmx doctor` | Check CLI, services, disk, auth, images, and prompts
68
+ `llmx build base` | Build and smoke-check the base image
69
+ `llmx build app KEY` | Build a per-app image from a git bundle
70
+ `llmx login` | Log the agent CLIs in to your plan subscriptions
71
+ `llmx run` | Run one trial, or the whole grid with `--all`
72
+ `llmx status` | Show which grid cells have results
73
+ `llmx shell [APP]` | Open a shell in a trial-shaped container
74
+ `llmx parse` | Turn transcripts into `events.jsonl` and `metrics.json`
75
+ `llmx metrics` | Report medians and exact Mann-Whitney U
76
+ `llmx sanitize` | Gate `results-raw/` into the committed `results/`
77
+ `llmx clean` | Reclaim disk, verified by observation
78
+ `llmx version` | Show the gem version and the default pins
79
+
80
+ Destructive commands take `--dry-run`. The grid resumes by default, so pass `--redo` to re-run a finished cell.
81
+
82
+ ## experiment.yml
83
+
84
+ One file configures the experiment.
85
+
86
+ ```yaml
87
+ name: at-file-mentions
88
+ question: "Does @path change what an agent does and what it costs?"
89
+
90
+ # Optional. Defaults ship with the gem; `llmx version` prints them.
91
+ pins:
92
+ claude_code: "2.1.233"
93
+ codex: "0.147.0"
94
+ opencode: "1.18.15"
95
+ ruby_versions: ["3.4.5", "4.0.1"]
96
+ node: "22"
97
+
98
+ # Trial agents. Only claude and codex are supported.
99
+ agents:
100
+ claude:
101
+ model: claude-opus-5
102
+ codex:
103
+ model: gpt-5.6-sol
104
+
105
+ # One prompt variant per condition.
106
+ conditions: [none, bare_noline, at_noline]
107
+
108
+ trial:
109
+ timeout_seconds: 900
110
+ memory: 6g # a unit suffix is required
111
+ cpus: 4
112
+
113
+ apps:
114
+ campfire:
115
+ ruby: "3.4.5"
116
+ bundler: "4.0.13" # or "default"
117
+ database: sqlite3 # sqlite3 or postgresql
118
+ test_command: bin/rails test
119
+ db_prepare: bin/rails db:test:prepare
120
+ suite_ran_pattern: '\d+ runs?, \d+ assertions?' # default shown
121
+ publish_transcripts: true # false for private code
122
+ branch_prefix: exp/path-hints # source branches on the host
123
+ neutralize: [CLAUDE.md, AGENTS.md, .mcp.json, .claude]
124
+
125
+ tasks:
126
+ - id: campfire-01
127
+ app: campfire
128
+ test_file: test/lib/restricted_http/private_network_guard_test.rb
129
+ impl_files: [lib/restricted_http/private_network_guard.rb]
130
+ ```
131
+
132
+ Unknown keys are errors. A typo that silently does nothing is how an experiment measures the wrong thing.
133
+
134
+ `postgresql` is available in the config format, but that full in-container path has not yet been validated end to end against a real app. Prefer `sqlite3` unless you plan to verify the PostgreSQL path yourself.
135
+
136
+ Host checkout paths never go in this file. They come from `LLMX_APP_<KEY>`.
137
+
138
+ Set one per app:
139
+
140
+ ```sh
141
+ export LLMX_APP_CAMPFIRE=/path/to/campfire
142
+ ```
143
+
144
+ The image builder expects two branch names per app: `<branch_prefix>/base` and `<branch_prefix>/<task-id>`.
145
+
146
+ ## How a trial stays clean
147
+
148
+ A trial measures the agent, not the machine it ran on.
149
+
150
+ - Every trial gets a fresh container. Nothing survives it.
151
+ - No host state crosses: no user `CLAUDE.md`, no MCP servers, no hooks, no project memory.
152
+ - Credentials live in `~/.llmx/auth` and are mounted, never baked into an image.
153
+ - The mount is a seed, not a home. Each trial copies the credential files and repoints `CLAUDE_CONFIG_DIR` or `CODEX_HOME` at the copy.
154
+ - Codex also runs with `--ignore-user-config --ephemeral`.
155
+ - Application code enters as a git bundle, so untracked secrets and local bundler overrides stay behind.
156
+ - History flattens to one anonymous orphan commit per branch, named "Import application source". The build fails if any other message survives.
157
+ - Coaching files listed under `neutralize` are deleted during the image build.
158
+ - Prompts cross as base64 in `LLMX_PROMPT_B64`, never as shell text.
159
+ - A trial aborts with exit 3 unless the suite ran and failed before the agent started.
160
+ - `llmx parse` exits non-zero on any trial that reports an MCP server, or a memory path inside the shared mount. It checks where a path points, not whether one exists. Pass `--lenient` to report and continue.
161
+
162
+ ## Disk
163
+
164
+ Budget about 10 GB for the base image and 4-8 GB per app image. The BuildKit cache reached 9.8 GB building one Rails app.
165
+
166
+ Plan against the unpacked snapshot, not the sizes `container image inspect` reports. Deleting three app images returned about 16 GB against manifests summing to 1.2-1.6 GB each.
167
+
168
+ Run `llmx clean` for a report that measures the snapshot:
169
+
170
+ ```sh
171
+ llmx clean # report only, deletes nothing
172
+ llmx clean --images # every llmx-app-* image, about 5 GB each
173
+ llmx clean --builder # the BuildKit cache, which is regenerable
174
+ ```
175
+
176
+ Both `llmx build base` and `llmx build app` refuse to start without room: 10 GB for the base, `LLMX_MIN_FREE_GB` (25 by default) for an app. Running out mid-build does not fail cleanly. The 25 is measured, not guessed — a Rails app image took about 24 GB, roughly 17 GB of it the BuildKit builder VM rather than the image.
177
+
178
+ `llmx clean --builder` verifies with `container ls -a` afterwards. `container builder stop` can exit 0 and do nothing, so the exit code is never the evidence.
179
+
180
+ ## Private code
181
+
182
+ Images built from private repositories are never pushed to a registry.
183
+
184
+ `llmx sanitize` is the gate between `results-raw/` and the committed `results/`. It refuses the whole publish on a host path or anything shaped like a secret. It refuses rather than redacts, because a surprise in a transcript should stop the commit.
185
+
186
+ Set `publish_transcripts: false` for a private app. Only derived measurements cross; raw transcripts and diffs stay in `results-raw/`, which is gitignored.
187
+
188
+ ## License
189
+
190
+ MIT
data/exe/llmx ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/llm-experiment"
5
+
6
+ exit LLMExperiment::CLI.run(ARGV)
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "llm_experiment"
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLMExperiment
4
+ # One-time interactive step: log the agent CLIs in to the user's plan
5
+ # subscriptions, and keep the credentials in a directory that every later
6
+ # trial mounts read-write.
7
+ #
8
+ # Nothing is baked into an image and no API key is required. Re-run this when
9
+ # a refresh token expires; `llmx run` stops with that instruction rather than
10
+ # burning trials against a dead login.
11
+ #
12
+ # Claude and Codex are pointed at the credential store through
13
+ # CLAUDE_CONFIG_DIR and CODEX_HOME, so their logins land there directly.
14
+ # opencode takes no such variable: it keeps credentials in XDG_DATA_HOME next
15
+ # to its sessions and its database, so logging in writes a whole state
16
+ # directory. This points XDG_DATA_HOME at a container-local directory that
17
+ # dies with the container and copies out the one credential file afterwards,
18
+ # which gets opencode the same shape as the other two without carrying its
19
+ # session store along.
20
+ class Auth
21
+ AGENTS = %w[claude codex opencode].freeze
22
+
23
+ def initialize(container: nil, shell: Shell)
24
+ @container = container || Container.new
25
+ @shell = shell
26
+ end
27
+
28
+ def login(agents: AGENTS)
29
+ wanted = wanted_agents(agents)
30
+ image = ready_image!
31
+
32
+ print_intro(wanted)
33
+ # Interactive: hand the terminal to the container so the device flows work.
34
+ @shell.interactive(*run_argv(image, login_script, interactive: true))
35
+
36
+ puts "\nVerifying what persisted..."
37
+ @shell.sh(*run_argv(image, verify_script), allow_failure: true)
38
+ print_persisted
39
+ end
40
+
41
+ def verify
42
+ @shell.sh(*run_argv(ready_image!, verify_script))
43
+ end
44
+
45
+ private
46
+
47
+ def wanted_agents(agents)
48
+ wanted = Array(agents).map(&:to_s)
49
+ wanted = AGENTS.dup if wanted.empty?
50
+ unknown = wanted - AGENTS
51
+ raise Error, "unknown agent: #{unknown.join(", ")} (supported: #{AGENTS.join(", ")})" if unknown.any?
52
+
53
+ wanted
54
+ end
55
+
56
+ def ready_image!
57
+ @container.ensure_system!
58
+ image = LLMExperiment.base_image
59
+ raise Error, "base image #{image} not found; run `llmx build base` first" unless @container.image?(image)
60
+
61
+ image
62
+ end
63
+
64
+ def run_argv(image, script, interactive: false)
65
+ @container.run_argv(image, script,
66
+ memory: LLMExperiment.default_memory,
67
+ cpus: LLMExperiment.default_cpus,
68
+ interactive: interactive)
69
+ end
70
+
71
+ # opencode's credentials live in XDG_DATA_HOME with its sessions and
72
+ # database, so the login runs against a directory inside the container and
73
+ # only auth.json is copied back out to the mounted store.
74
+ def opencode_login_home = "/home/#{LLMExperiment.agent_user}/.opencode-login"
75
+ def opencode_auth_out = "/home/#{LLMExperiment.agent_user}/.agent-auth/opencode/auth.json"
76
+
77
+ def verify_script
78
+ <<~SH
79
+ echo "--- claude ---"
80
+ claude auth status 2>&1 | head -5 || echo "claude: no status subcommand on this build"
81
+ echo "--- codex ---"
82
+ codex login status 2>&1 | head -5 || echo "codex: not logged in"
83
+ echo "--- opencode ---"
84
+ if [ -s "#{opencode_auth_out}" ]; then
85
+ echo "opencode: auth.json present ($(wc -c < "#{opencode_auth_out}") bytes)"
86
+ else
87
+ echo "opencode: no credential file in the store"
88
+ fi
89
+ SH
90
+ end
91
+
92
+ # `exec bash` would replace this shell and the copy-out would never run, so
93
+ # the shell is called and the file is moved after it returns.
94
+ def login_script
95
+ <<~SH
96
+ export XDG_DATA_HOME=#{opencode_login_home}
97
+ mkdir -p "$XDG_DATA_HOME"
98
+ echo "CLAUDE_CONFIG_DIR=$CLAUDE_CONFIG_DIR"
99
+ echo "CODEX_HOME=$CODEX_HOME"
100
+ echo "XDG_DATA_HOME=$XDG_DATA_HOME (opencode; dies with this container)"
101
+ echo
102
+ bash
103
+ if [ -s "$XDG_DATA_HOME/opencode/auth.json" ]; then
104
+ mkdir -p "$(dirname #{opencode_auth_out})"
105
+ cp "$XDG_DATA_HOME/opencode/auth.json" "#{opencode_auth_out}"
106
+ chmod 600 "#{opencode_auth_out}"
107
+ echo "copied opencode auth.json into the credential store"
108
+ fi
109
+ SH
110
+ end
111
+
112
+ def print_intro(wanted)
113
+ puts <<~INTRO
114
+
115
+ Logging the agent CLIs in to your plan subscriptions.
116
+
117
+ A container will open with a shell. Run the login command(s) shown below and
118
+ complete the browser device flow, then type `exit`.
119
+
120
+ Credentials are written to #{LLMExperiment.auth_dir} on this Mac and mounted into every
121
+ trial. They never enter an image and are never committed.
122
+
123
+ INTRO
124
+
125
+ steps = []
126
+ if wanted.include?("claude")
127
+ steps << "claude -> run: claude (then /login, complete the browser flow, then /exit)"
128
+ end
129
+ steps << "codex -> run: codex login" if wanted.include?("codex")
130
+ steps << "opencode -> run: opencode auth login" if wanted.include?("opencode")
131
+ puts steps.map { |s| " #{s}" }.join("\n")
132
+ puts "\n When you are done, type: exit\n\n"
133
+ end
134
+
135
+ def print_persisted
136
+ counts = AGENTS.map do |name|
137
+ "#{name}/ has #{Dir.glob(File.join(LLMExperiment.auth_dir, name, "**", "*")).size} file(s)"
138
+ end
139
+ puts "\n#{LLMExperiment.auth_dir}: #{counts.join(", ")}"
140
+ puts "If one is empty, that login did not persist - re-run `llmx login --agent NAME` for it."
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLMExperiment
4
+ # Disk reclamation, planned and verified by observation.
5
+ #
6
+ # Two measured facts shape this class. First, what an image costs is its
7
+ # unpacked snapshot, not the layer sizes `container image inspect` reports:
8
+ # deleting three app images returned about 16 GB against the 1.2-1.6 GB the
9
+ # manifests sum to. So the plan comes from `du` on the data directory.
10
+ #
11
+ # Second, `container builder stop` can report success and do nothing. On
12
+ # 0.5.0, with a builder up for three days, `builder stop`, `delete --force
13
+ # buildkit` and `system stop` all exited 0 while `container ls -a` went on
14
+ # reporting the builder as running and its 6.8 GB directory stayed put. So
15
+ # the builder is checked afterwards, and an exit code is never the evidence.
16
+ class Cleaner
17
+ UNITS = { "B" => 1, "K" => 1024, "M" => 1024**2, "G" => 1024**3, "T" => 1024**4 }.freeze
18
+
19
+ APP_PREFIX = "llmx-app-"
20
+ BASE_PREFIX = "llmx-base"
21
+
22
+ # Roughly what one app image costs unpacked, measured on 0.5.0.
23
+ APP_IMAGE_GB = 5
24
+
25
+ SNAPSHOT_NOTE = <<~NOTE
26
+ Sizes above are the unpacked snapshot, which is what an image actually
27
+ costs. `container image inspect` sums layers instead and reports about a
28
+ third of it: three app images that summed to 1.2-1.6 GB each returned
29
+ about 16 GB when deleted. Plan against this listing, not the manifest.
30
+ NOTE
31
+
32
+ # Printed instead of run. The runtime line is unambiguous because it names
33
+ # `--uuid buildkit`, but the VM line is a bare Virtualization.framework XPC
34
+ # service that looks identical for any VM on the Mac, so a pattern-kill on
35
+ # the name can take out Docker, UTM, Parallels or a Simulator.
36
+ RECOVERY = <<~TEXT
37
+ The builder is still running. Its exit codes lied; end the VM directly:
38
+
39
+ ps -eo pid,etime,command | grep -E 'container-runtime-linux|Virtualization' | grep -v grep
40
+ kill -TERM <runtime-pid> <vm-pid>
41
+ container delete --force buildkit
42
+
43
+ Read that `ps` output before killing anything. The runtime line names
44
+ `--uuid buildkit`, so it is unambiguous. The VM line is a bare
45
+ `Virtualization.framework` XPC service and looks identical for ANY VM on
46
+ this Mac (Docker, UTM, Parallels, a Simulator). Match it to the runtime
47
+ process; never pattern-kill on the name.
48
+
49
+ A plain TERM was enough when this happened; -9 was not needed. The
50
+ apiserver does not need to be touched. The builder is recreated on the
51
+ next build, which re-downloads the builder image once.
52
+ TEXT
53
+
54
+ def initialize(container:, shell: Shell)
55
+ @container = container
56
+ @shell = shell
57
+ end
58
+
59
+ # Never deletes anything. Shows what is on disk and what it would cost to
60
+ # get back.
61
+ def report
62
+ puts "container data: #{data_dir}"
63
+ print_disk
64
+ puts
65
+ print_images
66
+ puts
67
+ puts SNAPSHOT_NOTE
68
+ puts " llmx clean --builder the BuildKit cache; regenerable, often the largest item"
69
+ puts " llmx clean --images every #{APP_PREFIX}* image, about #{APP_IMAGE_GB} GB each unpacked"
70
+ puts " add --dry-run to either to see the list without deleting"
71
+ end
72
+
73
+ # App images only. The base image takes 20-40 minutes to rebuild, so it
74
+ # goes only when asked for by name.
75
+ def clean_images(dry_run:, base: false)
76
+ targets = app_images
77
+ targets += base_images if base
78
+ if targets.empty?
79
+ puts "no llmx images to delete"
80
+ return
81
+ end
82
+
83
+ if dry_run
84
+ targets.each { |image| puts "would delete #{image} (about #{APP_IMAGE_GB} GB unpacked)" }
85
+ puts "nothing deleted (--dry-run)"
86
+ return
87
+ end
88
+
89
+ before = @container.free_gb
90
+ targets.each do |image|
91
+ puts "deleting #{image}"
92
+ @container.delete_image(image)
93
+ end
94
+ after = @container.free_gb
95
+ puts "free space: #{before} GB -> #{after} GB"
96
+ end
97
+
98
+ # Stops the builder, then checks whether it actually stopped. The exit
99
+ # codes of the two commands are ignored on purpose.
100
+ def clean_builder(dry_run:)
101
+ if dry_run
102
+ puts "would run: container builder stop"
103
+ puts "would run: container delete --force buildkit"
104
+ puts "then verify with `container ls -a`, because those two can exit 0 and do nothing"
105
+ return
106
+ end
107
+
108
+ @shell.sh("container", "builder", "stop", allow_failure: true)
109
+ @shell.sh("container", "delete", "--force", "buildkit", allow_failure: true)
110
+ verify_builder_gone!
111
+ end
112
+
113
+ private
114
+
115
+ def data_dir = LLMExperiment.container_data_dir
116
+
117
+ def verify_builder_gone!
118
+ if builder_running?
119
+ puts
120
+ puts RECOVERY
121
+ raise Error, "builder still reported as running by `container ls -a` after stop and delete"
122
+ end
123
+
124
+ puts "builder gone (verified with `container ls -a`)"
125
+ puts "images are untouched; the builder is recreated on the next build"
126
+ end
127
+
128
+ def builder_running?
129
+ @container.containers_all.lines.any? { |line| line.match?(/^buildkit\b.*\brunning\b/) }
130
+ end
131
+
132
+ def app_images
133
+ @container.images.select { |image| image.start_with?(APP_PREFIX) }
134
+ end
135
+
136
+ def base_images
137
+ @container.images.select { |image| image.start_with?(BASE_PREFIX) }
138
+ end
139
+
140
+ def print_images
141
+ apps = app_images
142
+ puts "app images (#{apps.size}):"
143
+ if apps.empty?
144
+ puts " none"
145
+ else
146
+ apps.each { |image| puts " #{image}" }
147
+ end
148
+ base = base_images
149
+ puts "base images (kept unless you pass --base):"
150
+ puts(base.empty? ? " none" : base.map { |image| " #{image}" })
151
+ end
152
+
153
+ def print_disk
154
+ entries = disk_entries
155
+ if entries.empty?
156
+ puts " nothing on disk, or the CLI keeps its data somewhere else"
157
+ puts " (set LLMX_CONTAINER_DATA_DIR if this path is wrong)"
158
+ return
159
+ end
160
+
161
+ entries.each { |size, path| puts format(" %-8s %s", size, File.basename(path)) }
162
+ end
163
+
164
+ # `du -sh` on each entry, largest first. Human sizes are what a person
165
+ # compares against a disk, so they are parsed only to sort them.
166
+ def disk_entries
167
+ return [] unless Dir.exist?(data_dir)
168
+
169
+ paths = Dir.children(data_dir).sort.map { |child| File.join(data_dir, child) }
170
+ return [] if paths.empty?
171
+
172
+ rows = @shell.capture("du", "-sh", *paths, allow_failure: true).lines.filter_map do |line|
173
+ size, path = line.chomp.split("\t", 2)
174
+ [size, path] if path
175
+ end
176
+ rows.sort_by { |size, _path| -bytes(size) }
177
+ end
178
+
179
+ def bytes(size)
180
+ size.to_f * UNITS.fetch(size[-1].to_s.upcase, 1)
181
+ end
182
+ end
183
+ end