ruby_coded 0.1.1

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 (85) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +76 -0
  3. data/.github/workflows/release.yml +24 -0
  4. data/.rubocop_todo.yml +122 -0
  5. data/CHANGELOG.md +9 -0
  6. data/CODE_OF_CONDUCT.md +10 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +140 -0
  9. data/Rakefile +12 -0
  10. data/exe/ruby_coded +6 -0
  11. data/lib/ruby_coded/auth/auth_manager.rb +145 -0
  12. data/lib/ruby_coded/auth/callback_servlet.rb +41 -0
  13. data/lib/ruby_coded/auth/credentials_store.rb +35 -0
  14. data/lib/ruby_coded/auth/oauth_callback_server.rb +38 -0
  15. data/lib/ruby_coded/auth/pkce.rb +19 -0
  16. data/lib/ruby_coded/auth/providers/anthropic.rb +32 -0
  17. data/lib/ruby_coded/auth/providers/openai.rb +55 -0
  18. data/lib/ruby_coded/chat/app/event_dispatch.rb +78 -0
  19. data/lib/ruby_coded/chat/app.rb +104 -0
  20. data/lib/ruby_coded/chat/command_handler/agent_commands.rb +53 -0
  21. data/lib/ruby_coded/chat/command_handler/history_commands.rb +38 -0
  22. data/lib/ruby_coded/chat/command_handler/model_commands.rb +91 -0
  23. data/lib/ruby_coded/chat/command_handler/plan_commands.rb +112 -0
  24. data/lib/ruby_coded/chat/command_handler/token_commands.rb +128 -0
  25. data/lib/ruby_coded/chat/command_handler/token_formatting.rb +26 -0
  26. data/lib/ruby_coded/chat/command_handler.rb +89 -0
  27. data/lib/ruby_coded/chat/help.txt +28 -0
  28. data/lib/ruby_coded/chat/input_handler/modal_inputs.rb +102 -0
  29. data/lib/ruby_coded/chat/input_handler/normal_mode_input.rb +116 -0
  30. data/lib/ruby_coded/chat/input_handler.rb +39 -0
  31. data/lib/ruby_coded/chat/llm_bridge/plan_mode.rb +73 -0
  32. data/lib/ruby_coded/chat/llm_bridge/streaming_retries.rb +86 -0
  33. data/lib/ruby_coded/chat/llm_bridge/tool_call_handling.rb +129 -0
  34. data/lib/ruby_coded/chat/llm_bridge.rb +131 -0
  35. data/lib/ruby_coded/chat/model_filter.rb +115 -0
  36. data/lib/ruby_coded/chat/plan_clarification_parser.rb +38 -0
  37. data/lib/ruby_coded/chat/renderer/chat_panel.rb +128 -0
  38. data/lib/ruby_coded/chat/renderer/chat_panel_input.rb +56 -0
  39. data/lib/ruby_coded/chat/renderer/chat_panel_thinking.rb +124 -0
  40. data/lib/ruby_coded/chat/renderer/model_selector.rb +96 -0
  41. data/lib/ruby_coded/chat/renderer/plan_clarifier.rb +112 -0
  42. data/lib/ruby_coded/chat/renderer/plan_clarifier_layout.rb +42 -0
  43. data/lib/ruby_coded/chat/renderer/status_bar.rb +47 -0
  44. data/lib/ruby_coded/chat/renderer.rb +64 -0
  45. data/lib/ruby_coded/chat/state/message_assistant.rb +77 -0
  46. data/lib/ruby_coded/chat/state/message_token_tracking.rb +57 -0
  47. data/lib/ruby_coded/chat/state/messages.rb +70 -0
  48. data/lib/ruby_coded/chat/state/model_selection.rb +79 -0
  49. data/lib/ruby_coded/chat/state/plan_tracking.rb +140 -0
  50. data/lib/ruby_coded/chat/state/scrollable.rb +42 -0
  51. data/lib/ruby_coded/chat/state/token_cost.rb +128 -0
  52. data/lib/ruby_coded/chat/state/tool_confirmation.rb +129 -0
  53. data/lib/ruby_coded/chat/state.rb +205 -0
  54. data/lib/ruby_coded/config/user_config.rb +110 -0
  55. data/lib/ruby_coded/errors/auth_error.rb +12 -0
  56. data/lib/ruby_coded/initializer/cover.rb +29 -0
  57. data/lib/ruby_coded/initializer.rb +52 -0
  58. data/lib/ruby_coded/plugins/base.rb +44 -0
  59. data/lib/ruby_coded/plugins/command_completion/input_extension.rb +30 -0
  60. data/lib/ruby_coded/plugins/command_completion/plugin.rb +27 -0
  61. data/lib/ruby_coded/plugins/command_completion/renderer_extension.rb +54 -0
  62. data/lib/ruby_coded/plugins/command_completion/state_extension.rb +90 -0
  63. data/lib/ruby_coded/plugins/registry.rb +88 -0
  64. data/lib/ruby_coded/plugins.rb +21 -0
  65. data/lib/ruby_coded/strategies/api_key_strategy.rb +39 -0
  66. data/lib/ruby_coded/strategies/base.rb +37 -0
  67. data/lib/ruby_coded/strategies/oauth_strategy.rb +106 -0
  68. data/lib/ruby_coded/tools/agent_cancelled_error.rb +7 -0
  69. data/lib/ruby_coded/tools/agent_iteration_limit_error.rb +7 -0
  70. data/lib/ruby_coded/tools/base_tool.rb +50 -0
  71. data/lib/ruby_coded/tools/create_directory_tool.rb +34 -0
  72. data/lib/ruby_coded/tools/delete_path_tool.rb +50 -0
  73. data/lib/ruby_coded/tools/edit_file_tool.rb +40 -0
  74. data/lib/ruby_coded/tools/list_directory_tool.rb +53 -0
  75. data/lib/ruby_coded/tools/plan_system_prompt.rb +72 -0
  76. data/lib/ruby_coded/tools/read_file_tool.rb +54 -0
  77. data/lib/ruby_coded/tools/registry.rb +66 -0
  78. data/lib/ruby_coded/tools/run_command_tool.rb +75 -0
  79. data/lib/ruby_coded/tools/system_prompt.rb +32 -0
  80. data/lib/ruby_coded/tools/tool_rejected_error.rb +7 -0
  81. data/lib/ruby_coded/tools/write_file_tool.rb +31 -0
  82. data/lib/ruby_coded/version.rb +10 -0
  83. data/lib/ruby_coded.rb +16 -0
  84. data/sig/ruby_coded.rbs +4 -0
  85. metadata +206 -0
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCoded
4
+ module Plugins
5
+ # Stores registered plugins and provides access to their extensions.
6
+ # Used by Chat::App at boot time to wire everything together.
7
+ class Registry
8
+ attr_reader :plugins
9
+
10
+ def initialize
11
+ @plugins = []
12
+ end
13
+
14
+ def register(plugin_class)
15
+ validate!(plugin_class)
16
+ return if @plugins.include?(plugin_class)
17
+
18
+ @plugins << plugin_class
19
+ end
20
+
21
+ def state_extensions
22
+ @plugins.filter_map(&:state_extension)
23
+ end
24
+
25
+ def input_extensions
26
+ @plugins.filter_map(&:input_extension)
27
+ end
28
+
29
+ def renderer_extensions
30
+ @plugins.filter_map(&:renderer_extension)
31
+ end
32
+
33
+ def command_handler_extensions
34
+ @plugins.filter_map(&:command_handler_extension)
35
+ end
36
+
37
+ # Returns an array of { method: Symbol } for each plugin that
38
+ # contributes an input handler hook.
39
+ def input_handler_configs
40
+ @plugins.filter_map do |plugin|
41
+ next unless plugin.input_handler_method
42
+
43
+ { method: plugin.input_handler_method }
44
+ end
45
+ end
46
+
47
+ # Returns an array of { method: Symbol } for each plugin that
48
+ # contributes a render overlay.
49
+ def render_configs
50
+ @plugins.filter_map do |plugin|
51
+ next unless plugin.render_method
52
+
53
+ { method: plugin.render_method }
54
+ end
55
+ end
56
+
57
+ def all_commands
58
+ @plugins.each_with_object({}) { |p, h| h.merge!(p.commands) }
59
+ end
60
+
61
+ def all_command_descriptions
62
+ @plugins.each_with_object({}) { |p, h| h.merge!(p.command_descriptions) }
63
+ end
64
+
65
+ # Includes all plugin modules into the target classes in one pass.
66
+ def apply_extensions!(state_class:, input_handler_class:, renderer_class:, command_handler_class:)
67
+ safe_include(state_class, state_extensions)
68
+ safe_include(input_handler_class, input_extensions)
69
+ safe_include(renderer_class, renderer_extensions)
70
+ safe_include(command_handler_class, command_handler_extensions)
71
+ end
72
+
73
+ private
74
+
75
+ def validate!(plugin_class)
76
+ return if plugin_class.is_a?(Class) && plugin_class < Base
77
+
78
+ raise ArgumentError, "#{plugin_class} must be a subclass of RubyCoded::Plugins::Base"
79
+ end
80
+
81
+ def safe_include(target_class, modules)
82
+ modules.each do |mod|
83
+ target_class.include(mod) unless target_class.ancestors.include?(mod)
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "plugins/base"
4
+ require_relative "plugins/registry"
5
+
6
+ module RubyCoded # :nodoc:
7
+ # Returns the global plugin registry.
8
+ def self.plugin_registry
9
+ @plugin_registry ||= Plugins::Registry.new
10
+ end
11
+
12
+ # Register a plugin class that extends the chat functionality.
13
+ def self.register_plugin(plugin_class)
14
+ plugin_registry.register(plugin_class)
15
+ end
16
+ end
17
+
18
+ require_relative "plugins/command_completion/plugin"
19
+
20
+ # Register built-in plugins
21
+ RubyCoded.register_plugin(RubyCoded::Plugins::CommandCompletion::Plugin)
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-prompt"
4
+ require_relative "base"
5
+
6
+ module RubyCoded
7
+ module Strategies
8
+ # This class is used to authenticate using an API key
9
+ class APIKeyStrategy < Base
10
+ def authenticate
11
+ @prompt.say("Opening #{@provider.display_name} API key authentication in your browser...")
12
+ open_browser(@provider.console_url)
13
+
14
+ key = @prompt.ask("Please generate your API key and paste it here:")
15
+
16
+ raise "No API key provided" if key.nil? || key.empty?
17
+ raise "Invalid API key for #{@provider.display_name}" unless valid_format?(key)
18
+
19
+ @prompt.say("API key validated successfully")
20
+
21
+ { "auth_method" => "api_key", "key" => key }
22
+ end
23
+
24
+ def refresh(credentials)
25
+ credentials
26
+ end
27
+
28
+ def validate(credentials)
29
+ credentials["auth_method"] == "api_key" && valid_format?(credentials["key"])
30
+ end
31
+
32
+ private
33
+
34
+ def valid_format?(key)
35
+ @provider.key_pattern.match?(key)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-prompt"
4
+
5
+ module RubyCoded
6
+ module Strategies
7
+ # Base interface for all authentication strategies
8
+ class Base
9
+ def initialize(provider)
10
+ @provider = provider
11
+ @prompt = TTY::Prompt.new
12
+ end
13
+
14
+ def authenticate
15
+ raise NotImplementedError
16
+ end
17
+
18
+ def refresh(credentials)
19
+ raise NotImplementedError
20
+ end
21
+
22
+ def validate(credentials)
23
+ raise NotImplementedError
24
+ end
25
+
26
+ private
27
+
28
+ def open_browser(url)
29
+ case RbConfig::CONFIG["host_os"]
30
+ when /darwin/ then system("open", url)
31
+ when /linux/ then system("xdg-open", url)
32
+ when /mswin|mingw/ then system("start", url)
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "faraday"
5
+ require "json"
6
+ require "uri"
7
+ require "time"
8
+
9
+ require_relative "base"
10
+ require_relative "../auth/pkce"
11
+ require_relative "../auth/oauth_callback_server"
12
+ require_relative "../errors/auth_error"
13
+
14
+ module RubyCoded
15
+ module Strategies
16
+ # OAuth strategy for authentication with OAuth providers (OPENAI)
17
+ class OAuthStrategy < Base
18
+ def authenticate
19
+ pkce = Auth::PKCE.generate
20
+ state = SecureRandom.hex(16)
21
+
22
+ result = perform_oauth_flow(pkce[:challenge], state)
23
+ validate_callback!(result, state)
24
+
25
+ tokens = exchange_code(result[:code], pkce[:verifier])
26
+ build_token_response(tokens)
27
+ end
28
+
29
+ def refresh(credentials)
30
+ response = Faraday.post(@provider.token_url, {
31
+ "grant_type" => "refresh_token",
32
+ "refresh_token" => credentials["refresh_token"],
33
+ "client_id" => @provider.client_id
34
+ })
35
+ tokens = JSON.parse(response.body)
36
+
37
+ {
38
+ "auth_method" => "oauth",
39
+ "access_token" => tokens["access_token"],
40
+ "refresh_token" => tokens["refresh_token"] ||
41
+ credentials["refresh_token"],
42
+ "expires_at" => (Time.now + tokens["expires_in"].to_i).iso8601
43
+ }
44
+ end
45
+
46
+ def validate(credentials)
47
+ return false unless credentials&.fetch("auth_method") ==
48
+ "oauth" && credentials&.fetch("access_token")
49
+
50
+ Time.parse(credentials["expires_at"]) > Time.now
51
+ end
52
+
53
+ private
54
+
55
+ def perform_oauth_flow(challenge, state)
56
+ server = Auth::OAuthCallbackServer.new
57
+ server.start
58
+
59
+ url = build_auth_url(challenge, state)
60
+ puts "Please open the following URL in your browser to authenticate in #{@provider.display_name}..."
61
+ open_browser(url)
62
+ puts "Waiting authentication... (this may take a while)"
63
+
64
+ server.wait_for_callback
65
+ end
66
+
67
+ def validate_callback!(result, state)
68
+ raise RubyCoded::Errors::AuthError, result[:error] if result[:error]
69
+ raise RubyCoded::Errors::AuthError, "State mismatch" if result[:state] != state
70
+ end
71
+
72
+ def build_token_response(tokens)
73
+ {
74
+ "auth_method" => "oauth",
75
+ "access_token" => tokens["access_token"],
76
+ "refresh_token" => tokens["refresh_token"],
77
+ "expires_at" => (Time.now + tokens["expires_in"].to_i).iso8601
78
+ }
79
+ end
80
+
81
+ def build_auth_url(challenge, state)
82
+ params = URI.encode_www_form(
83
+ client_id: @provider.client_id,
84
+ redirect_uri: @provider.redirect_uri,
85
+ response_type: "code",
86
+ scope: @provider.scopes,
87
+ code_challenge: challenge,
88
+ code_challenge_method: "S256",
89
+ state: state
90
+ )
91
+ "#{@provider.auth_url}?#{params}"
92
+ end
93
+
94
+ def exchange_code(code, verifier)
95
+ response = Faraday.post(@provider.token_url, {
96
+ "grant_type" => "authorization_code",
97
+ "code" => code,
98
+ "redirect_uri" => @provider.redirect_uri,
99
+ "client_id" => @provider.client_id,
100
+ "code_verifier" => verifier
101
+ })
102
+ JSON.parse(response.body)
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCoded
4
+ module Tools
5
+ class AgentCancelledError < StandardError; end
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCoded
4
+ module Tools
5
+ class AgentIterationLimitError < StandardError; end
6
+ end
7
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm"
4
+ require_relative "tool_rejected_error"
5
+
6
+ module RubyCoded
7
+ module Tools
8
+ # Base class for all tools.
9
+ class BaseTool < RubyLLM::Tool
10
+ SAFE_RISK = :safe
11
+ CONFIRM_RISK = :confirm
12
+ DANGEROUS_RISK = :dangerous
13
+
14
+ class << self
15
+ attr_reader :risk_level
16
+
17
+ private
18
+
19
+ def risk(level)
20
+ @risk_level = level
21
+ end
22
+ end
23
+
24
+ def initialize(project_root:)
25
+ super()
26
+ @project_root = File.realpath(project_root)
27
+ end
28
+
29
+ private
30
+
31
+ def resolve_path(relative_path)
32
+ expanded = File.expand_path(relative_path, @project_root)
33
+ File.realpath(expanded)
34
+ rescue Errno::ENOENT
35
+ expanded
36
+ end
37
+
38
+ def inside_project?(full_path)
39
+ full_path.start_with?(@project_root)
40
+ end
41
+
42
+ def validate_path!(relative_path)
43
+ full = resolve_path(relative_path)
44
+ return full if inside_project?(full)
45
+
46
+ { error: "Path is outside the project directory. Only paths within #{@project_root} are allowed." }
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "base_tool"
5
+
6
+ module RubyCoded
7
+ module Tools
8
+ # Create a directory (and any necessary parent directories) at the given path
9
+ class CreateDirectoryTool < BaseTool
10
+ description "Create a directory (and any necessary parent directories) at the given path"
11
+ risk :confirm
12
+
13
+ params do
14
+ string :path, description: "Relative directory path from the project root"
15
+ end
16
+
17
+ def execute(path:)
18
+ full_path = validate_path!(path)
19
+ return full_path if full_path.is_a?(Hash)
20
+
21
+ if File.exist?(full_path)
22
+ return { error: "Path already exists: #{path}" } unless File.directory?(full_path)
23
+
24
+ return "Directory already exists: #{path}"
25
+ end
26
+
27
+ FileUtils.mkdir_p(full_path)
28
+ "Directory created: #{path}"
29
+ rescue SystemCallError => e
30
+ { error: "Failed to create directory #{path}: #{e.message}" }
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "base_tool"
5
+
6
+ module RubyCoded
7
+ module Tools
8
+ # Delete a file or an empty directory at the given path
9
+ class DeletePathTool < BaseTool
10
+ description "Delete a file or an empty directory at the given path"
11
+ risk :dangerous
12
+
13
+ params do
14
+ string :path, description: "Relative path from the project root to delete"
15
+ end
16
+
17
+ def execute(path:)
18
+ full_path = validate_path!(path)
19
+ return full_path if full_path.is_a?(Hash)
20
+ return { error: "Path not found: #{path}" } unless File.exist?(full_path)
21
+ return { error: "Cannot delete the project root" } if full_path == @project_root
22
+
23
+ perform_delete(path, full_path)
24
+ rescue SystemCallError => e
25
+ { error: "Failed to delete #{path}: #{e.message}" }
26
+ end
27
+
28
+ private
29
+
30
+ def perform_delete(path, full_path)
31
+ if File.directory?(full_path)
32
+ delete_directory(path, full_path)
33
+ else
34
+ File.delete(full_path)
35
+ "Deleted file: #{path}"
36
+ end
37
+ end
38
+
39
+ def delete_directory(path, full_path)
40
+ entries = Dir.children(full_path)
41
+ unless entries.empty?
42
+ return { error: "Directory not empty: #{path} (#{entries.length} entries). Remove contents first." }
43
+ end
44
+
45
+ Dir.rmdir(full_path)
46
+ "Deleted empty directory: #{path}"
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_tool"
4
+
5
+ module RubyCoded
6
+ module Tools
7
+ # Replace a specific text occurrence in an existing file (search and replace)
8
+ class EditFileTool < BaseTool
9
+ description "Replace a specific text occurrence in an existing file (search and replace)"
10
+ risk :confirm
11
+
12
+ params do
13
+ string :path, description: "Relative file path from the project root"
14
+ string :old_text, description: "The exact text to find in the file (must match exactly)"
15
+ string :new_text, description: "The replacement text"
16
+ end
17
+
18
+ def execute(path:, old_text:, new_text:)
19
+ full_path = validate_path!(path)
20
+ return full_path if full_path.is_a?(Hash)
21
+ return { error: "File not found: #{path}" } unless File.exist?(full_path)
22
+ return { error: "Not a file: #{path}" } unless File.file?(full_path)
23
+
24
+ apply_edit(path, full_path, old_text, new_text)
25
+ rescue SystemCallError => e
26
+ { error: "Failed to edit #{path}: #{e.message}" }
27
+ end
28
+
29
+ private
30
+
31
+ def apply_edit(path, full_path, old_text, new_text)
32
+ original = File.read(full_path)
33
+ return { error: "old_text not found in #{path}" } unless original.include?(old_text)
34
+
35
+ File.write(full_path, original.sub(old_text, new_text))
36
+ "File edited: #{path}"
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_tool"
4
+
5
+ module RubyCoded
6
+ module Tools
7
+ # List files and directories at the given path relative to the project root
8
+ class ListDirectoryTool < BaseTool
9
+ description "List files and directories at the given path relative to the project root. " \
10
+ "Set include_hidden to true to include hidden/ignored directories."
11
+ risk :safe
12
+
13
+ IGNORED_DIRS = %w[
14
+ .git node_modules vendor/bundle tmp log .bundle
15
+ .cache coverage .yardoc pkg dist build
16
+ ].freeze
17
+
18
+ params do
19
+ string :path, description: "Relative directory path from the project root (use '.' for root)"
20
+ boolean :include_hidden, description: "Include hidden and commonly ignored directories (default: false)",
21
+ required: false
22
+ end
23
+
24
+ def execute(path:, include_hidden: false)
25
+ full_path = validate_path!(path)
26
+ return full_path if full_path.is_a?(Hash)
27
+ return { error: "Directory not found: #{path}" } unless File.exist?(full_path)
28
+ return { error: "Not a directory: #{path}" } unless File.directory?(full_path)
29
+
30
+ list_entries(full_path, include_hidden)
31
+ end
32
+
33
+ private
34
+
35
+ def list_entries(full_path, include_hidden)
36
+ children = Dir.children(full_path).sort
37
+ children = children.reject { |name| ignored_entry?(name) } unless include_hidden
38
+
39
+ entries = children.map do |name|
40
+ entry_path = File.join(full_path, name)
41
+ type = File.directory?(entry_path) ? "dir" : "file"
42
+ "#{type} #{name}"
43
+ end
44
+
45
+ entries.empty? ? "(empty directory)" : entries.join("\n")
46
+ end
47
+
48
+ def ignored_entry?(name)
49
+ IGNORED_DIRS.include?(name)
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCoded
4
+ module Tools
5
+ module PlanSystemPrompt # :nodoc:
6
+ TEMPLATE = <<~PROMPT
7
+ You are a development planning assistant with access to the project directory: %<project_root>s
8
+
9
+ Your role is to help the user create structured, actionable development plans.
10
+
11
+ ## Project exploration
12
+
13
+ You have read-only tools to explore the project:
14
+ - **list_directory**: list files and directories at a given path.
15
+ - **read_file**: read the contents of a file.
16
+
17
+ Use these tools proactively to understand the project structure, conventions, and existing
18
+ code before creating a plan. Always start by exploring the project when you need context
19
+ rather than asking the user for information you can discover yourself.
20
+
21
+ Guidelines for exploration:
22
+ - Always use paths relative to the project root.
23
+ - Start with the project root directory to orient yourself.
24
+ - Read relevant files (READMEs, Gemfiles, configs, key source files) to understand the stack and conventions.
25
+ - Use the information you gather to make your plans concrete and grounded in the actual codebase.
26
+
27
+ ## Clarification protocol
28
+
29
+ After exploring the project, if you still need information that cannot be found in the
30
+ codebase (e.g., business decisions, user preferences, deployment constraints), ask ONE
31
+ question at a time using this exact XML format:
32
+
33
+ <clarification>
34
+ <question>Your question here</question>
35
+ <option>First concrete option</option>
36
+ <option>Second concrete option</option>
37
+ <option>Third concrete option (if needed)</option>
38
+ </clarification>
39
+
40
+ Rules for clarification:
41
+ - Explore the project FIRST — only ask when the answer is not in the codebase.
42
+ - Ask only ONE question per response.
43
+ - Provide between 2 and 5 concrete, actionable options.
44
+ - You may include explanatory text BEFORE the <clarification> tag.
45
+ - Do NOT include any text AFTER the closing </clarification> tag.
46
+ - Only ask when genuinely needed; do not over-ask.
47
+
48
+ ## Plan generation
49
+
50
+ When the request is clear enough, generate the plan directly (no clarification tags).
51
+
52
+ Structure the plan in markdown with these sections:
53
+ - **Objective**: one-sentence summary of what will be built.
54
+ - **Scope**: what is included and what is explicitly excluded.
55
+ - **Steps**: numbered list of concrete implementation steps, each with a brief description.
56
+ - **Dependencies**: libraries, services, or prerequisites needed.
57
+ - **Risks**: potential issues or trade-offs to consider.
58
+ - **Estimates**: rough time estimate per step (optional, include if enough context).
59
+
60
+ Guidelines:
61
+ - Be concise but thorough.
62
+ - Prefer small, incremental steps over large monolithic ones.
63
+ - Reference specific files, classes, and patterns you found during exploration.
64
+ - Use relative paths when referencing project files.
65
+ PROMPT
66
+
67
+ def self.build(project_root:)
68
+ format(TEMPLATE, project_root: project_root)
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_tool"
4
+
5
+ module RubyCoded
6
+ module Tools
7
+ # Read the contents of a file at the given path relative to the project root
8
+ class ReadFileTool < BaseTool
9
+ description "Read the contents of a file at the given path relative to the project root. " \
10
+ "Use offset and max_lines to read specific sections of large files."
11
+ risk :safe
12
+
13
+ DEFAULT_MAX_LINES = 200
14
+
15
+ params do
16
+ string :path, description: "Relative file path from the project root"
17
+ integer :offset, description: "Line number to start reading from (1-based, default: 1)", required: false
18
+ integer :max_lines, description: "Maximum number of lines to return (default: #{DEFAULT_MAX_LINES})",
19
+ required: false
20
+ end
21
+
22
+ def execute(path:, offset: nil, max_lines: nil)
23
+ full_path = validate_path!(path)
24
+ return full_path if full_path.is_a?(Hash)
25
+ return { error: "File not found: #{path}" } unless File.exist?(full_path)
26
+ return { error: "Not a file: #{path}" } unless File.file?(full_path)
27
+
28
+ read_file_section(full_path, offset, max_lines)
29
+ end
30
+
31
+ private
32
+
33
+ def read_file_section(full_path, offset, max_lines)
34
+ lines = File.readlines(full_path)
35
+ return { error: "File is empty" } if lines.empty?
36
+
37
+ start_line = [offset || 1, 1].max
38
+ selected = lines[start_line - 1, max_lines || DEFAULT_MAX_LINES] || []
39
+
40
+ format_lines_output(selected, start_line, lines.length)
41
+ end
42
+
43
+ def format_lines_output(selected, start_line, total)
44
+ result = selected.join
45
+ end_line = start_line - 1 + selected.length
46
+ remaining = total - end_line
47
+ return result unless remaining.positive?
48
+
49
+ result << "\n... (showing lines #{start_line}-#{end_line} of #{total}. " \
50
+ "#{remaining} lines remaining, use offset to read more)"
51
+ end
52
+ end
53
+ end
54
+ end