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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8b1ca55e10bd9ae4ec17fbdbfa196577fda468788c083905679a2c1e7e782181
4
+ data.tar.gz: f82e928da5975b754dca641f00488bb6360ffbd4cd45d538909348a4b1ff6b05
5
+ SHA512:
6
+ metadata.gz: da671165752719684baaf371f4c1a47d7a9f3e8604af37360b106827d15c08a1508219b7fc47ae859a1c8f54f9e756a7e7a459c66fff4cbb718e70610eb4684a
7
+ data.tar.gz: 7f6f19c30b1ba42e74aeb0f3b0ec936d8f14ac63b6d52949d87c0714fce3b76c041c3cc8bf67a8eb07c36453b6d7602ae35736c46b1771646ce71bcebac748b4
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 matthewsuttles
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,251 @@
1
+ <p align="center">
2
+ <img src="lib/rubyn/engine/app/assets/images/rubyn/RubynLogo.png" alt="Rubyn" width="80" height="80" />
3
+ </p>
4
+
5
+ <h1 align="center">Rubyn</h1>
6
+
7
+ <p align="center">
8
+ <strong>AI Code Assistant for Ruby & Rails</strong>
9
+ </p>
10
+
11
+ <p align="center">
12
+ <a href="https://rubygems.org/gems/rubyn"><img src="https://img.shields.io/gem/v/rubyn?color=CC342D&label=gem" alt="Gem Version" /></a>
13
+ <a href="https://github.com/MatthewSuttles/rubyn"><img src="https://img.shields.io/badge/specs-412%20passing-brightgreen" alt="Tests" /></a>
14
+ <a href="https://github.com/rubocop/rubocop"><img src="https://img.shields.io/badge/code_style-rubocop-brightgreen.svg" alt="Ruby Style Guide" /></a>
15
+ <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License: MIT" /></a>
16
+ <a href="https://www.ruby-lang.org"><img src="https://img.shields.io/badge/ruby-%3E%3D%202.7-CC342D.svg" alt="Ruby" /></a>
17
+ <a href="https://github.com/MatthewSuttles/rubyn"><img src="https://img.shields.io/badge/coverage-95%25-brightgreen.svg" alt="Coverage" /></a>
18
+ </p>
19
+
20
+ <p align="center">
21
+ Refactor controllers, generate idiomatic RSpec, catch N+1 queries, and review code for anti-patterns — all context-aware with your schema, routes, and specs. Built for Ruby developers who care about conventions.
22
+ </p>
23
+
24
+ ---
25
+
26
+ <!-- Screenshots: replace these paths with your actual screenshots -->
27
+ <p align="center">
28
+ <img src="docs/screenshots/dashboard.png" alt="Rubyn Dashboard" width="700" />
29
+ </p>
30
+
31
+ <p align="center">
32
+ <em>The Rubyn web dashboard mounted inside your Rails app</em>
33
+ </p>
34
+
35
+ <p align="center">
36
+ <img src="docs/screenshots/console.png" alt="Rubyn Dashboard" width="700" />
37
+ </p>
38
+
39
+ <p align="center">
40
+ <em>The Rubyn console for those hardcore users</em>
41
+ </p>
42
+
43
+ ---
44
+
45
+ ## Why Rubyn?
46
+
47
+ General AI tools write Ruby like they write Python. Rubyn is different:
48
+
49
+ - **Rails-native** — Knows when to extract a service object, how to write idiomatic RSpec, and why your controller is too fat
50
+ - **Context-aware** — Automatically includes your schema, routes, specs, factories, and related models before generating suggestions
51
+ - **Best practices built in** — Every request is enriched with curated Ruby and Rails guidelines matched to what you're working on
52
+
53
+ ---
54
+
55
+ ## Installation
56
+
57
+ Add Rubyn to your Gemfile:
58
+
59
+ ```ruby
60
+ gem "rubyn"
61
+ ```
62
+
63
+ Then run:
64
+
65
+ ```sh
66
+ bundle install
67
+ ```
68
+
69
+ Or install it directly:
70
+
71
+ ```sh
72
+ gem install rubyn
73
+ ```
74
+
75
+ ### Requirements
76
+
77
+ - Ruby >= 2.7
78
+ - Rails >= 6.0 (for the web dashboard engine; the CLI works without Rails)
79
+ - A Rubyn API key — sign up at [rubyn.ai](https://rubyn.ai)
80
+
81
+ ---
82
+
83
+ ## Quick Start
84
+
85
+ ### 1. Initialize your project
86
+
87
+ ```sh
88
+ rubyn init
89
+ ```
90
+
91
+ This will:
92
+
93
+ - Prompt for your API key (or read from `RUBYN_API_KEY` env var)
94
+ - Scan your project (Ruby version, Rails version, test framework, gems)
95
+ - Create `.rubyn/project.yml` in your project root
96
+
97
+ ### 2. Refactor a file
98
+
99
+ ```sh
100
+ rubyn refactor app/controllers/orders_controller.rb
101
+ ```
102
+
103
+ Rubyn pulls in the related model, routes, request spec, and service objects — then suggests idiomatic improvements you can apply with one command.
104
+
105
+ <!-- Screenshot: CLI refactor output -->
106
+ <!-- <img src="docs/screenshots/cli-refactor.png" alt="CLI Refactor" width="600" /> -->
107
+
108
+ ### 3. Generate specs
109
+
110
+ ```sh
111
+ rubyn spec app/services/orders/create_service.rb
112
+ ```
113
+
114
+ Generates idiomatic tests that match your project's framework (RSpec or Minitest), factory setup, and assertion style.
115
+
116
+ ### 4. Review code
117
+
118
+ ```sh
119
+ rubyn review app/controllers/
120
+ ```
121
+
122
+ Catches N+1 queries, SQL injection, missing auth, fat controllers, and other anti-patterns — before your PR.
123
+
124
+ ### 5. Start an interactive session
125
+
126
+ ```sh
127
+ rubyn agent
128
+ ```
129
+
130
+ Ask Rubyn anything about your codebase. Attach files with `@filename`:
131
+
132
+ ```
133
+ you> How should I refactor @app/models/order.rb to use service objects?
134
+ rubyn> Looking at your Order model, I'd suggest extracting...
135
+ ```
136
+
137
+ ---
138
+
139
+ ## Web Dashboard
140
+
141
+ Rubyn includes a mountable Rails engine that provides a full web UI in development.
142
+
143
+ ```ruby
144
+ # config/routes.rb
145
+ mount Rubyn::Engine => "/rubyn" if Rails.env.development?
146
+ ```
147
+
148
+ Then visit [http://localhost:3000/rubyn](http://localhost:3000/rubyn).
149
+
150
+ <!-- Screenshot: File browser with categorized files -->
151
+ <!-- <img src="docs/screenshots/files.png" alt="File Browser" width="600" /> -->
152
+
153
+ Features:
154
+
155
+ - **File Browser** — Browse Ruby files by category (models, controllers, services, etc.) with one-click refactor, spec, and review
156
+ - **Refactor View** — Code blocks with Apply button for each file, Apply All for multi-file changes
157
+ - **Spec Generator** — Generated specs with Write to File button
158
+ - **Code Review** — Findings displayed inline
159
+ - **Agent Chat** — Conversational interface
160
+ - **Settings** — API key status and preferences
161
+
162
+ The engine is fully isolated — it uses its own layout, styles, and JavaScript. It will not interfere with your application.
163
+
164
+ ### Non-Rails Projects
165
+
166
+ ```sh
167
+ rubyn dashboard
168
+ # => Dashboard: http://localhost:9292/rubyn
169
+ ```
170
+
171
+ ---
172
+
173
+ ## All Commands
174
+
175
+ | Command | Description |
176
+ |---|---|
177
+ | `rubyn init` | Initialize Rubyn in your project |
178
+ | `rubyn refactor <file>` | Refactor a file toward best practices |
179
+ | `rubyn spec <file>` | Generate tests for a file |
180
+ | `rubyn review <file_or_dir>` | Review code for anti-patterns |
181
+ | `rubyn agent` | Start an interactive conversation |
182
+ | `rubyn usage` | Show credit balance and recent usage |
183
+ | `rubyn config [key] [value]` | View or set configuration |
184
+ | `rubyn dashboard` | Open the web dashboard |
185
+
186
+ ---
187
+
188
+ ## How Context Works
189
+
190
+ When you refactor a controller, Rubyn automatically includes:
191
+
192
+ | You pass | Rubyn also loads |
193
+ |---|---|
194
+ | Controller | Model, routes, request spec, service objects |
195
+ | Model | Schema, controller, model spec, factory |
196
+ | Service object | Referenced models, service spec |
197
+ | Gem lib file | Corresponding spec, sibling classes |
198
+
199
+ Plus relevant best practice documents matched to the file type — no configuration needed.
200
+
201
+ ---
202
+
203
+ ## Plans
204
+
205
+ | Feature | Free | Pro | Lifetime |
206
+ |---|:---:|:---:|:---:|
207
+ | All commands (refactor, spec, review, agent) | Yes | Yes | Yes |
208
+ | Web dashboard | Yes | Yes | Yes |
209
+ | Convention-based file context | Yes | Yes | Yes |
210
+ | Credits | 10/day | 250/month | 250/month |
211
+ | Price | Free | $19/mo | $300 one-time |
212
+ | Overages | - | $0.05/credit * | $0.05/credit * |
213
+
214
+ \* Overages are optional and can be turned off at any time in your account settings.
215
+
216
+ [Compare plans at rubyn.ai/pricing](https://rubyn.ai/pricing)
217
+
218
+ ---
219
+
220
+ ## Configuration
221
+
222
+ ### API Key
223
+
224
+ ```sh
225
+ export RUBYN_API_KEY=rk_your_key_here
226
+ ```
227
+
228
+ Or stored at `~/.rubyn/credentials` with `0600` permissions.
229
+
230
+ ### Environment Variables
231
+
232
+ | Variable | Description |
233
+ |---|---|
234
+ | `RUBYN_API_KEY` | API key (overrides credentials file) |
235
+ | `RUBYN_API_URL` | API base URL (default: `https://api.rubyn.ai`) |
236
+
237
+ ---
238
+
239
+ ## Development
240
+
241
+ ```sh
242
+ bin/setup
243
+ bundle exec rspec # 412 specs
244
+ bundle exec rubocop
245
+ ```
246
+
247
+ ---
248
+
249
+ ## License
250
+
251
+ The gem is available as open source under the terms of the [MIT License](LICENSE).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
data/exe/rubyn ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "rubyn"
5
+ Rubyn::CLI.start(ARGV)
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubyn
4
+ class InstallGenerator < Rails::Generators::Base
5
+ desc "Install Rubyn engine"
6
+
7
+ def add_route
8
+ route 'mount Rubyn::Engine => "/rubyn" if Rails.env.development?'
9
+ end
10
+
11
+ def show_instructions
12
+ say "Rubyn engine mounted at /rubyn (development only)"
13
+ say "Run 'rubyn init' to configure your API key and project"
14
+ end
15
+ end
16
+ end
data/lib/rubyn/cli.rb ADDED
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module Rubyn
6
+ class CLI < Thor
7
+ def self.exit_on_failure?
8
+ true
9
+ end
10
+
11
+ no_commands do
12
+ def check_for_updates
13
+ msg = Rubyn::VersionChecker.update_message
14
+ return unless msg
15
+
16
+ puts
17
+ puts Pastel.new.yellow(msg)
18
+ rescue Rubyn::Error, Net::HTTPError, SocketError, Timeout::Error
19
+ # Never let update check break the CLI
20
+ end
21
+ end
22
+
23
+ def self.start(given_args = ARGV, config = {})
24
+ super
25
+ ensure
26
+ # Check for updates after any command (in a non-blocking way)
27
+ new.check_for_updates if given_args.any? && !%w[help -h --help version -v --version].include?(given_args.first)
28
+ end
29
+
30
+ desc "init", "Initialize Rubyn in this project"
31
+ def init
32
+ require_relative "commands/init"
33
+ Commands::Init.new.execute
34
+ end
35
+
36
+ desc "refactor FILE", "Refactor a file toward best practices"
37
+ def refactor(file)
38
+ require_relative "commands/refactor"
39
+ Commands::Refactor.new.execute(file)
40
+ end
41
+
42
+ desc "spec FILE", "Generate RSpec tests for a file"
43
+ def spec(file)
44
+ require_relative "commands/spec"
45
+ Commands::Spec.new.execute(file)
46
+ end
47
+
48
+ desc "review FILE_OR_DIR", "Review code for anti-patterns"
49
+ def review(file_or_dir)
50
+ require_relative "commands/review"
51
+ Commands::Review.new.execute(file_or_dir)
52
+ end
53
+
54
+ desc "agent", "Start an interactive conversation with Rubyn"
55
+ def agent
56
+ require_relative "commands/agent"
57
+ Commands::Agent.new.execute
58
+ end
59
+
60
+ # desc "analyse", "Analyse your codebase for enhanced AI context"
61
+ # option :force, type: :boolean, default: false, desc: "Re-analyse all files, not just changed ones"
62
+ # def analyse
63
+ # require_relative "commands/index"
64
+ # Commands::Index.new.execute(force: options[:force])
65
+ # end
66
+
67
+ desc "usage", "Show credit balance and recent usage"
68
+ def usage
69
+ require_relative "commands/usage"
70
+ Commands::Usage.new.execute
71
+ end
72
+
73
+ desc "config [KEY] [VALUE]", "View or set configuration"
74
+ def config(key = nil, value = nil)
75
+ require_relative "commands/config"
76
+ Commands::Config.new.execute(key, value)
77
+ end
78
+
79
+ desc "dashboard", "Open the Rubyn web dashboard"
80
+ def dashboard
81
+ require_relative "commands/dashboard"
82
+ Commands::Dashboard.new.execute
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "faraday/multipart"
5
+ require "json"
6
+
7
+ module Rubyn
8
+ module Client
9
+ class ApiClient
10
+ attr_reader :base_url
11
+
12
+ def initialize(base_url: nil, api_key: nil)
13
+ @base_url = base_url || Rubyn.configuration.api_url
14
+ @api_key = api_key
15
+ end
16
+
17
+ def verify_auth
18
+ post("/api/v1/auth/verify")
19
+ end
20
+
21
+ def refactor(file_path:, code:, context_files:, project_token:)
22
+ post("/api/v1/ai/refactor", {
23
+ file_path: file_path,
24
+ code: code,
25
+ context_files: context_files,
26
+ project_token: project_token
27
+ })
28
+ end
29
+
30
+ def generate_spec(file_path:, code:, context_files:, project_token:)
31
+ post("/api/v1/ai/spec", {
32
+ file_path: file_path,
33
+ code: code,
34
+ context_files: context_files,
35
+ project_token: project_token
36
+ })
37
+ end
38
+
39
+ def review(files:, context_files:, project_token:)
40
+ post("/api/v1/ai/review", {
41
+ files: files,
42
+ context_files: context_files,
43
+ project_token: project_token
44
+ })
45
+ end
46
+
47
+ def agent_message(conversation_id:, message:, file_context:, project_token:)
48
+ post("/api/v1/ai/agent", {
49
+ conversation_id: conversation_id,
50
+ message: message,
51
+ file_context: file_context,
52
+ project_token: project_token
53
+ })
54
+ end
55
+
56
+ def submit_tool_results(conversation_id:, tool_results:, project_token:)
57
+ post("/api/v1/ai/agent/tool_results", {
58
+ conversation_id: conversation_id,
59
+ tool_results: tool_results,
60
+ project_token: project_token
61
+ })
62
+ end
63
+
64
+ def list_conversations(project_token:)
65
+ get("/api/v1/conversations", project_token: project_token)
66
+ end
67
+
68
+ def get_conversation(id:, project_token:)
69
+ get("/api/v1/conversations/#{id}", project_token: project_token)
70
+ end
71
+
72
+ def sync_project(metadata:, project_token:)
73
+ post("/api/v1/projects/sync", {
74
+ project_token: project_token,
75
+ name: metadata[:project_name],
76
+ project_type: metadata[:project_type],
77
+ ruby_version: metadata[:ruby_version],
78
+ rails_version: metadata[:rails_version],
79
+ test_framework: metadata[:test_framework],
80
+ factory_library: metadata[:factory_library],
81
+ gems_snapshot: metadata[:gems],
82
+ directory_structure: metadata[:directory_structure]
83
+ })
84
+ end
85
+
86
+ def index_project(project_token:, files:)
87
+ post("/api/v1/projects/index", project_token: project_token, files: files)
88
+ end
89
+
90
+ def join_project(project_token:)
91
+ post("/api/v1/projects/join", project_token: project_token)
92
+ end
93
+
94
+ def get_balance
95
+ get("/api/v1/usage/balance")
96
+ end
97
+
98
+ def get_history(page: 1)
99
+ get("/api/v1/usage/history", page: page)
100
+ end
101
+
102
+ def submit_feedback(interaction_id:, rating:, feedback: nil)
103
+ post("/api/v1/feedback", {
104
+ interaction_id: interaction_id,
105
+ rating: rating,
106
+ feedback: feedback
107
+ })
108
+ end
109
+
110
+ private
111
+
112
+ def api_key
113
+ @api_key || Rubyn::Config::Credentials.api_key
114
+ end
115
+
116
+ def connection
117
+ @connection ||= Faraday.new(url: @base_url) do |f|
118
+ f.request :json
119
+ f.response :json, content_type: /\bjson$/
120
+ f.adapter Faraday.default_adapter
121
+ f.options.timeout = 300
122
+ f.options.open_timeout = 10
123
+ f.headers["Authorization"] = "Bearer #{api_key}" if api_key
124
+ f.headers["User-Agent"] = "rubyn-gem/#{Rubyn::VERSION}"
125
+ end
126
+ end
127
+
128
+ def get(path, params = {})
129
+ response = connection.get(path, params)
130
+ parse_response(response)
131
+ end
132
+
133
+ def post(path, body = {})
134
+ response = connection.post(path, body)
135
+ parse_response(response)
136
+ end
137
+
138
+ def parse_response(response)
139
+ return response.body if response.status.between?(200, 299)
140
+
141
+ message = extract_error_message(response)
142
+
143
+ case response.status
144
+ when 400
145
+ raise APIError, message || "Bad request"
146
+ when 401
147
+ raise AuthenticationError, "Invalid API key. Run `rubyn init` to reconfigure."
148
+ when 402
149
+ raise APIError, message || "Insufficient credits. Check your balance with `rubyn usage`."
150
+ when 403
151
+ raise AuthenticationError, message || "Access denied. Check your project membership."
152
+ when 404
153
+ raise APIError, message || "Resource not found."
154
+ when 422
155
+ raise APIError, message || "Unprocessable entity"
156
+ when 429
157
+ raise APIError, "Rate limit exceeded. Please wait and try again."
158
+ else
159
+ raise APIError, "Unexpected error (#{response.status}): #{message}"
160
+ end
161
+ end
162
+
163
+ def extract_error_message(response)
164
+ body = response.body
165
+ return body["error"] if body.is_a?(Hash) && body["error"]
166
+ return body.to_s unless body.nil? || (body.is_a?(String) && body.empty?)
167
+
168
+ nil
169
+ end
170
+ end
171
+ end
172
+ end