Class: Clacky::Tools::Shell

Inherits:
Base
  • Object
show all
Defined in:
lib/clacky/tools/shell.rb

Direct Known Subclasses

SafeShell

Constant Summary collapse

INTERACTION_PATTERNS =
[
  [/\[Y\/n\]|\[y\/N\]|\(yes\/no\)|\(Y\/n\)|\(y\/N\)/i, 'confirmation'],
  [/[Pp]assword\s*:\s*$|Enter password|enter password/, 'password'],
  [/^\s*>>>\s*$|^\s*>>?\s*$|^irb\(.*\):\d+:\d+[>*]\s*$|^\>\s*$/, 'repl'],
  [/^\s*:\s*$|\(END\)|--More--|Press .* to continue|lines \d+-\d+/, 'pager'],
  [/Are you sure|Continue\?|Proceed\?|\bConfirm\b|\bConfirm\?|Overwrite/i, 'question'],
  [/Enter\s+\w+:|Input\s+\w+:|Please enter|please provide/i, 'input'],
  [/Select an option|Choose|Which one|select one/i, 'selection']
].freeze
SLOW_COMMANDS =
[
  'bundle install',
  'npm install',
  'yarn install',
  'pnpm install',
  'rspec',
  'rake test',
  'npm run build',
  'npm run test',
  'yarn build',
  'cargo build',
  'go build'
].freeze
MAX_LLM_OUTPUT_CHARS =

Format result for LLM consumption - limit output size to save tokens Maximum characters to include in LLM output

4000
MAX_LINE_CHARS =

Maximum characters per line before truncating (handles minified CSS/JS files)

500

Instance Method Summary collapse

Methods inherited from Base

#category, #description, #name, #parameters, #to_function_definition

Instance Method Details

#detect_interaction(output) ⇒ Object

Rule-based interaction detection: scan the last few lines of output for patterns that indicate the command is waiting for user input.



371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
# File 'lib/clacky/tools/shell.rb', line 371

def detect_interaction(output)
  return nil if output.empty?

  lines = output.split("\n").last(10)

  lines.reverse.each do |line|
    line_stripped = line.strip
    next if line_stripped.empty?

    INTERACTION_PATTERNS.each do |pattern, type|
      return { type: type, line: line_stripped } if line.match?(pattern)
    end
  end

  nil
end

#detect_sudo_waiting(command, wait_thr) ⇒ Object

Heuristic: sudo writes its password prompt to /dev/tty, bypassing stdout/stderr pipes. If the command contains sudo and the process is still alive after soft_timeout, it is almost certainly waiting for a password.



392
393
394
395
396
397
# File 'lib/clacky/tools/shell.rb', line 392

def detect_sudo_waiting(command, wait_thr)
  return nil unless wait_thr.alive?
  return nil unless command.match?(/\bsudo\b/)

  { type: "password", line: "[sudo] password:" }
end

#determine_timeouts(command, soft_timeout, hard_timeout) ⇒ Object



355
356
357
358
359
360
361
362
363
364
365
366
367
# File 'lib/clacky/tools/shell.rb', line 355

def determine_timeouts(command, soft_timeout, hard_timeout)
  is_slow = SLOW_COMMANDS.any? { |slow_cmd| command.include?(slow_cmd) }

  if is_slow
    soft_timeout ||= 30
    hard_timeout ||= 180
  else
    soft_timeout ||= 7
    hard_timeout ||= 60
  end

  [soft_timeout, hard_timeout]
end

#execute(command:, soft_timeout: nil, hard_timeout: nil, max_output_lines: 1000, on_output: nil, working_dir: nil) ⇒ Object



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
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
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
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
# File 'lib/clacky/tools/shell.rb', line 93

