mcpbeat Sign in

Ruby Skill for Cursor

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`).

7k tokens
context cost
the whole folder, loaded on every use
6
files
ships runnable scripts
0
copies elsewhere
how many repositories repackaged it
105
stars on the repo
on the repository, not the skill itself

Install

one command, takes just this skill from the repository
npx skills add https://github.com/ericrisco/rsc-harness --skill ruby

What comes with it

16 960 bytes besides the instruction
evals/README.md
evals/cases.yaml
references/gems-and-testing.md
references/metaprogramming.md
scripts/verify.sh

The instruction itself

12 sections, as written by the author

Idiomatic Ruby, the language

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.

Mental model

Internalize these before writing a line:

  • Everything is an expression and returns a value. if, case, blocks, method

bodies all yield a value — name and return it instead of threading a flag.

  • Everything is an object receiving messages. 5.times, nil.to_a, "x".freeze.

There are no primitives to special-case.

  • Reach for Enumerable before you write a loop. A manual index loop in Ruby is

almost always a map/select/reduce you have not spotted yet.

  • Prefer returning new values to mutating in place. Mutation is allowed but it is

the exception you justify, not the default.

  • Freeze your string literals. New files start with # frozen_string_literal: true.
  • Dynamism is the trap, not the feature. Metaprogramming is a scalpel; if a plain

method, a Struct, or a Data does the job, use that.

Blocks, procs, lambdas, yield

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)

Enumerable & Comparable

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

Modules & composition

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 can

wrap 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.

Value objects: Data vs Struct

# 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.

Metaprogramming, sparingly

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.

  • Prefer define_method (defines real, introspectable methods) over method_missing

(intercepts at call time, slower, invisible to respond_to? and tooling).

  • If you *must* use method_missing, you must also define respond_to_missing?

otherwise respond_to?, method, and duck-typing all lie about your object.

  • Never 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.

Gems & Bundler

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.

  • Use the pessimistic constraint ~> 2.3 (allows 2.x >= 2.3, blocks 3.0) for

libraries; pin tighter only when forced.

  • Commit Gemfile.lock for applications (reproducible installs); for a *library*

gem, do not commit the lock — let consumers resolve.

  • Scaffold a gem with 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.

Ruby 4.0 / 3.4 hygiene

# 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"`
  • The magic comment freezes every string literal in the file; mutating one then raises

FrozenError. This is the cause of most "my string froze after upgrading" reports.

  • it reads cleaner than _1 for one-arg blocks.
  • Ruby 4.0 ships ZJIT (a new JIT written in Rust, successor to YJIT) and removed

Ractor.yield/Ractor#take in favor of Ractor::Port.

Testing

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.

Linting & typing (optional layers)

  • Linter: Standard (zero-config, "no bikeshadding" wrapper) when you want one less

argument; RuboCop (.rubocop.yml, every cop opt-in/out) when the team wants control.

  • Types: two coexisting systems — RBS (official signatures in separate .rbs

files, 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-patterns

| 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 |

How to use it

Copy the folder

Take ericrisco/ruby from the repository into ~/.claude/skills for personal use, or into .claude/skills inside a project.

Check the name does not clash

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.