rubyn 0.1.0

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 (79) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +251 -0
  4. data/Rakefile +12 -0
  5. data/exe/rubyn +5 -0
  6. data/lib/generators/rubyn/install_generator.rb +16 -0
  7. data/lib/rubyn/cli.rb +85 -0
  8. data/lib/rubyn/client/api_client.rb +172 -0
  9. data/lib/rubyn/commands/agent.rb +191 -0
  10. data/lib/rubyn/commands/base.rb +60 -0
  11. data/lib/rubyn/commands/config.rb +51 -0
  12. data/lib/rubyn/commands/dashboard.rb +85 -0
  13. data/lib/rubyn/commands/index.rb +101 -0
  14. data/lib/rubyn/commands/init.rb +166 -0
  15. data/lib/rubyn/commands/refactor.rb +175 -0
  16. data/lib/rubyn/commands/review.rb +61 -0
  17. data/lib/rubyn/commands/spec.rb +72 -0
  18. data/lib/rubyn/commands/usage.rb +56 -0
  19. data/lib/rubyn/config/credentials.rb +39 -0
  20. data/lib/rubyn/config/project_config.rb +42 -0
  21. data/lib/rubyn/config/settings.rb +53 -0
  22. data/lib/rubyn/context/codebase_indexer.rb +195 -0
  23. data/lib/rubyn/context/context_builder.rb +36 -0
  24. data/lib/rubyn/context/file_resolver.rb +235 -0
  25. data/lib/rubyn/context/project_scanner.rb +132 -0
  26. data/lib/rubyn/engine/app/assets/images/rubyn/RubynLogo.png +0 -0
  27. data/lib/rubyn/engine/app/assets/javascripts/rubyn/application.js +579 -0
  28. data/lib/rubyn/engine/app/assets/stylesheets/rubyn/application.css +1073 -0
  29. data/lib/rubyn/engine/app/controllers/rubyn/agent_controller.rb +26 -0
  30. data/lib/rubyn/engine/app/controllers/rubyn/application_controller.rb +44 -0
  31. data/lib/rubyn/engine/app/controllers/rubyn/dashboard_controller.rb +19 -0
  32. data/lib/rubyn/engine/app/controllers/rubyn/feedback_controller.rb +17 -0
  33. data/lib/rubyn/engine/app/controllers/rubyn/files_controller.rb +54 -0
  34. data/lib/rubyn/engine/app/controllers/rubyn/refactor_controller.rb +56 -0
  35. data/lib/rubyn/engine/app/controllers/rubyn/reviews_controller.rb +32 -0
  36. data/lib/rubyn/engine/app/controllers/rubyn/settings_controller.rb +17 -0
  37. data/lib/rubyn/engine/app/controllers/rubyn/specs_controller.rb +33 -0
  38. data/lib/rubyn/engine/app/views/layouts/rubyn/application.html.erb +63 -0
  39. data/lib/rubyn/engine/app/views/rubyn/agent/show.html.erb +22 -0
  40. data/lib/rubyn/engine/app/views/rubyn/dashboard/index.html.erb +120 -0
  41. data/lib/rubyn/engine/app/views/rubyn/files/index.html.erb +45 -0
  42. data/lib/rubyn/engine/app/views/rubyn/refactor/show.html.erb +28 -0
  43. data/lib/rubyn/engine/app/views/rubyn/reviews/show.html.erb +28 -0
  44. data/lib/rubyn/engine/app/views/rubyn/settings/show.html.erb +42 -0
  45. data/lib/rubyn/engine/app/views/rubyn/specs/show.html.erb +28 -0
  46. data/lib/rubyn/engine/config/routes.rb +13 -0
  47. data/lib/rubyn/engine/engine.rb +18 -0
  48. data/lib/rubyn/output/diff_renderer.rb +106 -0
  49. data/lib/rubyn/output/formatter.rb +123 -0
  50. data/lib/rubyn/output/spinner.rb +26 -0
  51. data/lib/rubyn/tools/base_tool.rb +74 -0
  52. data/lib/rubyn/tools/bundle_add.rb +77 -0
  53. data/lib/rubyn/tools/create_file.rb +32 -0
  54. data/lib/rubyn/tools/delete_file.rb +29 -0
  55. data/lib/rubyn/tools/executor.rb +68 -0
  56. data/lib/rubyn/tools/find_files.rb +33 -0
  57. data/lib/rubyn/tools/find_references.rb +72 -0
  58. data/lib/rubyn/tools/git_commit.rb +65 -0
  59. data/lib/rubyn/tools/git_create_branch.rb +58 -0
  60. data/lib/rubyn/tools/git_diff.rb +42 -0
  61. data/lib/rubyn/tools/git_log.rb +43 -0
  62. data/lib/rubyn/tools/git_status.rb +26 -0
  63. data/lib/rubyn/tools/list_directory.rb +82 -0
  64. data/lib/rubyn/tools/move_file.rb +35 -0
  65. data/lib/rubyn/tools/patch_file.rb +47 -0
  66. data/lib/rubyn/tools/rails_generate.rb +40 -0
  67. data/lib/rubyn/tools/rails_migrate.rb +55 -0
  68. data/lib/rubyn/tools/rails_routes.rb +35 -0
  69. data/lib/rubyn/tools/read_file.rb +45 -0
  70. data/lib/rubyn/tools/registry.rb +28 -0
  71. data/lib/rubyn/tools/run_command.rb +48 -0
  72. data/lib/rubyn/tools/run_tests.rb +52 -0
  73. data/lib/rubyn/tools/search_files.rb +82 -0
  74. data/lib/rubyn/tools/write_file.rb +30 -0
  75. data/lib/rubyn/version.rb +5 -0
  76. data/lib/rubyn/version_checker.rb +74 -0
  77. data/lib/rubyn.rb +95 -0
  78. data/sig/rubyn.rbs +4 -0
  79. metadata +379 -0