def execute(command:, soft_timeout: nil, hard_timeout: nil, max_output_lines: 1000, on_output: nil, working_dir: nil)
  require "open3"
  require "stringio"

  soft_timeout, hard_timeout = determine_timeouts(command, soft_timeout, hard_timeout)

  stdout_buffer = EncodingSafeBuffer.new
  stderr_buffer = EncodingSafeBuffer.new
  soft_timeout_triggered = false

  # pgroup: 0 puts the child in its own process group so that Ctrl-C
  # (SIGINT sent to the terminal's foreground group) does NOT propagate
  # into the child.  The parent catches SIGINT via its own trap and
  # explicitly kills the child process group.
  # We do NOT use -i (interactive) here — that flag causes zsh to try
  # acquiring /dev/tty as a controlling terminal, which triggers SIGTTIN
  # when the process is not in the terminal's foreground group.
  # Instead, wrap_with_shell sources the user's rc file explicitly.
  #
  # close_others: true prevents the child from inheriting file descriptors
  # other than stdin/stdout/stderr. This is critical when running inside
  # openclacky server — without it, user commands (rails s, npm run dev, etc.)
  # inherit the server's listening socket (port 7070), causing port conflicts
  # when the child process spawns its own server that persists after shell exit.
  popen3_opts = { pgroup: 0, close_others: true }
  popen3_opts[:chdir] = working_dir if working_dir && Dir.exist?(working_dir)

  begin
    Open3.popen3(wrap_with_shell(command), **popen3_opts) do |stdin, stdout, stderr, wait_thr|
      start_time = Time.now

      stdout.sync = true
      stderr.sync = true

      begin
        loop do
          elapsed = Time.now - start_time

          # --- Hard timeout: kill process group and return ---
          if elapsed > hard_timeout
            Process.kill('TERM', -wait_thr.pid) rescue nil
            sleep 0.05
            Process.kill('KILL', -wait_thr.pid) rescue nil
            stdout.close rescue nil
            stderr.close rescue nil

            return format_timeout_result(
              command,
              stdout_buffer.string,
              stderr_buffer.string,
              elapsed,
              :hard_timeout,
              hard_timeout,
              max_output_lines
            )
          end

          # --- Soft timeout: check for interactive prompts ---
          if elapsed > soft_timeout && !soft_timeout_triggered
            soft_timeout_triggered = true

            interaction = detect_interaction(stdout_buffer.string) ||
                          detect_interaction(stderr_buffer.string) ||
                          detect_sudo_waiting(command, wait_thr)
            if interaction
              Process.kill('TERM', -wait_thr.pid) rescue nil
              stdout.close rescue nil
              stderr.close rescue nil
              return format_waiting_input_result(
                command,
                stdout_buffer.string,
                stderr_buffer.string,
                interaction,
                max_output_lines
              )
            end
          end

          break unless wait_thr.alive?

          begin
            ready = IO.select([stdout, stderr], nil, nil, 0.1)
            if ready
              ready[0].each do |io|
                begin
                  data = io.read_nonblock(4096)
                  if io == stdout
                    utf8 = Clacky::Utils::Encoding.to_utf8(data)
                    stdout_buffer.write(data)
                    on_output.call(:stdout, utf8) if on_output
                  else
                    utf8 = Clacky::Utils::Encoding.to_utf8(data)
                    stderr_buffer.write(data)
                    on_output.call(:stderr, utf8) if on_output
                  end
                rescue IO::WaitReadable, EOFError
                end
              end
            end
          rescue StandardError
          end

          sleep 0.1
        end

        # Drain any remaining output from pipes non-blockingly.
        # We must NOT use a plain blocking read here because background
        # processes launched with & inherit the pipe's write-end fd and
        # keep it open, causing read to block forever after the shell exits.
        # Select both stdout+stderr together so we wait at most drain_deadline
        # total (not per-IO), and break as soon as both pipes signal EOF.
        drain_deadline = Time.now + 2
        open_ios = [stdout, stderr].reject { |io| io.closed? }
        begin
          loop do
            remaining = drain_deadline - Time.now
            break if remaining <= 0 || open_ios.empty?
            ready = IO.select(open_ios, nil, nil, [remaining, 0.1].min)
            next unless ready
            ready[0].each do |io|
              buf = io == stdout ? stdout_buffer : stderr_buffer
              begin
                buf.write(io.read_nonblock(4096))
              rescue IO::WaitReadable
                # not ready yet, keep waiting
              rescue EOFError
                open_ios.delete(io)
              end
            end
          end
        rescue StandardError
        end

        stdout_output = stdout_buffer.string
        stderr_output = stderr_buffer.string

        {
          command: command,
          stdout: truncate_output(stdout_output, max_output_lines),
          stderr: truncate_output(stderr_output, max_output_lines),
          exit_code: wait_thr.value.exitstatus,
          success: wait_thr.value.success?,
          elapsed: Time.now - start_time,
          output_truncated: output_truncated?(stdout_output, stderr_output, max_output_lines)
        }
      ensure
        # Ensure child process group is killed when block exits
        if wait_thr&.alive?
          Process.kill('TERM', -wait_thr.pid) rescue nil
          sleep 0.1
          Process.kill('KILL', -wait_thr.pid) rescue nil
        end
      end
    end
  rescue StandardError => e
    stdout_output = stdout_buffer.string
    stderr_output = "Error executing command: #{e.message}\n#{e.backtrace.first(3).join("\n")}"

    {
      command: command,
      stdout: truncate_output(stdout_output, max_output_lines),
      stderr: truncate_output(stderr_output, max_output_lines),
      exit_code: -1,
      success: false,
      output_truncated: output_truncated?(stdout_output, stderr_output, max_output_lines)
    }
  end
