Here's my solution.

I spent a few hours writing a BNF parser which was supposed to let me do this:

-- begin buggy code --
CENT = BnfTerm.new(/(%)/ ) { '100' }
INTEGER = /([1-9][0-9]*)/
DICE = BnfTerm.new(CENT,:|,INTEGER)
term = BnfTerm.new()
ROLL = BnfTerm.new(term, /d/, DICE) {|a,b|
(1..a.to_i).inject(0){|s,i|s+rand(b.to_i)+1} }
term.define(DICE, :|,ROLL) {|m| m}
#...
class Dice
  @@rule = DIEROLL
  def initialize expr
    @expr = expr
  end
  def roll
   @@rule.parse(@expr)
  end
end
-- end --
but it was too brittle, and it would go into endless recursion on a
lot of valid inputs.

So I switched to a quick,short simple solution: add a #d method to
integer and let eval do the work:

--- dice.rb --
class Integer
  def d n
    (0...self).inject(0){|s,i| s+rand(n)+1}
  end
end

class Dice
  def initialize str
    @rule= str.gsub(/%/,'100').gsub(/([^\d)]|^)d/,'\1 1d')  # %->100
and bare d ->1d
    while @rule.gsub!(/([^.])d(\d+|\(.*\))/,'\1.d(\2)')          #
'dX' ->  '.d(X)'
    end                                                               
    #repeat to deal with nesting
  end
  def roll
    eval(@rule)
  end
end

d = Dice.new(ARGV[0]||'d6')
(ARGV[1] || 1).to_i.times { print "#{d.roll}  " }
puts

---
-Adam