@@ -0,0 +1,28 @@
1
+ <% content_for(:title) { "Spec Generator" } %>
2
+
3
+ <div class="rubyn-page-header">
4
+ <h1>Spec Generator</h1>
5
+ <p>AI-generated RSpec tests</p>
6
+ </div>
7
+
8
+ <% if @file %>
9
+ <div class="rubyn-tool-container">
10
+ <div class="rubyn-tool-header">
11
+ <span class="rubyn-tool-filepath">
12
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/></svg>
13
+ <%= @file %>
14
+ </span>
15
+ </div>
16
+ <div class="rubyn-tool-body" id="spec-container">
17
+ <div class="rubyn-loading">
18
+ <span class="rubyn-spinner"></span>
19
+ Generating specs<span class="rubyn-dots"><span></span><span></span><span></span></span>
20
+ </div>
21
+ </div>
22
+ </div>
23
+ <% else %>
24
+ <div class="rubyn-tool-empty">
25
+ <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" opacity="0.3"><path d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2M9 5a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2M9 5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2"/></svg>
26
+ <p>Select a file from the <%= link_to "file browser", rubyn.files_path %>.</p>
27
+ </div>
28
+ <% end %>
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ Rubyn::Engine.routes.draw do
4
+ root to: "dashboard#index"
5
+
6
+ resources :files, only: [:index]
7
+ resource :agent, only: %i[show create], controller: "agent"
8
+ resource :refactor, only: %i[show create update], controller: "refactor"
9
+ resource :specs, only: %i[show create], controller: "specs"
10
+ resource :reviews, only: %i[show create], controller: "reviews"
11
+ resource :settings, only: %i[show update], controller: "settings"
12
+ post "feedback", to: "feedback#create"
13
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubyn
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace Rubyn
6
+
7
+ # Engine files live under lib/rubyn/engine/ instead of the gem root
8
+ engine_root = File.expand_path("..", __FILE__)
9
+
10
+ config.root = engine_root
11
+
12
+ initializer "rubyn.assets" do |app|
13
+ if Rails.env.development? && app.config.respond_to?(:assets)
14
+ app.config.assets.precompile += %w[rubyn/application.css rubyn/application.js rubyn/RubynLogo.png]
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+
5
+ module Rubyn
6
+ module Output
7
+ class DiffRenderer
8
+ class << self
9
+ def render(original:, modified:)
10
+ original_lines = original.lines
11
+ modified_lines = modified.lines
12
+
13
+ pastel = Pastel.new
14
+ output = []
15
+ output << pastel.bold("--- original")
16
+ output << pastel.bold("+++ modified")
17
+
18
+ diff = compute_diff(original_lines, modified_lines)
19
+ diff.each do |change|
20
+ case change[:type]
21
+ when :unchanged
22
+ output << " #{change[:line]}"
23
+ when :removed
24
+ output << pastel.red("- #{change[:line]}")
25
+ when :added
26
+ output << pastel.green("+ #{change[:line]}")
27
+ end
28
+ end
29
+
30
+ puts output.join
31
+ end
32
+
33
+ private
34
+
35
+ def compute_diff(original, modified)
36
+ changes = []
37
+ orig_idx = 0
38
+ mod_idx = 0
39
+
40
+ lcs = compute_lcs(original, modified)
41
+ lcs_idx = 0
42
+
43
+ while orig_idx < original.size || mod_idx < modified.size
44
+ if lcs_idx < lcs.size && orig_idx < original.size && original[orig_idx] == lcs[lcs_idx] &&
45
+ mod_idx < modified.size && modified[mod_idx] == lcs[lcs_idx]
46
+ changes << { type: :unchanged, line: original[orig_idx] }
47
+ orig_idx += 1
48
+ mod_idx += 1
49
+ lcs_idx += 1
50
+ elsif orig_idx < original.size && (lcs_idx >= lcs.size || original[orig_idx] != lcs[lcs_idx])
51
+ changes << { type: :removed, line: original[orig_idx] }
52
+ orig_idx += 1
53
+ elsif mod_idx < modified.size
54
+ changes << { type: :added, line: modified[mod_idx] }
55
+ mod_idx += 1
56
+ end
57
+ end
58
+
59
+ changes
60
+ end
61
+
62
+ def compute_lcs(a, b)
63
+ dp = build_dp_table(a, b)
64
+ backtrack_lcs(dp, a, b)
65
+ end
66
+
67
+ def build_dp_table(a, b)
68
+ m = a.size
69
+ n = b.size
70
+ dp = Array.new(m + 1) { Array.new(n + 1, 0) }
71
+
72
+ (1..m).each do |i|
73
+ (1..n).each do |j|
74
+ dp[i][j] = if a[i - 1] == b[j - 1]
75
+ dp[i - 1][j - 1] + 1
76
+ else
77
+ [dp[i - 1][j], dp[i][j - 1]].max
78
+ end
79
+ end
80
+ end
81
+
82
+ dp
83
+ end
84
+
85
+ def backtrack_lcs(dp, a, b)
86
+ lcs = []
87
+ i = a.size
88
+ j = b.size
89
+ while i.positive? && j.positive?
90
+ if a[i - 1] == b[j - 1]
91
+ lcs.unshift(a[i - 1])
92
+ i -= 1
93
+ j -= 1
94
+ elsif dp[i - 1][j] > dp[i][j - 1]
95
+ i -= 1
96
+ else
97
+ j -= 1
98
+ end
99
+ end
100
+
101
+ lcs
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+ require "rouge"
5
+
6
+ module Rubyn
7
+ module Output
8
+ class Formatter
9
+ class << self
10
+ def header(text)
11
+ puts "\n#{pastel.bold(text)}"
12
+ puts pastel.dim("\u2500" * text.length)
13
+ end
14
+
15
+ def success(text)
16
+ puts pastel.green("\u2713 #{text}")
17
+ end
18
+
19
+ def warning(text)
20
+ puts pastel.yellow("\u26A0 #{text}")
21
+ end
22
+
23
+ def error(text)
24
+ puts pastel.red("\u2717 #{text}")
25
+ end
26
+
27
+ def info(text)
28
+ puts pastel.cyan("\u2192 #{text}")
29
+ end
30
+
31
+ def code_block(code, language: "ruby")
32
+ puts highlight(code, language)
33
+ end
34
+
35
+ def credit_usage(credits, balance = nil)
36
+ if balance
37
+ puts pastel.dim("Credits: #{credits} used | #{balance} remaining")
38
+ else
39
+ puts pastel.dim("Credits: #{credits} used")
40
+ end
41
+ end
42
+
43
+ def stream_print(text)
44
+ print text
45
+ end
46
+
47
+ # Print response with syntax-highlighted code blocks
48
+ def print_content(text)
49
+ print_with_highlighted_code(text)
50
+ end
51
+
52
+ def newline
53
+ puts
54
+ end
55
+
56
+ private
57
+
58
+ def pastel
59
+ @pastel ||= Pastel.new
60
+ end
61
+
62
+ def rouge_formatter
63
+ @rouge_formatter ||= Rouge::Formatters::Terminal256.new(
64
+ theme: Rouge::Themes::Monokai.new
65
+ )
66
+ end
67
+
68
+ def highlight(code, language = "ruby")
69
+ lexer = Rouge::Lexer.find(language) || Rouge::Lexers::Ruby.new
70
+ rouge_formatter.format(lexer.lex(code))
71
+ rescue Rouge::Error, ArgumentError
72
+ code
73
+ end
74
+
75
+ # Parse markdown-style code blocks and highlight them,
76
+ # pass everything else through as plain text
77
+ def print_with_highlighted_code(text)
78
+ in_code_block = false
79
+ language = "ruby"
80
+ code_buffer = []
81
+
82
+ text.each_line do |line|
83
+ if line.match?(/^```(\w*)/)
84
+ if in_code_block
85
+ # End of code block — highlight and print
86
+ puts highlight(code_buffer.join, language)
87
+ puts pastel.dim("```")
88
+ code_buffer = []
89
+ in_code_block = false
90
+ else
91
+ # Start of code block
92
+ language = line.match(/^```(\w+)/)&.send(:[], 1) || "ruby"
93
+ puts pastel.dim(line.chomp)
94
+ in_code_block = true
95
+ end
96
+ elsif in_code_block
97
+ code_buffer << line
98
+ else
99
+ # Regular text — print with light formatting
100
+ print_formatted_line(line)
101
+ end
102
+ end
103
+
104
+ # Flush any unclosed code block
105
+ puts code_buffer.join if code_buffer.any?
106
+ end
107
+
108
+ def print_formatted_line(line)
109
+ case line
110
+ when /^\#{1,3}\s/
111
+ puts pastel.bold(line.chomp)
112
+ when /^\*\*(.+)\*\*/
113
+ puts pastel.bold(line.gsub(/\*\*(.+?)\*\*/, '\1').chomp)
114
+ when /^[-*]\s/
115
+ puts pastel.cyan(" #{line.chomp}")
116
+ else
117
+ puts line.chomp
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-spinner"
4
+
5
+ module Rubyn
6
+ module Output
7
+ class Spinner
8
+ def initialize
9
+ @spinner = nil
10
+ end
11
+
12
+ def start(message)
13
+ @spinner = TTY::Spinner.new("[:spinner] #{message}", format: :dots)
14
+ @spinner.auto_spin
15
+ end
16
+
17
+ def stop_success(message = "Done!")
18
+ @spinner&.success(message)
19
+ end
20
+
21
+ def stop_error(message = "Failed!")
22
+ @spinner&.error(message)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubyn
4
+ module Tools
5
+ class BaseTool
6
+ attr_reader :project_root
7
+
8
+ DESCRIPTION = "Base tool"
9
+ PARAMETERS = {}.freeze
10
+ REQUIRES_CONFIRMATION = false
11
+
12
+ def initialize(project_root = Dir.pwd)
13
+ @project_root = project_root
14
+ end
15
+
16
+ def call(raw_params)
17
+ params = self.class.symbolize_params(raw_params)
18
+ execute(params)
19
+ end
20
+
21
+ def execute(params)
22
+ raise NotImplementedError, "#{self.class}#execute must be implemented"
23
+ end
24
+
25
+ def requires_confirmation?
26
+ self.class::REQUIRES_CONFIRMATION
27
+ end
28
+
29
+ # Normalize string keys to symbol keys so tools can use params[:key]
30
+ def self.symbolize_params(params)
31
+ return {} unless params.is_a?(Hash)
32
+
33
+ params.each_with_object({}) do |(k, v), h|
34
+ h[k.to_sym] = v
35
+ end
36
+ end
37
+
38
+ EXCLUDED_DIRS = %w[.git node_modules vendor/bundle vendor tmp].freeze
39
+ DEFAULT_MAX_OUTPUT_LENGTH = 10_000
40
+
41
+ private
42
+
43
+ def excluded?(path)
44
+ rel = relative_path(path)
45
+ EXCLUDED_DIRS.any? { |dir| rel.start_with?("#{dir}/") || rel.include?("/#{dir}/") }
46
+ end
47
+
48
+ def truncate_output(output, max_length = self.class::DEFAULT_MAX_OUTPUT_LENGTH)
49
+ return output if output.length <= max_length
50
+
51
+ output[0...max_length] + "\n... (truncated, #{output.length} total chars)"
52
+ end
53
+
54
+ def resolve_path(path)
55
+ expanded = File.expand_path(path, project_root)
56
+ return error("Access denied: path is outside project root") unless expanded.start_with?(project_root)
57
+
58
+ expanded
59
+ end
60
+
61
+ def relative_path(full_path)
62
+ full_path.sub("#{project_root}/", "")
63
+ end
64
+
65
+ def success(data)
66
+ { success: true }.merge(data)
67
+ end
68
+
69
+ def error(message)
70
+ { success: false, error: message }
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require_relative "base_tool"
5
+ require_relative "registry"
6
+
7
+ module Rubyn
8
+ module Tools
9
+ class BundleAdd < BaseTool
10
+ DESCRIPTION = "Add a gem to the Gemfile and run bundle install"
11
+ PARAMETERS = {
12
+ gem_name: { type: :string, description: "Name of the gem to add", required: true },
13
+ version: { type: :string, description: "Version constraint (e.g. ~> 2.0)", required: false },
14
+ group: { type: :string, description: "Gemfile group (e.g. development, test)", required: false }
15
+ }.freeze
16
+ REQUIRES_CONFIRMATION = true
17
+
18
+ def execute(params)
19
+ gem_name = params[:gem_name]
20
+ return error("gem_name is required") unless gem_name && !gem_name.strip.empty?
21
+
22
+ gemfile_path = File.join(project_root, "Gemfile")
23
+ return error("Gemfile not found in project root") unless File.exist?(gemfile_path)
24
+
25
+ gem_line = build_gem_line(gem_name, params[:version])
26
+ group = params[:group]
27
+
28
+ begin
29
+ gemfile_content = File.read(gemfile_path)
30
+
31
+ gemfile_content = if group && !group.strip.empty?
32
+ insert_into_group(gemfile_content, group, gem_line)
33
+ else
34
+ "#{gemfile_content.rstrip}\n#{gem_line}\n"
35
+ end
36
+
37
+ File.write(gemfile_path, gemfile_content)
38
+ rescue StandardError => e
39
+ return error("Failed to update Gemfile: #{e.message}")
40
+ end
41
+
42
+ begin
43
+ stdout, stderr, status = Open3.capture3("bundle install", chdir: project_root)
44
+ rescue StandardError => e
45
+ return error("Failed to run bundle install: #{e.message}")
46
+ end
47
+
48
+ return error("bundle install failed: #{stderr}") unless status.exitstatus.zero?
49
+
50
+ success(gem_name: gem_name, output: stdout)
51
+ end
52
+
53
+ private
54
+
55
+ def build_gem_line(gem_name, version)
56
+ if version && !version.strip.empty?
57
+ "gem \"#{gem_name}\", \"#{version}\""
58
+ else
59
+ "gem \"#{gem_name}\""
60
+ end
61
+ end
62
+
63
+ def insert_into_group(content, group, gem_line)
64
+ group_pattern = /^group\s+:#{Regexp.escape(group)}.*?\bdo\b/
65
+ if content.match?(group_pattern)
66
+ content.sub(group_pattern) do |match|
67
+ "#{match}\n #{gem_line}"
68
+ end
69
+ else
70
+ "#{content.rstrip}\n\ngroup :#{group} do\n #{gem_line}\nend\n"
71
+ end
72
+ end
73
+ end
74
+
75
+ Registry.register("bundle_add", BundleAdd)
76
+ end
77
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "base_tool"
5
+ require_relative "registry"
6
+
7
+ module Rubyn
8
+ module Tools
9
+ class CreateFile < BaseTool
10
+ DESCRIPTION = "Create a new file. Fails if the file already exists."
11
+ PARAMETERS = {
12
+ path: { type: :string, description: "Path to the file to create", required: true },
13
+ content: { type: :string, description: "Content to write to the new file", required: true }
14
+ }.freeze
15
+ REQUIRES_CONFIRMATION = true
16
+
17
+ def execute(params)
18
+ resolved = resolve_path(params[:path])
19
+ return resolved if resolved.is_a?(Hash) && resolved[:error]
20
+
21
+ return error("File already exists: #{params[:path]}") if File.exist?(resolved)
22
+
23
+ FileUtils.mkdir_p(File.dirname(resolved))
24
+ bytes_written = File.write(resolved, params[:content])
25
+
26
+ success(path: params[:path], bytes_written: bytes_written)
27
+ end
28
+ end
29
+
30
+ Registry.register("create_file", CreateFile)
31
+ end
32
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_tool"
4
+ require_relative "registry"
5
+
6
+ module Rubyn
7
+ module Tools
8
+ class DeleteFile < BaseTool
9
+ DESCRIPTION = "Delete a file"
10
+ PARAMETERS = {
11
+ path: { type: :string, description: "Path to the file to delete", required: true }
12
+ }.freeze
13
+ REQUIRES_CONFIRMATION = true
14
+
15
+ def execute(params)
16
+ resolved = resolve_path(params[:path])
17
+ return resolved if resolved.is_a?(Hash) && resolved[:error]
18
+
19
+ return error("File not found: #{params[:path]}") unless File.exist?(resolved)
20
+
21
+ File.delete(resolved)
22
+
23
+ success(path: params[:path])
24
+ end
25
+ end
26
+
27
+ Registry.register("delete_file", DeleteFile)
28
+ end
29
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubyn
4
+ module Tools
5
+ class Executor
6
+ attr_reader :project_root, :auto_confirm
7
+
8
+ def initialize(project_root = Dir.pwd, auto_confirm: false)
9
+ @project_root = project_root
10
+ @auto_confirm = auto_confirm
11
+ end
12
+
13
+ # server_requires_confirmation: the API's flag for whether this call needs user approval.
14
+ # Falls back to the tool's local REQUIRES_CONFIRMATION constant if not provided.
15
+ def execute(tool_name, params, server_requires_confirmation: nil)
16
+ klass = Registry.get(tool_name)
17
+ return { success: false, error: "Unknown tool: #{tool_name}" } unless klass
18
+
19
+ tool = klass.new(project_root)
20
+
21
+ needs_confirmation = if server_requires_confirmation.nil?
22
+ tool.requires_confirmation?
23
+ else
24
+ server_requires_confirmation
25
+ end
26
+
27
+ if needs_confirmation && !auto_confirm && !confirm_action(tool_name, params)
28
+ return { success: false, error: "denied_by_user" }
29
+ end
30
+
31
+ tool.call(params)
32
+ rescue StandardError => e
33
+ { success: false, error: "#{e.class}: #{e.message}" }
34
+ end
35
+
36
+ private
37
+
38
+ def confirm_action(tool_name, params)
39
+ summary = format_action(tool_name, params)
40
+ Rubyn::Output::Formatter.warning("Agent wants to: #{summary}")
41
+ print "Allow? (y/n) "
42
+ response = $stdin.gets&.strip&.downcase
43
+ response == "y"
44
+ end
45
+
46
+ ACTION_FORMATTERS = {
47
+ "write_file" => ->(p) { "write to #{p[:path]}" },
48
+ "create_file" => ->(p) { "write to #{p[:path]}" },
49
+ "patch_file" => ->(p) { "edit #{p[:path]}" },
50
+ "delete_file" => ->(p) { "delete #{p[:path]}" },
51
+ "move_file" => ->(p) { "move #{p[:source]} to #{p[:destination]}" },
52
+ "run_command" => ->(p) { "run: #{p[:command]}" },
53
+ "run_tests" => ->(p) { "run tests: #{p[:path] || "full suite"}" },
54
+ "rails_generate" => ->(p) { "rails generate #{p[:generator]} #{p[:args]}" },
55
+ "rails_migrate" => ->(_) { "rails db:migrate" },
56
+ "bundle_add" => ->(p) { "add gem '#{p[:gem_name]}' to Gemfile" },
57
+ "git_commit" => ->(p) { "git commit: #{p[:message]}" },
58
+ "git_create_branch" => ->(p) { "create branch: #{p[:branch_name]}" }
59
+ }.freeze
60
+
61
+ def format_action(tool_name, params)
62
+ p = BaseTool.symbolize_params(params)
63
+ formatter = ACTION_FORMATTERS[tool_name]
64
+ formatter ? formatter.call(p) : "#{tool_name} #{params.inspect}"
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubyn
4
+ module Tools
5
+ class FindFiles < BaseTool
6
+ DESCRIPTION = "Find files matching a glob pattern"
7
+ PARAMETERS = {
8
+ pattern: { type: "string", required: true, description: "Glob pattern (e.g. 'app/models/**/*.rb')" }
9
+ }.freeze
10
+ REQUIRES_CONFIRMATION = false
11
+
12
+ MAX_RESULTS = 200
13
+
14
+ def execute(params)
15
+ pattern = params[:pattern]
16
+ return error("Missing required parameter: pattern") unless pattern
17
+
18
+ glob_path = File.join(project_root, pattern)
19
+ all_files = Dir.glob(glob_path, File::FNM_DOTMATCH)
20
+
21
+ files = all_files
22
+ .reject { |f| excluded?(f) }
23
+ .select { |f| File.file?(f) }
24
+ .first(MAX_RESULTS)
25
+ .map { |f| relative_path(f) }
26
+
27
+ success(pattern: pattern, files: files, count: files.size)
28
+ end
29
+ end
30
+ end
31
+ end
32
+
33
+ Rubyn::Tools::Registry.register("find_files", Rubyn::Tools::FindFiles)