Module: Terret::CLI
- Defined in:
- lib/terret/cli.rb
Overview
The trt command line interface (docs/composition.md §8).
Non-interactive: optparse, no thor, no REPL, no TUI. Nothing here is a chat
window and nothing here competes with the socket. trt boot starts the
reactor and parks, the other two print and exit, and every one of them is a
thin wrapper over Terret.boot or over pure resolution.
start returns a process exit status rather than calling exit, so the tests can drive it in-process with captured IO. exe/trt is the one caller that turns the status into an exit.
Defined Under Namespace
Classes: Options
Constant Summary collapse
- COMMANDS =
%w[boot dump-config doctor acp].freeze
- USAGE =
"Usage: trt <command> [options]\n\nCommands:\n boot compose a profile, mount it, and park until interrupted\n dump-config print the resolved rows, annotated with the layer that\n contributed each one; secrets stay unresolved\n doctor resolve a profile and report on its rows without booting\n acp compose a profile and serve the Agent Client Protocol\n on stdio, so an editor can drive an agent; stdout carries\n only ACP frames and diagnostics go to stderr\n\nOptions:\n -p, --profile NAME profile to compose (required)\n --patch FILE overlay a patch file; repeatable, applied in order\n --home DIR Terret home (default: $TERRET_HOME, else ~/.terret)\n --allow-config-ruby permit !ruby scalars in this composition\n -v, --version print the terret version\n -h, --help print this message\n"
Class Method Summary collapse
-
.acp(opts, out:, err:, input:) ⇒ Object
Boot a profile and serve the Agent Client Protocol on stdio, so an editor can drive an agent (docs/acp.md).
-
.align(lines) ⇒ Object
Comments live in a column so the provenance reads as a column.
-
.boot(opts, out:, err:) ⇒ Object
-- boot ------------------------------------------------------------------.
- .dispatch(opts, out:, err:, input: $stdin) ⇒ Object
-
.dump_config(opts, out:) ⇒ Object
The resolved tree with each row annotated by the layer that contributed it.
-
.key_cell(key) ⇒ Object
A config KEY is attacker-influenceable the same way a value or a plugin name is (an explicit YAML key can carry a newline that forges a provenance line, or spell a secret), so it goes through the same one_line/redact path the values already use — not the Psych.dump
scalarpath, because a key is printed bare rather than quoted. - .nested?(value) ⇒ Boolean
-
.park ⇒ Object
An agent is a task tree on the fiber scheduler, so a booted process belongs on the reactor even when this command has nothing of its own to run.
-
.parse(argv, out:, err:) ⇒ Object
Returns Options, or an exit status when the run is over (help, version, usage error).
- .resolve(opts) ⇒ Object
- .safe(value) ⇒ Object
-
.scalar(value) ⇒ Object
Psych does the quoting, so a value that would reparse as a boolean, a number, or a null comes back quoted.
- .start(argv = ARGV, out: $stdout, err: $stderr, input: $stdin) ⇒ Object
- .usage_error(message, err:) ⇒ Object
- .yaml_lines(value, depth) ⇒ Object
Class Method Details
.acp(opts, out:, err:, input:) ⇒ Object
Boot a profile and serve the Agent Client Protocol on stdio, so an editor
can drive an agent (docs/acp.md). The one hard rule of this wire is that
stdout carries ONLY ACP frames: diagnostics go to err (stderr), never
out, and serve blocks on the reactor until the editor closes the pipe.
The IO trio is injectable so a test drives the whole subcommand over an
in-memory pipe; exe/trt passes $stdout/$stderr/$stdin.
156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 |
# File 'lib/terret/cli.rb', line 156 def self.acp(opts, out:, err:, input:) require_relative "boot" ctx = nil ctx = Terret.boot(profile: opts.profile, home: opts.home, patches: opts.patches, allow_config_ruby: opts.allow_config_ruby) unless ctx.service?(:acp) err.puts "trt: profile #{opts.profile.inspect} mounts no acp row to serve" return 1 end err.puts "trt: profile #{opts.profile.inspect} is up; serving ACP on stdio. Interrupt to stop." err.flush ctx[:acp].serve(input: input, output: out) 0 rescue Interrupt err.puts "trt: interrupted" 130 rescue StandardError => e err.puts "trt: acp failed: #{e.class}: #{e.message}" 1 ensure # The same teardown boot does, and for the same reason: a serve that # returned on EOF, or raised, must still take its container, its bash, and # its open database down. A Terret.boot that never returned leaves ctx nil. Boot.shutdown(ctx) if ctx end |
.align(lines) ⇒ Object
Comments live in a column so the provenance reads as a column.
228 229 230 231 232 |
# File 'lib/terret/cli.rb', line 228 def self.align(lines) width = lines.filter_map { |text, comment| text.length if comment }.max.to_i column = [width + 2, 32].max lines.map { |text, comment| comment ? "#{text.ljust(column)}# #{comment}" : text } end |
.boot(opts, out:, err:) ⇒ Object
-- boot ------------------------------------------------------------------
122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 |
# File 'lib/terret/cli.rb', line 122 def self.boot(opts, out:, err:) require_relative "boot" # the one command that needs it; already loaded via exe/trt ctx = nil ctx = Terret.boot(profile: opts.profile, home: opts.home, patches: opts.patches, allow_config_ruby: opts.allow_config_ruby) out.puts "trt: profile #{opts.profile.inspect} is up. Interrupt to stop." out.flush begin park rescue Interrupt out.puts end out.puts "trt: stopping" 0 rescue StandardError => e err.puts "trt: boot failed: #{e.class}: #{e.message}" 1 ensure # Teardown belongs here, not on the success path: a park that raises # rather than catching its Interrupt used to return 1 and leak the whole # booted world — its container, its bash, its open database. A boot that # got as far as a live context is always torn down; a Terret.boot that # never returned leaves ctx nil and nothing to shut down. Boot.shutdown(ctx) if ctx end |
.dispatch(opts, out:, err:, input: $stdin) ⇒ Object
69 70 71 72 73 74 75 76 |
# File 'lib/terret/cli.rb', line 69 def self.dispatch(opts, out:, err:, input: $stdin) case opts.command when "boot" then boot(opts, out: out, err: err) when "dump-config" then dump_config(opts, out: out) when "doctor" then Doctor.run(resolve(opts), allow_config_ruby: opts.allow_config_ruby, out: out) when "acp" then acp(opts, out: out, err: err, input: input) end end |
.dump_config(opts, out:) ⇒ Object
The resolved tree with each row annotated by the layer that contributed
it. SECRETS RENDER AS THEIR UNRESOLVED TAG — api_key: !env OPENROUTER_API_KEY prints as written and the resolved value never
appears here at all. This output exists to be pasted into an issue, and a
resolved credential printed once is a credential rotated.
200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 |
# File 'lib/terret/cli.rb', line 200 def self.dump_config(opts, out:) resolved = resolve(opts) lines = [["# resolved: profile #{resolved.profile.inspect}", nil], ["rows:", nil]] resolved.rows.each do |row| # Row ids are validated at resolution, but plugin names (constant paths) # and layer labels (bundle names, --patch paths) are not — so every # identifier printed here goes through one_line, and a newline in one # cannot forge a row or a provenance line in this output. lines << [" - id: #{safe(row.id)}", "row: #{safe(row.row_layer)}"] # Annotated only when a later layer swapped it, so the annotation means # "somebody changed this" rather than being visual noise on every row. swapped = row.plugin_layer unless row.plugin_layer == row.row_layer lines << [" plugin: #{safe(row.plugin)}", swapped && "plugin: #{safe(swapped)}"] lines << [" disabled: true", nil] if row.disabled if row.config.empty? lines << [" config: {}", "config: #{safe(row.config_layer)}"] else lines << [" config:", "config: #{safe(row.config_layer)}"] yaml_lines(row.config, 3).each { |l| lines << [l, nil] } end end out.puts align(lines) 0 end |
.key_cell(key) ⇒ Object
A config KEY is attacker-influenceable the same way a value or a plugin
name is (an explicit YAML key can carry a newline that forges a provenance
line, or spell a secret), so it goes through the same one_line/redact path
the values already use — not the Psych.dump scalar path, because a key is
printed bare rather than quoted.
262 |
# File 'lib/terret/cli.rb', line 262 def self.key_cell(key) = Composition.one_line(Composition.redact_secrets(key.to_s)) |
.nested?(value) ⇒ Boolean
253 |
# File 'lib/terret/cli.rb', line 253 def self.nested?(value) = (value.is_a?(Hash) || value.is_a?(Array)) && !value.empty? |
.park ⇒ Object
An agent is a task tree on the fiber scheduler, so a booted process belongs on the reactor even when this command has nothing of its own to run. Without async there is nothing to park on but the process itself.
186 187 188 189 190 191 |
# File 'lib/terret/cli.rb', line 186 def self.park require "async" Async { |task| task.sleep } rescue LoadError sleep end |
.parse(argv, out:, err:) ⇒ Object
Returns Options, or an exit status when the run is over (help, version, usage error).
82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 |
# File 'lib/terret/cli.rb', line 82 def self.parse(argv, out:, err:) opts = Options.new(nil, nil, [], nil, false) parser = OptionParser.new do |o| o. = USAGE o.on("-p", "--profile NAME") { |v| opts.profile = v } o.on("--patch FILE") { |v| opts.patches << v } o.on("--home DIR") { |v| opts.home = v } o.on("--allow-config-ruby") { opts.allow_config_ruby = true } o.on("-v", "--version") { out.puts "trt #{Terret::Meta::VERSION}"; return 0 } o.on("-h", "--help") { out.puts USAGE; return 0 } end rest = begin parser.parse(argv.dup) rescue OptionParser::ParseError => e return usage_error(e., err: err) end opts.command = rest.shift return usage_error("no command given", err: err) if opts.command.nil? return usage_error("unknown command #{opts.command.inspect}", err: err) unless COMMANDS.include?(opts.command) return usage_error("unexpected arguments: #{rest.join(' ')}", err: err) unless rest.empty? return usage_error("#{opts.command} needs --profile NAME", err: err) if opts.profile.nil? opts end |
.resolve(opts) ⇒ Object
116 117 118 |
# File 'lib/terret/cli.rb', line 116 def self.resolve(opts) Composition.resolve(profile: opts.profile, home: opts.home, patches: opts.patches) end |
.safe(value) ⇒ Object
255 |
# File 'lib/terret/cli.rb', line 255 def self.safe(value) = Composition.one_line(value.to_s) |
.scalar(value) ⇒ Object
Psych does the quoting, so a value that would reparse as a boolean, a number, or a null comes back quoted. A tag renders as itself (unresolved). A LITERAL string value is the one way a real secret reaches this output — !env/!setting stay tags — so a secret-shaped literal is redacted, and any control characters are neutralized so a value cannot forge a line either.
269 270 271 272 273 274 275 276 277 |
# File 'lib/terret/cli.rb', line 269 def self.scalar(value) return safe(value) if value.is_a?(Composition::Tagged) return "{}" if value == {} return "[]" if value == [] value = Composition.one_line(Composition.redact_secrets(value)) if value.is_a?(String) body = Psych.dump(value).delete_prefix("---").sub(/\n\z/, "").strip body.empty? ? "~" : body end |
.start(argv = ARGV, out: $stdout, err: $stderr, input: $stdin) ⇒ Object
46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 |
# File 'lib/terret/cli.rb', line 46 def self.start(argv = ARGV, out: $stdout, err: $stderr, input: $stdin) opts = parse(argv, out: out, err: err) return opts if opts.is_a?(Integer) dispatch(opts, out: out, err: err, input: input) rescue Composition::Error => e # Boot failures are caught in .boot, which is also the only command that # needs boot.rb — this file must not name a constant from the file that # requires it. err.puts "trt: #{e.message}" 1 rescue SystemCallError, IOError, ScriptError => e # Belt and braces. Resolution turns these into Composition::Error where # it meets them, but a config file is a file on somebody's disk and a # !ruby scalar is a compiler — neither is done surprising us, and a # backtrace is not an error message. err.puts "trt: #{e.class}: #{e.message.lines.first.to_s.strip}" 1 rescue Interrupt err.puts "trt: interrupted" 130 end |
.usage_error(message, err:) ⇒ Object
109 110 111 112 113 114 |
# File 'lib/terret/cli.rb', line 109 def self.usage_error(, err:) err.puts "trt: #{message}" err.puts err.puts USAGE 2 end |
.yaml_lines(value, depth) ⇒ Object
234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 |
# File 'lib/terret/cli.rb', line 234 def self.yaml_lines(value, depth) pad = " " * depth case value when Hash value.flat_map do |key, sub| k = key_cell(key) nested?(sub) ? ["#{pad}#{k}:", *yaml_lines(sub, depth + 1)] : ["#{pad}#{k}: #{scalar(sub)}"] end when Array value.flat_map do |sub| next ["#{pad}- #{scalar(sub)}"] unless nested?(sub) nested = yaml_lines(sub, depth + 1) ["#{pad}- #{nested.first.lstrip}", *nested.drop(1)] end else [pad + scalar(value)] end end |