ralph.rb 1.2.435535439

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.
Files changed (60) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/gem-push.yml +47 -0
  3. data/.gitignore +79 -0
  4. data/.rubocop.yml +6018 -0
  5. data/.ruby-version +1 -0
  6. data/AGENTS.md +113 -0
  7. data/Gemfile +11 -0
  8. data/LICENSE +21 -0
  9. data/README.md +656 -0
  10. data/bin/rubocop +8 -0
  11. data/bin/test +5 -0
  12. data/exe/ralph +8 -0
  13. data/lib/ralph/agents/base.rb +132 -0
  14. data/lib/ralph/agents/claude_code.rb +24 -0
  15. data/lib/ralph/agents/codex.rb +25 -0
  16. data/lib/ralph/agents/open_code.rb +30 -0
  17. data/lib/ralph/agents.rb +24 -0
  18. data/lib/ralph/cli.rb +222 -0
  19. data/lib/ralph/config.rb +40 -0
  20. data/lib/ralph/git/file_snapshot.rb +60 -0
  21. data/lib/ralph/helpers.rb +76 -0
  22. data/lib/ralph/iteration.rb +220 -0
  23. data/lib/ralph/loop.rb +196 -0
  24. data/lib/ralph/output/active_loop_error.rb +13 -0
  25. data/lib/ralph/output/banner.rb +29 -0
  26. data/lib/ralph/output/completion_deferred.rb +12 -0
  27. data/lib/ralph/output/completion_detected.rb +17 -0
  28. data/lib/ralph/output/config_summary.rb +31 -0
  29. data/lib/ralph/output/context_consumed.rb +11 -0
  30. data/lib/ralph/output/iteration.rb +45 -0
  31. data/lib/ralph/output/max_iterations_reached.rb +16 -0
  32. data/lib/ralph/output/no_plugin_warning.rb +14 -0
  33. data/lib/ralph/output/nonzero_exit_warning.rb +11 -0
  34. data/lib/ralph/output/plugin_error.rb +12 -0
  35. data/lib/ralph/output/status.rb +176 -0
  36. data/lib/ralph/output/struggle_warning.rb +18 -0
  37. data/lib/ralph/output/task_completion.rb +12 -0
  38. data/lib/ralph/output/tasks_file_created.rb +11 -0
  39. data/lib/ralph/prompt_template.rb +183 -0
  40. data/lib/ralph/storage/context.rb +58 -0
  41. data/lib/ralph/storage/history.rb +117 -0
  42. data/lib/ralph/storage/state.rb +178 -0
  43. data/lib/ralph/storage/tasks.rb +244 -0
  44. data/lib/ralph/threads/heartbeat.rb +44 -0
  45. data/lib/ralph/threads/stream_reader.rb +50 -0
  46. data/lib/ralph/version.rb +5 -0
  47. data/lib/ralph.rb +67 -0
  48. data/original/bin/ralph.js +13 -0
  49. data/original/ralph.ts +1706 -0
  50. data/ralph.gemspec +35 -0
  51. data/ralph2.gemspec +35 -0
  52. data/screenshot.webp +0 -0
  53. data/specs/README.md +46 -0
  54. data/specs/agents.md +172 -0
  55. data/specs/cli.md +223 -0
  56. data/specs/iteration.md +173 -0
  57. data/specs/output.md +104 -0
  58. data/specs/storage/local-data-structure.md +246 -0
  59. data/specs/tasks.md +295 -0
  60. metadata +150 -0
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Ralph
6
+ module Agents
7
+ class Base
8
+ include ::Ralph::Helpers
9
+
10
+ # Result of executing an agent process
11
+ ExecutionResult = Struct.new(:stdout_text, :stderr_text, :tool_counts, :exit_code, keyword_init: true) do
12
+ def combined_output
13
+ "#{stdout_text}\n#{stderr_text}"
14
+ end
15
+ end
16
+
17
+ def type = raise NotImplementedError
18
+ def command = raise NotImplementedError
19
+ def config_name = raise NotImplementedError
20
+
21
+ def parse_tool_output(_line) = raise NotImplementedError
22
+ def build_args(_prompt, _model, _options) = raise NotImplementedError
23
+
24
+ def build_env(_options) = ENV.to_h.dup
25
+
26
+ def execute(prompt, options = {})
27
+ command_args = build_args(prompt, options[:model],
28
+ { allow_all_permissions: options[:allow_all_permissions] })
29
+ environment = build_env(
30
+ filter_plugins: options[:disable_plugins],
31
+ allow_all_permissions: options[:allow_all_permissions]
32
+ )
33
+ full_command = [command] + command_args
34
+
35
+ if options[:stream_output]
36
+ execute_streaming(environment, full_command, options[:on_line])
37
+ else
38
+ execute_captured(environment, full_command)
39
+ end
40
+ rescue StandardError => agent_error
41
+ Agents::Base::ExecutionResult.new(
42
+ stdout_text: "", stderr_text: agent_error.to_s, tool_counts: {}, exit_code: -1
43
+ )
44
+ end
45
+
46
+ # Collects tool usage counts from output text.
47
+ # Delegates to parse_tool_output for each line.
48
+ def collect_tool_counts(text)
49
+ Hash.new(0).tap do |counts|
50
+ text.each_line do |line|
51
+ tool = parse_tool_output(line)
52
+ counts[tool] += 1 if tool
53
+ end
54
+ end
55
+ end
56
+
57
+ # Returns a fatal error message if one is detected in the output,
58
+ # or nil if no fatal error is found. Subclasses may override.
59
+ def detect_fatal_error(_output) = nil
60
+
61
+ # Extracts error patterns from agent output. Subclasses may override
62
+ # for agent-specific error formats.
63
+ def extract_errors(output)
64
+ errors = []
65
+ output.each_line do |line|
66
+ lower = line.downcase
67
+ if lower.include?("error:") ||
68
+ lower.include?("failed:") ||
69
+ lower.include?("exception:") ||
70
+ lower.include?("typeerror") ||
71
+ lower.include?("syntaxerror") ||
72
+ lower.include?("referenceerror") ||
73
+ (lower.include?("test") && lower.include?("fail"))
74
+ cleaned = line.strip[0, 200]
75
+ errors << cleaned if cleaned && !cleaned.empty? && !errors.include?(cleaned)
76
+ end
77
+ end
78
+ errors.first(10)
79
+ end
80
+
81
+ def validate!
82
+ path = which(command)
83
+ unless path
84
+ $stderr.puts "Error: #{config_name} CLI ('#{command}') not found."
85
+ exit 1
86
+ end
87
+ end
88
+
89
+ private
90
+
91
+ def execute_streaming(environment, full_command, on_line)
92
+ tool_counts = Hash.new(0)
93
+ stdout_text = +""
94
+ stderr_text = +""
95
+ mutex = Mutex.new
96
+
97
+ stdin, stdout, stderr, wait_thread = Open3.popen3(environment, *full_command)
98
+ stdin.close
99
+
100
+ stdout_reader = Threads::StreamReader.new(
101
+ stdout, stdout_text, mutex, tool_counts, on_line, false, method(:parse_tool_output)
102
+ )
103
+ stderr_reader = Threads::StreamReader.new(
104
+ stderr, stderr_text, mutex, tool_counts, on_line, true, method(:parse_tool_output)
105
+ )
106
+
107
+ stdout_reader.join
108
+ stderr_reader.join
109
+ exit_status = wait_thread.value
110
+
111
+ ExecutionResult.new(
112
+ stdout_text: stdout_text,
113
+ stderr_text: stderr_text,
114
+ tool_counts: tool_counts,
115
+ exit_code: exit_status.exitstatus || 1
116
+ )
117
+ end
118
+
119
+ def execute_captured(environment, full_command)
120
+ stdout, stderr, status = Open3.capture3(environment, *full_command, stdin_data: "")
121
+ tool_counts = collect_tool_counts("#{stdout}\n#{stderr}")
122
+
123
+ ExecutionResult.new(
124
+ stdout_text: stdout,
125
+ stderr_text: stderr,
126
+ tool_counts: tool_counts,
127
+ exit_code: status.exitstatus || 1
128
+ )
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ralph
4
+ module Agents
5
+ class ClaudeCode < Base
6
+
7
+ def type = :claude_code
8
+ def command = "claude"
9
+ def config_name = "Claude Code"
10
+
11
+ def build_args(prompt, model, options)
12
+ args = ["-p", prompt]
13
+ args.push("--model", model) if model && !model.empty?
14
+ args.push("--dangerously-skip-permissions") if options && options[:allow_all_permissions]
15
+ args
16
+ end
17
+
18
+ def parse_tool_output(line)
19
+ match = strip_ansi(line).match(/(?:Using|Called|Tool:)\s+([A-Za-z0-9_-]+)/i)
20
+ match ? match[1] : nil
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ralph
4
+ module Agents
5
+ class Codex < Base
6
+
7
+ def type = :codex
8
+ def command = "codex"
9
+ def config_name = "Codex"
10
+
11
+ def build_args(prompt, model, options)
12
+ args = ["exec"]
13
+ args.push("--model", model) if model && !model.empty?
14
+ args.push("--full-auto") if options && options[:allow_all_permissions]
15
+ args.push(prompt)
16
+ args
17
+ end
18
+
19
+ def parse_tool_output(line)
20
+ match = strip_ansi(line).match(/(?:Tool:|Using|Calling|Running)\s+([A-Za-z0-9_-]+)/i)
21
+ match ? match[1] : nil
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ralph
4
+ module Agents
5
+ class OpenCode < Base
6
+
7
+ def type = :opencode
8
+ def command = "opencode"
9
+ def config_name = "OpenCode"
10
+
11
+ def build_args(prompt, model, _options)
12
+ args = ["run"]
13
+ args.push("-m", model) if model && !model.empty?
14
+ args.push(prompt)
15
+ args
16
+ end
17
+
18
+ def parse_tool_output(line)
19
+ match = strip_ansi(line).match(/^\|\s{2}([A-Za-z0-9_-]+)/)
20
+ match ? match[1] : nil
21
+ end
22
+
23
+ def detect_fatal_error(output)
24
+ if output.include?("ralph-wiggum is not yet ready for use. This is a placeholder package.")
25
+ "Placeholder plugin error detected"
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,24 @@
1
+ module Ralph
2
+ module Agents
3
+
4
+ module_function
5
+
6
+ AGENT_NAME_MAP = {
7
+ "opencode" => :opencode,
8
+ "claude-code" => :claude_code,
9
+ "codex" => :codex
10
+ }.freeze
11
+
12
+ def valid_agent_names = AGENT_NAME_MAP.keys
13
+
14
+ def resolve(name_str)
15
+ AGENT_NAME_MAP[name_str].then do |sym|
16
+ case sym
17
+ when :opencode then OpenCode.new
18
+ when :claude_code then ClaudeCode.new
19
+ when :codex then Codex.new
20
+ else nil end
21
+ end
22
+ end
23
+ end
24
+ end
data/lib/ralph/cli.rb ADDED
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ralph
4
+ class CLI
5
+ def initialize(argv = ARGV)
6
+ @config = Config.new
7
+
8
+ @parser = OptionParser.new do |o|
9
+ o.banner = <<~BANNER
10
+ Ralph Wiggum Loop - Iterative AI development with AI agents
11
+
12
+ Usage:
13
+ ralph "<prompt>" [options]
14
+
15
+ Commands:
16
+ --status Show current Ralph loop status and history
17
+ --add-context TEXT Add context for the next iteration
18
+ --clear-context Clear any pending context
19
+ --list-tasks Display the current task list with indices
20
+ --add-task "desc" Add a new task to the list
21
+ --remove-task N Remove task at index N (including subtasks)
22
+
23
+ Options:
24
+ BANNER
25
+
26
+ o.on("--agent AGENT", Agents.valid_agent_names, "AI agent: #{Agents.valid_agent_names.join(', ')} (default: opencode)") do |v|
27
+ @config.chosen_agent = v
28
+ end
29
+
30
+ o.on("--min-iterations N", Integer, "Minimum iterations before completion (default: 1)") do |v|
31
+ @config.min_iterations = v
32
+ end
33
+
34
+ o.on("--max-iterations N", Integer, "Maximum iterations before stopping (default: unlimited)") do |v|
35
+ @config.max_iterations = v
36
+ end
37
+
38
+ o.on("--completion-promise TEXT", "Phrase that signals completion (default: COMPLETE)") do |v|
39
+ @config.completion_promise = v
40
+ end
41
+
42
+ o.on("-t", "--tasks", "Enable Tasks Mode for structured task tracking") do
43
+ @config.tasks_mode = true
44
+ end
45
+
46
+ o.on("--task-promise TEXT", "Phrase that signals task completion (default: READY_FOR_NEXT_TASK)") do |v|
47
+ @config.task_promise = v
48
+ end
49
+
50
+ o.on("--model MODEL", "Model to use (agent-specific)") do |v|
51
+ @config.model = v
52
+ end
53
+
54
+ o.on("--[no-]stream", "Stream agent output in real-time (default: on)") do |v|
55
+ @config.stream_output = v
56
+ end
57
+
58
+ o.on("--verbose-tools", "Print every tool line (disable compact summary)") do
59
+ @config.verbose_tools = true
60
+ end
61
+
62
+ o.on("--no-plugins", "Disable non-auth OpenCode plugins (opencode only)") do
63
+ @config.disable_plugins = true
64
+ end
65
+
66
+ o.on("--[no-]allow-all", "Auto-approve all tool permissions (default: on)") do |v|
67
+ @config.allow_all_permissions = v
68
+ end
69
+
70
+ # Subcommands -- these set a command to dispatch after parsing
71
+ o.on("-v", "--version", "Show version") do
72
+ puts "ralph #{VERSION}"
73
+ exit 0
74
+ end
75
+
76
+ o.on("--status", "Show current loop status and history") do
77
+ Output::Status.call(options: @config.to_h)
78
+ exit 0
79
+ end
80
+
81
+ o.on("--add-context TEXT", "Add context for the next iteration") do |context_text|
82
+
83
+ Storage::Context.new.append(
84
+ "\n## Context added at #{Time.now.utc.iso8601}\n#{context_text}\n"
85
+ )
86
+
87
+ puts "✅ Context added for next iteration"
88
+ puts " File: #{Storage::Context.new.path}"
89
+
90
+ Storage::State.load.then do |state|
91
+ if state&.active
92
+ puts " Will be picked up in iteration #{state.iteration + 1}"
93
+ else
94
+ puts " Will be used when loop starts"
95
+ end
96
+ end
97
+
98
+ exit 0
99
+ end
100
+
101
+ o.on("--clear-context", "Clear any pending context") do
102
+ Storage::Context.new.then do |context|
103
+ if context.present?
104
+ context.clear
105
+ puts "✅ Context cleared"
106
+ else
107
+ puts "ℹ️ No pending context to clear"
108
+ end
109
+ end
110
+
111
+ exit 0
112
+ end
113
+
114
+ o.on("--list-tasks", "Display the current task list") do
115
+ begin
116
+ Storage::Tasks.new.load_tasks.then do |tasks|
117
+ if tasks
118
+ tasks.display_with_indices
119
+ else
120
+ puts "No tasks file found. Use --add-task to create your first task."
121
+ end
122
+ end
123
+ rescue StandardError => e
124
+ $stderr.puts "Error reading tasks file: #{e}"
125
+ exit 1
126
+ end
127
+
128
+ exit 0
129
+ end
130
+
131
+ o.on("--add-task DESC", "Add a new task to the list") do |description|
132
+ begin
133
+ Storage::Tasks.new.add_task(description)
134
+ puts "✅ Task added: \"#{description}\""
135
+ rescue StandardError => e
136
+ $stderr.puts "Error adding task: #{e}"
137
+ exit 1
138
+ end
139
+
140
+ exit 0
141
+ end
142
+
143
+ o.on("--remove-task N", Integer, "Remove task at index N") do |task_index|
144
+ begin
145
+ Storage::Tasks.new.remove_task(task_index)
146
+ puts "✅ Removed task #{task_index} and its subtasks"
147
+ rescue IndexError => e
148
+ $stderr.puts "Error: #{e.message}"
149
+ exit 1
150
+ rescue RuntimeError => e
151
+ $stderr.puts "Error: #{e.message}"
152
+ exit 1
153
+ rescue StandardError => e
154
+ $stderr.puts "Error removing task: #{e}"
155
+ exit 1
156
+ end
157
+
158
+ exit 0
159
+ end
160
+
161
+ o.separator ""
162
+ o.separator "Examples:"
163
+ o.separator ' ralph "Build a REST API for todos"'
164
+ o.separator ' ralph "Fix the auth bug" --max-iterations 10'
165
+ o.separator ' ralph "Add tests" --completion-promise "ALL TESTS PASS" --model openai/gpt-5.1'
166
+ o.separator ' ralph --status'
167
+ o.separator ' ralph --add-context "Focus on the auth module first"'
168
+ o.separator ""
169
+ o.separator "How it works:"
170
+ o.separator " 1. Sends your prompt to the selected AI agent"
171
+ o.separator " 2. AI agent works on the task"
172
+ o.separator " 3. Checks output for completion promise"
173
+ o.separator " 4. If not complete, repeats with same prompt"
174
+ o.separator " 5. AI sees its previous work in files"
175
+ o.separator " 6. Continues until promise detected or max iterations"
176
+ o.separator ""
177
+ o.separator "To stop manually: Ctrl+C"
178
+ o.separator "Learn more: https://ghuntley.com/ralph/"
179
+ end
180
+
181
+ end
182
+
183
+ def run(argv = ARGV.dup)
184
+ @user_prompt = @parser.parse(argv).then do |remaining_args|
185
+ if $stdin.tty?
186
+ remaining_args.join(" ").strip
187
+ else
188
+ [$stdin.read, remaining_args.join(" ")].join("\n").strip
189
+ end
190
+ end
191
+
192
+ if @user_prompt.empty?
193
+ abort "
194
+ Error: No prompt provided
195
+ Usage: ralph 'Your task description' [options]
196
+ Run 'ralph --help' for more information
197
+ "
198
+ end
199
+
200
+ tasks = Storage::Tasks.new
201
+ context = Storage::Context.new
202
+
203
+ PromptTemplate.inject(@user_prompt, context: context, tasks: tasks).then do |prompt|
204
+ @config.prompt = prompt
205
+
206
+ if @config.max_iterations > 0 && @config.min_iterations > @config.max_iterations
207
+ abort "Error: --min-iterations (#{@config.min_iterations}) cannot be greater than --max-iterations (#{@config.max_iterations})"
208
+ end
209
+
210
+ state = Storage::State.from_config(@config, prompt: prompt)
211
+ history = Storage::History.new
212
+
213
+ Ralph::Loop.new(@config, state, history, context, tasks).run
214
+ end
215
+
216
+ rescue StandardError => e
217
+ $stderr.puts "Fatal error: #{e}"
218
+ Storage::State.clear
219
+ exit 1
220
+ end
221
+ end
222
+ end
@@ -0,0 +1,40 @@
1
+ module Ralph
2
+ class Config
3
+ # Single source of truth for config option names and their defaults.
4
+ # Use a lambda for mutable defaults to avoid shared state.
5
+ OPTIONS = {
6
+ prompt: -> { PromptTemplate.new("") },
7
+ min_iterations: 1,
8
+ max_iterations: 0,
9
+ completion_promise: "COMPLETE",
10
+ tasks_mode: false,
11
+ task_promise: "READY_FOR_NEXT_TASK",
12
+ model: "",
13
+ chosen_agent: "opencode",
14
+ disable_plugins: false,
15
+ allow_all_permissions: true,
16
+ stream_output: true,
17
+ verbose_tools: false,
18
+ }.freeze
19
+
20
+ attr_accessor(*OPTIONS.keys)
21
+
22
+ # Runtime state (not constructor args, not in to_h).
23
+ attr_accessor :current_pid, :stopping
24
+
25
+ def initialize(**opts)
26
+ OPTIONS.each do |key, default|
27
+ value = opts.fetch(key) { default.respond_to?(:call) ? default.call : default }
28
+ instance_variable_set(:"@#{key}", value)
29
+ end
30
+ @current_pid = nil
31
+ @stopping = false
32
+ end
33
+
34
+ # Returns a hash of options suitable for passing to Loop#call.
35
+ # Excludes runtime state (current_pid, stopping).
36
+ def to_h
37
+ OPTIONS.keys.map { |k| [k, send(k)] }.to_h
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Ralph
6
+ module Git
7
+ # Captures and compares file state via git for change detection
8
+ class FileSnapshot
9
+ attr_reader :files
10
+
11
+ def initialize(files)
12
+ @files = files
13
+ end
14
+
15
+ # Capture a snapshot of all tracked/modified files and their git hashes
16
+ def self.capture
17
+ files = {}
18
+ begin
19
+ status, _, _ = Open3.capture3("git", "status", "--porcelain")
20
+ tracked, _, _ = Open3.capture3("git", "ls-files")
21
+
22
+ all_files = Set.new
23
+ status.strip.each_line do |line|
24
+ name = line[3..]&.strip
25
+ all_files.add(name) if name && !name.empty?
26
+ end
27
+ tracked.strip.each_line do |file|
28
+ f = file.strip
29
+ all_files.add(f) unless f.empty?
30
+ end
31
+
32
+ all_files.each do |file|
33
+ begin
34
+ hash, _, _ = Open3.capture3("git", "hash-object", file)
35
+ files[file] = hash.strip unless hash.strip.empty?
36
+ rescue StandardError
37
+ # skip
38
+ end
39
+ end
40
+ rescue StandardError
41
+ # git not available
42
+ end
43
+ new(files)
44
+ end
45
+
46
+ # Return list of files that changed between this snapshot and a later one
47
+ def modified_since(other)
48
+ Array.new.tap do |changed|
49
+ other.files.each do |file, hash|
50
+ changed << file if files[file] != hash
51
+ end
52
+
53
+ files.each_key do |file|
54
+ changed << file unless other.files.key?(file)
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ralph
4
+ module Helpers
5
+ def strip_ansi(input)
6
+ input.gsub(/\x1B\[[0-9;]*m/, "")
7
+ end
8
+
9
+ def escape_regex(str)
10
+ Regexp.escape(str)
11
+ end
12
+
13
+ def format_duration(ms)
14
+ total_seconds = [0, (ms / 1000).floor].max
15
+ hours = total_seconds / 3600
16
+ minutes = (total_seconds % 3600) / 60
17
+ seconds = total_seconds % 60
18
+
19
+ if hours > 0
20
+ "#{hours}:#{minutes.to_s.rjust(2, '0')}:#{seconds.to_s.rjust(2, '0')}"
21
+ else
22
+ "#{minutes}:#{seconds.to_s.rjust(2, '0')}"
23
+ end
24
+ end
25
+
26
+ def format_duration_long(ms)
27
+ total_seconds = [0, (ms / 1000).floor].max
28
+ hours = total_seconds / 3600
29
+ minutes = (total_seconds % 3600) / 60
30
+ seconds = total_seconds % 60
31
+
32
+ if hours > 0
33
+ "#{hours}h #{minutes}m #{seconds}s"
34
+ elsif minutes > 0
35
+ "#{minutes}m #{seconds}s"
36
+ else
37
+ "#{seconds}s"
38
+ end
39
+ end
40
+
41
+ def format_tool_summary(tool_counts, max_items = 6)
42
+ if tool_counts.empty?
43
+ ""
44
+ else
45
+ entries = tool_counts.sort_by { |_, v| -v }
46
+ shown = entries.first(max_items)
47
+ remaining = entries.length - shown.length
48
+
49
+ shown.map { |name, count| "#{name} #{count}" }
50
+ .tap { |parts| parts << "+#{remaining} more" if remaining > 0 }
51
+ .then { |parts| parts.join(" • ") }
52
+ end
53
+ end
54
+
55
+ def check_completion(output, promise)
56
+ pattern = /<promise>\s*#{escape_regex(promise)}\s*<\/promise>/i
57
+ output.match?(pattern)
58
+ end
59
+
60
+ # Cross-platform which
61
+ def which(cmd)
62
+ exts = ENV["PATHEXT"] ? ENV["PATHEXT"].split(";") : [""]
63
+ ENV["PATH"].split(File::PATH_SEPARATOR).each do |path|
64
+ exts.each do |ext|
65
+ exe = File.join(path, "#{cmd}#{ext}")
66
+ return exe if File.executable?(exe) && !File.directory?(exe)
67
+ end
68
+ end
69
+ nil
70
+ end
71
+
72
+ def now_ms
73
+ (Time.now.to_f * 1000).to_i
74
+ end
75
+ end
76
+ end