Project

rubyshell

0.03
A long-lived project that still receives updates
A rubist way to run shell commands
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies
 Project Readme

RubyShell

The Rubyist way to write shell scripts

Gem Version Gem Version Build Status License

Installation · Usage · Wiki · Examples · Contributing



  cd "/log" do
    ls.each_line do |line|
      puts cat(line)
    end
  end

Yes, that's valid Ruby! ls and cat are just shell commands, but RubyShell makes them behave like Ruby methods.

Installation

bundle add rubyshell

Or install directly:

gem install rubyshell

Why RubyShell?

The Problem

Ever written something like this?

# Bash: Find large files modified in the last 7 days, show top 10 with human sizes
find . -type f -mtime -7 -exec ls -lh {} \; 2>/dev/null | \
  awk '{print $5, $9}' | \
  sort -hr | \
  head -10

Or tried to do error handling in bash?

# Bash: Hope nothing goes wrong...
output=$(some_command 2>&1) || echo "failed somehow"

The Solution

sh do
  # Ruby + Shell: Same task, actually readable
  find(".", type: "f", mtime: "-7")
    .lines
    .map { |f| [File.size(f.strip), f.strip] }
    .sort_by(&:first)
    .last(10)
    .each { |size, file| puts "#{size / 1024}KB  #{file}" }
rescue RubyShell::CommandError => e
  puts "Failed: #{e.message}"
  puts "Exit code: #{e.status}"
end

Usage

Basic Commands

require 'rubyshell'

sh do
  pwd                        # Run any command
  ls("-la")                  # With arguments
  mkdir("project")           # Create directories
  docker("ps", all: true)    # --all flag
  git("status", s: true)     # -s flag
end

# Or chain directly
sh.git("log", oneline: true, n: 5)

Pipelines

sh do
  # Using chain block
  chain { cat("access.log") | grep("ERROR") | wc("-l") }

  # Using bang pattern
  (cat!("data.csv") | sort! | uniq!).exec
end

Directory Scoping

sh do
  cd "/var/log" do
    # Commands run here, then return to original dir
    tail("-n", "100", "syslog")
  end
  # Back to original directory
end

Error Handling

sh do
  begin
    rm("-rf", "important_folder")
  rescue RubyShell::CommandError => e
    puts "Command: #{e.command}"
    puts "Stderr: #{e.stderr}"
    puts "Exit code: #{e.status}"
  end
end

Parallel Execution

Run multiple commands concurrently and get results as they complete:

sh do
  results = parallel do
    curl("https://api1.example.com")
    curl("https://api2.example.com")
    chain { ls | wc("-l") }
  end

  results.each { |r| puts r }
end

Returns an Enumerator with results in completion order. Errors are captured and returned as values (not raised).

Environment Variables

# Command-level
sh.npm("start", _env: { NODE_ENV: "production" })

# Block-level
sh(env: { DATABASE_URL: "postgres://localhost/db" }) do
  rake("db:migrate")
end

# Global
RubyShell.env[:API_KEY] = "secret"
RubyShell.config(env: { DEBUG: "true" })

Debug Mode

# Global
RubyShell.debug = true

# Block scope
RubyShell.debug { sh.ls }

# Per command
sh.git("status", _debug: true)
# Output:
#   Executed: git status
#   Duration: 0.003521s
#   Pid: 12345
#   Exit code: 0
#   Stdout: "On branch main..."

Output Parsers

Parse command output directly into Ruby objects:

sh.cat("data.json", _parse: :json)   # => Hash
sh.cat("config.yml", _parse: :yaml)  # => Hash
sh.cat("users.csv", _parse: :csv)    # => Array

Chain Options

# Debug mode for chains
chain(debug: true) { ls | grep("test") }

# Parse chain output
chain(parse: :json) { curl("https://api.example.com") }

Real-World Examples

Git Workflow Automation

sh do
  # Stash changes, pull, pop, and show what changed
  changes = git("status", porcelain: true).lines

  if changes.any?
    puts "Stashing #{changes.count} changed files..."
    git("stash")
    git("pull", rebase: true)
    git("stash", "pop")
  else
    git("pull", rebase: true)
  end

  # Show recent commits by author
  git("log", oneline: true, n: 100)
    .lines
    .map { |line| `git show -s --format='%an' #{line.split.first}`.strip }
    .tally
    .sort_by { |_, count| -count }
    .first(5)
    .each { |author, count| puts "#{author}: #{count} commits" }
end

Log Analysis

