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.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +76 -0
- data/.github/workflows/release.yml +24 -0
- data/.rubocop_todo.yml +122 -0
- data/CHANGELOG.md +9 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +140 -0
- data/Rakefile +12 -0
- data/exe/ruby_coded +6 -0
- data/lib/ruby_coded/auth/auth_manager.rb +145 -0
- data/lib/ruby_coded/auth/callback_servlet.rb +41 -0
- data/lib/ruby_coded/auth/credentials_store.rb +35 -0
- data/lib/ruby_coded/auth/oauth_callback_server.rb +38 -0
- data/lib/ruby_coded/auth/pkce.rb +19 -0
- data/lib/ruby_coded/auth/providers/anthropic.rb +32 -0
- data/lib/ruby_coded/auth/providers/openai.rb +55 -0
- data/lib/ruby_coded/chat/app/event_dispatch.rb +78 -0
- data/lib/ruby_coded/chat/app.rb +104 -0
- data/lib/ruby_coded/chat/command_handler/agent_commands.rb +53 -0
- data/lib/ruby_coded/chat/command_handler/history_commands.rb +38 -0
- data/lib/ruby_coded/chat/command_handler/model_commands.rb +91 -0
- data/lib/ruby_coded/chat/command_handler/plan_commands.rb +112 -0
- data/lib/ruby_coded/chat/command_handler/token_commands.rb +128 -0
- data/lib/ruby_coded/chat/command_handler/token_formatting.rb +26 -0
- data/lib/ruby_coded/chat/command_handler.rb +89 -0
- data/lib/ruby_coded/chat/help.txt +28 -0
- data/lib/ruby_coded/chat/input_handler/modal_inputs.rb +102 -0
- data/lib/ruby_coded/chat/input_handler/normal_mode_input.rb +116 -0
- data/lib/ruby_coded/chat/input_handler.rb +39 -0
- data/lib/ruby_coded/chat/llm_bridge/plan_mode.rb +73 -0
- data/lib/ruby_coded/chat/llm_bridge/streaming_retries.rb +86 -0
- data/lib/ruby_coded/chat/llm_bridge/tool_call_handling.rb +129 -0
- data/lib/ruby_coded/chat/llm_bridge.rb +131 -0
- data/lib/ruby_coded/chat/model_filter.rb +115 -0
- data/lib/ruby_coded/chat/plan_clarification_parser.rb +38 -0
- data/lib/ruby_coded/chat/renderer/chat_panel.rb +128 -0
- data/lib/ruby_coded/chat/renderer/chat_panel_input.rb +56 -0
- data/lib/ruby_coded/chat/renderer/chat_panel_thinking.rb +124 -0
- data/lib/ruby_coded/chat/renderer/model_selector.rb +96 -0
- data/lib/ruby_coded/chat/renderer/plan_clarifier.rb +112 -0
- data/lib/ruby_coded/chat/renderer/plan_clarifier_layout.rb +42 -0
- data/lib/ruby_coded/chat/renderer/status_bar.rb +47 -0
- data/lib/ruby_coded/chat/renderer.rb +64 -0
- data/lib/ruby_coded/chat/state/message_assistant.rb +77 -0
- data/lib/ruby_coded/chat/state/message_token_tracking.rb +57 -0
- data/lib/ruby_coded/chat/state/messages.rb +70 -0
- data/lib/ruby_coded/chat/state/model_selection.rb +79 -0
- data/lib/ruby_coded/chat/state/plan_tracking.rb +140 -0
- data/lib/ruby_coded/chat/state/scrollable.rb +42 -0
- data/lib/ruby_coded/chat/state/token_cost.rb +128 -0
- data/lib/ruby_coded/chat/state/tool_confirmation.rb +129 -0
- data/lib/ruby_coded/chat/state.rb +205 -0
- data/lib/ruby_coded/config/user_config.rb +110 -0
- data/lib/ruby_coded/errors/auth_error.rb +12 -0
- data/lib/ruby_coded/initializer/cover.rb +29 -0
- data/lib/ruby_coded/initializer.rb +52 -0
- data/lib/ruby_coded/plugins/base.rb +44 -0
- data/lib/ruby_coded/plugins/command_completion/input_extension.rb +30 -0
- data/lib/ruby_coded/plugins/command_completion/plugin.rb +27 -0
- data/lib/ruby_coded/plugins/command_completion/renderer_extension.rb +54 -0
- data/lib/ruby_coded/plugins/command_completion/state_extension.rb +90 -0
- data/lib/ruby_coded/plugins/registry.rb +88 -0
- data/lib/ruby_coded/plugins.rb +21 -0
- data/lib/ruby_coded/strategies/api_key_strategy.rb +39 -0
- data/lib/ruby_coded/strategies/base.rb +37 -0
- data/lib/ruby_coded/strategies/oauth_strategy.rb +106 -0
- data/lib/ruby_coded/tools/agent_cancelled_error.rb +7 -0
- data/lib/ruby_coded/tools/agent_iteration_limit_error.rb +7 -0
- data/lib/ruby_coded/tools/base_tool.rb +50 -0
- data/lib/ruby_coded/tools/create_directory_tool.rb +34 -0
- data/lib/ruby_coded/tools/delete_path_tool.rb +50 -0
- data/lib/ruby_coded/tools/edit_file_tool.rb +40 -0
- data/lib/ruby_coded/tools/list_directory_tool.rb +53 -0
- data/lib/ruby_coded/tools/plan_system_prompt.rb +72 -0
- data/lib/ruby_coded/tools/read_file_tool.rb +54 -0
- data/lib/ruby_coded/tools/registry.rb +66 -0
- data/lib/ruby_coded/tools/run_command_tool.rb +75 -0
- data/lib/ruby_coded/tools/system_prompt.rb +32 -0
- data/lib/ruby_coded/tools/tool_rejected_error.rb +7 -0
- data/lib/ruby_coded/tools/write_file_tool.rb +31 -0
- data/lib/ruby_coded/version.rb +10 -0
- data/lib/ruby_coded.rb +16 -0
- data/sig/ruby_coded.rbs +4 -0
- 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,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
|