Use when writing or refactoring plain Ruby outside Rails — scripts, CLIs, gems, libraries: Enumerable chains and blocks, module mixins and value objects, Bundler/gemspec packaging, whether metaprogramming earns its keep, Minitest or RSpec, Ruby 3.4/4.0 frozen-string hygiene. NOT Rails, ActiveRecord, or ActiveSupport idioms (that is `rails`).
npx skills add https://github.com/ericrisco/rsc-harness --skill ruby
Write Ruby a senior Rubyist would sign off on: expression-oriented, block-driven,
leaning on Enumerable and Comparable instead of hand-rolled loops, with
frozen_string_literal hygiene and a real Gemfile/.gemspec when you package.
This is Ruby the language and its non-Rails ecosystem — blocks, modules, gems,
metaprogramming, Minitest/RSpec. Delegate: Rails apps, ActiveRecord models, controllers,
migrations, views, Hotwire — anything that loads Rails or uses ActiveSupport/ActiveRecord
idioms — to rails; the same task in another language to
python, go, rust,
elixir, php or
typescript; the CI pipeline that runs your Ruby tests to
github-actions — this skill writes the tests, not the workflow.
Targets Ruby 4.0 (released 2025-12-25) on 3.4 semantics: Prism is the default
parser, string literals warn on mutation without a magic comment, it is an implicit
block param, and Set is now a core class.
Internalize these before writing a line:
if, case, blocks, methodbodies all yield a value — name and return it instead of threading a flag.
5.times, nil.to_a, "x".freeze.There are no primitives to special-case.
Enumerable before you write a loop. A manual index loop in Ruby isalmost always a map/select/reduce you have not spotted yet.
the exception you justify, not the default.
# frozen_string_literal: true.method, a Struct, or a Data does the job, use that.
Blocks are the spine of Ruby. A method takes a block implicitly via yield, or
explicitly with &block when it must store or forward it.
# frozen_string_literal: true
# Implicit: yield runs the caller's block and returns its value.
def with_timing
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
result = yield
[result, Process.clock_gettime(Process::CLOCK_MONOTONIC) - start]
end
# Explicit: capture the block as a Proc to forward or store it.
def retrying(times, &block)
attempts = 0
begin
block.call
rescue StandardError
(attempts += 1) < times ? retry : raise
end
end
proc vs lambda differ in two ways that bite:
| Aspect | proc / block | lambda / -> |
|--------|----------------|-----------------|
| Arity | Lenient — extra args dropped, missing become nil | Strict — wrong count raises ArgumentError |
| return | Returns from the enclosing method (can surprise) | Returns from the lambda only |
Rule: use a lambda for a function-like value you call yourself; use a block/proc
for the "do this with each element" pattern. For cheap accumulation prefer
each_with_object (returns the seed) or reduce; use tap (returns the receiver) for
side effects and then for piping a value through a transform.
# Bad: index loop building a result by mutation.
result = []
(0...items.length).each { |i| result << items[i].upcase if items[i].size > 3 }
# Good: intent reads top-to-bottom, no index, no pre-seeded array.
result = items.select { |s| s.size > 3 }.map(&:upcase)
Most loops map to one named method. Pick by intent, not by habit:
| You want to… | Use |
|---------------------------------------|------------------------------|
| Transform every element | map |
| Keep / drop by predicate | select / reject |
| Fold into one value | reduce / sum / min_by |
| Build a Hash or accumulate into a seed| each_with_object |
| Count occurrences | tally |
| Bucket by a key | group_by |
| First match | find (detect) |
| Pair index with element | each_with_index |
Make your own classes first-class collections by including Enumerable and defining
each; get <, >, between?, clamp, sort for free by including Comparable and
defining <=>.
# frozen_string_literal: true
class Playlist
include Enumerable
def initialize(tracks) = @tracks = tracks
def each(&) = @tracks.each(&) # one method unlocks map/select/sort/...
end
class Version
include Comparable
attr_reader :major, :minor
def initialize(major, minor)
@major = major
@minor = minor
end
def <=>(other) = [major, minor] <=> [other.major, other.minor]
end
Ruby favors composition through mixins over deep inheritance.
include M — adds M's instance methods to instances (the default mixin).prepend M — inserts M *before* the class in the lookup chain, so M's methods canwrap the originals via super (cleaner than aliasing for decoration).
extend M — adds M's methods as *class/singleton* methods.Rule: reach for a module mixin when the same behavior is shared across unrelated
classes; reserve inheritance for a genuine is-a hierarchy. Namespace with module Foo
to avoid global constant clashes. Need to patch a class without polluting it globally?
Use a refinement (refine … using) — a scoped, reversible scalpel — never a bare
monkey-patch. Full refinement scoping and prepend-wrapping in
references/metaprogramming.md.
# frozen_string_literal: true
Point = Data.define(:x, :y) # immutable value object (Ruby 3.2+)
origin = Point.new(x: 0, y: 0) # no setters; with(x: 1) returns a copy
Cursor = Struct.new(:x, :y) # mutable; has setters and is Enumerable
| Need | Reach for |
|--------------------------------------------|---------------|
| Immutable value with equality + with | Data.define |
| Mutable record, or array/positional access | Struct.new |
Default to Data for value semantics; pick Struct only when you genuinely need
mutation or positional/to_a behavior.
Decision rule, in order: could a plain method, a Struct, or a Data do this?
If yes, do that. Only when the method *names themselves* are dynamic (driven by data or
config) does metaprogramming earn its keep.
define_method (defines real, introspectable methods) over method_missing(intercepts at call time, slower, invisible to respond_to? and tooling).
method_missing, you must also define respond_to_missing? —otherwise respond_to?, method, and duck-typing all lie about your object.
eval a string built from user input.# Good: dynamic method names, but real methods that introspect and document themselves.
class Config
def initialize(**data) = @data = data
%i[host port timeout].each do |key|
define_method(key) { @data[key] }
define_method("#{key}=") { |value| @data[key] = value }
end
end
Building a real DSL, instance_eval/class_eval, and the full
method_missing + respond_to_missing? pairing live in
references/metaprogramming.md.
Bundler is the universal dependency manager. bundle init creates a Gemfile;
bundle install resolves and writes Gemfile.lock; run project binaries with
bundle exec so they use the locked versions.
~> 2.3 (allows 2.x >= 2.3, blocks 3.0) forlibraries; pin tighter only when forced.
Gemfile.lock for applications (reproducible installs); for a *library*gem, do not commit the lock — let consumers resolve.
bundle gem NAME, which generates the lib/, test//spec/,.gemspec, and Rakefile layout.
# my_gem.gemspec
Gem::Specification.new do |spec|
spec.name = "my_gem"
spec.version = MyGem::VERSION # SemVer: MAJOR.MINOR.PATCH
spec.summary = "One-line description."
spec.authors = ["You"]
spec.files = Dir["lib/**/*.rb"]
spec.required_ruby_version = ">= 3.4"
spec.add_dependency "thor", "~> 1.3"
end
Full bundle gem tree, publishing flow (gem build / gem push), and Rake test tasks
are in references/gems-and-testing.md.
# frozen_string_literal: true # without it, 3.4+ warns under -W:deprecated on mutation
nums.map { it * 2 } # `it` is the implicit single block param (3.4+)
seen = Set.new([1, 2, 3]) # Set is a core class in 4.0 — no `require "set"`
FrozenError. This is the cause of most "my string froze after upgrading" reports.
it reads cleaner than _1 for one-arg blocks.Ractor.yield/Ractor#take in favor of Ractor::Port.
Both ship a clean path; choose by ceremony tolerance:
| Framework | Pick when… |
|-----------|-----------|
| Minitest | You want the default — ships with Ruby, tiny surface, assert-style or spec-style, fast. |
| RSpec | You want the expressive DSL and large ecosystem (describe/context/it/expect, let, mocks). |
# test/tally_test.rb (Minitest)
require "minitest/autorun"
require "tally"
class TallyTest < Minitest::Test
def test_counts_words
assert_equal({ "a" => 2, "b" => 1 }, Tally.count("a b a"))
end
end
# spec/tally_spec.rb (RSpec)
RSpec.describe Tally do
subject(:counts) { described_class.count("a b a") }
it { is_expected.to eq("a" => 2, "b" => 1) }
end
Run via bundle exec rake test (Minitest) or bundle exec rspec (RSpec). Fuller
examples with let/subject/context are in
references/gems-and-testing.md.
argument; RuboCop (.rubocop.yml, every cop opt-in/out) when the team wants control.
.rbsfiles, checked by Steep) and Sorbet (inline sig blocks, with experimental
inline-RBS-comment support). Add types only on a library's public surface or a hot,
bug-prone core — not on throwaway scripts. RuboCop vs Standard config sits in
references/gems-and-testing.md.
| Anti-pattern | Why it bites | Do instead |
|--------------|--------------|------------|
| Monkey-patching a core class globally | Action-at-a-distance; collides with gems and future Ruby | A refinement (scoped) or a wrapper/helper |
| method_missing without respond_to_missing? | respond_to?/method/duck-typing all lie | Pair them, or prefer define_method |
| Mutating a frozen string literal | FrozenError after the magic comment | dup before mutating, or build a new string |
| for x in coll loops | Leaks the loop var; un-idiomatic | coll.each/map/select |
| rescue Exception (bare) | Swallows SignalException, NoMemoryError, exit | rescue StandardError (or specific classes) |
| Not committing Gemfile.lock for an app | Non-reproducible installs across machines | Commit it for apps; omit only for library gems |
| eval of user-derived input | Remote code execution | Parse explicitly; never eval untrusted data |
| Over-DSLing config a Hash would serve | Hidden control flow, slow method_missing | A plain Hash or Data object |
Take ericrisco/ruby from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.