rubycode 0.1.2 → 0.1.3

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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +4 -0
  3. data/README.md +33 -4
  4. data/config/locales/en.yml +87 -0
  5. data/config/system_prompt.md +54 -0
  6. data/config/tools/done.json +2 -2
  7. data/config/tools/update.json +25 -0
  8. data/config/tools/write.json +21 -0
  9. data/docs/images/demo.png +0 -0
  10. data/lib/rubycode/adapters/ollama.rb +76 -3
  11. data/lib/rubycode/agent_loop.rb +41 -16
  12. data/lib/rubycode/client/approval_handler.rb +70 -0
  13. data/lib/rubycode/client/display_formatter.rb +32 -12
  14. data/lib/rubycode/client/response_handler.rb +20 -12
  15. data/lib/rubycode/client.rb +25 -36
  16. data/lib/rubycode/configuration.rb +8 -1
  17. data/lib/rubycode/database.rb +50 -0
  18. data/lib/rubycode/errors.rb +12 -0
  19. data/lib/rubycode/models/base.rb +68 -0
  20. data/lib/rubycode/models/memory.rb +57 -0
  21. data/lib/rubycode/models.rb +4 -0
  22. data/lib/rubycode/tools/base.rb +1 -10
  23. data/lib/rubycode/tools/bash.rb +10 -7
  24. data/lib/rubycode/tools/read.rb +3 -0
  25. data/lib/rubycode/tools/update.rb +80 -0
  26. data/lib/rubycode/tools/write.rb +57 -0
  27. data/lib/rubycode/tools.rb +4 -0
  28. data/lib/rubycode/version.rb +1 -1
  29. data/lib/rubycode/views/agent_loop/adapter_error.rb +14 -0
  30. data/lib/rubycode/views/agent_loop/iteration_footer.rb +17 -0
  31. data/lib/rubycode/views/agent_loop/iteration_header.rb +24 -0
  32. data/lib/rubycode/views/agent_loop/response_received.rb +17 -0
  33. data/lib/rubycode/views/agent_loop/retry_status.rb +14 -0
  34. data/lib/rubycode/views/agent_loop/thinking_status.rb +17 -0
  35. data/lib/rubycode/views/agent_loop/tool_error.rb +14 -0
  36. data/lib/rubycode/views/agent_loop.rb +8 -0
  37. data/lib/rubycode/views/bash_approval.rb +28 -0
  38. data/lib/rubycode/views/cli/configuration_table.rb +28 -0
  39. data/lib/rubycode/views/cli/error_display.rb +19 -0
  40. data/lib/rubycode/views/cli/error_message.rb +17 -0
  41. data/lib/rubycode/views/cli/exit_message.rb +17 -0
  42. data/lib/rubycode/views/cli/interrupt_message.rb +17 -0
  43. data/lib/rubycode/views/cli/memory_cleared_message.rb +17 -0
  44. data/lib/rubycode/views/cli/ready_message.rb +17 -0
  45. data/lib/rubycode/views/cli/response_box.rb +29 -0
  46. data/lib/rubycode/views/cli.rb +11 -0
  47. data/lib/rubycode/views/formatter/debug_tool_info.rb +17 -0
  48. data/lib/rubycode/views/formatter/info_message.rb +17 -0
  49. data/lib/rubycode/views/formatter/minimal_tool_info.rb +26 -0
  50. data/lib/rubycode/views/formatter/tool_result.rb +20 -0
  51. data/lib/rubycode/views/formatter.rb +7 -0
  52. data/lib/rubycode/views/response_handler/agent_finished.rb +31 -0
  53. data/lib/rubycode/views/response_handler/complete_message.rb +31 -0
  54. data/lib/rubycode/views/response_handler/max_iterations.rb +29 -0
  55. data/lib/rubycode/views/response_handler/max_tool_calls.rb +29 -0
  56. data/lib/rubycode/views/response_handler/tool_injection_warning.rb +17 -0
  57. data/lib/rubycode/views/response_handler.rb +8 -0
  58. data/lib/rubycode/views/skip_notification.rb +15 -0
  59. data/lib/rubycode/views/update_approval.rb +36 -0
  60. data/lib/rubycode/views/welcome.rb +27 -0
  61. data/lib/rubycode/views/write_approval.rb +42 -0
  62. data/lib/rubycode/views.rb +12 -0
  63. data/lib/rubycode.rb +9 -1
  64. data/rubycode_cli.rb +41 -51
  65. metadata +220 -5
  66. data/lib/rubycode/history.rb +0 -39
