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

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

Returns:

  • (Boolean)


253
# File 'lib/terret/cli.rb', line 253

def self.nested?(value) = (value.is_a?(Hash) || value.is_a?(Array)) && !value.empty?

.parkObject

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.banner = 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.message, 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(message, 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