This guide covers how to create tools (commands) for devex, including all available interfaces, best practices, and patterns.
Create a file in tools/ (or lib/devex/builtins/ for built-ins):
# tools/hello.rb
desc "Say hello"
def run
$stdout.print "Hello, world!\n"
endRun with: dx hello
Devex automatically adds your project's lib/ directory to the load path. This means tools can require project code directly:
# tools/deploy.rb
require "myproject/config" # loads lib/myproject/config.rb
require "myproject/deploy" # loads lib/myproject/deploy.rb
desc "Deploy the application"
def run
config = MyProject::Config.load
MyProject::Deploy.run(config)
endNo require_relative needed - just use standard require with your library's namespace.
desc "Short description (shown in help listing)"
long_desc <<~DESC
Longer description shown when running `dx help <tool>`.
Can span multiple lines and include examples.
DESCflag :dry_run, "-n", "--dry-run", desc: "Show what would happen"
flag :count, "-c COUNT", "--count=COUNT", desc: "Number of times"
flag :output, "-o FILE", "--output=FILE", desc: "Output file"
flag :format, "--format=FMT", desc: "Output format", default: "text"Access in run: dry_run, count, output, format (as methods) or options[:dry_run]
Flag options:
desc:- Description shown in helpdefault:- Default value for the flag. Boolean flags without values default tofalse. Flags with arguments default tonilunlessdefault:is specified.
Reserved flags: The following flags are reserved for global use and cannot be used by tools:
-v,--verbose- Useverbose?to check global verbose level-f,--format- Useglobal_options[:format]-q,--quiet- Useglobal_options[:quiet]--no-color,--color- Color is handled automatically
Tools that define conflicting flags will fail with an error when invoked.
required_arg :filename, desc: "File to process"
optional_arg :output, desc: "Output file (default: stdout)"
remaining_args :files, desc: "Additional files"Access in run: filename, output, files (as methods)
# tools/db.rb
desc "Database operations"
tool "migrate" do
desc "Run migrations"
def run
# ...
end
end
tool "seed" do
desc "Seed the database"
flag :env, "-e ENV", desc: "Environment"
def run
# ...
end
endAccess as: dx db migrate, dx db seed --env=test
The Devex::Exec module provides methods for running external commands with automatic environment handling.
| Method | Purpose | stdout | Returns |
|---|---|---|---|
cmd(*args) |
Run command, wait | streams | Result |
cmd?(*args) |
Test if command succeeds | silent | bool |
capture(*args) |
Run and capture output | captured | Result |
spawn(*args) |
Run in background | configurable | Controller |
exec!(*args) |
Replace this process | N/A | never returns |
shell(str) |
Run via shell | streams | Result |
shell?(str) |
Test shell command | silent | bool |
ruby(*args) |
Run Ruby subprocess | streams | Result |
tool(name, *args) |
Run another dx tool | streams | Result |
Note: cmd and cmd? are aliases for run and run?. Use cmd/cmd? inside tools to avoid shadowing the def run entry point.
# In your tool's run method:
include Devex::Exec
def run
# Use `cmd` instead of `run` to avoid collision with `def run`
cmd "bundle", "install"
# Check if command succeeded
result = cmd "make", "test"
if result.failed?
Output.error "Tests failed"
exit result.exit_code
end
# Exit immediately on failure
cmd("bundle", "install").exit_on_failure!
# Chain commands (short-circuit on failure)
cmd("lint").then { cmd("test") }.then { cmd("build") }.exit_on_failure!
endNote: Use cmd instead of run when including Devex::Exec in tools. The def run entry point shadows Devex::Exec.run, so cmd is provided as an alias to avoid this collision.
The workhorse method. Runs a command, streams output, waits for completion.
Use cmd inside tools (alias for run) to avoid shadowing def run.
cmd "bundle", "install"
# With options
cmd "make", "test", env: { CI: "1" }, chdir: "subproject/"
# With timeout (seconds)
cmd "slow_task", timeout: 30Behavior:
- Streams stdout/stderr to terminal
- Applies environment stack (cleans bundler pollution)
- Returns
Resultobject - Never raises on non-zero exit
Silent execution, returns boolean. Perfect for conditionals.
Use cmd? inside tools (alias for run?).
if cmd? "which", "rubocop"
cmd "rubocop", "--autocorrect"
end
unless cmd? "git", "diff", "--quiet"
Output.warn "Uncommitted changes"
endWhen you need the output as a string.
result = capture "git", "rev-parse", "HEAD"
commit = result.stdout.strip
result = capture "git", "status", "--porcelain"
if result.success? && result.stdout.empty?
Output.success "Working directory clean"
endStart a process without waiting. Returns immediately with a Controller.
# Start server in background
server = spawn "rails", "server", "-p", "3000"
# Do other work...
run "curl", "http://localhost:3000/health"
# Clean up
server.kill(:TERM)
result = server.result # Wait for exitReplaces the current process. Use sparingly.
exec! "vim", filename
# This line never executesWhen you need shell features (pipes, globs, variable expansion).
# Pipes and variables
shell "grep TODO **/*.rb | wc -l"
shell "echo $HOME"
# Test with shell
if shell? "command -v docker"
shell "docker compose up -d"
endSecurity note: Never interpolate untrusted input into shell commands.
Run Ruby with clean environment.
ruby "-e", "puts RUBY_VERSION"
ruby "script.rb", "--verbose"Invoke another devex tool programmatically.
tool "lint", "--fix"
if tool?("test")
tool "deploy"
end
# Capture tool output
result = tool "version", capture: truePropagates call tree so child tool knows it was invoked from parent.
All commands (except run?/shell?/exec!) return a Result:
result = run "make", "test"
# Status
result.success? # exit_code == 0
result.failed? # exit_code != 0 or didn't start
result.signaled? # killed by signal
result.timed_out? # killed due to timeout
# Info
result.command # ["make", "test"]
result.exit_code # 0-255 or nil if signaled
result.pid # Process ID
result.duration # Seconds elapsed
# Output (if captured)
result.stdout # String or nil
result.stderr # String or nil
result.stdout_lines # Array of lines
# Monad operations
result.exit_on_failure! # Exit process if failed
result.then { run("next") } # Chain if successful
result.map { |out| out.strip } # Transform stdoutspawn returns a Controller for managing background processes:
ctrl = spawn "server"
ctrl.pid # Process ID
ctrl.executing? # Still running?
ctrl.elapsed # Seconds since start
ctrl.kill(:TERM) # Send signal
ctrl.terminate # TERM + wait
ctrl.result # Wait and get Result
ctrl.result(timeout: 30) # With timeoutrun "command",
env: { KEY: "value" }, # Additional environment variables
chdir: "subdir/", # Working directory
timeout: 30, # Seconds before killing
raw: true, # Skip all environment wrappers
bundle: false, # Skip bundle exec wrapping
mise: false, # Skip mise exec wrapping
dotenv: true, # Enable dotenv wrapper (explicit opt-in)
clean_env: true # Clean bundler pollution (default)When running commands, devex automatically applies a wrapper chain:
[dotenv] [mise exec --] [bundle exec] your-command
| Wrapper | When Applied | Default |
|---|---|---|
dotenv |
Explicit opt-in only | OFF |
mise exec -- |
Auto if .mise.toml or .tool-versions exists |
AUTO |
bundle exec |
Auto if Gemfile exists and command looks like a gem |
AUTO |
Examples:
# Just runs: echo hello
run "echo", "hello"
# Auto-detected Gemfile, runs: bundle exec rspec
run "rspec"
# Auto-detected .mise.toml, runs: mise exec -- bundle exec rspec
run "rspec"
# Explicit dotenv, runs: dotenv mise exec -- bundle exec rspec
run "rspec", dotenv: true
# Skip mise wrapping: bundle exec rspec
run "rspec", mise: false
# Skip all wrappers: rspec
run "rspec", raw: true
# Force mise even if not detected: mise exec -- echo hello
run "echo", "hello", mise: true
# Force bundle exec even for non-gem commands: bundle exec custom-script
run "custom-script", bundle: trueGem commands that trigger bundle exec:
rake, rspec, rubocop, standardrb, steep, rbs, rails, sidekiq, puma, unicorn, thin, bundler, bundle, erb, rdoc, ri, yard
Note: The dotenv option requires the dotenv CLI to be installed (gem install dotenv). It loads .env files before running the command.
Devex provides a rich directory context system for tools that need to work with project paths.
# Where dx was invoked from
Devex::Dirs.invoked_dir # => Path
# The destination directory (usually same as invoked_dir)
Devex::Dirs.dest_dir # => Path
# Project root (found by walking up looking for markers)
Devex::Dirs.project_dir # => Path
# Where devex gem itself lives
Devex::Dirs.dx_src_dir # => Path
# Is this inside a project?
Devex::Dirs.in_project? # => true/falseProject markers searched (in order): .dx.yml, .dx/, .git, Gemfile, Rakefile
Lazy path resolution with fail-fast feedback:
prj = Devex::ProjectPaths.new(root: Devex::Dirs.project_dir)
# Standard paths (raises if not found)
prj.root # => /path/to/project
prj.lib # => /path/to/project/lib
prj.src # => /path/to/project/src
prj.bin # => /path/to/project/bin
prj.exe # => /path/to/project/exe
# Paths with alternatives (tries each in order)
prj.test # => finds test/, spec/, or tests/
prj.docs # => finds docs/ or doc/
# Glob from root
prj["*.rb"] # => Array of Path objects
prj["lib/**/*.rb"] # => Array of Path objects
# Config detection (simple vs organized mode)
prj.config # => .dx.yml or .dx/config.yml
prj.tools # => tools/ or .dx/tools/
# Version file detection
prj.version # => VERSION, version.rb, or similar
# Check mode
prj.organized_mode? # => true if .dx/ directory existsImmutable working directory for command execution:
include Devex::WorkingDirMixin
def run
# Current working directory
working_dir # => Path to current context
# Execute block in different directory
within "packages/core" do
working_dir # => /project/packages/core
run "npm", "test" # Runs from packages/core
end
working_dir # => /project (unchanged)
# Nest as deep as needed
within "apps" do
within "web" do
run "yarn", "build"
end
end
# Use with project paths
within prj.test do
run "rspec"
end
endThe within block:
- Takes relative or absolute paths
- Restores directory on block exit (even if exception)
- Thread-safe via mutex
- Passes directory to spawned commands via
chdir:
All directory methods return Devex::Support::Path objects:
path = Devex::Support::Path["/some/path"]
path = Devex::Support::Path.pwd
# Navigation (returns new Path, immutable)
path / "subdir" # => Path to /some/path/subdir
path.parent # => Path to /some
path.join("a", "b") # => Path to /some/path/a/b
# Queries
path.exist?
path.file?
path.directory?
path.readable?
path.writable?
path.executable?
path.absolute?
path.relative?
path.empty? # Empty file or empty directory
# File operations
path.read # => String contents
path.write("content")
path.append("more")
path.touch
path.mkdir
path.mkdir_p
path.rm
path.rm_rf
path.cp(dest)
path.mv(dest)
# Metadata
path.basename # => "path"
path.extname # => ".rb"
path.dirname # => Path to parent
path.expand # => Expanded Path
path.realpath # => Resolved symlinks
# Enumeration
path.children # => Array of Paths
path.glob("**/*.rb") # => Array of Paths
path.find { |p| ... } # Recursive find
# Conversion
path.to_s # => "/some/path"
path.to_str # => "/some/path" (implicit)def run
# What environment are we in?
Devex::Context.env # => "development", "test", "staging", "production"
Devex::Context.development? # => true/false
Devex::Context.production? # => true/false
Devex::Context.safe_env? # => true for dev/test, false for staging/prod
endSet via DX_ENV, DEVEX_ENV, RAILS_ENV, or RACK_ENV.
When invoked by an AI agent (Claude, etc.), output should be structured and machine-readable:
def run
if Devex::Context.agent_mode?
# Output JSON, avoid colors, no interactive prompts
else
# Rich terminal output okay
end
endAgent mode is detected when:
DX_AGENT_MODE=1environment variable is set- stdout/stderr are merged (
2>&1redirection) - Not a TTY and not CI
def run
if Devex::Context.interactive?
# Can prompt user, show progress bars, etc.
else
# Non-interactive: fail or use defaults, no prompts
end
enddef run
if Devex::Context.ci?
# Running in GitHub Actions, GitLab CI, etc.
end
endTools can know if they were invoked from another tool:
def run
Devex::Context.invoked_from_task? # => true if called by another tool
Devex::Context.invoking_task # => "pre-commit" (immediate parent)
Devex::Context.root_task # => "pre-commit" (first in chain)
Devex::Context.call_tree # => ["pre-commit", "lint", "rubocop"]
endUse case: A lint tool might skip certain checks when invoked from pre-commit vs directly.
Devex::Context.terminal? # All three streams are TTYs
Devex::Context.stdout_tty? # stdout specifically
Devex::Context.piped? # Data being piped in or out
Devex::Context.color? # Should we use colors?Tools have access to global flags set by the user:
def run
# Access global options
global_options[:format] # --format value
global_options[:verbose] # -v count (0, 1, 2, ...)
global_options[:quiet] # -q was set
# Convenience methods
verbose? # true if -v was passed
verbose # verbosity level (0, 1, 2, ...)
quiet? # true if -q was passed
# Effective output format (considers global + tool flags + context)
output_format # => :text, :json, or :yaml
endBad:
puts "Header"
puts "Line 1"
puts "Line 2"Good:
$stdout.print Devex.render_template("my_template", data)def run
data = { status: "ok", count: 42 }
case output_format
when :json, :yaml
Devex::Output.data(data, format: output_format)
else
$stdout.print Devex.render_template("my_template", data)
end
endTemplates live in lib/devex/templates/*.erb:
# In your tool:
$stdout.print Devex.render_template("status", {
name: "myproject",
version: "1.0.0",
healthy: true
})<%# lib/devex/templates/status.erb %>
<%= heading "Status" %>
Project: <%= c :emphasis, name %>
Version: <%= version %>
Health: <%= healthy ? csym(:success) : csym(:error) %> <%= healthy ? "OK" : "FAILING" %>Available in all templates:
| Helper | Description | Example |
|---|---|---|
c(color, text) |
Colorize text | <%= c :success, "done" %> |
c(style, color, text) |
Multiple styles | <%= c :bold, :white, "HEADER" %> |
sym(name) |
Unicode symbol | <%= sym :success %> → ✓ |
csym(name) |
Colored symbol | <%= csym :error %> → red ✗ |
heading(text) |
Section heading | <%= heading "Results" %> |
muted(text) |
Gray/secondary | <%= muted "optional info" %> |
bold(text) |
Bold text | <%= bold "important" %> |
hr |
Horizontal rule | <%= hr %> |
Colors: :success, :error, :warning, :info, :header, :muted, :emphasis
Symbols: :success (✓), :error (✗), :warning (⚠), :info (ℹ), :arrow (→), :bullet (•), :dot (·)
Colors automatically respect --no-color. Symbols are always unicode (basic unicode works everywhere).
For composed tools outputting multiple results:
# YAML stream with proper separators
Devex::Output.yaml_stream([result1, result2, result3])
# Outputs: doc1, ---, doc2, ---, doc3, ...
# JSON Lines (one object per line)
Devex::Output.jsonl_stream([result1, result2, result3])def run
unless File.exist?(filename)
Devex::Output.error("File not found: #{filename}")
exit(1)
end
endThe Output.error method automatically adapts to context.
0- Success1- General error2- Usage/argument error
Commands return Result objects instead of raising exceptions:
result = run "might_fail"
if result.failed?
if result.exception
# Command failed to start (not found, permission denied)
Output.error "Command failed to start: #{result.exception.message}"
else
# Command ran but returned non-zero
Output.error "Command failed with exit code #{result.exit_code}"
end
exit 1
endEnable Ruby refinements for cleaner code:
using Devex::Support::CoreExt
# String
"hello".present? # => true
"".blank? # => true
"HELLO".underscore # => "hello"
"hello".titleize # => "Hello"
# Array/Hash
[].blank? # => true
{ a: 1 }.present? # => true
# Enumerable
[1, 2, 3].average # => 2.0
[1, 2, 3].sum_by { |x| x * 2 } # => 12
# Numeric
5.clamp(1, 3) # => 3
5.positive? # => trueOr load globally (for tools that prefer it):
Devex::Support::Global.load!Direct access to terminal colors:
Devex::Support::ANSI["Hello", :green]
Devex::Support::ANSI["Error", :red, :bold]
Devex::Support::ANSI["Text", :white, bg: :blue]
# Check if colors enabled
Devex::Support::ANSI.enabled?
Devex::Support::ANSI.disable!
Devex::Support::ANSI.enable!def run
cli.project_root # Path to project root (where .dx.yml or .git is)
cli.executable_name # "dx"
enddef run
# Via the tool() method (recommended - tracks call tree)
tool "test"
tool "lint", "--fix"
# Legacy method
run_tool("test")
run_tool("lint", "--fix")
endProject tasks override built-ins of the same name:
# tools/version.rb - overrides built-in version command
desc "Custom version display"
def run
# Your custom implementation
# Optionally call the built-in:
builtin.run if builtin
endFor reproducing issues, users can force context detection:
dx --dx-agent-mode version # Force agent mode
dx --dx-no-agent-mode version # Force non-agent mode
dx --dx-env=production version # Force environment
dx --dx-force-color version # Force colors on
dx --dx-no-color version # Force colors offIn tests, use Context.with_overrides:
Devex::Context.with_overrides(agent_mode: true, color: false) do
# Test code here
end# tools/check.rb
desc "Run project health checks"
long_desc <<~DESC
Runs various health checks on the project and reports status.
Use --fix to automatically fix issues where possible.
DESC
flag :fix, "--fix", desc: "Automatically fix issues"
flag :strict, "--strict", desc: "Fail on warnings"
include Devex::Exec
include Devex::WorkingDirMixin
def run
results = {
checks: [],
passed: 0,
failed: 0,
warnings: 0
}
# Run tests
within prj.test do
result = capture "rspec", "--format", "json"
if result.success?
results[:passed] += 1
results[:checks] << { name: "tests", status: "passed" }
else
results[:failed] += 1
results[:checks] << { name: "tests", status: "failed" }
end
end
# Run linter (use cmd/cmd? inside def run to avoid shadowing)
if cmd? "which", "rubocop"
result = cmd "rubocop", *(fix ? ["--autocorrect"] : [])
status = result.success? ? "passed" : "failed"
results[:checks] << { name: "lint", status: status }
result.success? ? results[:passed] += 1 : results[:failed] += 1
end
# Output based on format
case output_format
when :json, :yaml
Devex::Output.data(results, format: output_format)
else
$stdout.print Devex.render_template("check_results", results)
end
# Exit code
exit(1) if results[:failed] > 0
exit(1) if strict && results[:warnings] > 0
end<%# lib/devex/templates/check_results.erb %>
<%= heading "Health Check Results" %>
<% checks.each do |check| -%>
<%= csym(check[:status] == "passed" ? :success : :error) %> <%= check[:name] %>
<% end -%>
<%= muted "#{passed} passed, #{failed} failed, #{warnings} warnings" %>| Interface | Purpose |
|---|---|
| Context | |
Devex::Context.* |
Runtime detection (agent, CI, env, call tree) |
Devex::Dirs.* |
Core directories (invoked, project, dest) |
Devex::ProjectPaths |
Lazy project path resolution |
Devex::WorkingDirMixin |
Working directory context |
| Execution | |
Devex::Exec |
Command execution (run, capture, spawn, etc.) |
Devex::Exec::Result |
Command result with monad operations |
Devex::Exec::Controller |
Background process management |
| Output | |
Devex::Output.* |
Styled output, structured data |
Devex.render_template(name, locals) |
Render ERB template |
| Support | |
Devex::Support::Path |
Immutable path operations |
Devex::Support::ANSI |
Terminal colors |
Devex::Support::CoreExt |
Ruby refinements |
| Tool Runtime | |
output_format |
Effective format (:text, :json, :yaml) |
verbose?, quiet? |
Global verbosity flags |
cli.project_root |
Project root path |
tool(name, *args) |
Invoke another tool |
builtin |
Access overridden built-in |
options |
Tool-specific flag/arg values |
global_options |
Global flag values |