Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Command Line Applications in Ruby -- RubyConf 2026

Command Line Applications in Ruby -- RubyConf 2026

Avatar for keithrbennett

keithrbennett

July 14, 2026

More Decks by keithrbennett

Other Decks in Programming

Transcript

  1. Keith Bennett Available for Ruby consulting, development, and technical mentoring

    Command-Line Applications in Ruby -- Design Patterns and Best Practices Building clear, intuitive, rich, versatile, and maintainable tools 1
  2. Command Line Interfaces (CLIs) vs. Command Line Applications (CLAs) "Interface"

    understates the complexity and rich functionality of many CLAs It implies a superficial adapter layer lacking business logic We don't call a web application a "web interface" CLI is a real thing but it is just one of several possible interfaces to a CLA 3
  3. CLAs Containing Substantial Core Logic Not just plumbing around a

    separate backend: Version control: git The Ruby language: ruby Web framework: rails Image manipulation: magick Multimedia: ffmpeg AI agents: claude, codex, agy, opencode, kilo 4
  4. Consumers & Interfaces INTERFACES CONSUMERS Human at Terminal Scripts /

    Applications Ruby Program AI Agent Browser / Web App curl / REST Client CLI REPL / Shell Ruby Library API MCP Server HTTP Server SHARED APPLICATION CORE 5
  5. CovLoupe Provides higher-level query types for accessing raw SimpleCov coverage

    data CLI mode for humans and scripts Library mode for Ruby callers MCP server mode for agent tool calls https://github.com/keithrbennett/cov-loupe gem install cov-loupe 7
  6. WifiWand Presents macOS and Ubuntu WiFi management as a unified

    Ruby domain model CLI mode for humans and scripts Library mode for Ruby callers REPL mode for interactive exploration https://github.com/keithrbennett/wifiwand gem install wifi-wand 8
  7. Definitions: Cohesion and Coupling Cohesion — how focused a module

    is. High cohesion means one responsibility, done well. Coupling — how much each module's code is vulnerable to changes in other modules. Low coupling means depending on stable interfaces, not internals. 10
  8. Practice of High Cohesion, Low Coupling Applying this to interfaces

    and the core interface code should not contain core functionality (business logic) core code should not know or care what kind of interface is calling it 11
  9. Benefits of High Cohesion, Low Coupling Simpler More comprehensible More

    maintainable Easier to add and remove interfaces 12
  10. Thin Executable #!/usr/bin/env ruby # frozen_string_literal: true require 'my_tool/main' #

    Handle Ctrl-C and termination (kill) signals def install_signal_handlers %w[INT TERM].each do |sig| Signal.trap(sig) do $stderr.write "\nReceived SIG#{sig}. Exiting.\n" exit(128 + Signal.list[sig]) end end end install_signal_handlers exit_code = MyTool::Main.call(ARGV) exit(exit_code) The front gate lives in exe/ , but the building lives in lib/ . 14
  11. Gemspec: bindir Is exe, not bin # my_tool.gemspec Gem::Specification.new do

    |spec| # ... spec.bindir = 'exe' spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } # ... end Directory Consumer Included in gem bin/ developer no exe/ user yes 15
  12. Short and Long Command and Option Names Provide both: short

    - for ad hoc terminal work, faster to type long - for longer lived code, e.g. scripts, to be self documenting Kind Short Long Command i info Option -v --verbose Option -f json --format json Option -f j --format json 17
  13. Online Help - Header $ cov-loupe -h | head -8

    ------------------------------------------------------------------------------- Usage: cov-loupe [options] [subcommand] [args] (default subcommand: list) Repository: https://github.com/keithrbennett/cov-loupe Documentation (Web): https://keithrbennett.github.io/cov-loupe/ Documentation (Local): /Users/kbennett/.local/share/mise/installs/ruby/4.0.5/lib/ruby/gems/4.0.0/gems/cov-loupe-6.0.0/README.md Screencast: https://www.bbs-software.com/screencasts/cov-loupe Version: 6.0.0 ------------------------------------------------------------------------------- 18
  14. Online Help - Body: Subcommands, Options, Examples Subcommands: detailed, d

    <path> Show per-line rows with hits/covered list, l Show files coverage (default: table, or use --format) ... Options: -r, --resultset PATH Path or directory that contains .resultset.json (default: coverage/.resultset.json) -R, --root PATH Project root (default: .) ... Examples: cov-loupe --resultset coverage list cov-loupe --format json --resultset coverage summary lib/foo.rb 19
  15. Leave Globals at the Boundary def initialize( argv: ARGV, stdin:

    $stdin, stdout: $stdout, stderr: $stderr, env: ENV, cwd: Dir.pwd ) @argv = argv @stdin = stdin @stdout = stdout @stderr = stderr @env = env @cwd = cwd end By decoupling from global state at the entry point, you can replace integration tests with fast, isolated unit tests. 20
  16. Option Parsing Libraries OptionParser — stdlib, imperative, zero dependencies. Fine

    for simple CLIs. Gets verbose with many subcommands. Thor — class-based, declarative, most widely adopted. Subcommands, shared options, Rails generators. Heavy for small tools. dry-cli — minimal, declarative, lightweight. Start with OptionParser . Reach for frameworks when you need them. 21
  17. Support Diverse Parsers and Formatters Here is a formatters example;

    the same approach applies to parsers. FORMATTERS = { text: ->(object) { object.to_s }, json: ->(object) { JSON.generate(object) }, pretty_json: ->(object) { JSON.pretty_generate(object) }, yaml: ->(object) { YAML.safe_dump(object) }, ap: ->(object) { object.ai } }.freeze # After startup option parsing: output_formatter = FORMATTERS.fetch(format, DEFAULT_OUTPUT_FORMAT) # An example of using the formatter: puts(output_formatter.call(ENV.to_h)) 23
  18. Duck Typing: Diverse Callables Ruby's duck typing enables using any

    object having a #call method: Lambdas Modules / Class Methods Class Instances Method Objects The calling logic remains completely unchanged. 24
  19. Strict Separation of Stdout and Stderr Streams stdout — data,

    results, output meant to be consumed stderr — diagnostics, errors, status messages Scripts rely on this separation: cov-loupe --format json | jq ... 25
  20. Errors Are UX Errno::ENOENT is not a user friendly message.

    Translate low level to high level Say what failed Say what can be done to fix it If in debug/verbose mode, output stack trace 26
  21. Handling Errors at the CLA Boundary Translate internal exceptions into

    clean output and exit codes. def run(argv) parse_options!(argv) execute_command 0 rescue OptionParser::ParseError => error stderr.puts "usage error: #{error.message}" 2 rescue Errno::ENOENT => error stderr.puts "file not found: #{error.message}" 1 rescue ConfigError => error stderr.puts "config error: #{error.message}" 3 rescue Interrupt => e # use this or Signal.trap stderr.puts "\nInterrupted by user pressing Ctrl+C" stderr.puts e.full_message if debug 130 end 27
  22. Custom Environment Variables Minimize environment variable count for production use;

    ideally, one. For configuring other uses, e.g. tests and scripts, more is ok 28
  23. The Options Environment Variable Provide a single "custom defaults" environment

    variable for command line arguments. can be invaluable to users who always want the same formats, directories, filespecs, log and verbose modes, etc. the variable name should identify the application, e.g. COV_LOUPE_OPTS for the CovLoupe application Mention the variable in the online help, e.g.: COV_LOUPE_OPTS can be used to set default options. Command-line arguments override these defaults. Example: export COV_LOUPE_OPTS="--format json --sort-order ascending" 29
  24. Configuration Order of Precedence (lowest to highest) 1. Built-in defaults

    2. Config file 3. Environment variable 4. Command-line flags 30
  25. Output Argument Parse Results When in Verbose Mode $ wifiwand

    --output-format y --verbose true info | sponge | head -7 ------------------------------------------------------------------------------- Run at: 2026-06-21 13:46:04 UTC WIFIWAND_OPTS: "--utc true --output-format a" raw_argv: ["--output-format", "y", "--verbose", "true", "info"] Command: info Options: verbose=true utc=true wifi_interface=nil output_format=yaml ------------------------------------------------------------------------------- Let users see exactly what the tool thinks it was told. Invaluable for debugging. 31
  26. Ruby Methods for External Commands Method Returns stdout stderr `cmd`

    stdout Captured Terminal system true / false / nil Terminal Terminal Open3.capture2 [stdout, status] Captured Terminal Open3.capture3 [stdout, stderr, status] Captured Captured Open3.popen3 [stdin, stdout, stderr, wait_thread] Interactive Interactive Terminal — inherited from parent; written to terminal unless redirected Captured — returned by parent; not written to terminal. Interactive — streams connected to parent; read and write in real time. 33
  27. Prefer Argument Arrays to Shell Strings filename = "file; rm

    -rf ~" # DANGER! # Shell string — shell parses ; | $ and more system("cat #{filename}") # runs "cat file; rm -rf ~" !!! # Argument array — passed directly to the kernel system('cat', filename) # cat gets one literal filename Arrays bypass the shell. No injection. No escaping. 34
  28. Shell String vs. Argument Array Shell string Argument array Pros

    Pipes, redirects, globs, $(…) — full shell syntax in one expression Bypasses the shell — no injection, no escaping; exact argument boundaries Cons Shell parses ; | $ and more — injection and quoting bugs No pipes or globs unless you wrap in sh -c (which reintroduces the risks) Best for Trusted, static one-liners: system("git diff | grep foo") Any user-supplied or variable input: system('cat', filename) Default to arrays. Reach for a shell string only when you deliberately need shell features and trust every part of the string. 35
  29. Prefer Ruby APIs to External Commands for Filesystem and System

    Work FileUtils.mkdir_p over system('mkdir -p ...') FileUtils.rm_rf over `rm -rf` Pathname for path arithmetic File and Dir for reads, writes, and iteration Ruby APIs are cross-platform, testable without subprocess mocking, and free from shell injection risk. 36
  30. But Consider Subprocesses for Concurrency While we minimize shelling out

    for simple tasks, spawning child processes is often superior to Ruby threads for background work: Guaranteed Cleanup — The OS reliably cleans up child processes ( SIGTERM / SIGKILL ) and frees their resources, whereas killing threads is unsafe. Fail-Safe Isolation — A crashed or hung network probe won't take down the parent process or corrupt memory. True Parallelism — Bypasses Ruby's GVL (Global VM Lock / GIL) to execute heavy I/O or network checks in parallel. 37
  31. Consider JRuby Compatibility Often trivial and potentially a big win

    Reach JVM-only environments where Ruby isn’t installed True thread parallelism — no GVL; multiple threads use multiple CPUs Access to rich set of stable, mature Java libraries Add this to the appropriate place in your .github/workflows/test.yml: include: # Test JRuby on Linux only (faster than Windows/macOS in CI) - os: ubuntu-latest ruby-version: jruby Caveat: JVM startup time is nontrivial, e.g. 2 seconds 38
  32. Term: REPL — Read Eval Print Loop A program that

    reads input, evaluates it, prints the result, and loops back for input again. Environment REPL Ruby irb, pry, rails console Python ipython, python Shells bash, zsh, fish, PowerShell Databases psql, mysql, sqlite3 For this talk, an interactive Ruby session built around your application. 40
  33. REPLs Are Cheap to Build class ReplContext def initialize(model) =

    (@model = model) def status = @model.status def connect(ssid) = @model.connect(ssid) def disconnect = @model.disconnect end ReplContext.new(a_model).pry Create a custom object as the REPL receiver Expose only what belongs in the session — not the full application 41
  34. REPL: Natural DSLs With Ruby Ruby's optional parentheses let method

    calls feel like commands: info status connect 'Cafe WiFi' disconnect password network_name 42
  35. Detailed, Linked Documentation Go beyond a single README — build

    a network of doc files, linked in both directions README.md — entry point, links to everything CHANGELOG.md or RELEASE_NOTES.md — version history docs/ — user-facing guides, examples, troubleshooting dev/docs/ — architecture, internals, contribution guides 45
  36. Distribute User Documentation With the Gem Distribute docs/**/* but not

    dev/docs/**/* with the gem Users and AI agents can read docs from the local filesystem — no Internet access needed The docs are the exact docs for the installed version The small cost of the additional storage is well worth the benefit. 46
  37. Key Takeaways Treat command-line applications as first-class applications. Consider providing

    multiple interfaces, not just a CLI: library, REPL (shell), and MCP server. Build cohesive, loosely coupled systems with core logic shared across those interfaces. Treat stdout, stderr, errors, output formats, and exit codes as part of the public API. Reduce user friction through custom defaults, multiple input and output formats, and effective diagnostics. Make the application easy to discover through detailed help and documentation available to both users and AI agents. Choose deliberately among Ruby APIs, subprocesses, and shell commands. 47