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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +251 -0
- data/Rakefile +12 -0
- data/exe/rubyn +5 -0
- data/lib/generators/rubyn/install_generator.rb +16 -0
- data/lib/rubyn/cli.rb +85 -0
- data/lib/rubyn/client/api_client.rb +172 -0
- data/lib/rubyn/commands/agent.rb +191 -0
- data/lib/rubyn/commands/base.rb +60 -0
- data/lib/rubyn/commands/config.rb +51 -0
- data/lib/rubyn/commands/dashboard.rb +85 -0
- data/lib/rubyn/commands/index.rb +101 -0
- data/lib/rubyn/commands/init.rb +166 -0
- data/lib/rubyn/commands/refactor.rb +175 -0
- data/lib/rubyn/commands/review.rb +61 -0
- data/lib/rubyn/commands/spec.rb +72 -0
- data/lib/rubyn/commands/usage.rb +56 -0
- data/lib/rubyn/config/credentials.rb +39 -0
- data/lib/rubyn/config/project_config.rb +42 -0
- data/lib/rubyn/config/settings.rb +53 -0
- data/lib/rubyn/context/codebase_indexer.rb +195 -0
- data/lib/rubyn/context/context_builder.rb +36 -0
- data/lib/rubyn/context/file_resolver.rb +235 -0
- data/lib/rubyn/context/project_scanner.rb +132 -0
- data/lib/rubyn/engine/app/assets/images/rubyn/RubynLogo.png +0 -0
- data/lib/rubyn/engine/app/assets/javascripts/rubyn/application.js +579 -0
- data/lib/rubyn/engine/app/assets/stylesheets/rubyn/application.css +1073 -0
- data/lib/rubyn/engine/app/controllers/rubyn/agent_controller.rb +26 -0
- data/lib/rubyn/engine/app/controllers/rubyn/application_controller.rb +44 -0
- data/lib/rubyn/engine/app/controllers/rubyn/dashboard_controller.rb +19 -0
- data/lib/rubyn/engine/app/controllers/rubyn/feedback_controller.rb +17 -0
- data/lib/rubyn/engine/app/controllers/rubyn/files_controller.rb +54 -0
- data/lib/rubyn/engine/app/controllers/rubyn/refactor_controller.rb +56 -0
- data/lib/rubyn/engine/app/controllers/rubyn/reviews_controller.rb +32 -0
- data/lib/rubyn/engine/app/controllers/rubyn/settings_controller.rb +17 -0
- data/lib/rubyn/engine/app/controllers/rubyn/specs_controller.rb +33 -0
- data/lib/rubyn/engine/app/views/layouts/rubyn/application.html.erb +63 -0
- data/lib/rubyn/engine/app/views/rubyn/agent/show.html.erb +22 -0
- data/lib/rubyn/engine/app/views/rubyn/dashboard/index.html.erb +120 -0
- data/lib/rubyn/engine/app/views/rubyn/files/index.html.erb +45 -0
- data/lib/rubyn/engine/app/views/rubyn/refactor/show.html.erb +28 -0
- data/lib/rubyn/engine/app/views/rubyn/reviews/show.html.erb +28 -0
- data/lib/rubyn/engine/app/views/rubyn/settings/show.html.erb +42 -0
- data/lib/rubyn/engine/app/views/rubyn/specs/show.html.erb +28 -0
- data/lib/rubyn/engine/config/routes.rb +13 -0
- data/lib/rubyn/engine/engine.rb +18 -0
- data/lib/rubyn/output/diff_renderer.rb +106 -0
- data/lib/rubyn/output/formatter.rb +123 -0
- data/lib/rubyn/output/spinner.rb +26 -0
- data/lib/rubyn/tools/base_tool.rb +74 -0
- data/lib/rubyn/tools/bundle_add.rb +77 -0
- data/lib/rubyn/tools/create_file.rb +32 -0
- data/lib/rubyn/tools/delete_file.rb +29 -0
- data/lib/rubyn/tools/executor.rb +68 -0
- data/lib/rubyn/tools/find_files.rb +33 -0
- data/lib/rubyn/tools/find_references.rb +72 -0
- data/lib/rubyn/tools/git_commit.rb +65 -0
- data/lib/rubyn/tools/git_create_branch.rb +58 -0
- data/lib/rubyn/tools/git_diff.rb +42 -0
- data/lib/rubyn/tools/git_log.rb +43 -0
- data/lib/rubyn/tools/git_status.rb +26 -0
- data/lib/rubyn/tools/list_directory.rb +82 -0
- data/lib/rubyn/tools/move_file.rb +35 -0
- data/lib/rubyn/tools/patch_file.rb +47 -0
- data/lib/rubyn/tools/rails_generate.rb +40 -0
- data/lib/rubyn/tools/rails_migrate.rb +55 -0
- data/lib/rubyn/tools/rails_routes.rb +35 -0
- data/lib/rubyn/tools/read_file.rb +45 -0
- data/lib/rubyn/tools/registry.rb +28 -0
- data/lib/rubyn/tools/run_command.rb +48 -0
- data/lib/rubyn/tools/run_tests.rb +52 -0
- data/lib/rubyn/tools/search_files.rb +82 -0
- data/lib/rubyn/tools/write_file.rb +30 -0
- data/lib/rubyn/version.rb +5 -0
- data/lib/rubyn/version_checker.rb +74 -0
- data/lib/rubyn.rb +95 -0
- data/sig/rubyn.rbs +4 -0
- 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)
|