claude_agent 0.7.8 → 0.7.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +73 -0
- data/README.md +603 -47
- data/SPEC.md +92 -28
- data/lib/claude_agent/client.rb +181 -7
- data/lib/claude_agent/content_blocks.rb +193 -5
- data/lib/claude_agent/control_protocol.rb +97 -11
- data/lib/claude_agent/conversation.rb +248 -0
- data/lib/claude_agent/cumulative_usage.rb +106 -0
- data/lib/claude_agent/event_handler.rb +152 -0
- data/lib/claude_agent/get_session_messages.rb +236 -0
- data/lib/claude_agent/hooks.rb +104 -253
- data/lib/claude_agent/list_sessions.rb +398 -0
- data/lib/claude_agent/mcp/server.rb +3 -3
- data/lib/claude_agent/mcp/tool.rb +4 -4
- data/lib/claude_agent/message_parser.rb +201 -185
- data/lib/claude_agent/messages.rb +86 -13
- data/lib/claude_agent/options.rb +5 -4
- data/lib/claude_agent/permission_queue.rb +87 -0
- data/lib/claude_agent/permission_request.rb +151 -0
- data/lib/claude_agent/permissions.rb +4 -2
- data/lib/claude_agent/query.rb +34 -0
- data/lib/claude_agent/session.rb +71 -3
- data/lib/claude_agent/session_message_relation.rb +59 -0
- data/lib/claude_agent/session_paths.rb +120 -0
- data/lib/claude_agent/tool_activity.rb +78 -0
- data/lib/claude_agent/turn_result.rb +239 -0
- data/lib/claude_agent/types.rb +45 -0
- data/lib/claude_agent/version.rb +1 -1
- data/lib/claude_agent.rb +58 -2
- data/sig/claude_agent.rbs +336 -7
- metadata +12 -1
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module ClaudeAgent
|
|
6
|
+
# Discovers and lists past Claude Code sessions from disk.
|
|
7
|
+
#
|
|
8
|
+
# Reads session metadata directly from ~/.claude/projects/ without spawning
|
|
9
|
+
# a CLI subprocess. Matches the TypeScript SDK's listSessions() behavior.
|
|
10
|
+
#
|
|
11
|
+
# @example List all sessions
|
|
12
|
+
# sessions = ClaudeAgent.list_sessions
|
|
13
|
+
# sessions.each { |s| puts "#{s.summary} (#{s.session_id})" }
|
|
14
|
+
#
|
|
15
|
+
# @example List sessions for a specific directory
|
|
16
|
+
# sessions = ClaudeAgent.list_sessions(dir: "/path/to/project", limit: 10)
|
|
17
|
+
#
|
|
18
|
+
module ListSessions
|
|
19
|
+
# Constants matching TypeScript SDK
|
|
20
|
+
BUFFER_SIZE = 65_536 # 64KB
|
|
21
|
+
|
|
22
|
+
# Patterns for filtering non-meaningful user prompts (matches TypeScript MM regex)
|
|
23
|
+
META_MESSAGE_PATTERN = /\A(?:<local-command-stdout>|<session-start-hook>|<tick>|<goal>|\[Request interrupted by user[^\]]*\]|\s*<ide_opened_file>[\s\S]*<\/ide_opened_file>\s*\z|\s*<ide_selection>[\s\S]*<\/ide_selection>\s*\z)/
|
|
24
|
+
|
|
25
|
+
# Pattern for extracting command names
|
|
26
|
+
COMMAND_NAME_PATTERN = /<command-name>(.*?)<\/command-name>/
|
|
27
|
+
|
|
28
|
+
class << self
|
|
29
|
+
# List past sessions with metadata.
|
|
30
|
+
#
|
|
31
|
+
# @param dir [String, nil] Directory to scope sessions to (with worktree support).
|
|
32
|
+
# When nil, returns sessions from all projects.
|
|
33
|
+
# @param limit [Integer, nil] Maximum number of sessions to return.
|
|
34
|
+
# @return [Array<SessionInfo>] Sessions sorted by last_modified descending.
|
|
35
|
+
def call(dir: nil, limit: nil)
|
|
36
|
+
if dir
|
|
37
|
+
list_for_directory(dir, limit)
|
|
38
|
+
else
|
|
39
|
+
list_all(limit)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
# --- Session File Reading ---
|
|
46
|
+
|
|
47
|
+
# Read head and tail buffers from a session file.
|
|
48
|
+
#
|
|
49
|
+
# @param path [String] Path to .jsonl file
|
|
50
|
+
# @return [Hash, nil] { mtime:, size:, head:, tail: } or nil on error
|
|
51
|
+
def read_file_buffers(path)
|
|
52
|
+
stat = File.stat(path)
|
|
53
|
+
File.open(path, "r:UTF-8") do |f|
|
|
54
|
+
head = f.read(BUFFER_SIZE)
|
|
55
|
+
return nil if head.nil? || head.empty?
|
|
56
|
+
|
|
57
|
+
tail_offset = [ 0, stat.size - BUFFER_SIZE ].max
|
|
58
|
+
tail = if tail_offset > 0
|
|
59
|
+
f.seek(tail_offset)
|
|
60
|
+
f.read(BUFFER_SIZE) || head
|
|
61
|
+
else
|
|
62
|
+
head
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
{ mtime: (stat.mtime.to_f * 1000).to_i, size: stat.size, head: head, tail: tail }
|
|
66
|
+
end
|
|
67
|
+
rescue SystemCallError
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# --- JSON String Scanning ---
|
|
72
|
+
# These match TypeScript's K9 (first occurrence) and V9 (last occurrence)
|
|
73
|
+
# functions. They scan for JSON key-value pairs without full parsing.
|
|
74
|
+
|
|
75
|
+
# Extract the first occurrence of a JSON string value for the given key.
|
|
76
|
+
#
|
|
77
|
+
# @param buffer [String] Text to scan
|
|
78
|
+
# @param key [String] JSON key name
|
|
79
|
+
# @return [String, nil] Extracted value or nil
|
|
80
|
+
def extract_first(buffer, key)
|
|
81
|
+
prefixes = [ "\"#{key}\":\"", "\"#{key}\": \"" ]
|
|
82
|
+
prefixes.each do |prefix|
|
|
83
|
+
idx = buffer.index(prefix)
|
|
84
|
+
next unless idx
|
|
85
|
+
|
|
86
|
+
start = idx + prefix.length
|
|
87
|
+
pos = start
|
|
88
|
+
while pos < buffer.length
|
|
89
|
+
if buffer[pos] == "\\"
|
|
90
|
+
pos += 2
|
|
91
|
+
next
|
|
92
|
+
end
|
|
93
|
+
if buffer[pos] == '"'
|
|
94
|
+
return unescape_json_string(buffer[start...pos])
|
|
95
|
+
end
|
|
96
|
+
pos += 1
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Extract the last occurrence of a JSON string value for the given key.
|
|
103
|
+
#
|
|
104
|
+
# @param buffer [String] Text to scan
|
|
105
|
+
# @param key [String] JSON key name
|
|
106
|
+
# @return [String, nil] Extracted value or nil
|
|
107
|
+
def extract_last(buffer, key)
|
|
108
|
+
prefixes = [ "\"#{key}\":\"", "\"#{key}\": \"" ]
|
|
109
|
+
result = nil
|
|
110
|
+
prefixes.each do |prefix|
|
|
111
|
+
search_from = 0
|
|
112
|
+
loop do
|
|
113
|
+
idx = buffer.index(prefix, search_from)
|
|
114
|
+
break unless idx
|
|
115
|
+
|
|
116
|
+
start = idx + prefix.length
|
|
117
|
+
pos = start
|
|
118
|
+
while pos < buffer.length
|
|
119
|
+
if buffer[pos] == "\\"
|
|
120
|
+
pos += 2
|
|
121
|
+
next
|
|
122
|
+
end
|
|
123
|
+
if buffer[pos] == '"'
|
|
124
|
+
result = unescape_json_string(buffer[start...pos])
|
|
125
|
+
break
|
|
126
|
+
end
|
|
127
|
+
pos += 1
|
|
128
|
+
end
|
|
129
|
+
search_from = pos + 1
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
result
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Unescape a JSON string value (handles \\, \", \n, etc.).
|
|
136
|
+
#
|
|
137
|
+
# @param str [String]
|
|
138
|
+
# @return [String]
|
|
139
|
+
def unescape_json_string(str)
|
|
140
|
+
return str unless str.include?("\\")
|
|
141
|
+
|
|
142
|
+
JSON.parse("\"#{str}\"")
|
|
143
|
+
rescue JSON::ParserError
|
|
144
|
+
str
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# --- First Prompt Extraction ---
|
|
148
|
+
|
|
149
|
+
# Extract the first meaningful user prompt from the head buffer.
|
|
150
|
+
# Matches TypeScript's mW function.
|
|
151
|
+
#
|
|
152
|
+
# @param head [String] First 64KB of the session file
|
|
153
|
+
# @return [String, nil] First meaningful prompt or nil
|
|
154
|
+
def extract_first_prompt(head)
|
|
155
|
+
command_name = nil
|
|
156
|
+
|
|
157
|
+
head.each_line do |line|
|
|
158
|
+
line = line.chomp
|
|
159
|
+
# Skip non-user messages (quick string check before parsing)
|
|
160
|
+
next unless line.include?('"type":"user"') || line.include?('"type": "user"')
|
|
161
|
+
# Skip tool results
|
|
162
|
+
next if line.include?('"tool_result"')
|
|
163
|
+
# Skip meta messages
|
|
164
|
+
next if line.include?('"isMeta":true') || line.include?('"isMeta": true')
|
|
165
|
+
# Skip compact summaries
|
|
166
|
+
next if line.include?('"isCompactSummary":true') || line.include?('"isCompactSummary": true')
|
|
167
|
+
|
|
168
|
+
parsed = JSON.parse(line)
|
|
169
|
+
next unless parsed["type"] == "user"
|
|
170
|
+
|
|
171
|
+
message = parsed["message"]
|
|
172
|
+
next unless message
|
|
173
|
+
|
|
174
|
+
content = message["content"]
|
|
175
|
+
texts = extract_text_parts(content)
|
|
176
|
+
|
|
177
|
+
texts.each do |text|
|
|
178
|
+
cleaned = text.gsub("\n", " ").strip
|
|
179
|
+
next if cleaned.empty?
|
|
180
|
+
|
|
181
|
+
# Check for command name pattern
|
|
182
|
+
match = COMMAND_NAME_PATTERN.match(cleaned)
|
|
183
|
+
if match
|
|
184
|
+
command_name ||= match[1]
|
|
185
|
+
next
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Skip meta message patterns
|
|
189
|
+
next if META_MESSAGE_PATTERN.match?(cleaned)
|
|
190
|
+
|
|
191
|
+
# Truncate long prompts at 200 chars
|
|
192
|
+
cleaned = "#{cleaned[0, 200].strip}\u2026" if cleaned.length > 200
|
|
193
|
+
return cleaned
|
|
194
|
+
end
|
|
195
|
+
rescue JSON::ParserError
|
|
196
|
+
next
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
command_name
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Extract text parts from message content (string or array of blocks).
|
|
203
|
+
#
|
|
204
|
+
# @param content [String, Array, nil]
|
|
205
|
+
# @return [Array<String>]
|
|
206
|
+
def extract_text_parts(content)
|
|
207
|
+
case content
|
|
208
|
+
when String
|
|
209
|
+
[ content ]
|
|
210
|
+
when Array
|
|
211
|
+
content.filter_map do |block|
|
|
212
|
+
block["text"] if block.is_a?(Hash) && block["type"] == "text" && block["text"].is_a?(String)
|
|
213
|
+
end
|
|
214
|
+
else
|
|
215
|
+
[]
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# --- Session Metadata Parsing ---
|
|
220
|
+
|
|
221
|
+
# Parse a single session file into a SessionInfo.
|
|
222
|
+
#
|
|
223
|
+
# @param path [String] Path to .jsonl file
|
|
224
|
+
# @param session_id [String] UUID extracted from filename
|
|
225
|
+
# @return [SessionInfo, nil] Parsed session info or nil if filtered
|
|
226
|
+
def parse_session_file(path, session_id)
|
|
227
|
+
buffers = read_file_buffers(path)
|
|
228
|
+
return nil unless buffers
|
|
229
|
+
|
|
230
|
+
head = buffers[:head]
|
|
231
|
+
tail = buffers[:tail]
|
|
232
|
+
|
|
233
|
+
# Check first line only for sidechain
|
|
234
|
+
first_newline = head.index("\n")
|
|
235
|
+
first_line = first_newline ? head[0...first_newline] : head
|
|
236
|
+
return nil if first_line.include?('"isSidechain":true') || first_line.include?('"isSidechain": true')
|
|
237
|
+
|
|
238
|
+
# Filter team sessions
|
|
239
|
+
return nil if extract_first(head, "teamName")
|
|
240
|
+
|
|
241
|
+
# Extract metadata
|
|
242
|
+
custom_title = extract_last(tail, "customTitle")
|
|
243
|
+
first_prompt = extract_first_prompt(head)
|
|
244
|
+
git_branch = extract_last(tail, "gitBranch") || extract_first(head, "gitBranch")
|
|
245
|
+
cwd = extract_first(head, "cwd")
|
|
246
|
+
|
|
247
|
+
# Build summary: customTitle > last summary > firstPrompt > "(session)"
|
|
248
|
+
summary_from_file = extract_last(tail, "summary")
|
|
249
|
+
summary = custom_title || summary_from_file || first_prompt || "(session)"
|
|
250
|
+
|
|
251
|
+
SessionInfo.new(
|
|
252
|
+
session_id: session_id,
|
|
253
|
+
summary: summary,
|
|
254
|
+
last_modified: buffers[:mtime],
|
|
255
|
+
file_size: buffers[:size],
|
|
256
|
+
custom_title: custom_title,
|
|
257
|
+
first_prompt: first_prompt,
|
|
258
|
+
git_branch: git_branch,
|
|
259
|
+
cwd: cwd
|
|
260
|
+
)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# --- Directory Scanning ---
|
|
264
|
+
|
|
265
|
+
# Scan a project directory for session files.
|
|
266
|
+
#
|
|
267
|
+
# @param dir_path [String] Path to a project directory under ~/.claude/projects/
|
|
268
|
+
# @return [Array<SessionInfo>] Parsed sessions (unfiltered duplicates possible)
|
|
269
|
+
def scan_project_dir(dir_path)
|
|
270
|
+
return [] unless File.directory?(dir_path)
|
|
271
|
+
|
|
272
|
+
entries = Dir.entries(dir_path)
|
|
273
|
+
sessions = []
|
|
274
|
+
|
|
275
|
+
entries.each do |entry|
|
|
276
|
+
next unless entry.end_with?(".jsonl")
|
|
277
|
+
|
|
278
|
+
stem = entry[0...-6] # Remove .jsonl extension
|
|
279
|
+
next unless SessionPaths::UUID_PATTERN.match?(stem)
|
|
280
|
+
|
|
281
|
+
full_path = File.join(dir_path, entry)
|
|
282
|
+
session = parse_session_file(full_path, stem)
|
|
283
|
+
sessions << session if session
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
sessions
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# --- Listing Modes ---
|
|
290
|
+
|
|
291
|
+
# List sessions for a specific directory, including worktree siblings.
|
|
292
|
+
# Matches TypeScript's bM function.
|
|
293
|
+
#
|
|
294
|
+
# @param dir [String]
|
|
295
|
+
# @param limit [Integer, nil]
|
|
296
|
+
# @return [Array<SessionInfo>]
|
|
297
|
+
def list_for_directory(dir, limit)
|
|
298
|
+
resolved = SessionPaths.realpath(dir)
|
|
299
|
+
worktrees = SessionPaths.git_worktrees(resolved)
|
|
300
|
+
|
|
301
|
+
# Simple case: not in a worktree (or single worktree)
|
|
302
|
+
if worktrees.length <= 1
|
|
303
|
+
project_dir = SessionPaths.find_project_dir(resolved)
|
|
304
|
+
return [] unless project_dir
|
|
305
|
+
return sort_and_limit(scan_project_dir(project_dir), limit)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Complex case: multiple worktrees - scan all related project directories
|
|
309
|
+
base = SessionPaths.projects_dir
|
|
310
|
+
|
|
311
|
+
# Build prefix list from worktree paths (longest first for matching)
|
|
312
|
+
prefixes = worktrees.map { |wt| { path: wt, prefix: SessionPaths.encode_project_dir(wt) } }
|
|
313
|
+
prefixes.sort_by! { |p| -p[:prefix].length }
|
|
314
|
+
|
|
315
|
+
all_sessions = []
|
|
316
|
+
seen_dirs = Set.new
|
|
317
|
+
|
|
318
|
+
# First: sessions from the exact directory
|
|
319
|
+
project_dir = SessionPaths.find_project_dir(resolved)
|
|
320
|
+
if project_dir
|
|
321
|
+
seen_dirs.add(File.basename(project_dir))
|
|
322
|
+
all_sessions.concat(scan_project_dir(project_dir))
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Then: scan all project directories matching worktree prefixes
|
|
326
|
+
if File.directory?(base)
|
|
327
|
+
Dir.entries(base).each do |entry|
|
|
328
|
+
next if entry.start_with?(".")
|
|
329
|
+
next if seen_dirs.include?(entry)
|
|
330
|
+
entry_path = File.join(base, entry)
|
|
331
|
+
next unless File.directory?(entry_path)
|
|
332
|
+
|
|
333
|
+
prefixes.each do |p|
|
|
334
|
+
prefix = p[:prefix]
|
|
335
|
+
if entry == prefix || (prefix.length >= SessionPaths::MAX_SLUG_LENGTH && entry.start_with?("#{prefix[0, SessionPaths::MAX_SLUG_LENGTH]}-"))
|
|
336
|
+
seen_dirs.add(entry)
|
|
337
|
+
all_sessions.concat(scan_project_dir(entry_path))
|
|
338
|
+
break
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
sort_and_limit(deduplicate(all_sessions), limit)
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# List sessions from all project directories.
|
|
348
|
+
# Matches TypeScript's EM function.
|
|
349
|
+
#
|
|
350
|
+
# @param limit [Integer, nil]
|
|
351
|
+
# @return [Array<SessionInfo>]
|
|
352
|
+
def list_all(limit)
|
|
353
|
+
base = SessionPaths.projects_dir
|
|
354
|
+
return [] unless File.directory?(base)
|
|
355
|
+
|
|
356
|
+
all_sessions = []
|
|
357
|
+
|
|
358
|
+
Dir.entries(base).each do |entry|
|
|
359
|
+
next if entry.start_with?(".")
|
|
360
|
+
dir_path = File.join(base, entry)
|
|
361
|
+
next unless File.directory?(dir_path)
|
|
362
|
+
all_sessions.concat(scan_project_dir(dir_path))
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
sort_and_limit(deduplicate(all_sessions), limit)
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# --- Helpers ---
|
|
369
|
+
|
|
370
|
+
# Deduplicate sessions by session_id, keeping the most recent.
|
|
371
|
+
# Matches TypeScript's cW function.
|
|
372
|
+
#
|
|
373
|
+
# @param sessions [Array<SessionInfo>]
|
|
374
|
+
# @return [Array<SessionInfo>]
|
|
375
|
+
def deduplicate(sessions)
|
|
376
|
+
by_id = {}
|
|
377
|
+
sessions.each do |session|
|
|
378
|
+
existing = by_id[session.session_id]
|
|
379
|
+
if !existing || session.last_modified > existing.last_modified
|
|
380
|
+
by_id[session.session_id] = session
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
by_id.values
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# Sort by last_modified descending and apply optional limit.
|
|
387
|
+
# Matches TypeScript's E4 function.
|
|
388
|
+
#
|
|
389
|
+
# @param sessions [Array<SessionInfo>]
|
|
390
|
+
# @param limit [Integer, nil]
|
|
391
|
+
# @return [Array<SessionInfo>]
|
|
392
|
+
def sort_and_limit(sessions, limit)
|
|
393
|
+
sorted = sessions.sort_by { |s| -s.last_modified }
|
|
394
|
+
limit ? sorted.first(limit) : sorted
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
end
|
|
@@ -15,7 +15,7 @@ module ClaudeAgent
|
|
|
15
15
|
# name: "add",
|
|
16
16
|
# description: "Add two numbers",
|
|
17
17
|
# schema: {a: Float, b: Float}
|
|
18
|
-
# ) { |args| args[
|
|
18
|
+
# ) { |args| args[:a] + args[:b] }
|
|
19
19
|
#
|
|
20
20
|
# server = ClaudeAgent::MCP::Server.new(
|
|
21
21
|
# name: "calculator",
|
|
@@ -113,7 +113,7 @@ module ClaudeAgent
|
|
|
113
113
|
|
|
114
114
|
def handle_tools_call(params)
|
|
115
115
|
tool_name = params["name"]
|
|
116
|
-
arguments = params["arguments"] || {}
|
|
116
|
+
arguments = (params["arguments"] || {}).deep_symbolize_keys
|
|
117
117
|
|
|
118
118
|
tool = @tools[tool_name]
|
|
119
119
|
unless tool
|
|
@@ -156,7 +156,7 @@ module ClaudeAgent
|
|
|
156
156
|
#
|
|
157
157
|
# @example
|
|
158
158
|
# tool = ClaudeAgent::MCP.tool("greet", "Greet someone", {name: String}) do |args|
|
|
159
|
-
# "Hello, #{args[
|
|
159
|
+
# "Hello, #{args[:name]}!"
|
|
160
160
|
# end
|
|
161
161
|
#
|
|
162
162
|
def self.tool(name, description, schema = {}, annotations: nil, &handler)
|
|
@@ -10,7 +10,7 @@ module ClaudeAgent
|
|
|
10
10
|
# description: "Greet a person",
|
|
11
11
|
# schema: {name: String}
|
|
12
12
|
# ) do |args|
|
|
13
|
-
# "Hello, #{args[
|
|
13
|
+
# "Hello, #{args[:name]}!"
|
|
14
14
|
# end
|
|
15
15
|
#
|
|
16
16
|
# @example Tool with complex schema
|
|
@@ -27,9 +27,9 @@ module ClaudeAgent
|
|
|
27
27
|
# required: ["operation", "a", "b"]
|
|
28
28
|
# }
|
|
29
29
|
# ) do |args|
|
|
30
|
-
# case args[
|
|
31
|
-
# when "add" then args[
|
|
32
|
-
# when "subtract" then args[
|
|
30
|
+
# case args[:operation]
|
|
31
|
+
# when "add" then args[:a] + args[:b]
|
|
32
|
+
# when "subtract" then args[:a] - args[:b]
|
|
33
33
|
# end
|
|
34
34
|
# end
|
|
35
35
|
#
|