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,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Rubyn
|
|
6
|
+
module Commands
|
|
7
|
+
class Refactor < Base
|
|
8
|
+
def execute(file)
|
|
9
|
+
formatter = Rubyn::Output::Formatter
|
|
10
|
+
ensure_configured!
|
|
11
|
+
auto_index_if_needed!
|
|
12
|
+
|
|
13
|
+
unless File.exist?(file)
|
|
14
|
+
formatter.error("File not found: #{file}")
|
|
15
|
+
return
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
file_content = File.read(file)
|
|
19
|
+
relative_path = relative_to_project(file)
|
|
20
|
+
formatted_context = build_context_files(relative_path)
|
|
21
|
+
|
|
22
|
+
formatter.info("Analyzing #{File.basename(file)}...")
|
|
23
|
+
formatter.info("Context: #{formatted_context.size} related file(s)")
|
|
24
|
+
formatter.newline
|
|
25
|
+
begin
|
|
26
|
+
result = Rubyn.api_client.refactor(
|
|
27
|
+
file_path: relative_path,
|
|
28
|
+
code: file_content,
|
|
29
|
+
context_files: formatted_context,
|
|
30
|
+
project_token: project_token
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
full_response = result["response"] || ""
|
|
34
|
+
formatter.print_content(full_response)
|
|
35
|
+
formatter.newline
|
|
36
|
+
formatter.credit_usage(result["credits_used"], nil) if result["credits_used"]
|
|
37
|
+
formatter.newline
|
|
38
|
+
prompt_apply(file, file_content, full_response, formatter)
|
|
39
|
+
rescue Rubyn::AuthenticationError => e
|
|
40
|
+
formatter.error("Authentication failed: #{e.message}")
|
|
41
|
+
rescue Rubyn::APIError => e
|
|
42
|
+
formatter.error(e.message)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def prompt_apply(file, original, response, formatter)
|
|
49
|
+
file_blocks = extract_file_blocks(response)
|
|
50
|
+
return if file_blocks.empty?
|
|
51
|
+
|
|
52
|
+
if file_blocks.length == 1 && file_blocks.first[:tag].nil?
|
|
53
|
+
prompt_apply_single(file, original, file_blocks.first[:code], formatter)
|
|
54
|
+
else
|
|
55
|
+
prompt_apply_multi(file, original, file_blocks, formatter)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def extract_file_blocks(response)
|
|
60
|
+
blocks = []
|
|
61
|
+
|
|
62
|
+
# Match path header lines directly above ```ruby blocks
|
|
63
|
+
# Handles: `[NEW] path.rb`, `path.rb`, backtick-wrapped or plain
|
|
64
|
+
response.scan(/(?:\[NEW\]\s*)?`?([a-zA-Z0-9_\/\.\-]+\.rb)`?\s*\n```ruby\n(.*?)```/m) do
|
|
65
|
+
path = $1
|
|
66
|
+
code = $2
|
|
67
|
+
is_new = $~[0].include?("[NEW]")
|
|
68
|
+
blocks << { tag: is_new ? "NEW" : nil, path: path, code: code }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Fallback: no path headers found, grab first code block
|
|
72
|
+
if blocks.empty?
|
|
73
|
+
code_match = response.match(/```ruby\n(.*?)```/m)
|
|
74
|
+
blocks << { tag: nil, path: nil, code: code_match[1] } if code_match
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
blocks
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def prompt_apply_single(file, original, new_content, formatter)
|
|
81
|
+
print "\nApply changes? (y/n/diff) "
|
|
82
|
+
choice = $stdin.gets&.strip&.downcase
|
|
83
|
+
|
|
84
|
+
case choice
|
|
85
|
+
when "y"
|
|
86
|
+
File.write(file, new_content)
|
|
87
|
+
formatter.success("Changes applied to #{file}")
|
|
88
|
+
when "diff"
|
|
89
|
+
Rubyn::Output::DiffRenderer.render(original: original, modified: new_content)
|
|
90
|
+
print "\nApply changes? (y/n) "
|
|
91
|
+
if $stdin.gets&.strip&.downcase == "y"
|
|
92
|
+
File.write(file, new_content)
|
|
93
|
+
formatter.success("Changes applied to #{file}")
|
|
94
|
+
else
|
|
95
|
+
formatter.info("Changes discarded.")
|
|
96
|
+
end
|
|
97
|
+
else
|
|
98
|
+
formatter.info("Changes discarded.")
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def prompt_apply_multi(file, original, file_blocks, formatter)
|
|
103
|
+
formatter.newline
|
|
104
|
+
formatter.info("This refactor produces #{file_blocks.length} file(s):")
|
|
105
|
+
file_blocks.each do |block|
|
|
106
|
+
label = block[:tag] == "NEW" ? "NEW" : "MODIFIED"
|
|
107
|
+
path = block[:path] || file
|
|
108
|
+
formatter.info(" [#{label}] #{path}")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
print "\nApply all changes? (y/n/select) "
|
|
112
|
+
choice = $stdin.gets&.strip&.downcase
|
|
113
|
+
|
|
114
|
+
case choice
|
|
115
|
+
when "y"
|
|
116
|
+
apply_all_blocks(file, original, file_blocks, formatter)
|
|
117
|
+
when "select"
|
|
118
|
+
apply_selected_blocks(file, original, file_blocks, formatter)
|
|
119
|
+
else
|
|
120
|
+
formatter.info("All changes discarded.")
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def apply_all_blocks(file, original, file_blocks, formatter)
|
|
125
|
+
file_blocks.each do |block|
|
|
126
|
+
write_block(file, block, formatter)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def apply_selected_blocks(file, original, file_blocks, formatter)
|
|
131
|
+
file_blocks.each do |block|
|
|
132
|
+
label = block[:tag] == "NEW" ? "NEW" : "MODIFIED"
|
|
133
|
+
path = block[:path] || file
|
|
134
|
+
print " Apply [#{label}] #{path}? (y/n/diff) "
|
|
135
|
+
choice = $stdin.gets&.strip&.downcase
|
|
136
|
+
|
|
137
|
+
case choice
|
|
138
|
+
when "y"
|
|
139
|
+
write_block(file, block, formatter)
|
|
140
|
+
when "diff"
|
|
141
|
+
existing = block[:tag] == "NEW" ? "" : File.read(resolve_path(file, block))
|
|
142
|
+
Rubyn::Output::DiffRenderer.render(original: existing, modified: block[:code])
|
|
143
|
+
print " Apply? (y/n) "
|
|
144
|
+
if $stdin.gets&.strip&.downcase == "y"
|
|
145
|
+
write_block(file, block, formatter)
|
|
146
|
+
else
|
|
147
|
+
formatter.info(" Skipped #{path}")
|
|
148
|
+
end
|
|
149
|
+
else
|
|
150
|
+
formatter.info(" Skipped #{path}")
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def write_block(original_file, block, formatter)
|
|
156
|
+
target = resolve_path(original_file, block)
|
|
157
|
+
dir = File.dirname(target)
|
|
158
|
+
FileUtils.mkdir_p(dir) unless File.directory?(dir)
|
|
159
|
+
File.write(target, block[:code])
|
|
160
|
+
label = block[:tag] == "NEW" ? "Created" : "Updated"
|
|
161
|
+
formatter.success("#{label} #{target}")
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def resolve_path(original_file, block)
|
|
165
|
+
return original_file unless block[:path]
|
|
166
|
+
|
|
167
|
+
if block[:path].start_with?("/")
|
|
168
|
+
block[:path]
|
|
169
|
+
else
|
|
170
|
+
File.join(Dir.pwd, block[:path])
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubyn
|
|
4
|
+
module Commands
|
|
5
|
+
class Review < Base
|
|
6
|
+
def execute(file_or_dir)
|
|
7
|
+
formatter = Rubyn::Output::Formatter
|
|
8
|
+
ensure_configured!
|
|
9
|
+
auto_index_if_needed!
|
|
10
|
+
|
|
11
|
+
files = resolve_files(file_or_dir)
|
|
12
|
+
if files.empty?
|
|
13
|
+
formatter.error("No Ruby files found in: #{file_or_dir}")
|
|
14
|
+
return
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
formatter.info("Reviewing #{files.size} file(s)...")
|
|
18
|
+
formatter.newline
|
|
19
|
+
|
|
20
|
+
formatted_files = files.map do |file|
|
|
21
|
+
{ file_path: relative_to_project(file), code: File.read(file) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
file_paths = files.map { |f| relative_to_project(f) }
|
|
25
|
+
formatted_context = file_paths.flat_map { |rel_path| build_context_files(rel_path) }
|
|
26
|
+
.uniq { |ctx| ctx[:file_path] }
|
|
27
|
+
.reject { |ctx| file_paths.include?(ctx[:file_path]) }
|
|
28
|
+
|
|
29
|
+
formatter.header(formatted_files.map { |f| f[:file_path] }.join(", "))
|
|
30
|
+
|
|
31
|
+
begin
|
|
32
|
+
result = Rubyn.api_client.review(
|
|
33
|
+
files: formatted_files,
|
|
34
|
+
context_files: formatted_context,
|
|
35
|
+
project_token: project_token
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
formatter.print_content(result["response"] || "")
|
|
39
|
+
formatter.newline
|
|
40
|
+
formatter.credit_usage(result["credits_used"], nil) if result["credits_used"]
|
|
41
|
+
rescue Rubyn::AuthenticationError => e
|
|
42
|
+
formatter.error("Authentication failed: #{e.message}")
|
|
43
|
+
rescue Rubyn::APIError => e
|
|
44
|
+
formatter.error(e.message)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def resolve_files(path)
|
|
51
|
+
if File.directory?(path)
|
|
52
|
+
Dir.glob(File.join(path, "**/*.rb"))
|
|
53
|
+
elsif File.exist?(path)
|
|
54
|
+
[path]
|
|
55
|
+
else
|
|
56
|
+
[]
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubyn
|
|
4
|
+
module Commands
|
|
5
|
+
class Spec < Base
|
|
6
|
+
def execute(file)
|
|
7
|
+
formatter = Rubyn::Output::Formatter
|
|
8
|
+
ensure_configured!
|
|
9
|
+
auto_index_if_needed!
|
|
10
|
+
|
|
11
|
+
unless File.exist?(file)
|
|
12
|
+
formatter.error("File not found: #{file}")
|
|
13
|
+
return
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
file_content = File.read(file)
|
|
17
|
+
relative_path = relative_to_project(file)
|
|
18
|
+
formatted_context = build_context_files(relative_path)
|
|
19
|
+
|
|
20
|
+
formatter.info("Generating specs for #{File.basename(file)}...")
|
|
21
|
+
formatter.newline
|
|
22
|
+
begin
|
|
23
|
+
result = Rubyn.api_client.generate_spec(
|
|
24
|
+
file_path: relative_path,
|
|
25
|
+
code: file_content,
|
|
26
|
+
context_files: formatted_context,
|
|
27
|
+
project_token: project_token
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
full_response = result["response"] || ""
|
|
31
|
+
formatter.print_content(full_response)
|
|
32
|
+
formatter.newline
|
|
33
|
+
formatter.credit_usage(result["credits_used"], nil) if result["credits_used"]
|
|
34
|
+
formatter.newline
|
|
35
|
+
prompt_write_spec(relative_path, full_response, formatter)
|
|
36
|
+
rescue Rubyn::AuthenticationError => e
|
|
37
|
+
formatter.error("Authentication failed: #{e.message}")
|
|
38
|
+
rescue Rubyn::APIError => e
|
|
39
|
+
formatter.error(e.message)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def prompt_write_spec(source_path, response, formatter)
|
|
46
|
+
code_match = response.match(/```ruby\n(.*?)```/m)
|
|
47
|
+
return unless code_match
|
|
48
|
+
|
|
49
|
+
spec_content = code_match[1]
|
|
50
|
+
spec_path = derive_spec_path(source_path)
|
|
51
|
+
|
|
52
|
+
print "\nWrite spec to #{spec_path}? (y/n) "
|
|
53
|
+
choice = $stdin.gets&.strip&.downcase
|
|
54
|
+
|
|
55
|
+
if choice == "y"
|
|
56
|
+
FileUtils.mkdir_p(File.dirname(spec_path))
|
|
57
|
+
File.write(spec_path, spec_content)
|
|
58
|
+
formatter.success("Spec written to #{spec_path}")
|
|
59
|
+
else
|
|
60
|
+
formatter.info("Spec discarded.")
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def derive_spec_path(source_path)
|
|
65
|
+
source_path
|
|
66
|
+
.sub(%r{^app/}, "spec/")
|
|
67
|
+
.sub(%r{^lib/}, "spec/")
|
|
68
|
+
.sub(/\.rb$/, "_spec.rb")
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubyn
|
|
4
|
+
module Commands
|
|
5
|
+
class Usage < Base
|
|
6
|
+
MAX_HISTORY_ENTRIES = 10
|
|
7
|
+
|
|
8
|
+
def execute
|
|
9
|
+
formatter = Rubyn::Output::Formatter
|
|
10
|
+
ensure_credentials!
|
|
11
|
+
|
|
12
|
+
formatter.header("Rubyn Usage")
|
|
13
|
+
|
|
14
|
+
spinner = Rubyn::Output::Spinner.new
|
|
15
|
+
spinner.start("Fetching usage data...")
|
|
16
|
+
|
|
17
|
+
balance = Rubyn.api_client.get_balance
|
|
18
|
+
history = Rubyn.api_client.get_history
|
|
19
|
+
|
|
20
|
+
spinner.stop_success("Done!")
|
|
21
|
+
|
|
22
|
+
display_balance(formatter, balance)
|
|
23
|
+
display_history(formatter, history)
|
|
24
|
+
rescue Rubyn::ConfigurationError
|
|
25
|
+
raise
|
|
26
|
+
rescue Rubyn::Error => e
|
|
27
|
+
spinner&.stop_error("Failed")
|
|
28
|
+
formatter.error(e.message)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def display_balance(formatter, balance)
|
|
34
|
+
formatter.newline
|
|
35
|
+
formatter.info("Credits: #{balance["credit_balance"]}")
|
|
36
|
+
formatter.info("Credit allowance: #{balance["credit_allowance"]}") if balance["credit_allowance"]
|
|
37
|
+
formatter.info("Reset period: #{balance["reset_period"]}") if balance["reset_period"]
|
|
38
|
+
formatter.info("Resets at: #{balance["resets_at"]}") if balance["resets_at"]
|
|
39
|
+
formatter.newline
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def display_history(formatter, history)
|
|
43
|
+
entries = history["interactions"] || []
|
|
44
|
+
return formatter.info("No recent activity.") if entries.empty?
|
|
45
|
+
|
|
46
|
+
formatter.header("Recent Activity")
|
|
47
|
+
entries.first(MAX_HISTORY_ENTRIES).each do |entry|
|
|
48
|
+
time = entry["created_at"]
|
|
49
|
+
command = entry["command"]
|
|
50
|
+
credits = entry["credits_charged"]
|
|
51
|
+
formatter.info("#{time} #{command.ljust(10)} #{credits} credits")
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Rubyn
|
|
7
|
+
module Config
|
|
8
|
+
class Credentials
|
|
9
|
+
CREDENTIALS_DIR = File.expand_path("~/.rubyn")
|
|
10
|
+
CREDENTIALS_FILE = File.join(CREDENTIALS_DIR, "credentials")
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
def api_key
|
|
14
|
+
ENV["RUBYN_API_KEY"] || read_credentials["api_key"]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def store(api_key:)
|
|
18
|
+
FileUtils.mkdir_p(CREDENTIALS_DIR)
|
|
19
|
+
data = read_credentials.merge("api_key" => api_key)
|
|
20
|
+
File.write(CREDENTIALS_FILE, YAML.dump(data))
|
|
21
|
+
File.chmod(0o600, CREDENTIALS_FILE)
|
|
22
|
+
data
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def exists?
|
|
26
|
+
!api_key.nil? && !api_key.empty?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def read_credentials
|
|
32
|
+
return {} unless File.exist?(CREDENTIALS_FILE)
|
|
33
|
+
|
|
34
|
+
YAML.safe_load_file(CREDENTIALS_FILE) || {}
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Rubyn
|
|
7
|
+
module Config
|
|
8
|
+
class ProjectConfig
|
|
9
|
+
CONFIG_DIR = ".rubyn"
|
|
10
|
+
CONFIG_FILE = File.join(CONFIG_DIR, "project.yml")
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
def read(project_root = Dir.pwd)
|
|
14
|
+
path = File.join(project_root, CONFIG_FILE)
|
|
15
|
+
return {} unless File.exist?(path)
|
|
16
|
+
|
|
17
|
+
YAML.safe_load_file(path) || {}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def write(data, project_root = Dir.pwd)
|
|
21
|
+
dir = File.join(project_root, CONFIG_DIR)
|
|
22
|
+
path = File.join(project_root, CONFIG_FILE)
|
|
23
|
+
FileUtils.mkdir_p(dir)
|
|
24
|
+
File.write(path, YAML.dump(data))
|
|
25
|
+
data
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def exists?(project_root = Dir.pwd)
|
|
29
|
+
File.exist?(File.join(project_root, CONFIG_FILE))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def project_token(project_root = Dir.pwd)
|
|
33
|
+
read(project_root)["project_token"]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def project_id(project_root = Dir.pwd)
|
|
37
|
+
read(project_root)["project_id"]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Rubyn
|
|
7
|
+
module Config
|
|
8
|
+
class Settings
|
|
9
|
+
SETTINGS_DIR = File.expand_path("~/.rubyn")
|
|
10
|
+
SETTINGS_FILE = File.join(SETTINGS_DIR, "config.yml")
|
|
11
|
+
|
|
12
|
+
DEFAULTS = {
|
|
13
|
+
"default_model" => "haiku",
|
|
14
|
+
"auto_apply" => false,
|
|
15
|
+
"output_format" => "diff"
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
def get(key)
|
|
20
|
+
all[key.to_s]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def set(key, value)
|
|
24
|
+
data = all.merge(key.to_s => coerce_value(value))
|
|
25
|
+
FileUtils.mkdir_p(SETTINGS_DIR)
|
|
26
|
+
File.write(SETTINGS_FILE, YAML.dump(data))
|
|
27
|
+
data
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def all
|
|
31
|
+
DEFAULTS.merge(read_settings)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def read_settings
|
|
37
|
+
return {} unless File.exist?(SETTINGS_FILE)
|
|
38
|
+
|
|
39
|
+
YAML.safe_load_file(SETTINGS_FILE) || {}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def coerce_value(value)
|
|
43
|
+
case value
|
|
44
|
+
when "true" then true
|
|
45
|
+
when "false" then false
|
|
46
|
+
when /\A\d+\z/ then value.to_i
|
|
47
|
+
else value
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "yaml"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
|
|
7
|
+
module Rubyn
|
|
8
|
+
module Context
|
|
9
|
+
class CodebaseIndexer
|
|
10
|
+
CACHE_FILE = ".rubyn/index_cache.yml"
|
|
11
|
+
BATCH_SIZE = 50
|
|
12
|
+
|
|
13
|
+
attr_reader :project_root
|
|
14
|
+
|
|
15
|
+
def initialize(project_root = Dir.pwd)
|
|
16
|
+
@project_root = project_root
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Yields progress events: { phase:, current:, total:, file:, message: }
|
|
20
|
+
def index(force: false, &on_progress)
|
|
21
|
+
ruby_files = find_ruby_files
|
|
22
|
+
cache = force ? {} : load_cache
|
|
23
|
+
changed_files = detect_changes(ruby_files, cache)
|
|
24
|
+
|
|
25
|
+
if changed_files.empty?
|
|
26
|
+
report(on_progress, phase: :complete, message: "Everything up to date")
|
|
27
|
+
return { indexed: 0, skipped: ruby_files.size, chunks: 0 }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
report(on_progress, phase: :scan, message: "Found #{changed_files.size} files to analyse")
|
|
31
|
+
|
|
32
|
+
result = process_changed_files(changed_files, cache, on_progress)
|
|
33
|
+
|
|
34
|
+
report(on_progress, phase: :upload,
|
|
35
|
+
message: "Uploading #{result[:files].size} files in #{batch_count(result[:files])} batches")
|
|
36
|
+
|
|
37
|
+
send_files(result[:files], on_progress)
|
|
38
|
+
save_cache(result[:cache])
|
|
39
|
+
|
|
40
|
+
report(on_progress, phase: :complete,
|
|
41
|
+
message: "Analysed #{changed_files.size} files")
|
|
42
|
+
|
|
43
|
+
{ indexed: changed_files.size, skipped: ruby_files.size - changed_files.size }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def process_changed_files(changed_files, cache, on_progress = nil)
|
|
49
|
+
all_files = []
|
|
50
|
+
new_cache = cache.dup
|
|
51
|
+
|
|
52
|
+
changed_files.each_with_index do |file, idx|
|
|
53
|
+
report(on_progress, phase: :chunk, current: idx + 1, total: changed_files.size, file: file)
|
|
54
|
+
|
|
55
|
+
content = File.read(File.join(project_root, file))
|
|
56
|
+
file_hash = Digest::SHA256.hexdigest(content)
|
|
57
|
+
all_files << { file_path: file, content: content, file_hash: file_hash }
|
|
58
|
+
new_cache[file] = file_hash
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
{ files: all_files, cache: new_cache }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def chunk_file(file_path, content)
|
|
65
|
+
lines = content.lines
|
|
66
|
+
chunks = parse_chunks(file_path, lines)
|
|
67
|
+
|
|
68
|
+
if chunks.empty?
|
|
69
|
+
chunks << default_chunk(file_path, content, lines)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
chunks
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def parse_chunks(file_path, lines)
|
|
76
|
+
chunks = []
|
|
77
|
+
current_chunk = nil
|
|
78
|
+
current_start = nil
|
|
79
|
+
nesting = 0
|
|
80
|
+
|
|
81
|
+
lines.each_with_index do |line, index|
|
|
82
|
+
line_num = index + 1
|
|
83
|
+
|
|
84
|
+
if line.match?(/^\s*(class|module)\s+/)
|
|
85
|
+
finalize_chunk(chunks, current_chunk, file_path, current_start, line_num - 1) if current_chunk
|
|
86
|
+
current_chunk, current_start, nesting = start_class_or_module_chunk(line, line_num)
|
|
87
|
+
elsif line.match?(/^\s*def\s+/) && nesting <= 1
|
|
88
|
+
current_chunk, current_start = start_method_chunk(line, line_num, chunks, current_chunk, current_start, file_path)
|
|
89
|
+
else
|
|
90
|
+
current_chunk[:lines] << line if current_chunk
|
|
91
|
+
|
|
92
|
+
if line.match?(/\b(do|begin)\b/) || (line.match?(/\bclass\b|\bmodule\b|\bdef\b/) && !line.match?(/end/))
|
|
93
|
+
nesting += 1
|
|
94
|
+
end
|
|
95
|
+
nesting -= 1 if line.strip == "end"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
finalize_chunk(chunks, current_chunk, file_path, current_start, lines.size) if current_chunk
|
|
100
|
+
chunks
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def start_class_or_module_chunk(line, line_num)
|
|
104
|
+
match = line.match(/^\s*(class|module)\s+(\S+)/)
|
|
105
|
+
chunk = { type: match[1], name: match[2], lines: [line] }
|
|
106
|
+
[chunk, line_num, 1]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def start_method_chunk(line, line_num, chunks, current_chunk, current_start, file_path)
|
|
110
|
+
if current_chunk && current_chunk[:type] == "method"
|
|
111
|
+
finalize_chunk(chunks, current_chunk, file_path, current_start, line_num - 1)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
match = line.match(/^\s*def\s+(\S+)/)
|
|
115
|
+
method_chunk = { type: "method", name: match[1], lines: [line] }
|
|
116
|
+
|
|
117
|
+
if current_chunk.nil?
|
|
118
|
+
[method_chunk, line_num]
|
|
119
|
+
else
|
|
120
|
+
current_chunk[:lines] << line
|
|
121
|
+
[current_chunk, current_start]
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def default_chunk(file_path, content, lines)
|
|
126
|
+
{
|
|
127
|
+
file_path: file_path,
|
|
128
|
+
chunk_type: "file",
|
|
129
|
+
chunk_name: File.basename(file_path, ".rb"),
|
|
130
|
+
chunk_content: content,
|
|
131
|
+
line_start: 1,
|
|
132
|
+
line_end: lines.size
|
|
133
|
+
}
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def report(callback, **data)
|
|
137
|
+
callback&.call(data)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def batch_count(chunks)
|
|
141
|
+
(chunks.size.to_f / BATCH_SIZE).ceil
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def finalize_chunk(chunks, chunk, file_path, start_line, end_line)
|
|
145
|
+
return unless chunk
|
|
146
|
+
|
|
147
|
+
chunks << {
|
|
148
|
+
file_path: file_path,
|
|
149
|
+
chunk_type: chunk[:type],
|
|
150
|
+
chunk_name: chunk[:name],
|
|
151
|
+
chunk_content: chunk[:lines].join,
|
|
152
|
+
line_start: start_line,
|
|
153
|
+
line_end: end_line
|
|
154
|
+
}
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def find_ruby_files
|
|
158
|
+
Dir.glob(File.join(project_root, "**/*.rb"))
|
|
159
|
+
.reject { |f| f.include?("/vendor/") || f.include?("/node_modules/") || f.include?("/.rubyn/") }
|
|
160
|
+
.map { |f| f.sub("#{project_root}/", "") }
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def detect_changes(files, cache)
|
|
164
|
+
files.reject do |file|
|
|
165
|
+
full_path = File.join(project_root, file)
|
|
166
|
+
current_hash = Digest::SHA256.hexdigest(File.read(full_path))
|
|
167
|
+
cache[file] == current_hash
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def load_cache
|
|
172
|
+
path = File.join(project_root, CACHE_FILE)
|
|
173
|
+
return {} unless File.exist?(path)
|
|
174
|
+
|
|
175
|
+
YAML.safe_load_file(path) || {}
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def save_cache(cache)
|
|
179
|
+
path = File.join(project_root, CACHE_FILE)
|
|
180
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
181
|
+
File.write(path, YAML.dump(cache))
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def send_files(files, on_progress = nil)
|
|
185
|
+
project_token = Rubyn::Config::ProjectConfig.project_token
|
|
186
|
+
batches = files.each_slice(BATCH_SIZE).to_a
|
|
187
|
+
|
|
188
|
+
batches.each_with_index do |batch, idx|
|
|
189
|
+
report(on_progress, phase: :upload_batch, current: idx + 1, total: batches.size)
|
|
190
|
+
Rubyn.api_client.index_project(project_token: project_token, files: batch)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|