sh do
  cd "/var/log" do
    # Parse nginx logs: top 10 IPs by request count
    cat("nginx/access.log")
      .lines
      .map { |line| line.split.first }  # Extract IP
      .tally
      .sort_by { |_, count| -count }
      .first(10)
      .each { |ip, count| puts "#{ip.ljust(15)} #{count} requests" }
  end
end

Docker Cleanup

sh do
  # Remove containers that exited more than a day ago
  containers = docker("ps", a: true, format: "{{.ID}} {{.Status}}")
    .lines
    .select { |line| line.include?("Exited") }
    .map { |line| line.split.first }

  if containers.any?
    puts "Removing #{containers.count} dead containers..."
    docker("rm", *containers)
  end

  # Remove dangling images
  images = docker("images", f: "dangling=true", q: true).lines.map(&:strip)

  if images.any?
    puts "Removing #{images.count} dangling images..."
    docker("rmi", *images)
  end

  puts "Disk usage:"
  puts docker("system", "df")
end

Batch File Processing

sh do
  # Convert all PNGs to WebP, preserving directory structure
  find(".", name: "*.png")
    .lines
    .map(&:strip)
    .each do |png|
      webp = png.sub(/\.png$/, ".webp")
      puts "Converting: #{png}"

      begin
        cwebp("-q", "80", png, o: webp)
        rm(png)
      rescue RubyShell::CommandError => e
        puts "  Failed: #{e.message}"
      end
    end
end

System Health Check

sh do
  puts "=== System Health ==="

  # Disk usage warnings
  df("-h")
    .lines
    .drop(1)
    .each do |line|
      parts = line.split
      usage = parts[4].to_i
      mount = parts[5]
      puts "WARNING: #{mount} at #{usage}%" if usage > 80
    end

  # Memory info
  mem = cat("/proc/meminfo")
    .lines
    .first(3)
    .to_h { |l| k, v = l.split(":"); [k, v.strip] }

  puts "\nMemory: #{mem['MemAvailable']} available of #{mem['MemTotal']}"

  # Top 5 CPU consumers
  puts "\nTop CPU processes:"
  ps("aux", sort: "-%cpu")
    .lines
    .drop(1)
    .first(5)
    .each { |proc| puts "  #{proc.split[10]}% - #{proc.split[10..-1].join(' ').slice(0, 40)}" }
end

Interactive Script with Confirmation

sh do
  files = find(".", name: "*.tmp", mtime: "+30").lines.map(&:strip)

  if files.empty?
    puts "No old temp files found."
    exit
  end

  puts "Found #{files.count} temp files older than 30 days:"
  files.first(10).each { |f| puts "  #{f}" }
  puts "  ... and #{files.count - 10} more" if files.count > 10

  total_size = files.sum { |f| File.size(f) rescue 0 }
  puts "\nTotal size: #{total_size / 1024 / 1024}MB"

  print "\nDelete all? [y/N] "
  if gets.strip.downcase == 'y'
    files.each { |f| rm(f) }
    puts "Deleted #{files.count} files."
  end
end

Deploy Script

#!/usr/bin/env ruby
require 'rubyshell'

APP_NAME = "myapp"
DEPLOY_PATH = "/var/www/#{APP_NAME}"

sh do
  puts "Deploying #{APP_NAME}..."

  # Ensure clean state
  git("status", porcelain: true).lines.tap do |changes|
    abort "Uncommitted changes!" if changes.any?
  end

  # Run tests
  puts "Running tests..."
  rake("spec")

  # Build and deploy
  cd DEPLOY_PATH do
    git("pull", "origin", "main")
    bundle("install", deployment: true)
    rake("db:migrate")

    # Restart with zero downtime
    puts "Restarting..."
    systemctl("reload", APP_NAME)
  end

  puts "Deployed successfully!"

rescue RubyShell::CommandError => e
  puts "Deploy failed: #{e.message}"
  exit 1
end

Comparison

Task Bash RubyShell
Error handling cmd || echo "fail" rescue CommandError
String manipulation echo $var | sed | awk result.gsub(/.../)
Data structures Arrays only Hashes, objects, classes
Iteration for f in *; do .each, .map, .select
Testing DIY RSpec, Minitest

Documentation

See Wiki for complete documentation including all options and advanced features.

Development

bin/setup          # Install dependencies
rake spec          # Run tests
rake rubocop       # Lint code
bin/console        # Interactive console

Contributing

Bug reports and pull requests are welcome on GitHub. See CONTRIBUTING.md for guidelines and testing patterns.

Sponsors

Avantsoft

License

MIT License - see LICENSE.