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,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