The CLI Contract
The CLI Contract: a command-line tool is an API whose clients are shell scripts, cron, CI, and a tired human at 2am - and each of those clients reads a different channel. Data goes to stdout, diagnostics to stderr, the verdict goes in the EXIT CODE, and --format json exists because your most important user is a pipe. This wraps a plan in a CLI that honors all four, then proves it by invoking itself the way scripts would.
Data & Pipelines
Round 13
David Bryant Copeland
exit 0
bundle exec ruby examples/cli_contract.rb
a real captured run
THE CLI CONTRACT (four channels, each with one job)
$ digest (human at a terminal)
stdout | story-a
stdout | story-b
stdout | story-c
stderr | digest: running 2 tasks...
stderr | digest: done (3 stories)
exit | 0
$ digest --format=json --quiet (pipe to jq)
stdout | {"stories":["story-a","story-b","story-c"],"count":3}
exit | 0
$ digest --quiet --fail (cron (quiet until it matters))
stderr | digest: FAILED at rank: ranker 503
stderr | digest: hint: transient upstream error - rerun, or check the ranker's status page
exit | 1
$ digest --formt=json (typo'd flag)
stderr | error: unknown option --formt=json
stderr | usage: digest [--format=json|text] [--quiet]
exit | 64
read the invocations like their consumers would: the human got
progress on stderr and stories on stdout (so `digest > out.txt`
captures DATA, not chatter). the pipe got pure JSON and silence -
jq never chokes on a progress message. cron stayed quiet until
failure, then got a diagnosis AND a hint on stderr with exit 1
(so || alerting fires). and the typo got exit 64 - EX_USAGE -
because "you called me wrong" and "the work failed" are
different facts and scripts deserve to tell them apart. none of
this is glamorous; all of it is the difference between a CLI
people script against and one they script AROUND.
source
# frozen_string_literal: true # The CLI Contract: a command-line tool is an API whose clients are # shell scripts, cron, CI, and a tired human at 2am - and each of # those clients reads a different channel. Data goes to stdout, # diagnostics to stderr, the verdict goes in the EXIT CODE, and # --format json exists because your most important user is a pipe. # This wraps a plan in a CLI that honors all four, then proves it # by invoking itself the way scripts would. # # bundle exec ruby examples/cli_contract.rb # # Runs offline; each invocation is captured like a shell would see it. require class="s">"bundler/setup" require class="s">"agentic" require class="s">"json" require class="s">"stringio" Agentic.logger.level = class="y">:fatal # The tool: `digest [--format json|text] [--quiet] [--fail]` module DigestCLI EXIT_OK = 0 EXIT_PARTIAL = 1 EXIT_USAGE = 64 # EX_USAGE from sysexits.h - scripts can tell class="s">"it failed" from class="s">"I called it wrong" def self.run(argv, stdout:, stderr:) options = {format: class="s">"text", quiet: false, fail: false} argv.each do |arg| case arg when class="s">"--format=json" then options[class="y">:format] = class="s">"json" when class="s">"--format=text" then options[class="y">:format] = class="s">"text" when class="s">"--quiet" then options[class="y">:quiet] = true when class="s">"--fail" then options[class="y">:fail] = true # scripted failure, for the demo else stderr.puts class="s">"error: unknown option #{arg}" stderr.puts class="s">"usage: digest [--format=json|text] [--quiet]" return EXIT_USAGE end end orchestrator = Agentic:class="y">:PlanOrchestrator.new(retry_policy: {max_retries: 0, retryable_errors: []}) fetch = Agentic:class="y">:Task.new(description: class="s">"fetch", agent_spec: {class="s">"name" => class="s">"f", class="s">"instructions" => class="s">"w"}) rank = Agentic:class="y">:Task.new(description: class="s">"rank", agent_spec: {class="s">"name" => class="s">"r", class="s">"instructions" => class="s">"w"}) orchestrator.add_task(fetch, agent: ->(_t) { %w[story-a story-b story-c] }) orchestrator.add_task(rank, [fetch], agent: ->(t) { raise Agentic:class="y">:Errors:class="y">:LlmServerError, class="s">"ranker 503" if options[class="y">:fail] t.previous_output.sort }) stderr.puts class="s">"digest: running 2 tasks..." unless options[class="y">:quiet] result = orchestrator.execute_plan if result.successful? stories = result.task_result(rank.id).output if options[class="y">:format] == class="s">"json" stdout.puts JSON.generate({stories: stories, count: stories.size}) else stories.each { |s| stdout.puts s } end stderr.puts class="s">"digest: done (#{stories.size} stories)" unless options[class="y">:quiet] EXIT_OK else failure = result.results.values.find { |r| !r.successful? }.failure stderr.puts class="s">"digest: FAILED at rank: #{failure.message}" stderr.puts class="s">"digest: hint: transient upstream error - rerun, or check the ranker's status page" EXIT_PARTIAL end end end def invoke(argv) out = StringIO.new err = StringIO.new code = DigestCLI.run(argv, stdout: out, stderr: err) [code, out.string, err.string] end puts class="s">"THE CLI CONTRACT (four channels, each with one job)" puts INVOCATIONS = [ [class="s">"human at a terminal", []], [class="s">"pipe to jq", [class="s">"--format=json", class="s">"--quiet"]], [class="s">"cron (quiet until it matters)", [class="s">"--quiet", class="s">"--fail"]], [class="s">"typo'd flag", [class="s">"--formt=json"]] ].freeze INVOCATIONS.each do |label, argv| code, out, err = invoke(argv) puts class="s">" $ digest #{argv.join(" class="s">")}".rstrip + class="s">" (#{label})" out.lines.each { |l| puts class="s">" stdout | #{l}" } err.lines.each { |l| puts class="s">" stderr | #{l}" } puts class="s">" exit | #{code}" puts end puts class="s">" read the invocations like their consumers would: the human got" puts class="s">" progress on stderr and stories on stdout (so `digest > out.txt`" puts class="s">" captures DATA, not chatter). the pipe got pure JSON and silence -" puts class="s">" jq never chokes on a progress message. cron stayed quiet until" puts class="s">" failure, then got a diagnosis AND a hint on stderr with exit 1" puts class="s">" (so || alerting fires). and the typo got exit 64 - EX_USAGE -" puts class="s">" because \"you called me wrong\class="s">" and \"the work failed\class="s">" are" puts class="s">" different facts and scripts deserve to tell them apart. none of" puts class="s">" this is glamorous; all of it is the difference between a CLI" puts class="s">" people script against and one they script AROUND."