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
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLMExperiment
4
+ class CLI
5
+ class BuildCommand < Command
6
+ NAME = "build"
7
+ SUMMARY = "build the base image or a per-app image"
8
+
9
+ def add_options(parser)
10
+ parser.banner = "Usage: llmx build base [options]\n llmx build app KEY [options]"
11
+ parser.on("--no-cache", "rebuild every layer") { @no_cache = true }
12
+ parser.on("--tag TAG", "image tag (base only)") { |t| @tag = t }
13
+ end
14
+
15
+ def run(argv)
16
+ case argv.shift
17
+ when "base"
18
+ pins = begin
19
+ experiment.pins
20
+ rescue ConfigError
21
+ Pins.resolve
22
+ end
23
+ ImageBuilder::Base.new(container: container)
24
+ .build(pins: pins, tag: @tag || LLMExperiment.base_image, no_cache: !!@no_cache)
25
+ when "app"
26
+ key = argv.shift or raise Error, "usage: llmx build app KEY"
27
+ ImageBuilder::App.new(experiment: experiment, container: container)
28
+ .build(app: experiment.app(key), no_cache: !!@no_cache)
29
+ else
30
+ raise Error, "usage: llmx build base | llmx build app KEY"
31
+ end
32
+ end
33
+ end
34
+
35
+ register BuildCommand::NAME, BuildCommand
36
+ end
37
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLMExperiment
4
+ class CLI
5
+ class CleanCommand < Command
6
+ NAME = "clean"
7
+ SUMMARY = "reclaim disk: app images and the BuildKit cache, verified by observation"
8
+
9
+ def add_options(parser)
10
+ parser.banner = <<~BANNER
11
+ Usage: llmx clean [--images] [--builder] [--base] [--dry-run]
12
+
13
+ With no flags this reports and deletes nothing.
14
+
15
+ The report measures the unpacked snapshot on disk, because that is what
16
+ an image costs: `container image inspect` sums layers and reports about
17
+ a third of it.
18
+
19
+ `--builder` verifies afterwards with `container ls -a` and prints the
20
+ kill-the-VM recovery if the builder is still there, because `container
21
+ builder stop` can exit 0 and do nothing.
22
+
23
+ BANNER
24
+ parser.on("--images", "delete every llmx-app-* image") { @images = true }
25
+ parser.on("--base", "also delete llmx-base (implies --images; 20-40 min to rebuild)") { @base = true }
26
+ parser.on("--builder", "stop the BuildKit builder and delete its cache") { @builder = true }
27
+ parser.on("--dry-run", "print what would go, delete nothing") { @dry_run = true }
28
+ end
29
+
30
+ def run(_argv)
31
+ cleaner = Cleaner.new(container: container)
32
+ images = @images || @base
33
+ return cleaner.report unless images || @builder
34
+
35
+ cleaner.clean_images(dry_run: !@dry_run.nil?, base: !@base.nil?) if images
36
+ cleaner.clean_builder(dry_run: !!@dry_run) if @builder
37
+ end
38
+ end
39
+
40
+ register CleanCommand::NAME, CleanCommand
41
+ end
42
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLMExperiment
4
+ class CLI
5
+ class DoctorCommand < Command
6
+ NAME = "doctor"
7
+ SUMMARY = "preflight checks: CLI, services, disk, auth, images, prompts"
8
+
9
+ MARKS = { ok: "ok", warn: "warn", fail: "FAIL" }.freeze
10
+
11
+ def run(_argv)
12
+ exp = begin
13
+ experiment
14
+ rescue ConfigError
15
+ nil
16
+ end
17
+ checks = Doctor.new(container: container).checks(exp)
18
+ checks.each do |check|
19
+ line = format(" %-5s %-22s %s", MARKS[check.status], check.name, check.detail)
20
+ line += " -> #{check.hint}" if check.hint && check.status != :ok
21
+ puts line
22
+ end
23
+ puts exp ? "\nexperiment: #{exp.name} (#{exp.root})" : "\n(no experiment.yml found here; machine checks only)"
24
+ raise Error, "doctor found failures" if checks.any? { |c| c.status == :fail }
25
+ end
26
+ end
27
+
28
+ register DoctorCommand::NAME, DoctorCommand
29
+ end
30
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLMExperiment
4
+ class CLI
5
+ class LoginCommand < Command
6
+ NAME = "login"
7
+ SUMMARY = "log the agent CLIs in to your plan subscriptions"
8
+
9
+ def add_options(parser)
10
+ parser.banner = "Usage: llmx login [--agent NAME] [--verify]"
11
+ parser.on("--agent NAME", "log in one agent only (#{Auth::AGENTS.join(", ")})") { |a| @agent = a }
12
+ parser.on("--verify", "report what is logged in, log nothing in") { @verify = true }
13
+ end
14
+
15
+ def run(_argv)
16
+ auth = Auth.new(container: container)
17
+ return auth.verify if @verify
18
+
19
+ auth.login(agents: @agent ? [@agent] : Auth::AGENTS)
20
+ end
21
+ end
22
+
23
+ register LoginCommand::NAME, LoginCommand
24
+ end
25
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLMExperiment
4
+ class CLI
5
+ class MetricsCommand < Command
6
+ NAME = "metrics"
7
+ SUMMARY = "medians and exact Mann-Whitney U, never pooled"
8
+
9
+ def add_options(parser)
10
+ parser.banner = <<~BANNER
11
+ Usage: llmx metrics [options]
12
+
13
+ Reads every metrics.json written by `llmx parse` and reports one cell
14
+ per app and agent. Nothing is pooled across those: two agents do not
15
+ count tool calls the same way, and two apps are different sizes of
16
+ haystack, so a number averaged over them measures nothing.
17
+
18
+ p-values are exact. With a handful of tasks per cell the normal
19
+ approximation to the Mann-Whitney U is not trustworthy, so every
20
+ split of the pooled values is enumerated instead.
21
+
22
+ BANNER
23
+ end
24
+
25
+ def run(_argv)
26
+ MetricsReport.new(experiment: experiment).render
27
+ end
28
+ end
29
+
30
+ register MetricsCommand::NAME, MetricsCommand
31
+ end
32
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLMExperiment
4
+ class CLI
5
+ class NewCommand < Command
6
+ NAME = "new"
7
+ SUMMARY = "scaffold a new experiment directory"
8
+
9
+ def add_options(parser)
10
+ parser.banner = "Usage: llmx new NAME"
11
+ end
12
+
13
+ def run(argv)
14
+ target = argv.shift or raise Error, "usage: llmx new NAME"
15
+ Scaffold.new(target).create
16
+ end
17
+ end
18
+
19
+ register NewCommand::NAME, NewCommand
20
+ end
21
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLMExperiment
4
+ class CLI
5
+ class ParseCommand < Command
6
+ NAME = "parse"
7
+ SUMMARY = "transcripts to events.jsonl and metrics.json"
8
+
9
+ ROW = "%-18s %-8s %-6s %6s %8s %7s %7s %6s"
10
+
11
+ def add_options(parser)
12
+ parser.banner = <<~BANNER
13
+ Usage: llmx parse TRIAL_DIR [TRIAL_DIR...]
14
+ llmx parse --all
15
+
16
+ Reads each trial's transcript.jsonl and writes events.jsonl plus
17
+ metrics.json beside it. Re-running is safe: both files are derived.
18
+
19
+ ctx1st is the context the very first model request carried, before any
20
+ tool could have fetched anything. It is blank for Codex, which reports
21
+ usage once per turn rather than per request.
22
+
23
+ BANNER
24
+ parser.on("--all", "parse every trial under results-raw/") { @all = true }
25
+ parser.on("--lenient", "report contaminated trials without failing") { @lenient = true }
26
+ end
27
+
28
+ def run(argv)
29
+ reader = Transcript::Parser.new(experiment: experiment)
30
+ dirs = @all ? reader.trial_dirs : argv
31
+ raise Error, "llmx parse needs a trial directory, or --all" if dirs.empty?
32
+
33
+ rows = dirs.filter_map { |dir| reader.parse(dir) }
34
+ print_table(rows)
35
+ flagged = report_contamination(rows)
36
+ return if flagged.empty? || @lenient
37
+
38
+ # Exit non-zero rather than warn and succeed. A contaminated trial that
39
+ # only prints a message gets pooled with clean ones by whatever runs
40
+ # next, and in a 90-cell batch nobody re-reads the scrollback. Parsing
41
+ # still wrote every metrics.json, so --lenient recovers the old
42
+ # behaviour once the trials have been looked at.
43
+ raise Error, "#{flagged.size} contaminated trial(s); re-run them, or pass --lenient"
44
+ end
45
+
46
+ private
47
+
48
+ def print_table(rows)
49
+ puts format(ROW, "task", "agent", "cond", "tools", "ctx1st", "out", "wall", "fixed")
50
+ rows.each do |r|
51
+ puts format(ROW, r["task_id"], r["agent"], r["condition"],
52
+ r["total_tool_calls"], r["context_before_first_tool_call"],
53
+ r["output_tokens"], r["wall_seconds"], r["fix_verified"])
54
+ end
55
+ end
56
+
57
+ # Only Claude reports what it was given at startup, so in practice only
58
+ # Claude rows can be checked. Every row is still offered to the check
59
+ # rather than filtered by agent, so a future CLI that starts reporting
60
+ # its startup state is covered without anyone remembering to come back.
61
+ def report_contamination(rows)
62
+ flagged = rows.map { |r| [r, Transcript::Hermeticity.contamination(r)] }
63
+ .reject { |(_, reasons)| reasons.empty? }
64
+ return flagged if flagged.empty?
65
+
66
+ warn "\nWARNING: #{flagged.size} trial(s) ran with state they should not have had:"
67
+ flagged.each do |(row, reasons)|
68
+ warn " #{row["trial_dir"]}"
69
+ reasons.each { |reason| warn " - #{reason}" }
70
+ end
71
+ flagged
72
+ end
73
+ end
74
+
75
+ register ParseCommand::NAME, ParseCommand
76
+ end
77
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLMExperiment
4
+ class CLI
5
+ class RunCommand < Command
6
+ NAME = "run"
7
+ SUMMARY = "run one trial, or the whole grid with --all"
8
+
9
+ def add_options(parser)
10
+ parser.banner = <<~BANNER
11
+ Usage: llmx run --task ID --agent NAME --condition NAME [options]
12
+ llmx run --all [--app KEY] [--task ID] [--agent NAME] [options]
13
+
14
+ One trial is one task, one agent, one condition, in a fresh container.
15
+ --all runs the grid: sequentially, blocked by task, and resumable —
16
+ a cell that already has results is skipped unless --redo is given.
17
+
18
+ BANNER
19
+ parser.on("--task ID", "task id from experiment.yml (a filter under --all)") { |v| @task = v }
20
+ parser.on("--agent NAME", "agent to run (a filter under --all)") { |v| @agent = v }
21
+ parser.on("--condition NAME", "condition to run") { |v| @condition = v }
22
+ parser.on("--all", "run every remaining cell of the grid") { @all = true }
23
+ parser.on("--app KEY", "with --all: only this app's tasks") { |v| @app = v }
24
+ parser.on("--redo", "with --all: re-run cells that already have results") { @redo = true }
25
+ parser.on("--timeout S", Integer, "wall-clock seconds for the agent") { |v| @timeout = v }
26
+ parser.on("--dry-run", "show what would run, run nothing") { @dry_run = true }
27
+ end
28
+
29
+ def run(_argv)
30
+ return run_grid if @all
31
+
32
+ unless @task && @agent && @condition
33
+ raise Error, "llmx run needs --task, --agent and --condition (or --all for the grid)"
34
+ end
35
+
36
+ Trial.new(experiment: experiment, task_id: @task, agent: @agent, condition: @condition,
37
+ container: container, timeout: @timeout).run(dry_run: !!@dry_run)
38
+ end
39
+
40
+ private
41
+
42
+ def run_grid
43
+ grid = Grid.new(experiment: experiment, container: container)
44
+ filters = { app: @app, agent: @agent, task: @task }
45
+ planned = grid.plan(**filters).size
46
+ todo = @redo ? planned : grid.pending(**filters).size
47
+ puts "grid: #{planned} cell(s) - #{todo} to run, #{planned - todo} already have results"
48
+ puts
49
+
50
+ result = grid.run(**filters, redo_done: !@redo.nil?, dry_run: !@dry_run.nil?) do |index, total, cell|
51
+ puts format("[%d/%d] %s", index, total, grid.label(cell))
52
+ end
53
+ report(result)
54
+ end
55
+
56
+ def report(result)
57
+ if @dry_run
58
+ puts "\n(dry run: nothing was executed)"
59
+ return
60
+ end
61
+
62
+ puts
63
+ puts "ran #{result.ran}/#{result.total} cell(s)"
64
+ return if result.ok?
65
+
66
+ puts "\n#{result.failures.size} failed:"
67
+ result.failures.each { |cell| puts " #{cell[:task]} / #{cell[:agent]} / #{cell[:condition]}" }
68
+ raise Error, "#{result.failures.size} cell(s) failed; re-run `llmx run --all` to retry just those"
69
+ end
70
+ end
71
+
72
+ register RunCommand::NAME, RunCommand
73
+ end
74
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLMExperiment
4
+ class CLI
5
+ class SanitizeCommand < Command
6
+ NAME = "sanitize"
7
+ SUMMARY = "gate results-raw/ into the committed results/"
8
+
9
+ def add_options(parser)
10
+ parser.banner = <<~BANNER
11
+ Usage: llmx sanitize [--check]
12
+
13
+ Copies each trial from results-raw/ into results/. Derived measurements
14
+ always cross; raw transcripts cross only for an app declared with
15
+ `publish_transcripts: true`, because a transcript contains whatever
16
+ source the agent read.
17
+
18
+ Anything that looks like a host path or a secret is a hard failure, not
19
+ a redaction: something unexpected in a transcript should stop the commit
20
+ and be looked at, not be quietly rewritten into something that looks safe.
21
+
22
+ BANNER
23
+ parser.on("--check", "report what would be refused, copy nothing") { @check = true }
24
+ end
25
+
26
+ def run(_argv)
27
+ sanitizer = Sanitizer.new(experiment: experiment)
28
+ @check ? sanitizer.check : sanitizer.publish
29
+ end
30
+ end
31
+
32
+ register SanitizeCommand::NAME, SanitizeCommand
33
+ end
34
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLMExperiment
4
+ class CLI
5
+ class ShellCommand < Command
6
+ NAME = "shell"
7
+ SUMMARY = "interactive shell in a trial-shaped container, for debugging"
8
+
9
+ def add_options(parser)
10
+ parser.banner = "Usage: llmx shell [APP] [options]\n" \
11
+ " APP open the app's image at /workspace/app; " \
12
+ "omit for the base image"
13
+ end
14
+
15
+ def run(argv)
16
+ app_key = argv.shift
17
+ image = app_key ? experiment.app(app_key).image : LLMExperiment.base_image
18
+ container.ensure_system!
19
+ raise Error, "image #{image} not found; build it first" unless container.image?(image)
20
+
21
+ exp = app_key ? experiment : nil
22
+ argv_out = container.run_argv(
23
+ image, "bash",
24
+ memory: exp ? exp.memory : LLMExperiment.default_memory,
25
+ cpus: exp ? exp.cpus : LLMExperiment.default_cpus,
26
+ interactive: true,
27
+ workdir: app_key ? "/workspace/app" : nil
28
+ )
29
+ Shell.interactive(*argv_out)
30
+ end
31
+ end
32
+
33
+ register ShellCommand::NAME, ShellCommand
34
+ end
35
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLMExperiment
4
+ class CLI
5
+ class StatusCommand < Command
6
+ NAME = "status"
7
+ SUMMARY = "grid completion: which cells have results"
8
+
9
+ def add_options(parser)
10
+ parser.banner = <<~BANNER
11
+ Usage: llmx status [options]
12
+
13
+ One row per task, one column per agent x condition. A cell reads `done`
14
+ once it has a run that got as far as writing meta.json.
15
+
16
+ Trials run one at a time, on purpose: two at once share the machine and
17
+ contaminate wall_seconds. Expect the grid to take as long as the sum of
18
+ its cells, and resume it with `llmx run --all` whenever it is interrupted.
19
+
20
+ BANNER
21
+ parser.on("--app KEY", "only this app's tasks") { |v| @app = v }
22
+ parser.on("--task ID", "only this task") { |v| @task = v }
23
+ parser.on("--agent NAME", "only this agent") { |v| @agent = v }
24
+ end
25
+
26
+ def run(_argv)
27
+ cells = Grid.new(experiment: experiment)
28
+ .status(app: @app, task: @task, agent: @agent)
29
+ print_table(cells)
30
+ print_totals(cells)
31
+ end
32
+
33
+ private
34
+
35
+ # Columns come from experiment.yml, not from the plan: the plan rotates
36
+ # the conditions per task, and a header that rotated with it would be
37
+ # unreadable.
38
+ def print_table(cells)
39
+ agents = experiment.agents.keys & cells.map { |c| c[:agent] }
40
+ columns = agents.product(experiment.conditions)
41
+ tasks = cells.map { |c| c[:task] }.uniq
42
+ done = cells.to_h { |c| [[c[:task], c[:agent], c[:condition]], c[:done]] }
43
+
44
+ headers = columns.map { |agent, condition| "#{agent}/#{condition}" }
45
+ widths = headers.map { |header| [header.length, 4].max }
46
+ first = [tasks.map(&:length).max, 4].max
47
+
48
+ puts row(["task".ljust(first)], headers, widths)
49
+ tasks.each do |task|
50
+ marks = columns.map { |agent, condition| done[[task, agent, condition]] ? "done" : "-" }
51
+ puts row([task.ljust(first)], marks, widths)
52
+ end
53
+ end
54
+
55
+ def row(lead, values, widths)
56
+ (lead + values.each_with_index.map { |value, i| value.ljust(widths[i]) }).join(" ").rstrip
57
+ end
58
+
59
+ def print_totals(cells)
60
+ finished = cells.count { |c| c[:done] }
61
+ puts
62
+ puts "#{finished}/#{cells.size} cell(s) have results; #{cells.size - finished} still to run."
63
+ puts "Run them with: llmx run --all"
64
+ end
65
+ end
66
+
67
+ register StatusCommand::NAME, StatusCommand
68
+ end
69
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLMExperiment
4
+ class CLI
5
+ class VersionCommand < Command
6
+ NAME = "version"
7
+ SUMMARY = "gem version and default toolchain pins"
8
+
9
+ def run(_argv)
10
+ puts "llm-experiment #{VERSION}"
11
+ Pins::DEFAULTS.each do |tool, pin|
12
+ puts format(" %-14s %s", tool, Array(pin).join(" "))
13
+ end
14
+ end
15
+ end
16
+
17
+ register VersionCommand::NAME, VersionCommand
18
+ end
19
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module LLMExperiment
6
+ class CLI
7
+ COMMANDS = {} # rubocop:disable Style/MutableConstant -- command files register themselves at load time
8
+
9
+ def self.register(name, klass)
10
+ COMMANDS[name] = klass
11
+ end
12
+
13
+ def self.run(argv)
14
+ argv = argv.dup
15
+ name = argv.shift
16
+ name = "version" if %w[-v --version].include?(name)
17
+
18
+ if name.nil? || %w[-h --help help].include?(name)
19
+ print_help
20
+ return 0
21
+ end
22
+
23
+ klass = COMMANDS[name]
24
+ unless klass
25
+ warn "llmx: unknown command #{name.inspect}"
26
+ warn ""
27
+ capture_help_to_stderr
28
+ return 1
29
+ end
30
+
31
+ klass.new.call(argv)
32
+ 0
33
+ rescue Error => e
34
+ warn "llmx: #{e.message}"
35
+ 1
36
+ rescue Interrupt
37
+ 130
38
+ end
39
+
40
+ def self.print_help(io = $stdout)
41
+ io.puts "llmx — run controlled, reproducible experiments on coding agents"
42
+ io.puts
43
+ io.puts "Usage: llmx COMMAND [options]"
44
+ io.puts
45
+ width = COMMANDS.keys.map(&:length).max
46
+ COMMANDS.sort.each do |name, klass|
47
+ io.puts format(" %-#{width}s %s", name, klass.summary)
48
+ end
49
+ io.puts
50
+ io.puts "Run `llmx COMMAND --help` for details."
51
+ end
52
+
53
+ def self.capture_help_to_stderr
54
+ print_help($stderr)
55
+ end
56
+
57
+ # Base class for commands. Subclasses set NAME and SUMMARY, implement
58
+ # #run(argv), and optionally #add_options(parser).
59
+ class Command
60
+ def self.summary = self::SUMMARY
61
+
62
+ def initialize(container: nil)
63
+ @container = container
64
+ @experiment_dir = nil
65
+ @help_shown = false
66
+ end
67
+
68
+ def call(argv)
69
+ parser.parse!(argv)
70
+ return if @help_shown
71
+
72
+ run(argv)
73
+ end
74
+
75
+ def run(_argv)
76
+ raise NotImplementedError
77
+ end
78
+
79
+ private
80
+
81
+ def container
82
+ @container ||= Container.new
83
+ end
84
+
85
+ def parser
86
+ @parser ||= OptionParser.new do |o|
87
+ o.banner = "Usage: llmx #{self.class::NAME} [options]"
88
+ add_options(o)
89
+ o.on("--experiment DIR", "experiment directory (default: walk up to experiment.yml)") do |d|
90
+ @experiment_dir = d
91
+ end
92
+ o.on("--verbose", "echo every container command") { LLMExperiment.verbose = true }
93
+ o.on("-h", "--help", "show this help") do
94
+ puts o
95
+ @help_shown = true
96
+ end
97
+ end
98
+ end
99
+
100
+ def add_options(_parser); end
101
+
102
+ def experiment
103
+ @experiment ||= @experiment_dir ? Experiment.load(@experiment_dir) : Experiment.find
104
+ end
105
+ end
106
+ end
107
+ end
108
+
109
+ require_relative "cli/version_command"
110
+ require_relative "cli/new_command"
111
+ require_relative "cli/doctor_command"
112
+ require_relative "cli/build_command"
113
+ require_relative "cli/login_command"
114
+ require_relative "cli/shell_command"
115
+ require_relative "cli/run_command"
116
+ require_relative "cli/status_command"
117
+ require_relative "cli/parse_command"
118
+ require_relative "cli/metrics_command"
119
+ require_relative "cli/sanitize_command"
120
+ require_relative "cli/clean_command"