@@ -1,25 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "pastel"
4
5
 
5
6
  module RubyCode
6
7
  class Client
7
8
  # Handles formatting and display of tool information and results
8
9
  class DisplayFormatter
9
- TOOL_ICONS = {
10
- "bash" => ["šŸ’»", "command"],
11
- "read" => ["šŸ“–", "file_path"],
12
- "search" => ["šŸ”", "pattern"]
10
+ TOOL_LABELS = {
11
+ "bash" => ["[BASH]", "command"],
12
+ "read" => ["[READ]", "file_path"],
13
+ "search" => ["[SEARCH]", "pattern"],
14
+ "write" => ["[WRITE]", "file_path"],
15
+ "update" => ["[UPDATE]", "file_path"]
13
16
  }.freeze
14
17
 
15
18
  def initialize(config:)
16
19
  @config = config
20
+ @pastel = Pastel.new
17
21
  end
18
22
 
19
23
  def display_tool_info(tool_name, arguments)
20
24
  if @config.debug
21
- puts "\nšŸ”§ Tool: #{tool_name}"
22
- puts " Args: #{arguments.inspect}"
25
+ puts Views::Formatter::DebugToolInfo.build(tool_name: tool_name, arguments: arguments)
23
26
  else
24
27
  display_minimal_tool_info(tool_name, arguments)
25
28
  end
@@ -28,19 +31,36 @@ module RubyCode
28
31
  def display_result(result)
29
32
  return unless @config.debug
30
33
 
31
- first_line = result.lines.first&.strip || "(empty)"
32
- suffix = result.lines.count > 1 ? "... (#{result.lines.count} lines)" : ""
33
- puts " āœ“ Result: #{first_line}#{suffix}"
34
+ puts Views::Formatter::ToolResult.build(result: result)
35
+ end
36
+
37
+ def display_info(message)
38
+ puts Views::Formatter::InfoMessage.build(message: message)
39
+ end
40
+
41
+ def display_skip_notification(_tool_name, detail)
42
+ puts @pastel.yellow(" ā“˜ Skipped: #{detail}")
34
43
  end
35
44
 
36
45
  private
37
46
 
38
47
  def display_minimal_tool_info(tool_name, arguments)
39
- return unless TOOL_ICONS.key?(tool_name)
48
+ return unless TOOL_LABELS.key?(tool_name)
40
49
 
41
- icon, key = TOOL_ICONS[tool_name]
50
+ label, key = TOOL_LABELS[tool_name]
42
51
  value = extract_argument_value(arguments, key)
43
- puts " #{icon} #{value}" if value
52
+ return unless value
53
+
54
+ puts Views::Formatter::MinimalToolInfo.build(
55
+ label: label,
56
+ value: truncate_value(value, 60)
57
+ )
58
+ end
59
+
60
+ def truncate_value(value, max_length)
61
+ return value if value.length <= max_length
62
+
63
+ "#{value[0...(max_length - 3)]}..."
44
64
  end
45
65
 
46
66
  def extract_argument_value(arguments, key)
@@ -7,15 +7,16 @@ module RubyCode
7
7
  MAX_ITERATIONS = 25
8
8
  MAX_TOOL_CALLS = 50
9
9
 
10
- def initialize(history:, config:)
11
- @history = history
10
+ def initialize(memory:, config:)
11
+ @memory = memory
12
12
  @config = config
13
13
  end
14
14
 
15
15
  def handle_max_iterations(_iteration)
16
- error_msg = "āš ļø Reached maximum iterations (#{MAX_ITERATIONS}). The agent may be stuck in a loop."
17
- puts "\n#{error_msg}\n"
18
- @history.add_message(role: "assistant", content: error_msg)
16
+ puts Views::ResponseHandler::MaxIterations.build(max_iterations: MAX_ITERATIONS)
17
+
18
+ error_msg = I18n.t("rubycode.errors.max_iterations_reached")
19
+ @memory.add_message(role: "assistant", content: error_msg)
19
20
  error_msg
20
21
  end
21
22
 
@@ -25,27 +26,34 @@ module RubyCode
25
26
  return nil
26
27
  end
27
28
 
28
- puts "\nāœ… Agent finished (#{iteration} iterations, #{total_tool_calls} tool calls)\n" unless @config.debug
29
+ unless @config.debug
30
+ puts Views::ResponseHandler::CompleteMessage.build(iteration: iteration,
31
+ total_tool_calls: total_tool_calls)
32
+ end
29
33
  content
30
34
  end
31
35
 
32
36
  def handle_max_tool_calls(content, _total_tool_calls)
33
- error_msg = "āš ļø Reached maximum tool calls (#{MAX_TOOL_CALLS}). Stopping to prevent excessive operations."
34
- puts "\n#{error_msg}\n"
35
- @history.add_message(role: "assistant", content: error_msg)
37
+ puts Views::ResponseHandler::MaxToolCalls.build(max_tool_calls: MAX_TOOL_CALLS)
38
+
39
+ error_msg = I18n.t("rubycode.errors.max_tool_calls_reached")
40
+ @memory.add_message(role: "assistant", content: error_msg)
36
41
  content.empty? ? error_msg : content
37
42
  end
38
43
 
39
44
  def finalize_response(done_result, iteration, total_tool_calls)
40
- puts "\nāœ… Agent finished (#{iteration} iterations, #{total_tool_calls + 1} tool calls)\n" unless @config.debug
45
+ unless @config.debug
46
+ puts Views::ResponseHandler::AgentFinished.build(iteration: iteration,
47
+ total_tool_calls: total_tool_calls + 1)
48
+ end
41
49
  done_result
42
50
  end
43
51
 
44
52
  private
45
53
 
46
54
  def inject_tool_reminder(iteration)
47
- puts " āš ļø No tool calls - injecting reminder (iteration #{iteration})" unless @config.debug
48
- @history.add_message(
55
+ puts Views::ResponseHandler::ToolInjectionWarning.build(iteration: iteration) unless @config.debug
56
+ @memory.add_message(
49
57
  role: "user",
50
58
  content: "You MUST call a tool. Do not respond with text. Call search, read, bash, or done tool now."
51
59
  )
@@ -1,30 +1,36 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "set"
4
+
3
5
  module RubyCode
4
6
  # Main client that provides the public API for the agent
5
7
  class Client
6
- attr_reader :history
8
+ attr_reader :memory
7
9
 
8
- def initialize
10
+ def initialize(tty_prompt: nil)
9
11
  @config = RubyCode.config
10
12
  @adapter = build_adapter
11
- @history = History.new
13
+ @memory = Memory.new
14
+ @read_files = Set.new
15
+ @tty_prompt = tty_prompt
12
16
  end
13
17
 
14
18
  def ask(prompt:)
15
- @history.add_message(role: "user", content: prompt)
19
+ @memory.add_message(role: "user", content: prompt)
16
20
  system_prompt = build_system_prompt
17
21
 
18
22
  AgentLoop.new(
19
23
  adapter: @adapter,
20
- history: @history,
24
+ memory: @memory,
21
25
  config: @config,
22
- system_prompt: system_prompt
26
+ system_prompt: system_prompt,
27
+ options: { read_files: @read_files, tty_prompt: @tty_prompt }
23
28
  ).run
24
29
  end
25
30
 
26
- def clear_history
27
- @history.clear
31
+ def clear_memory
32
+ @memory.clear
33
+ @read_files.clear
28
34
  end
29
35
 
30
36
  private
@@ -34,41 +40,24 @@ module RubyCode
34
40
  when :ollama
35
41
  Adapters::Ollama.new(@config)
36
42
  else
37
- raise "Unknown Adapter"
43
+ raise I18n.t("rubycode.errors.unknown_adapter")
38
44
  end
39
45
  end
40
46
 
41
47
  def build_system_prompt
42
48
  context = ContextBuilder.new(root_path: @config.root_path).environment_context
49
+ instructions = load_system_prompt
43
50
 
44
- <<~PROMPT
45
- You are a helpful Ruby on Rails coding assistant.
46
-
47
- #{context}
48
-
49
- # CRITICAL RULE
50
- You MUST call a tool in EVERY response. You MUST NEVER respond with just text.
51
-
52
- # Available tools
53
- - bash: run any safe command (ls, find, grep, cat, etc.)
54
- - search: simplified search (use bash + grep for more control)
55
- - read: view file contents with line numbers
56
- - done: call when you have the answer
57
-
58
- # Recommended workflow
59
- 1. Use bash with grep to search: `grep -rn "pattern" directory/`
60
- 2. Use bash with find to locate files: `find . -name "*.rb"`
61
- 3. Once found → use read to see the file
62
- 4. When ready → call done with your final answer
63
-
64
- # Example searches
65
- - `grep -rn "button" app/views` - search for "button" in views
66
- - `grep -ri "new product" .` - case-insensitive search
67
- - `find . -name "*product*"` - find files with "product" in name
51
+ [
52
+ instructions,
53
+ "",
54
+ context
55
+ ].join("\n")
56
+ end
68
57
 
69
- IMPORTANT: You cannot respond with plain text. You must ALWAYS call one of the tools.
70
- When you're ready to provide your answer, call the "done" tool with your answer as the parameter.
71
- PROMPT
58
+ def load_system_prompt
59
+ prompt_path = File.join(__dir__, "..", "..", "config", "system_prompt.md")
60
+ File.read(prompt_path)
72
61
  end
73
62
  end
74
63
  end
@@ -3,7 +3,8 @@
3
3
  module RubyCode
4
4
  # Configuration class for Rubycode settings
5
5
  class Configuration
6
- attr_accessor :adapter, :url, :model, :root_path, :debug, :enable_tool_injection_workaround
6
+ attr_accessor :adapter, :url, :model, :root_path, :debug, :enable_tool_injection_workaround,
7
+ :http_read_timeout, :http_open_timeout, :max_retries, :retry_base_delay
7
8
 
8
9
  def initialize
9
10
  @adapter = :ollama
@@ -16,6 +17,12 @@ module RubyCode
16
17
  # When enabled, injects reminder messages if model generates text instead of calling tools
17
18
  # Enabled by default as most models need this nudge
18
19
  @enable_tool_injection_workaround = true
20
+
21
+ # HTTP timeout and retry configuration
22
+ @http_read_timeout = 120 # 2 minutes for LLM inference
23
+ @http_open_timeout = 10 # 10 seconds for connection
24
+ @max_retries = 3 # Number of retries (4 total attempts)
25
+ @retry_base_delay = 2.0 # Base delay for exponential backoff
19
26
  end
20
27
  end
21
28
  end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sequel"
4
+ require "fileutils"
5
+
6
+ module RubyCode
7
+ # Database connection manager for all models
8
+ class Database
9
+ class << self
10
+ attr_accessor :db
11
+
12
+ def connect(db_path: nil)
13
+ path = db_path || default_db_path
14
+ @db = Sequel.sqlite(path)
15
+ run_migrations
16
+ @db
17
+ end
18
+
19
+ def disconnect
20
+ @db&.disconnect
21
+ @db = nil
22
+ end
23
+
24
+ def connection
25
+ @db || connect
26
+ end
27
+
28
+ private
29
+
30
+ def default_db_path
31
+ dir = File.join(Dir.home, ".rubycode")
32
+ FileUtils.mkdir_p(dir)
33
+ File.join(dir, "memory.db")
34
+ end
35
+
36
+ def run_migrations
37
+ create_messages_table
38
+ end
39
+
40
+ def create_messages_table
41
+ @db.create_table?(:messages) do
42
+ primary_key :id
43
+ String :role, null: false
44
+ String :content, null: false, text: true
45
+ DateTime :created_at, default: Sequel::CURRENT_TIMESTAMP
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -18,4 +18,16 @@ module RubyCode
18
18
 
19
19
  # Raised when command execution fails
20
20
  class CommandExecutionError < ToolError; end
21
+
22
+ # Base class for all adapter-related errors
23
+ class AdapterError < Error; end
24
+
25
+ # Raised when adapter request times out
26
+ class AdapterTimeoutError < AdapterError; end
27
+
28
+ # Raised when adapter cannot connect to server
29
+ class AdapterConnectionError < AdapterError; end
30
+
31
+ # Raised when all retry attempts are exhausted
32
+ class AdapterRetryExhaustedError < AdapterError; end
21
33
  end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCode
4
+ module Models
5
+ # Base class for all models that use the database
6
+ class Base
7
+ class << self
8
+ # Expose Sequel dataset for direct querying
9
+ # Subclasses must implement table_name
10
+ def dataset
11
+ Database.connection[table_name]
12
+ end
13
+
14
+ # Delegate common Sequel methods to dataset
15
+ def where(*args)
16
+ dataset.where(*args)
17
+ end
18
+
19
+ def order(field, direction = :asc)
20
+ if direction == :desc
21
+ dataset.order(Sequel.desc(field))
22
+ else
23
+ dataset.order(field)
24
+ end
25
+ end
26
+
27
+ def latest(field = :id)
28
+ order(field, :desc)
29
+ end
30
+
31
+ def oldest(field = :id)
32
+ order(field, :asc)
33
+ end
34
+
35
+ def delete
36
+ dataset.delete
37
+ end
38
+
39
+ def all
40
+ dataset.all
41
+ end
42
+
43
+ def first
44
+ dataset.first
45
+ end
46
+
47
+ def last
48
+ dataset.order(:id).last
49
+ end
50
+
51
+ def count
52
+ dataset.count
53
+ end
54
+
55
+ # Subclasses must define their table name
56
+ def table_name
57
+ raise NotImplementedError, "Subclasses must define table_name"
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def db
64
+ Database.connection
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCode
4
+ # Manages conversation memory using shared database connection
5
+ class Memory < Models::Base
6
+ class << self
7
+ def table_name
8
+ :messages
9
+ end
10
+ end
11
+
12
+ # Accept either Message objects or keyword arguments for backwards compatibility
13
+ def add_message(message = nil, role: nil, content: nil)
14
+ if message.is_a?(Message)
15
+ insert_message(message.role, message.content)
16
+ elsif role && content
17
+ insert_message(role, content)
18
+ else
19
+ raise ArgumentError, "Must provide either a Message object or role: and content: keyword arguments"
20
+ end
21
+ end
22
+
23
+ def messages
24
+ db[:messages].order(:id).map do |row|
25
+ Message.new(role: row[:role], content: row[:content])
26
+ end
27
+ end
28
+
29
+ def to_llm_format
30
+ messages.map(&:to_h)
31
+ end
32
+
33
+ def clear
34
+ db[:messages].delete
35
+ end
36
+
37
+ def last_user_message
38
+ row = self.class.latest.where(role: "user").first
39
+ return nil unless row
40
+
41
+ Message.new(role: row[:role], content: row[:content])
42
+ end
43
+
44
+ def last_assistant_message
45
+ row = self.class.latest.where(role: "assistant").first
46
+ return nil unless row
47
+
48
+ Message.new(role: row[:role], content: row[:content])
49
+ end
50
+
51
+ private
52
+
53
+ def insert_message(role, content)
54
+ db[:messages].insert(role: role, content: content)
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "models/base"
4
+ require_relative "models/memory"
@@ -4,7 +4,7 @@ require "json"
4
4
 
5
5
  module RubyCode
6
6
  module Tools
7
- # Base class for all tools providing common functionality
7
+ # Base class for all tools that can be executed by the AI agent
8
8
  class Base
9
9
  attr_reader :context
10
10
 
@@ -12,25 +12,20 @@ module RubyCode
12
12
  @context = context
13
13
  end
14
14
 
15
- # Public execute method that validates and calls perform
16
15
  def execute(params)
17
16
  validate_params!(params)
18
17
  result = perform(params)
19
18
  wrap_result(result)
20
19
  rescue ToolError => e
21
- # Re-raise tool errors as-is
22
20
  raise e
23
21
  rescue StandardError => e
24
- # Wrap unexpected errors in ToolError
25
22
  raise ToolError, "Error in #{self.class.name}: #{e.message}"
26
23
  end
27
24
 
28
- # Load tool schema from JSON file in config/tools/
29
25
  def self.definition
30
26
  @definition ||= load_schema
31
27
  end
32
28
 
33
- # Load schema from config/tools/{tool_name}.json
34
29
  def self.load_schema
35
30
  tool_name = name.split("::").last.downcase
36
31
  schema_path = File.join(__dir__, "..", "..", "..", "config", "tools", "#{tool_name}.json")
@@ -42,12 +37,10 @@ module RubyCode
42
37
 
43
38
  private
44
39
 
45
- # Must be implemented by subclasses to perform the actual work
46
40
  def perform(params)
47
41
  raise NotImplementedError, "#{self.class.name} must implement #perform"
48
42
  end
49
43
 
50
- # Validates required parameters based on schema
51
44
  def validate_params!(params)
52
45
  return unless self.class.definition.dig(:function, :parameters, :required)
53
46
 
@@ -59,7 +52,6 @@ module RubyCode
59
52
  end
60
53
  end
61
54
 
62
- # Wraps string results in ToolResult, passes through ToolResult and CommandResult
63
55
  def wrap_result(result)
64
56
  case result
65
57
  when ToolResult, CommandResult
@@ -71,7 +63,6 @@ module RubyCode
71
63
  end
72
64
  end
73
65
 
74
- # Helper to get root path from context
75
66
  def root_path
76
67
  context[:root_path]
77
68
  end
@@ -5,9 +5,8 @@ require "shellwords"
5
5
 
6
6
  module RubyCode
7
7
  module Tools
8
- # Tool for executing safe bash commands
8
+ # Tool for executing bash commands with safety checks and approval workflow
9
9
  class Bash < Base
10
- # Whitelist of safe commands
11
10
  SAFE_COMMANDS = %w[
12
11
  ls
13
12
  pwd
@@ -30,15 +29,19 @@ module RubyCode
30
29
  command = params["command"].strip
31
30
  base_command = command.split.first
32
31
 
33
- raise UnsafeCommandError, safe_command_error(base_command) unless SAFE_COMMANDS.include?(base_command)
32
+ unless SAFE_COMMANDS.include?(base_command)
33
+ approval_handler = context[:approval_handler]
34
+ unless approval_handler.request_bash_approval(command, base_command, SAFE_COMMANDS)
35
+ message = I18n.t("rubycode.errors.user_cancelled_bash",
36
+ command: base_command,
37
+ safe_commands: SAFE_COMMANDS.join(", "))
38
+ raise ToolError, message
39
+ end
40
+ end
34
41
 
35
42
  execute_command(command)
36
43
  end
37
44
 
38
- def safe_command_error(base_command)
39
- "Command '#{base_command}' is not allowed. Safe commands: #{SAFE_COMMANDS.join(", ")}"
40
- end
41
-
42
45
  def execute_command(command)
43
46
  Dir.chdir(root_path) do
44
47
  output = `#{command} 2>&1`
@@ -16,6 +16,9 @@ module RubyCode
16
16
 
17
17
  return list_directory(full_path, file_path) if File.directory?(full_path)
18
18
 
19
+ # Track that this file was read
20
+ context[:read_files]&.add(full_path)
21
+
19
22
  format_file_lines(full_path, offset, limit)
20
23
  end
21
24
 
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCode
4
+ module Tools
5
+ # Tool for updating existing files
6
+ class Update < Base
7
+ private
8
+
9
+ def perform(params)
10
+ file_path = params["file_path"]
11
+ old_string = params["old_string"]
12
+ new_string = params["new_string"]
13
+
14
+ full_path = resolve_path(file_path)
15
+ validate_file_exists(file_path, full_path)
16
+ auto_read_file_if_needed(full_path)
17
+
18
+ content = File.read(full_path)
19
+ validate_string_match(content, old_string)
20
+ validate_uniqueness(content, old_string)
21
+ request_approval(file_path, old_string, new_string)
22
+
23
+ update_file(full_path, content, old_string, new_string)
24
+ build_result(file_path, old_string, new_string)
25
+ end
26
+
27
+ def validate_file_exists(file_path, full_path)
28
+ raise FileNotFoundError, I18n.t("rubycode.errors.file_not_found", path: file_path) unless File.exist?(full_path)
29
+ end
30
+
31
+ def auto_read_file_if_needed(full_path)
32
+ read_files = context[:read_files]
33
+ return if read_files&.include?(full_path)
34
+
35
+ context[:display_formatter].display_info("Auto-reading file before update...")
36
+ read_files&.add(full_path)
37
+ end
38
+
39
+ def validate_string_match(content, old_string)
40
+ return if content.include?(old_string)
41
+
42
+ raise ToolError, I18n.t("rubycode.errors.string_not_found", string: old_string[0..100])
43
+ end
44
+
45
+ def validate_uniqueness(content, old_string)
46
+ occurrences = content.scan(old_string).count
47
+ return if occurrences == 1
48
+
49
+ raise ToolError, I18n.t("rubycode.errors.string_not_unique", count: occurrences)
50
+ end
51
+
52
+ def request_approval(file_path, old_string, new_string)
53
+ approval_handler = context[:approval_handler]
54
+ return if approval_handler.request_update_approval(file_path, old_string, new_string)
55
+
56
+ raise ToolError, I18n.t("rubycode.errors.user_cancelled_update")
57
+ end
58
+
59
+ def update_file(full_path, content, old_string, new_string)
60
+ new_content = content.sub(old_string, new_string)
61
+ File.write(full_path, new_content)
62
+ end
63
+
64
+ def build_result(file_path, old_string, new_string)
65
+ ToolResult.new(
66
+ content: "Updated '#{file_path}' (replaced #{old_string.lines.count} lines)",
67
+ metadata: {
68
+ file_path: file_path,
69
+ old_lines: old_string.lines.count,
70
+ new_lines: new_string.lines.count
71
+ }
72
+ )
73
+ end
74
+
75
+ def resolve_path(file_path)
76
+ File.absolute_path?(file_path) ? file_path : File.join(root_path, file_path)
77
+ end
78
+ end
79
+ end
80
+ end