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,191 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module Rubyn
|
|
6
|
+
module Commands
|
|
7
|
+
class Agent < Base
|
|
8
|
+
MAX_TOOL_ROUNDS = 25
|
|
9
|
+
MAX_PARAM_DISPLAY_LENGTH = 50
|
|
10
|
+
|
|
11
|
+
def execute
|
|
12
|
+
formatter = Rubyn::Output::Formatter
|
|
13
|
+
ensure_configured!
|
|
14
|
+
auto_index_if_needed!
|
|
15
|
+
|
|
16
|
+
formatter.header("Rubyn Agent")
|
|
17
|
+
formatter.info("Type your message. Use @filename to attach files.")
|
|
18
|
+
formatter.info("Commands: /quit /credits /new")
|
|
19
|
+
formatter.newline
|
|
20
|
+
|
|
21
|
+
@conversation_id = nil
|
|
22
|
+
@project_token = project_token
|
|
23
|
+
@executor = Rubyn::Tools::Executor.new(Dir.pwd)
|
|
24
|
+
|
|
25
|
+
loop do
|
|
26
|
+
print "you> "
|
|
27
|
+
input = $stdin.gets
|
|
28
|
+
break if input.nil?
|
|
29
|
+
|
|
30
|
+
input = input.strip
|
|
31
|
+
break if ["/quit", "exit"].include?(input)
|
|
32
|
+
|
|
33
|
+
next if input.empty?
|
|
34
|
+
|
|
35
|
+
dispatch_input(input, formatter)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
formatter.newline
|
|
39
|
+
formatter.info("Session ended.")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def dispatch_input(input, formatter)
|
|
45
|
+
case input
|
|
46
|
+
when "/credits"
|
|
47
|
+
show_credits(formatter)
|
|
48
|
+
when "/new"
|
|
49
|
+
@conversation_id = nil
|
|
50
|
+
formatter.info("New conversation started.")
|
|
51
|
+
else
|
|
52
|
+
send_message(input, formatter)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def send_message(input, formatter)
|
|
57
|
+
file_context = extract_file_context(input)
|
|
58
|
+
message = input.gsub(/@\S+/, "").strip
|
|
59
|
+
|
|
60
|
+
begin
|
|
61
|
+
result = Rubyn.api_client.agent_message(
|
|
62
|
+
conversation_id: @conversation_id,
|
|
63
|
+
message: message,
|
|
64
|
+
file_context: file_context,
|
|
65
|
+
project_token: @project_token
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
@conversation_id ||= result["conversation_id"]
|
|
69
|
+
|
|
70
|
+
display_agent_result(result, formatter)
|
|
71
|
+
rescue Rubyn::AuthenticationError => e
|
|
72
|
+
formatter.error("Authentication failed: #{e.message}")
|
|
73
|
+
rescue Rubyn::APIError => e
|
|
74
|
+
formatter.error(e.message)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def display_agent_result(result, formatter)
|
|
79
|
+
if result.fetch("type", nil) == "tool_calls"
|
|
80
|
+
tool_calls = result.fetch("tool_calls", []).map do |tc|
|
|
81
|
+
{
|
|
82
|
+
id: tc.fetch("id"),
|
|
83
|
+
name: tc.fetch("name"),
|
|
84
|
+
input: tc.fetch("input", {}),
|
|
85
|
+
requires_confirmation: tc.fetch("requires_confirmation", false)
|
|
86
|
+
}
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
formatter.credit_usage(result["credits_used"], nil) if result["credits_used"]
|
|
90
|
+
|
|
91
|
+
process_tool_loop(tool_calls, formatter)
|
|
92
|
+
else
|
|
93
|
+
print "rubyn> "
|
|
94
|
+
formatter.print_content(result.dig("response") || result.dig("content") || "")
|
|
95
|
+
formatter.newline
|
|
96
|
+
formatter.credit_usage(result["credits_used"], nil) if result["credits_used"]
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def process_tool_loop(tool_calls, formatter, round: 0)
|
|
101
|
+
if round >= MAX_TOOL_ROUNDS
|
|
102
|
+
formatter.warning("Maximum tool rounds (#{MAX_TOOL_ROUNDS}) reached. Stopping.")
|
|
103
|
+
return
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
results = execute_tool_calls(tool_calls, formatter)
|
|
107
|
+
|
|
108
|
+
begin
|
|
109
|
+
result = Rubyn.api_client.submit_tool_results(
|
|
110
|
+
conversation_id: @conversation_id,
|
|
111
|
+
tool_results: results,
|
|
112
|
+
project_token: @project_token
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
@conversation_id ||= result["conversation_id"]
|
|
116
|
+
|
|
117
|
+
display_agent_result(result, formatter)
|
|
118
|
+
rescue Rubyn::AuthenticationError => e
|
|
119
|
+
formatter.error("Authentication failed: #{e.message}")
|
|
120
|
+
rescue Rubyn::APIError => e
|
|
121
|
+
formatter.error(e.message)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def execute_tool_calls(tool_calls, formatter)
|
|
126
|
+
tool_calls.map do |tc|
|
|
127
|
+
formatter.info("Tool: #{tc[:name]}(#{format_tool_params(tc[:input])})")
|
|
128
|
+
|
|
129
|
+
result = @executor.execute(
|
|
130
|
+
tc[:name],
|
|
131
|
+
tc[:input],
|
|
132
|
+
server_requires_confirmation: tc[:requires_confirmation]
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
if result[:success]
|
|
136
|
+
Rubyn::Output::Formatter.success("#{tc[:name]} completed")
|
|
137
|
+
elsif result[:error] == "denied_by_user"
|
|
138
|
+
Rubyn::Output::Formatter.warning("#{tc[:name]} denied by user")
|
|
139
|
+
else
|
|
140
|
+
Rubyn::Output::Formatter.error("#{tc[:name]}: #{result[:error]}")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
{ tool_call_id: tc[:id], result: result.is_a?(Hash) ? result.to_json : result }
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def format_tool_params(params)
|
|
148
|
+
return "" unless params.is_a?(Hash)
|
|
149
|
+
|
|
150
|
+
params.map do |k, v|
|
|
151
|
+
val = v.is_a?(String) && v.length > MAX_PARAM_DISPLAY_LENGTH ? "#{v[0..MAX_PARAM_DISPLAY_LENGTH - 3]}..." : v
|
|
152
|
+
"#{k}: #{val}"
|
|
153
|
+
end.join(", ")
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Override base to use larger context limit for agent
|
|
157
|
+
def build_context_files(relative_path)
|
|
158
|
+
resolver = Rubyn::Context::FileResolver.new
|
|
159
|
+
related = resolver.resolve(relative_path)
|
|
160
|
+
builder = Rubyn::Context::ContextBuilder.new(Dir.pwd, max_bytes: Rubyn::Context::ContextBuilder::AGENT_MAX_CONTEXT_BYTES)
|
|
161
|
+
context = builder.build(relative_path, related)
|
|
162
|
+
context.reject { |path, _| path == relative_path }
|
|
163
|
+
.map { |path, content| { file_path: path, code: content } }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def extract_file_context(input)
|
|
167
|
+
files = input.scan(/@(\S+)/).flatten.select { |file| File.exist?(file) }
|
|
168
|
+
return [] if files.empty?
|
|
169
|
+
|
|
170
|
+
# Resolve dependencies for each attached file so the agent
|
|
171
|
+
# has full context and won't break related code during refactors
|
|
172
|
+
all_files = Set.new(files)
|
|
173
|
+
files.each do |file|
|
|
174
|
+
relative = relative_to_project(file)
|
|
175
|
+
related = build_context_files(relative)
|
|
176
|
+
related.each { |ctx| all_files << ctx[:file_path] }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
all_files.to_a
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def show_credits(formatter)
|
|
183
|
+
result = Rubyn.api_client.get_balance
|
|
184
|
+
formatter.info("Balance: #{result["balance"]} credits")
|
|
185
|
+
formatter.info("Plan: #{result["plan"]}")
|
|
186
|
+
rescue Rubyn::Error => e
|
|
187
|
+
formatter.error(e.message)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubyn
|
|
4
|
+
module Commands
|
|
5
|
+
class Base
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
# Full check: credentials + project config (used by most commands)
|
|
9
|
+
def ensure_configured!
|
|
10
|
+
return if Rubyn::Config::Credentials.exists? && Rubyn::Config::ProjectConfig.exists?
|
|
11
|
+
|
|
12
|
+
Rubyn::Output::Formatter.error("Not configured. Run `rubyn init` first.")
|
|
13
|
+
raise Rubyn::ConfigurationError, "Not configured"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Auto-index codebase if user has Pro/Lifetime but hasn't indexed yet
|
|
17
|
+
# def auto_index_if_needed!
|
|
18
|
+
# result = Rubyn.api_client.verify_auth
|
|
19
|
+
# return unless result.dig("user", "needs_indexing")
|
|
20
|
+
#
|
|
21
|
+
# formatter = Rubyn::Output::Formatter
|
|
22
|
+
# formatter.info("Pro feature unlocked — indexing your codebase for enhanced context...")
|
|
23
|
+
# formatter.newline
|
|
24
|
+
#
|
|
25
|
+
# indexer = Rubyn::Context::CodebaseIndexer.new
|
|
26
|
+
# indexer.index
|
|
27
|
+
# formatter.success("Codebase indexed! Your AI commands now include project context.")
|
|
28
|
+
# formatter.newline
|
|
29
|
+
# rescue Rubyn::Error => e
|
|
30
|
+
# Rubyn::Output::Formatter.warning("Auto-indexing skipped: #{e.message}")
|
|
31
|
+
# end
|
|
32
|
+
def auto_index_if_needed!; end
|
|
33
|
+
|
|
34
|
+
# Credentials-only check (used by usage command)
|
|
35
|
+
def ensure_credentials!
|
|
36
|
+
return if Rubyn::Config::Credentials.exists?
|
|
37
|
+
|
|
38
|
+
Rubyn::Output::Formatter.error("Not configured. Run `rubyn init` first.")
|
|
39
|
+
raise Rubyn::ConfigurationError, "Not configured"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def relative_to_project(file)
|
|
43
|
+
File.expand_path(file).sub("#{Dir.pwd}/", "")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def build_context_files(relative_path)
|
|
47
|
+
resolver = Rubyn::Context::FileResolver.new
|
|
48
|
+
related = resolver.resolve(relative_path)
|
|
49
|
+
builder = Rubyn::Context::ContextBuilder.new
|
|
50
|
+
context = builder.build(relative_path, related)
|
|
51
|
+
context.reject { |path, _| path == relative_path }
|
|
52
|
+
.map { |path, content| { file_path: path, code: content } }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def project_token
|
|
56
|
+
Rubyn::Config::ProjectConfig.project_token
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubyn
|
|
4
|
+
module Commands
|
|
5
|
+
class Config
|
|
6
|
+
VALID_KEYS = %w[default_model auto_apply output_format].freeze
|
|
7
|
+
|
|
8
|
+
def execute(key = nil, value = nil)
|
|
9
|
+
formatter = Output::Formatter
|
|
10
|
+
|
|
11
|
+
if key.nil?
|
|
12
|
+
display_all(formatter)
|
|
13
|
+
elsif value.nil?
|
|
14
|
+
display_key(formatter, key)
|
|
15
|
+
else
|
|
16
|
+
set_key(formatter, key, value)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def display_all(formatter)
|
|
23
|
+
formatter.header("Rubyn Configuration")
|
|
24
|
+
Rubyn::Config::Settings.all.each do |k, v|
|
|
25
|
+
formatter.info("#{k}: #{v}")
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def display_key(formatter, key)
|
|
30
|
+
value = Rubyn::Config::Settings.get(key)
|
|
31
|
+
if value.nil?
|
|
32
|
+
formatter.warning("Unknown key: #{key}")
|
|
33
|
+
formatter.info("Valid keys: #{VALID_KEYS.join(", ")}")
|
|
34
|
+
else
|
|
35
|
+
formatter.info("#{key}: #{value}")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def set_key(formatter, key, value)
|
|
40
|
+
unless VALID_KEYS.include?(key)
|
|
41
|
+
formatter.error("Invalid key: #{key}")
|
|
42
|
+
formatter.info("Valid keys: #{VALID_KEYS.join(", ")}")
|
|
43
|
+
return
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
Rubyn::Config::Settings.set(key, value)
|
|
47
|
+
formatter.success("#{key} set to #{value}")
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubyn
|
|
4
|
+
module Commands
|
|
5
|
+
class Dashboard
|
|
6
|
+
DEFAULT_PORT = 9292
|
|
7
|
+
|
|
8
|
+
def execute
|
|
9
|
+
formatter = Rubyn::Output::Formatter
|
|
10
|
+
|
|
11
|
+
if rails_project_with_engine?
|
|
12
|
+
formatter.info("Dashboard available at: http://localhost:3000/rubyn")
|
|
13
|
+
formatter.info("Start your Rails server with `rails s` to access.")
|
|
14
|
+
else
|
|
15
|
+
start_standalone_dashboard(formatter)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def rails_project_with_engine?
|
|
22
|
+
routes_file = File.join(Dir.pwd, "config", "routes.rb")
|
|
23
|
+
return false unless File.exist?(routes_file)
|
|
24
|
+
|
|
25
|
+
File.read(routes_file).include?("Rubyn::Engine")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def start_standalone_dashboard(formatter)
|
|
29
|
+
formatter.info("Starting standalone dashboard...")
|
|
30
|
+
formatter.info("Dashboard: http://localhost:#{DEFAULT_PORT}/rubyn")
|
|
31
|
+
|
|
32
|
+
require "sinatra/base"
|
|
33
|
+
|
|
34
|
+
port = DEFAULT_PORT
|
|
35
|
+
app = Class.new(Sinatra::Base) do
|
|
36
|
+
set :port, port
|
|
37
|
+
set :bind, "0.0.0.0"
|
|
38
|
+
|
|
39
|
+
get "/rubyn" do
|
|
40
|
+
content_type :html
|
|
41
|
+
build_dashboard_html
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
get "/rubyn/health" do
|
|
45
|
+
content_type :json
|
|
46
|
+
{ status: "ok", version: Rubyn::VERSION }.to_json
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
helpers do
|
|
50
|
+
def build_dashboard_html
|
|
51
|
+
<<~HTML
|
|
52
|
+
<!DOCTYPE html>
|
|
53
|
+
<html>
|
|
54
|
+
<head>
|
|
55
|
+
<title>Rubyn Dashboard</title>
|
|
56
|
+
<style>
|
|
57
|
+
body { font-family: system-ui; background: #1a1a2e; color: #e0e0e0; margin: 0; padding: 2rem; }
|
|
58
|
+
h1 { color: #e94560; }
|
|
59
|
+
.card { background: #16213e; border-radius: 8px; padding: 1.5rem; margin: 1rem 0; }
|
|
60
|
+
.info { color: #a0a0a0; }
|
|
61
|
+
</style>
|
|
62
|
+
</head>
|
|
63
|
+
<body>
|
|
64
|
+
<h1>Rubyn Dashboard</h1>
|
|
65
|
+
<div class="card">
|
|
66
|
+
<h2>Standalone Mode</h2>
|
|
67
|
+
<p class="info">Mount the Rubyn engine in your Rails app for the full dashboard experience.</p>
|
|
68
|
+
<p class="info">Run: <code>rails generate rubyn:install</code></p>
|
|
69
|
+
</div>
|
|
70
|
+
<div class="card">
|
|
71
|
+
<h2>Version</h2>
|
|
72
|
+
<p>#{Rubyn::VERSION}</p>
|
|
73
|
+
</div>
|
|
74
|
+
</body>
|
|
75
|
+
</html>
|
|
76
|
+
HTML
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
app.run!
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pastel"
|
|
4
|
+
|
|
5
|
+
module Rubyn
|
|
6
|
+
module Commands
|
|
7
|
+
class Index < Base
|
|
8
|
+
PROGRESS_BAR_WIDTH = 30
|
|
9
|
+
SUMMARY_BOX_WIDTH = 44
|
|
10
|
+
|
|
11
|
+
def execute(force: false)
|
|
12
|
+
formatter = Rubyn::Output::Formatter
|
|
13
|
+
pastel = Pastel.new
|
|
14
|
+
ensure_configured!
|
|
15
|
+
|
|
16
|
+
formatter.header("Codebase Analysis")
|
|
17
|
+
formatter.newline
|
|
18
|
+
|
|
19
|
+
indexer = Rubyn::Context::CodebaseIndexer.new
|
|
20
|
+
chunk_bar = nil
|
|
21
|
+
upload_bar = nil
|
|
22
|
+
|
|
23
|
+
result = indexer.index(force: force) do |progress|
|
|
24
|
+
case progress[:phase]
|
|
25
|
+
when :scan
|
|
26
|
+
formatter.info(progress[:message])
|
|
27
|
+
formatter.newline
|
|
28
|
+
when :chunk
|
|
29
|
+
if chunk_bar.nil?
|
|
30
|
+
chunk_bar = TTY::Spinner::Multi.new("[:spinner] Chunking files")
|
|
31
|
+
@file_spinners = {}
|
|
32
|
+
end
|
|
33
|
+
render_file_progress(chunk_bar, progress, pastel)
|
|
34
|
+
when :upload
|
|
35
|
+
chunk_bar&.success
|
|
36
|
+
formatter.newline
|
|
37
|
+
formatter.info(progress[:message])
|
|
38
|
+
when :upload_batch
|
|
39
|
+
if upload_bar.nil?
|
|
40
|
+
upload_bar = TTY::Spinner.new(
|
|
41
|
+
"[:spinner] Uploading batch :current/:total",
|
|
42
|
+
format: :dots
|
|
43
|
+
)
|
|
44
|
+
upload_bar.auto_spin
|
|
45
|
+
end
|
|
46
|
+
upload_bar.update(current: progress[:current], total: progress[:total])
|
|
47
|
+
when :complete
|
|
48
|
+
upload_bar&.success("Done!")
|
|
49
|
+
formatter.newline
|
|
50
|
+
formatter.success(progress[:message])
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
formatter.newline
|
|
55
|
+
render_summary(formatter, pastel, result)
|
|
56
|
+
rescue Rubyn::Error => e
|
|
57
|
+
formatter.error(e.message)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def render_file_progress(_multi, progress, pastel)
|
|
63
|
+
pct = ((progress[:current].to_f / progress[:total]) * 100).round
|
|
64
|
+
filled = (PROGRESS_BAR_WIDTH * progress[:current].to_f / progress[:total]).round
|
|
65
|
+
empty = PROGRESS_BAR_WIDTH - filled
|
|
66
|
+
|
|
67
|
+
bar = pastel.green("#" * filled) + pastel.dim("." * empty)
|
|
68
|
+
file_name = truncate_path(progress[:file], 40)
|
|
69
|
+
|
|
70
|
+
print "\r #{bar} #{pct}% #{pastel.dim(file_name)}#{" " * 20}"
|
|
71
|
+
puts if progress[:current] == progress[:total]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def render_summary(_formatter, pastel, result)
|
|
75
|
+
return if result.nil?
|
|
76
|
+
|
|
77
|
+
border = pastel.dim("+#{"-" * (SUMMARY_BOX_WIDTH - 2)}+")
|
|
78
|
+
spacer = pastel.dim("|") + (" " * (SUMMARY_BOX_WIDTH - 2)) + pastel.dim("|")
|
|
79
|
+
|
|
80
|
+
puts border
|
|
81
|
+
puts spacer
|
|
82
|
+
puts row(pastel, " Files analysed", result[:indexed].to_s, SUMMARY_BOX_WIDTH)
|
|
83
|
+
puts row(pastel, " Files skipped", result[:skipped].to_s, SUMMARY_BOX_WIDTH)
|
|
84
|
+
puts row(pastel, " Chunks created", result[:chunks].to_s, SUMMARY_BOX_WIDTH)
|
|
85
|
+
puts spacer
|
|
86
|
+
puts border
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def row(pastel, label, value, width)
|
|
90
|
+
content = "#{label}#{" " * (width - 4 - label.length - value.length)}#{pastel.bold(value)}"
|
|
91
|
+
"#{pastel.dim("|")} #{content} #{pastel.dim("|")}"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def truncate_path(path, max)
|
|
95
|
+
return path if path.length <= max
|
|
96
|
+
|
|
97
|
+
"...#{path[-(max - 3)..]}"
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "tty-prompt"
|
|
5
|
+
|
|
6
|
+
module Rubyn
|
|
7
|
+
module Commands
|
|
8
|
+
class Init
|
|
9
|
+
PROJECT_TOKEN_PREFIX = "rprj_".freeze
|
|
10
|
+
PROJECT_TOKEN_HEX_LENGTH = 12
|
|
11
|
+
TOKEN_PREVIEW_LENGTH = 9
|
|
12
|
+
|
|
13
|
+
def execute
|
|
14
|
+
formatter = Rubyn::Output::Formatter
|
|
15
|
+
prompt = TTY::Prompt.new
|
|
16
|
+
|
|
17
|
+
formatter.header("Rubyn Init")
|
|
18
|
+
|
|
19
|
+
if Rubyn::Config::ProjectConfig.exists?
|
|
20
|
+
join_existing_project(formatter)
|
|
21
|
+
return
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
api_key = resolve_api_key(formatter, prompt)
|
|
25
|
+
return unless api_key
|
|
26
|
+
|
|
27
|
+
verify_api_key(formatter, api_key)
|
|
28
|
+
|
|
29
|
+
metadata = scan_project(formatter)
|
|
30
|
+
project_token = "#{PROJECT_TOKEN_PREFIX}#{SecureRandom.hex(PROJECT_TOKEN_HEX_LENGTH)}"
|
|
31
|
+
result = sync_project(formatter, metadata, project_token)
|
|
32
|
+
|
|
33
|
+
write_project_config(result)
|
|
34
|
+
# offer_indexing(formatter, prompt, result)
|
|
35
|
+
display_summary(formatter, metadata, result)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def join_existing_project(formatter)
|
|
41
|
+
project_token = Rubyn::Config::ProjectConfig.project_token
|
|
42
|
+
formatter.info("Existing project detected. Joining with token: #{project_token[0..TOKEN_PREVIEW_LENGTH - 1]}...")
|
|
43
|
+
|
|
44
|
+
spinner = Rubyn::Output::Spinner.new
|
|
45
|
+
spinner.start("Joining project...")
|
|
46
|
+
|
|
47
|
+
begin
|
|
48
|
+
result = Rubyn.api_client.join_project(project_token: project_token)
|
|
49
|
+
spinner.stop_success("Joined!")
|
|
50
|
+
formatter.success("Successfully joined project. Project ID: #{result.dig("project", "id")}")
|
|
51
|
+
rescue Rubyn::Error => e
|
|
52
|
+
spinner.stop_error("Failed")
|
|
53
|
+
formatter.error("Could not join project: #{e.message}")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def resolve_api_key(formatter, prompt)
|
|
58
|
+
if Rubyn::Config::Credentials.exists?
|
|
59
|
+
formatter.info("API key found in credentials")
|
|
60
|
+
return Rubyn::Config::Credentials.api_key
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
if ENV["RUBYN_API_KEY"]
|
|
64
|
+
formatter.info("Using RUBYN_API_KEY from environment")
|
|
65
|
+
return ENV["RUBYN_API_KEY"]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
api_key = prompt.ask("Enter your Rubyn API key:") do |q|
|
|
69
|
+
q.required true
|
|
70
|
+
q.validate(/\Ark_/, "API key should start with 'rk_'")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
Rubyn::Config::Credentials.store(api_key: api_key)
|
|
74
|
+
formatter.success("API key saved to ~/.rubyn/credentials")
|
|
75
|
+
api_key
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def verify_api_key(formatter, api_key)
|
|
79
|
+
spinner = Rubyn::Output::Spinner.new
|
|
80
|
+
spinner.start("Verifying API key...")
|
|
81
|
+
|
|
82
|
+
Rubyn.configuration.api_key = api_key
|
|
83
|
+
result = Rubyn.api_client.verify_auth
|
|
84
|
+
spinner.stop_success("Verified!")
|
|
85
|
+
|
|
86
|
+
formatter.info("Authenticated as: #{result.dig("user", "email")}")
|
|
87
|
+
formatter.info("Plan: #{result.dig("user", "plan")}")
|
|
88
|
+
rescue Rubyn::AuthenticationError
|
|
89
|
+
spinner.stop_error("Invalid!")
|
|
90
|
+
formatter.error("API key is invalid. Get your key at https://rubyn.dev/settings")
|
|
91
|
+
raise
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def scan_project(_formatter)
|
|
95
|
+
spinner = Rubyn::Output::Spinner.new
|
|
96
|
+
spinner.start("Scanning project...")
|
|
97
|
+
scanner = Rubyn::Context::ProjectScanner.new
|
|
98
|
+
metadata = scanner.scan
|
|
99
|
+
spinner.stop_success("Scanned!")
|
|
100
|
+
metadata
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def sync_project(_formatter, metadata, project_token)
|
|
104
|
+
spinner = Rubyn::Output::Spinner.new
|
|
105
|
+
spinner.start("Syncing project with Rubyn...")
|
|
106
|
+
result = Rubyn.api_client.sync_project(metadata: metadata, project_token: project_token)
|
|
107
|
+
spinner.stop_success("Synced!")
|
|
108
|
+
result
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def write_project_config(result)
|
|
112
|
+
project = result.fetch("project")
|
|
113
|
+
Rubyn::Config::ProjectConfig.write({
|
|
114
|
+
"project_token" => project.fetch("project_token"),
|
|
115
|
+
"project_id" => project.fetch("id")
|
|
116
|
+
})
|
|
117
|
+
ensure_gitignore
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def ensure_gitignore
|
|
121
|
+
gitignore_path = File.join(Dir.pwd, ".gitignore")
|
|
122
|
+
return unless File.exist?(gitignore_path)
|
|
123
|
+
|
|
124
|
+
content = File.read(gitignore_path)
|
|
125
|
+
return if content.match?(/^\/?\.rubyn\/?$/)
|
|
126
|
+
|
|
127
|
+
File.open(gitignore_path, "a") do |f|
|
|
128
|
+
f.puts unless content.end_with?("\n")
|
|
129
|
+
f.puts "\n# Rubyn project config"
|
|
130
|
+
f.puts ".rubyn/"
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# def offer_indexing(formatter, prompt, _result)
|
|
135
|
+
# return unless prompt.yes?("Analyse your codebase for enhanced context? (Pro feature)")
|
|
136
|
+
#
|
|
137
|
+
# spinner = Rubyn::Output::Spinner.new
|
|
138
|
+
# spinner.start("Analysing codebase...")
|
|
139
|
+
# indexer = Rubyn::Context::CodebaseIndexer.new
|
|
140
|
+
# stats = indexer.index
|
|
141
|
+
# spinner.stop_success("Done!")
|
|
142
|
+
# formatter.info("Analysed #{stats[:indexed]} files (#{stats[:skipped]} unchanged)")
|
|
143
|
+
# rescue Rubyn::AuthenticationError
|
|
144
|
+
# spinner&.stop_error("Skipped")
|
|
145
|
+
# formatter.warning("Codebase analysis requires a Pro plan. Skipping.")
|
|
146
|
+
# rescue Rubyn::APIError => e
|
|
147
|
+
# spinner&.stop_error("Failed")
|
|
148
|
+
# formatter.error("Could not analyse codebase: #{e.message}")
|
|
149
|
+
# end
|
|
150
|
+
|
|
151
|
+
def display_summary(formatter, metadata, result)
|
|
152
|
+
formatter.newline
|
|
153
|
+
formatter.header("Project Summary")
|
|
154
|
+
formatter.info("Project: #{metadata[:project_name]}")
|
|
155
|
+
formatter.info("Type: #{metadata[:project_type]}")
|
|
156
|
+
formatter.info("Ruby: #{metadata[:ruby_version] || "not detected"}")
|
|
157
|
+
formatter.info("Rails: #{metadata[:rails_version] || "N/A"}")
|
|
158
|
+
formatter.info("Tests: #{metadata[:test_framework]}")
|
|
159
|
+
formatter.info("Credits: N/A")
|
|
160
|
+
formatter.newline
|
|
161
|
+
formatter.info("Run `rubyn refactor <file>` to get started.")
|
|
162
|
+
formatter.info("Or visit http://localhost:3000/rubyn for the dashboard.")
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|