Module: Ace::Core::Atoms::CommandExecutor
- Defined in:
- lib/ace/core/atoms/command_executor.rb
Overview
Pure command execution functions with safety features Configuration is loaded from .ace-defaults/core/settings.yml and can be overridden at .ace/core/settings.yml (ADR-022)
Constant Summary collapse
- DEFAULT_TIMEOUT =
Fallback timeout value if config is not available
30- MAX_OUTPUT_SIZE =
Maximum output size (1MB)
1_048_576
Class Method Summary collapse
-
.available?(command) ⇒ Boolean
Check if a command is available in PATH.
-
.build_command(command, *args) ⇒ String
Build a safe command string with proper escaping.
-
.capture(command, **options) ⇒ String?
Execute command and return only stdout if successful.
-
.configured_timeout ⇒ Integer
Get configured timeout from config cascade Falls back to DEFAULT_TIMEOUT if config is unavailable.
-
.execute(command, timeout: nil, max_output: MAX_OUTPUT_SIZE, cwd: nil) ⇒ Hash
Execute a command with timeout and output capture.
-
.execute_batch(commands, **options) ⇒ Array<Hash>
Execute multiple commands in sequence.
-
.stream(command, output_callback: nil, timeout: nil, cwd: nil) ⇒ Hash
Execute command with real-time output streaming.
Class Method Details
.available?(command) ⇒ Boolean
Check if a command is available in PATH
138 139 140 141 142 143 144 145 146 147 148 149 150 151 |
# File 'lib/ace/core/atoms/command_executor.rb', line 138 def available?(command) return false if command.nil? || command.empty? # Extract just the command name (first word) cmd = command.split.first # Check if command exists in PATH ENV["PATH"].split(File::PATH_SEPARATOR).any? do |path| executable = File.join(path, cmd) File.executable?(executable) && !File.directory?(executable) end rescue false end |
.build_command(command, *args) ⇒ String
Build a safe command string with proper escaping
221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 |
# File 'lib/ace/core/atoms/command_executor.rb', line 221 def build_command(command, *args) return nil if command.nil? escaped_args = args.flatten.compact.map do |arg| # Shell escape the argument if arg.match?(/[^A-Za-z0-9_\-.,:\/@]/) # Properly escape single quotes in shell arguments "'#{arg.gsub("'", "'\\''")}'" else arg end end [command, *escaped_args].join(" ") end |
.capture(command, **options) ⇒ String?
Execute command and return only stdout if successful
130 131 132 133 |
# File 'lib/ace/core/atoms/command_executor.rb', line 130 def capture(command, **) result = execute(command, **) result[:success] ? result[:stdout] : nil end |
.configured_timeout ⇒ Integer
Get configured timeout from config cascade Falls back to DEFAULT_TIMEOUT if config is unavailable
24 25 26 27 28 |
# File 'lib/ace/core/atoms/command_executor.rb', line 24 def configured_timeout Ace::Core.get("core", "command_executor", "timeout") || DEFAULT_TIMEOUT rescue DEFAULT_TIMEOUT end |
.execute(command, timeout: nil, max_output: MAX_OUTPUT_SIZE, cwd: nil) ⇒ Hash
Execute a command with timeout and output capture
36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 |
# File 'lib/ace/core/atoms/command_executor.rb', line 36 def execute(command, timeout: nil, max_output: MAX_OUTPUT_SIZE, cwd: nil) timeout ||= configured_timeout return {success: false, error: "Command cannot be nil"} if command.nil? return {success: false, error: "Command cannot be empty"} if command.strip.empty? stdout_data = String.new stderr_data = String.new exit_status = nil truncated = false = {} [:chdir] = cwd if cwd && Dir.exist?(cwd) begin Timeout.timeout(timeout) do Open3.popen3(command, ) do |stdin, stdout, stderr, wait_thr| stdin.close # Read output with size limits stdout_reader = Thread.new do Thread.current.report_on_exception = false begin stdout.each_char do |char| if stdout_data.bytesize < max_output stdout_data << char else truncated = true break end end rescue IOError # Stream was closed, this is expected on timeout end end stderr_reader = Thread.new do Thread.current.report_on_exception = false begin stderr.each_char do |char| if stderr_data.bytesize < max_output stderr_data << char else truncated = true break end end rescue IOError # Stream was closed, this is expected on timeout end end stdout_reader.join stderr_reader.join exit_status = wait_thr.value end end result = { success: exit_status.success?, stdout: stdout_data, stderr: stderr_data, exit_code: exit_status.exitstatus } result[:warning] = "Output truncated (exceeded #{max_output} bytes)" if truncated result rescue Timeout::Error { success: false, stdout: stdout_data, stderr: stderr_data, error: "Command timed out after #{timeout} seconds" } rescue Errno::ENOENT { success: false, error: "Command not found: #{command.split.first}" } rescue => e { success: false, stdout: stdout_data, stderr: stderr_data, error: "Command execution failed: #{e.}" } end end |
.execute_batch(commands, **options) ⇒ Array<Hash>
Execute multiple commands in sequence
207 208 209 210 211 212 213 214 215 |
# File 'lib/ace/core/atoms/command_executor.rb', line 207 def execute_batch(commands, **) return [] if commands.nil? || commands.empty? commands.map do |command| result = execute(command, **) result[:command] = command result end end |
.stream(command, output_callback: nil, timeout: nil, cwd: nil) ⇒ Hash
Execute command with real-time output streaming
159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 |
# File 'lib/ace/core/atoms/command_executor.rb', line 159 def stream(command, output_callback: nil, timeout: nil, cwd: nil) timeout ||= configured_timeout return {success: false, error: "Command cannot be nil"} if command.nil? = {} [:chdir] = cwd if cwd && Dir.exist?(cwd) exit_status = nil begin Timeout.timeout(timeout) do Open3.popen2e(command, ) do |stdin, stdout_err, wait_thr| stdin.close stdout_err.each_line do |line| output_callback&.call(line.chomp) end exit_status = wait_thr.value end end { success: exit_status.success?, exit_code: exit_status.exitstatus } rescue Timeout::Error { success: false, error: "Command timed out after #{timeout} seconds" } rescue Errno::ENOENT { success: false, error: "Command not found: #{command.split.first}" } rescue => e { success: false, error: "Command execution failed: #{e.}" } end end |