end

#extract_command_name(command) ⇒ Object

Extract command name from full command for temp file naming



540
541
542
543
# File 'lib/clacky/tools/shell.rb', line 540

def extract_command_name(command)
  first_word = command.strip.split(/\s+/).first
  File.basename(first_word, ".*")
end

#format_call(args) ⇒ Object



480
481
482
483
484
485
486
# File 'lib/clacky/tools/shell.rb', line 480

def format_call(args)
  cmd = args[:command] || args['command'] || ''
  cmd_parts = cmd.split
  cmd_short = cmd_parts.first(3).join(' ')
  cmd_short += '...' if cmd_parts.size > 3
  "Shell(#{cmd_short})"
end

#format_result(result) ⇒ Object



488
489
490
491
492
493
494
495
496
497
498
499
500
# File 'lib/clacky/tools/shell.rb', line 488

def format_result(result)
  exit_code = result[:exit_code] || result['exit_code'] || 0
  stdout = result[:stdout] || result['stdout'] || ""
  stderr = result[:stderr] || result['stderr'] || ""

  if exit_code == 0
    lines = stdout.lines.size
    "[OK] Completed#{lines > 0 ? " (#{lines} lines)" : ''}"
  else
    error_msg = stderr.lines.first&.strip || "Failed"
    "[Exit #{exit_code}] #{error_msg[0..50]}"
  end
end

#format_result_for_llm(result) ⇒ Object



508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
# File 'lib/clacky/tools/shell.rb', line 508

def format_result_for_llm(result)
  return result if result[:error] || result[:state] == 'TIMEOUT' || result[:state] == 'WAITING_INPUT'

  enc = Clacky::Utils::Encoding
  stdout = enc.to_utf8(result[:stdout] || "")
  stderr = enc.to_utf8(result[:stderr] || "")
  exit_code = result[:exit_code] || 0

  compact = {
    command: enc.to_utf8(result[:command].to_s),
    exit_code: exit_code,
    success: result[:success]
  }

  compact[:elapsed] = result[:elapsed] if result[:elapsed]

  command_name = extract_command_name(compact[:command])

  stdout_info = truncate_and_save(stdout, MAX_LLM_OUTPUT_CHARS, "stdout", command_name)
  compact[:stdout] = stdout_info[:content]
  compact[:stdout_full] = stdout_info[:temp_file] if stdout_info[:temp_file]

  stderr_info = truncate_and_save(stderr, MAX_LLM_OUTPUT_CHARS, "stderr", command_name)
  compact[:stderr] = stderr_info[:content]
  compact[:stderr_full] = stderr_info[:temp_file] if stderr_info[:temp_file]

  compact[:output_truncated] = true if result[:output_truncated]

  compact
end

#format_timeout_result(command, stdout, stderr, elapsed, type, timeout, max_output_lines) ⇒ Object



444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
# File 'lib/clacky/tools/shell.rb', line 444

def format_timeout_result(command, stdout, stderr, elapsed, type, timeout, max_output_lines)
  {
    command: command,
    stdout: truncate_output(stdout, max_output_lines),
    stderr: truncate_output(
      stderr.empty? ? "Command timed out after #{elapsed.round(1)} seconds (#{type}=#{timeout}s)" : stderr,
      max_output_lines
    ),
    exit_code: -1,
    success: false,
    state: 'TIMEOUT',
    timeout_type: type,
    output_truncated: output_truncated?(stdout, stderr, max_output_lines)
  }
end

#format_waiting_input_result(command, stdout, stderr, interaction, max_output_lines) ⇒ Object



399
400
401
402
403
404
405
406
407
408
409
410
411
# File 'lib/clacky/tools/shell.rb', line 399

def format_waiting_input_result(command, stdout, stderr, interaction, max_output_lines)
  {
    command: command,
    stdout: truncate_output(stdout, max_output_lines),
    stderr: truncate_output(stderr, max_output_lines),
    exit_code: -2,
    success: false,
    state: 'WAITING_INPUT',
    interaction_type: interaction[:type],
    message: format_waiting_message(truncate_output(stdout, max_output_lines), interaction),
    output_truncated: output_truncated?(stdout, stderr, max_output_lines)
  }
end

#format_waiting_message(output, interaction) ⇒ Object



413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
# File 'lib/clacky/tools/shell.rb', line 413

def format_waiting_message(output, interaction)
  password_hint = if interaction[:type] == "password"
    <<~HINT

      Password prompt detected.
      sudo reads the password from /dev/tty, not stdin, so it cannot be piped in directly.
      Action required:
      1. Ask the user: "Please enter your sudo password:"
      2. Once provided, retry the command using: echo 'PASSWORD' | sudo -S original_command
         (replace PASSWORD with what the user provides)
    HINT
  end

  <<~MSG
    #{output}

    #{'=' * 60}
    [Terminal State: WAITING_INPUT]
    #{'=' * 60}

    The terminal is waiting for your input.

    Detected pattern: #{interaction[:type]}
    Last line: #{interaction[:line]}
    #{password_hint}
    Suggested actions:
    • Provide answer: run shell with your response
    • Cancel: send Ctrl+C (\x03)
  MSG
end

#output_truncated?(stdout, stderr, max_lines) ⇒ Boolean

Check if output was truncated

Returns:

  • (Boolean)


474
475
476
477
478
# File 'lib/clacky/tools/shell.rb', line 474

def output_truncated?(stdout, stderr, max_lines)
  stdout_lines = stdout&.lines&.length || 0
  stderr_lines = stderr&.lines&.length || 0
  stdout_lines > max_lines || stderr_lines > max_lines
end

#truncate_and_save(output, max_chars, label, command_name) ⇒ Object

Truncate output for LLM and optionally save full content to temp file



546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
# File 'lib/clacky/tools/shell.rb', line 546

def truncate_and_save(output, max_chars, label, command_name)
  return { content: "", temp_file: nil } if output.empty?

  output = truncate_long_lines(output, MAX_LINE_CHARS)
  return { content: output, temp_file: nil } if output.length <= max_chars

  safe_name = command_name.gsub(/[^\w\-.]/, "_")[0...50]
  temp_dir = Dir.mktmpdir
  temp_file = File.join(temp_dir, "#{safe_name}_#{Time.now.strftime("%Y%m%d_%H%M%S")}.output")
  File.write(temp_file, output)

  lines = output.lines
  return { content: output, temp_file: nil } if lines.length <= 2

  notice_overhead = 200
  available_chars = max_chars - notice_overhead

  first_part = []
  accumulated = 0
  lines.each do |line|
    break if accumulated + line.length > available_chars
    first_part << line
    accumulated += line.length
  end

  total_lines = lines.length
  shown_lines = first_part.length

  notice = if label == "stderr"
    "\n... [Error output truncated for LLM: showing #{shown_lines} of #{total_lines} lines, full content: #{temp_file} (use grep to search)] ...\n"
  else
    "\n... [Output truncated for LLM: showing #{shown_lines} of #{total_lines} lines, full content: #{temp_file} (use grep to search)] ...\n"
  end

  { content: first_part.join + notice, temp_file: temp_file }
end

#truncate_long_lines(output, max_line_chars) ⇒ Object

Truncate individual lines that exceed max_line_chars Useful for minified CSS/JS files where a single line can be megabytes



585
586
587
588
589
590
591
592
593
594
595
596
597
# File 'lib/clacky/tools/shell.rb', line 585

def truncate_long_lines(output, max_line_chars)
  lines = output.lines
  return output if lines.none? { |l| l.chomp.length > max_line_chars }

  lines.map do |line|
    chopped = line.chomp
    if chopped.length > max_line_chars
      "#{chopped[0...max_line_chars]}... [line truncated: #{chopped.length} chars total]\n"
    else
      line
    end
  end.join
end

#truncate_output(output, max_lines) ⇒ Object

Truncate output to max_lines and max line length, adding a truncation notice if needed



461
462
463
464
465
466
467
468
469
470
471
# File 'lib/clacky/tools/shell.rb', line 461

def truncate_output(output, max_lines)
  return output if output.nil? || output.empty?

  output = truncate_long_lines(output, MAX_LINE_CHARS)
  lines = output.lines
  return output if lines.length <= max_lines

  truncated_lines = lines.first(max_lines)
  truncation_notice = "\n\n... [Output truncated: showing #{max_lines} of #{lines.length} lines] ...\n"
  truncated_lines.join + truncation_notice
end

#wrap_with_shell(command) ⇒ Object

Wrap command in a login shell when shell config files have changed since the last check. This ensures PATH updates (nvm, rbenv, mise, brew, etc.) are picked up without paying the ~1s startup cost on every command.

Strategy:

- On first call: snapshot MD5 hashes of all shell config files in memory.
- On subsequent calls: re-hash and compare. If any file changed, use
  `shell -l -c 'source RC; command'` once to pick up the fresh env.
- If nothing changed: run command directly (zero overhead).

We NEVER use -i (interactive) because it causes zsh to acquire /dev/tty as a controlling terminal. With pgroup: 0 the child is not in the terminal’s foreground process group, so the attempt triggers SIGTTIN and the process is stopped (not killed) — wait_thr.alive? stays true forever and the command only exits after the 60 s hard_timeout. Instead we explicitly source the user’s rc file so that tool-chain hooks (nvm, rbenv, mise, brew, etc.) are still initialised correctly.



279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
# File 'lib/clacky/tools/shell.rb', line 279

def wrap_with_shell(command)
  shell = current_shell

  if shell_configs_changed?(shell)
    rc = shell_config_hashes(shell, rc_only: true).keys.first
    if rc
      # Source rc explicitly — no -i flag, no SIGTTIN risk
      "#{shell} -l -c #{Shellwords.escape("source #{Shellwords.escape(rc)} 2>/dev/null; #{command}")}"
    else
      "#{shell} -l -c #{Shellwords.escape(command)}"
    end
  else
    command
  end
end