rubyn-code 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 (235) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +620 -0
  4. data/db/migrations/000_create_schema_migrations.sql +4 -0
  5. data/db/migrations/001_create_sessions.sql +16 -0
  6. data/db/migrations/002_create_messages.sql +16 -0
  7. data/db/migrations/003_create_tasks.sql +17 -0
  8. data/db/migrations/004_create_task_dependencies.sql +8 -0
  9. data/db/migrations/005_create_memories.sql +44 -0
  10. data/db/migrations/006_create_cost_records.sql +16 -0
  11. data/db/migrations/007_create_hooks.sql +12 -0
  12. data/db/migrations/008_create_skills_cache.sql +8 -0
  13. data/db/migrations/009_create_teams.sql +27 -0
  14. data/db/migrations/010_create_instincts.sql +15 -0
  15. data/exe/rubyn-code +6 -0
  16. data/lib/rubyn_code/agent/conversation.rb +193 -0
  17. data/lib/rubyn_code/agent/loop.rb +517 -0
  18. data/lib/rubyn_code/agent/loop_detector.rb +78 -0
  19. data/lib/rubyn_code/auth/oauth.rb +174 -0
  20. data/lib/rubyn_code/auth/server.rb +126 -0
  21. data/lib/rubyn_code/auth/token_store.rb +153 -0
  22. data/lib/rubyn_code/autonomous/daemon.rb +233 -0
  23. data/lib/rubyn_code/autonomous/idle_poller.rb +111 -0
  24. data/lib/rubyn_code/autonomous/task_claimer.rb +100 -0
  25. data/lib/rubyn_code/background/job.rb +19 -0
  26. data/lib/rubyn_code/background/notifier.rb +44 -0
  27. data/lib/rubyn_code/background/worker.rb +146 -0
  28. data/lib/rubyn_code/cli/app.rb +118 -0
  29. data/lib/rubyn_code/cli/input_handler.rb +79 -0
  30. data/lib/rubyn_code/cli/renderer.rb +205 -0
  31. data/lib/rubyn_code/cli/repl.rb +519 -0
  32. data/lib/rubyn_code/cli/spinner.rb +100 -0
  33. data/lib/rubyn_code/cli/stream_formatter.rb +149 -0
  34. data/lib/rubyn_code/config/defaults.rb +43 -0
  35. data/lib/rubyn_code/config/project_config.rb +120 -0
  36. data/lib/rubyn_code/config/settings.rb +127 -0
  37. data/lib/rubyn_code/context/auto_compact.rb +81 -0
  38. data/lib/rubyn_code/context/compactor.rb +89 -0
  39. data/lib/rubyn_code/context/manager.rb +91 -0
  40. data/lib/rubyn_code/context/manual_compact.rb +87 -0
  41. data/lib/rubyn_code/context/micro_compact.rb +135 -0
  42. data/lib/rubyn_code/db/connection.rb +176 -0
  43. data/lib/rubyn_code/db/migrator.rb +146 -0
  44. data/lib/rubyn_code/db/schema.rb +106 -0
  45. data/lib/rubyn_code/hooks/built_in.rb +124 -0
  46. data/lib/rubyn_code/hooks/registry.rb +99 -0
  47. data/lib/rubyn_code/hooks/runner.rb +88 -0
  48. data/lib/rubyn_code/hooks/user_hooks.rb +90 -0
  49. data/lib/rubyn_code/learning/extractor.rb +191 -0
  50. data/lib/rubyn_code/learning/injector.rb +138 -0
  51. data/lib/rubyn_code/learning/instinct.rb +172 -0
  52. data/lib/rubyn_code/llm/client.rb +218 -0
  53. data/lib/rubyn_code/llm/message_builder.rb +116 -0
  54. data/lib/rubyn_code/llm/streaming.rb +203 -0
  55. data/lib/rubyn_code/mcp/client.rb +139 -0
  56. data/lib/rubyn_code/mcp/config.rb +83 -0
  57. data/lib/rubyn_code/mcp/sse_transport.rb +225 -0
  58. data/lib/rubyn_code/mcp/stdio_transport.rb +196 -0
  59. data/lib/rubyn_code/mcp/tool_bridge.rb +164 -0
  60. data/lib/rubyn_code/memory/models.rb +62 -0
  61. data/lib/rubyn_code/memory/search.rb +181 -0
  62. data/lib/rubyn_code/memory/session_persistence.rb +194 -0
  63. data/lib/rubyn_code/memory/store.rb +199 -0
  64. data/lib/rubyn_code/observability/budget_enforcer.rb +159 -0
  65. data/lib/rubyn_code/observability/cost_calculator.rb +61 -0
  66. data/lib/rubyn_code/observability/models.rb +29 -0
  67. data/lib/rubyn_code/observability/token_counter.rb +42 -0
  68. data/lib/rubyn_code/observability/usage_reporter.rb +140 -0
  69. data/lib/rubyn_code/output/diff_renderer.rb +212 -0
  70. data/lib/rubyn_code/output/formatter.rb +120 -0
  71. data/lib/rubyn_code/permissions/deny_list.rb +49 -0
  72. data/lib/rubyn_code/permissions/policy.rb +59 -0
  73. data/lib/rubyn_code/permissions/prompter.rb +80 -0
  74. data/lib/rubyn_code/permissions/tier.rb +22 -0
  75. data/lib/rubyn_code/protocols/interrupt_handler.rb +95 -0
  76. data/lib/rubyn_code/protocols/plan_approval.rb +67 -0
  77. data/lib/rubyn_code/protocols/shutdown_handshake.rb +109 -0
  78. data/lib/rubyn_code/skills/catalog.rb +70 -0
  79. data/lib/rubyn_code/skills/document.rb +80 -0
  80. data/lib/rubyn_code/skills/loader.rb +57 -0
  81. data/lib/rubyn_code/sub_agents/runner.rb +168 -0
  82. data/lib/rubyn_code/sub_agents/summarizer.rb +57 -0
  83. data/lib/rubyn_code/tasks/dag.rb +208 -0
  84. data/lib/rubyn_code/tasks/manager.rb +212 -0
  85. data/lib/rubyn_code/tasks/models.rb +31 -0
  86. data/lib/rubyn_code/teams/mailbox.rb +128 -0
  87. data/lib/rubyn_code/teams/manager.rb +175 -0
  88. data/lib/rubyn_code/teams/teammate.rb +38 -0
  89. data/lib/rubyn_code/tools/background_run.rb +41 -0
  90. data/lib/rubyn_code/tools/base.rb +84 -0
  91. data/lib/rubyn_code/tools/bash.rb +81 -0
  92. data/lib/rubyn_code/tools/bundle_add.rb +53 -0
  93. data/lib/rubyn_code/tools/bundle_install.rb +41 -0
  94. data/lib/rubyn_code/tools/compact.rb +57 -0
  95. data/lib/rubyn_code/tools/db_migrate.rb +52 -0
  96. data/lib/rubyn_code/tools/edit_file.rb +49 -0
  97. data/lib/rubyn_code/tools/executor.rb +62 -0
  98. data/lib/rubyn_code/tools/git_commit.rb +97 -0
  99. data/lib/rubyn_code/tools/git_diff.rb +61 -0
  100. data/lib/rubyn_code/tools/git_log.rb +59 -0
  101. data/lib/rubyn_code/tools/git_status.rb +59 -0
  102. data/lib/rubyn_code/tools/glob.rb +44 -0
  103. data/lib/rubyn_code/tools/grep.rb +81 -0
  104. data/lib/rubyn_code/tools/load_skill.rb +41 -0
  105. data/lib/rubyn_code/tools/memory_search.rb +77 -0
  106. data/lib/rubyn_code/tools/memory_write.rb +52 -0
  107. data/lib/rubyn_code/tools/rails_generate.rb +54 -0
  108. data/lib/rubyn_code/tools/read_file.rb +38 -0
  109. data/lib/rubyn_code/tools/read_inbox.rb +64 -0
  110. data/lib/rubyn_code/tools/registry.rb +48 -0
  111. data/lib/rubyn_code/tools/review_pr.rb +145 -0
  112. data/lib/rubyn_code/tools/run_specs.rb +75 -0
  113. data/lib/rubyn_code/tools/schema.rb +59 -0
  114. data/lib/rubyn_code/tools/send_message.rb +53 -0
  115. data/lib/rubyn_code/tools/spawn_agent.rb +154 -0
  116. data/lib/rubyn_code/tools/spawn_teammate.rb +168 -0
  117. data/lib/rubyn_code/tools/task.rb +148 -0
  118. data/lib/rubyn_code/tools/web_fetch.rb +108 -0
  119. data/lib/rubyn_code/tools/web_search.rb +196 -0
  120. data/lib/rubyn_code/tools/write_file.rb +30 -0
  121. data/lib/rubyn_code/version.rb +5 -0
  122. data/lib/rubyn_code.rb +203 -0
  123. data/skills/code_quality/fits_in_your_head.md +189 -0
  124. data/skills/code_quality/naming_conventions.md +213 -0
  125. data/skills/code_quality/null_object.md +205 -0
  126. data/skills/code_quality/technical_debt.md +135 -0
  127. data/skills/code_quality/value_objects.md +216 -0
  128. data/skills/code_quality/yagni.md +176 -0
  129. data/skills/design_patterns/adapter.md +191 -0
  130. data/skills/design_patterns/bridge_memento_visitor.md +254 -0
  131. data/skills/design_patterns/builder.md +158 -0
  132. data/skills/design_patterns/command.md +126 -0
  133. data/skills/design_patterns/composite.md +147 -0
  134. data/skills/design_patterns/decorator.md +204 -0
  135. data/skills/design_patterns/facade.md +133 -0
  136. data/skills/design_patterns/factory_method.md +169 -0
  137. data/skills/design_patterns/iterator.md +116 -0
  138. data/skills/design_patterns/mediator.md +133 -0
  139. data/skills/design_patterns/observer.md +177 -0
  140. data/skills/design_patterns/proxy.md +140 -0
  141. data/skills/design_patterns/singleton.md +124 -0
  142. data/skills/design_patterns/state.md +207 -0
  143. data/skills/design_patterns/strategy.md +127 -0
  144. data/skills/design_patterns/template_method.md +173 -0
  145. data/skills/gems/devise.md +365 -0
  146. data/skills/gems/dry_rb.md +186 -0
  147. data/skills/gems/factory_bot.md +268 -0
  148. data/skills/gems/faraday.md +263 -0
  149. data/skills/gems/graphql_ruby.md +514 -0
  150. data/skills/gems/pundit.md +446 -0
  151. data/skills/gems/redis.md +219 -0
  152. data/skills/gems/rubocop.md +257 -0
  153. data/skills/gems/sidekiq.md +360 -0
  154. data/skills/gems/stripe.md +224 -0
  155. data/skills/minitest/assertions.md +185 -0
  156. data/skills/minitest/fixtures.md +238 -0
  157. data/skills/minitest/integration_tests.md +210 -0
  158. data/skills/minitest/mailers_and_jobs.md +218 -0
  159. data/skills/minitest/mocking_stubbing.md +202 -0
  160. data/skills/minitest/service_tests_and_performance.md +246 -0
  161. data/skills/minitest/structure_and_conventions.md +169 -0
  162. data/skills/minitest/system_tests.md +237 -0
  163. data/skills/rails/action_cable.md +160 -0
  164. data/skills/rails/active_record_basics.md +174 -0
  165. data/skills/rails/active_storage.md +242 -0
  166. data/skills/rails/api_design.md +212 -0
  167. data/skills/rails/associations.md +182 -0
  168. data/skills/rails/background_jobs.md +212 -0
  169. data/skills/rails/caching.md +158 -0
  170. data/skills/rails/callbacks.md +135 -0
  171. data/skills/rails/concerns_controllers.md +218 -0
  172. data/skills/rails/concerns_models.md +280 -0
  173. data/skills/rails/controllers.md +190 -0
  174. data/skills/rails/engines.md +201 -0
  175. data/skills/rails/form_objects.md +168 -0
  176. data/skills/rails/hotwire.md +229 -0
  177. data/skills/rails/internationalization.md +192 -0
  178. data/skills/rails/logging.md +198 -0
  179. data/skills/rails/mailers.md +180 -0
  180. data/skills/rails/migrations.md +200 -0
  181. data/skills/rails/multitenancy.md +207 -0
  182. data/skills/rails/n_plus_one.md +151 -0
  183. data/skills/rails/presenters.md +244 -0
  184. data/skills/rails/query_objects.md +177 -0
  185. data/skills/rails/routing.md +194 -0
  186. data/skills/rails/scopes.md +187 -0
  187. data/skills/rails/security.md +233 -0
  188. data/skills/rails/serializers.md +243 -0
  189. data/skills/rails/service_objects.md +184 -0
  190. data/skills/rails/testing_strategy.md +258 -0
  191. data/skills/rails/validations.md +206 -0
  192. data/skills/refactoring/code_smells.md +251 -0
  193. data/skills/refactoring/command_query_separation.md +166 -0
  194. data/skills/refactoring/encapsulate_collection.md +125 -0
  195. data/skills/refactoring/extract_class.md +138 -0
  196. data/skills/refactoring/extract_method.md +185 -0
  197. data/skills/refactoring/replace_conditional.md +211 -0
  198. data/skills/refactoring/value_objects.md +246 -0
  199. data/skills/rspec/build_stubbed.md +199 -0
  200. data/skills/rspec/factory_design.md +206 -0
  201. data/skills/rspec/let_vs_let_bang.md +161 -0
  202. data/skills/rspec/mocking_stubbing.md +209 -0
  203. data/skills/rspec/request_specs.md +212 -0
  204. data/skills/rspec/service_specs.md +262 -0
  205. data/skills/rspec/shared_examples.md +244 -0
  206. data/skills/rspec/system_specs.md +286 -0
  207. data/skills/rspec/test_performance.md +215 -0
  208. data/skills/ruby/blocks_procs_lambdas.md +204 -0
  209. data/skills/ruby/classes.md +155 -0
  210. data/skills/ruby/concurrency.md +194 -0
  211. data/skills/ruby/data_struct_openstruct.md +158 -0
  212. data/skills/ruby/debugging_profiling.md +204 -0
  213. data/skills/ruby/enumerable_patterns.md +168 -0
  214. data/skills/ruby/exception_handling.md +199 -0
  215. data/skills/ruby/file_io.md +217 -0
  216. data/skills/ruby/hashes.md +195 -0
  217. data/skills/ruby/metaprogramming.md +170 -0
  218. data/skills/ruby/modules.md +210 -0
  219. data/skills/ruby/pattern_matching.md +177 -0
  220. data/skills/ruby/regular_expressions.md +166 -0
  221. data/skills/ruby/result_objects.md +200 -0
  222. data/skills/ruby/strings.md +177 -0
  223. data/skills/ruby_project/bundler_dependencies.md +181 -0
  224. data/skills/ruby_project/cli_tools.md +224 -0
  225. data/skills/ruby_project/rake_tasks.md +146 -0
  226. data/skills/ruby_project/structure.md +261 -0
  227. data/skills/sinatra/application_structure.md +241 -0
  228. data/skills/sinatra/middleware_and_deployment.md +221 -0
  229. data/skills/sinatra/testing.md +233 -0
  230. data/skills/solid/dependency_inversion.md +195 -0
  231. data/skills/solid/interface_segregation.md +237 -0
  232. data/skills/solid/liskov_substitution.md +263 -0
  233. data/skills/solid/open_closed.md +212 -0
  234. data/skills/solid/single_responsibility.md +183 -0
  235. metadata +397 -0
@@ -0,0 +1,146 @@
1
+ # Ruby: Rake Tasks
2
+
3
+ ## Pattern
4
+
5
+ Rake is Ruby's task runner. Organize tasks in namespaces, document them with descriptions, and keep task bodies thin by delegating to service objects or scripts.
6
+
7
+ ```ruby
8
+ # Rakefile
9
+ require_relative "config/environment"
10
+
11
+ # Import tasks from lib/tasks/
12
+ Dir[File.join(__dir__, "lib", "tasks", "**", "*.rake")].each { |f| load f }
13
+ ```
14
+
15
+ ```ruby
16
+ # lib/tasks/db.rake
17
+ namespace :db do
18
+ desc "Sync best practice documents to database and generate embeddings"
19
+ task sync_best_practices: :environment do
20
+ puts "Syncing best practices..."
21
+ result = BestPractices::SyncService.call
22
+ puts "Synced #{result.created} new, updated #{result.updated}, removed #{result.removed}"
23
+ end
24
+
25
+ desc "Backfill embeddings for documents missing them"
26
+ task backfill_embeddings: :environment do
27
+ documents = BestPracticeDocument.where(embedding: nil)
28
+ puts "Backfilling #{documents.count} documents..."
29
+
30
+ documents.find_each do |doc|
31
+ Embeddings::DocumentEmbedder.call(doc)
32
+ print "."
33
+ end
34
+
35
+ puts "\nDone!"
36
+ end
37
+
38
+ desc "Reset all embeddings (re-embed everything)"
39
+ task reset_embeddings: :environment do
40
+ abort("This will delete all embeddings. Run with CONFIRM=true") unless ENV["CONFIRM"] == "true"
41
+
42
+ CodeEmbedding.delete_all
43
+ BestPracticeDocument.update_all(embedding: nil, last_embedded_at: nil)
44
+ puts "All embeddings cleared. Run db:backfill_embeddings to regenerate."
45
+ end
46
+ end
47
+ ```
48
+
49
+ ```ruby
50
+ # lib/tasks/credits.rake
51
+ namespace :credits do
52
+ desc "Report credit usage for the current month"
53
+ task monthly_report: :environment do
54
+ report = Credits::MonthlyReportService.call(Date.current)
55
+
56
+ puts "=== Credit Report: #{Date.current.strftime('%B %Y')} ==="
57
+ puts "Total users: #{report.active_users}"
58
+ puts "Total credits used: #{report.total_credits}"
59
+ puts "Total revenue: $#{format('%.2f', report.revenue / 100.0)}"
60
+ puts "Average credits/user: #{report.avg_per_user}"
61
+ end
62
+
63
+ desc "Grant credits to a user (usage: rake credits:grant USER_ID=1 AMOUNT=100)"
64
+ task grant: :environment do
65
+ user_id = ENV.fetch("USER_ID") { abort "USER_ID required" }
66
+ amount = ENV.fetch("AMOUNT") { abort "AMOUNT required" }.to_i
67
+ abort "AMOUNT must be positive" unless amount > 0
68
+
69
+ user = User.find(user_id)
70
+ user.credit_ledger_entries.create!(amount: amount, description: "Manual grant via rake")
71
+ puts "Granted #{amount} credits to #{user.email}. New balance: #{user.credit_balance}"
72
+ end
73
+ end
74
+ ```
75
+
76
+ ```ruby
77
+ # lib/tasks/data.rake
78
+ namespace :data do
79
+ desc "Import orders from CSV (usage: rake data:import_orders FILE=orders.csv)"
80
+ task import_orders: :environment do
81
+ file = ENV.fetch("FILE") { abort "FILE required" }
82
+ abort "File not found: #{file}" unless File.exist?(file)
83
+
84
+ imported = 0
85
+ errors = 0
86
+
87
+ CSV.foreach(file, headers: true) do |row|
88
+ result = Orders::ImportService.call(row.to_h)
89
+ if result.success?
90
+ imported += 1
91
+ else
92
+ errors += 1
93
+ puts "Row #{$.}: #{result.error}"
94
+ end
95
+ end
96
+
97
+ puts "Imported: #{imported}, Errors: #{errors}"
98
+ end
99
+ end
100
+ ```
101
+
102
+ ### Default Task and Test Task
103
+
104
+ ```ruby
105
+ # Rakefile
106
+ require "rake/testtask"
107
+
108
+ Rake::TestTask.new(:test) do |t|
109
+ t.libs << "test"
110
+ t.libs << "lib"
111
+ t.test_files = FileList["test/**/*_test.rb"]
112
+ end
113
+
114
+ task default: :test
115
+ ```
116
+
117
+ ## Why This Is Good
118
+
119
+ - **`desc` makes tasks discoverable.** `rake -T` lists all tasks with descriptions. Undocumented tasks are invisible.
120
+ - **Namespaces organize tasks.** `rake db:sync_best_practices`, `rake credits:grant`, `rake data:import_orders` — clear, grouped, no collisions.
121
+ - **ENV parameters for input.** `USER_ID=1 AMOUNT=100 rake credits:grant` is explicit and scriptable. No interactive prompts.
122
+ - **Task bodies are thin.** The task calls a service object. The service contains the logic, is testable, and reusable outside of rake.
123
+ - **Safety guards for destructive tasks.** `abort unless ENV["CONFIRM"]` prevents accidental data deletion.
124
+
125
+ ## Anti-Pattern
126
+
127
+ ```ruby
128
+ # BAD: Business logic inside the rake task
129
+ task :process_orders do
130
+ Order.where(status: :pending).each do |order|
131
+ order.line_items.each do |item|
132
+ product = Product.find(item.product_id)
133
+ product.update!(stock: product.stock - item.quantity)
134
+ end
135
+ order.update!(status: :confirmed)
136
+ OrderMailer.confirmation(order).deliver_now
137
+ end
138
+ end
139
+ # 10 lines of untestable, unreusable business logic
140
+ ```
141
+
142
+ ## When To Apply
143
+
144
+ - **Every operational task.** Data migrations, reporting, manual operations, maintenance scripts.
145
+ - **One-off tasks stay in Rake.** Don't build an admin UI for a task you'll run once.
146
+ - **Keep the body under 10 lines.** If it's longer, extract to a service object.
@@ -0,0 +1,261 @@
1
+ # Ruby: Project Structure
2
+
3
+ ## Pattern
4
+
5
+ Whether you're building a gem, a CLI tool, or a library, follow Ruby conventions for directory layout, naming, and require paths. Use Bundler's gem skeleton as the starting point.
6
+
7
+ ### Standard Ruby Gem / Library Layout
8
+
9
+ ```
10
+ my_gem/
11
+ ├── lib/
12
+ │ ├── my_gem.rb # Main entry point, requires sub-files
13
+ │ └── my_gem/
14
+ │ ├── version.rb
15
+ │ ├── configuration.rb
16
+ │ ├── client.rb
17
+ │ ├── models/
18
+ │ │ ├── order.rb
19
+ │ │ └── user.rb
20
+ │ └── errors.rb
21
+ ├── test/ # or spec/
22
+ │ ├── test_helper.rb
23
+ │ ├── my_gem/
24
+ │ │ ├── client_test.rb
25
+ │ │ └── models/
26
+ │ │ └── order_test.rb
27
+ │ └── integration/
28
+ │ └── api_test.rb
29
+ ├── bin/
30
+ │ └── my_gem # CLI executable (if applicable)
31
+ ├── Gemfile
32
+ ├── Rakefile
33
+ ├── my_gem.gemspec
34
+ ├── README.md
35
+ ├── LICENSE.txt
36
+ └── CHANGELOG.md
37
+ ```
38
+
39
+ ### The Main Entry Point
40
+
41
+ ```ruby
42
+ # lib/my_gem.rb
43
+ require_relative "my_gem/version"
44
+ require_relative "my_gem/configuration"
45
+ require_relative "my_gem/errors"
46
+ require_relative "my_gem/client"
47
+
48
+ module MyGem
49
+ class << self
50
+ attr_accessor :configuration
51
+
52
+ def configure
53
+ self.configuration ||= Configuration.new
54
+ yield(configuration) if block_given?
55
+ end
56
+
57
+ def reset!
58
+ self.configuration = Configuration.new
59
+ end
60
+ end
61
+ end
62
+ ```
63
+
64
+ ```ruby
65
+ # lib/my_gem/version.rb
66
+ module MyGem
67
+ VERSION = "1.0.0"
68
+ end
69
+ ```
70
+
71
+ ```ruby
72
+ # lib/my_gem/configuration.rb
73
+ module MyGem
74
+ class Configuration
75
+ attr_accessor :api_key, :base_url, :timeout, :logger
76
+
77
+ def initialize
78
+ @base_url = "https://api.example.com"
79
+ @timeout = 30
80
+ @logger = Logger.new($stdout)
81
+ end
82
+ end
83
+ end
84
+ ```
85
+
86
+ ```ruby
87
+ # lib/my_gem/errors.rb
88
+ module MyGem
89
+ class Error < StandardError; end
90
+ class AuthenticationError < Error; end
91
+ class RateLimitError < Error; end
92
+ class ApiError < Error
93
+ attr_reader :status, :body
94
+ def initialize(message, status:, body: nil)
95
+ @status = status
96
+ @body = body
97
+ super(message)
98
+ end
99
+ end
100
+ end
101
+ ```
102
+
103
+ ```ruby
104
+ # lib/my_gem/client.rb
105
+ require "faraday"
106
+ require "json"
107
+
108
+ module MyGem
109
+ class Client
110
+ def initialize(api_key: nil, base_url: nil)
111
+ config = MyGem.configuration || Configuration.new
112
+ @api_key = api_key || config.api_key
113
+ @base_url = base_url || config.base_url
114
+ @conn = build_connection
115
+ end
116
+
117
+ def get_order(id)
118
+ response = @conn.get("/orders/#{id}")
119
+ handle_response(response)
120
+ end
121
+
122
+ def create_order(params)
123
+ response = @conn.post("/orders", params.to_json)
124
+ handle_response(response)
125
+ end
126
+
127
+ private
128
+
129
+ def build_connection
130
+ Faraday.new(url: @base_url) do |f|
131
+ f.request :json
132
+ f.response :json
133
+ f.headers["Authorization"] = "Bearer #{@api_key}"
134
+ f.options.timeout = MyGem.configuration&.timeout || 30
135
+ end
136
+ end
137
+
138
+ def handle_response(response)
139
+ case response.status
140
+ when 200..299 then response.body
141
+ when 401 then raise AuthenticationError, "Invalid API key"
142
+ when 429 then raise RateLimitError, "Rate limited"
143
+ else raise ApiError.new("API error", status: response.status, body: response.body)
144
+ end
145
+ end
146
+ end
147
+ end
148
+ ```
149
+
150
+ ### Usage
151
+
152
+ ```ruby
153
+ # Configuration (once, at boot)
154
+ MyGem.configure do |config|
155
+ config.api_key = ENV["MY_GEM_API_KEY"]
156
+ config.timeout = 60
157
+ end
158
+
159
+ # Usage
160
+ client = MyGem::Client.new
161
+ order = client.get_order(123)
162
+ ```
163
+
164
+ ### The Gemspec
165
+
166
+ ```ruby
167
+ # my_gem.gemspec
168
+ Gem::Specification.new do |spec|
169
+ spec.name = "my_gem"
170
+ spec.version = MyGem::VERSION
171
+ spec.authors = ["Your Name"]
172
+ spec.email = ["you@example.com"]
173
+ spec.summary = "A Ruby client for the Example API"
174
+ spec.homepage = "https://github.com/you/my_gem"
175
+ spec.license = "MIT"
176
+ spec.required_ruby_version = ">= 3.1"
177
+
178
+ spec.files = Dir["lib/**/*", "LICENSE.txt", "README.md"]
179
+ spec.require_paths = ["lib"]
180
+
181
+ spec.add_dependency "faraday", "~> 2.0"
182
+
183
+ # Dev dependencies in Gemfile, not gemspec (modern convention)
184
+ end
185
+ ```
186
+
187
+ ### Testing (Minitest)
188
+
189
+ ```ruby
190
+ # test/test_helper.rb
191
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
192
+
193
+ require "my_gem"
194
+ require "minitest/autorun"
195
+ require "minitest/pride"
196
+ require "webmock/minitest"
197
+
198
+ # Configure for tests
199
+ MyGem.configure do |config|
200
+ config.api_key = "test-key"
201
+ config.base_url = "https://api.example.com"
202
+ end
203
+ ```
204
+
205
+ ```ruby
206
+ # test/my_gem/client_test.rb
207
+ require "test_helper"
208
+
209
+ class MyGem::ClientTest < Minitest::Test
210
+ def setup
211
+ @client = MyGem::Client.new
212
+ end
213
+
214
+ def test_get_order
215
+ stub_request(:get, "https://api.example.com/orders/123")
216
+ .to_return(status: 200, body: { id: 123, status: "pending" }.to_json, headers: { "Content-Type" => "application/json" })
217
+
218
+ result = @client.get_order(123)
219
+
220
+ assert_equal 123, result["id"]
221
+ assert_equal "pending", result["status"]
222
+ end
223
+
224
+ def test_raises_on_auth_failure
225
+ stub_request(:get, "https://api.example.com/orders/123")
226
+ .to_return(status: 401)
227
+
228
+ assert_raises MyGem::AuthenticationError do
229
+ @client.get_order(123)
230
+ end
231
+ end
232
+
233
+ def test_raises_on_rate_limit
234
+ stub_request(:get, "https://api.example.com/orders/123")
235
+ .to_return(status: 429)
236
+
237
+ assert_raises MyGem::RateLimitError do
238
+ @client.get_order(123)
239
+ end
240
+ end
241
+ end
242
+ ```
243
+
244
+ ## Why This Is Good
245
+
246
+ - **Convention over configuration.** `lib/my_gem.rb` → `require "my_gem"`. `lib/my_gem/client.rb` → `MyGem::Client`. The directory structure maps to the module structure.
247
+ - **Block configuration is idiomatic Ruby.** `MyGem.configure { |c| c.api_key = "..." }` is the pattern every Rubyist expects.
248
+ - **Custom error hierarchy.** `rescue MyGem::Error` catches all gem errors. `rescue MyGem::RateLimitError` catches specific ones. Clean, selective handling.
249
+ - **Dependency injection via constructor.** `Client.new(api_key: custom_key)` overrides config for testing. The default reads from global config for convenience.
250
+ - **Development dependencies in Gemfile.** Modern convention keeps dev deps out of the gemspec, keeping the gem lightweight for consumers.
251
+
252
+ ## When To Apply
253
+
254
+ - **Every Ruby library, gem, or standalone project.** This structure scales from 3 files to 300.
255
+ - **CLI tools.** Add `exe/` or `bin/` directory with the executable script. Use `Thor` or `OptionParser` for argument parsing.
256
+ - **Internal company gems.** Same structure as public gems. Publish to a private gem server (Gemfury, GitHub Packages).
257
+
258
+ ## When NOT To Apply
259
+
260
+ - **Rails apps.** Rails has its own conventions (`app/`, `config/`, `db/`). Don't fight them.
261
+ - **Single-file scripts.** A 50-line utility script doesn't need a gem structure. Just use `#!/usr/bin/env ruby` and run it.
@@ -0,0 +1,241 @@
1
+ # Sinatra: Application Structure
2
+
3
+ ## Pattern
4
+
5
+ Sinatra apps should be structured for clarity and growth. Small apps can use a single file. Anything beyond a prototype should use the modular style (`Sinatra::Base` subclass) with extracted helpers, services, and a clear directory layout.
6
+
7
+ ### Single-File (Prototypes Only)
8
+
9
+ ```ruby
10
+ # app.rb
11
+ require "sinatra"
12
+ require "json"
13
+
14
+ get "/health" do
15
+ content_type :json
16
+ { status: "ok", timestamp: Time.now.iso8601 }.to_json
17
+ end
18
+
19
+ get "/orders/:id" do
20
+ order = Order.find(params[:id])
21
+ halt 404, { error: "Not found" }.to_json unless order
22
+
23
+ content_type :json
24
+ order.to_json
25
+ end
26
+ ```
27
+
28
+ ### Modular Style (Recommended)
29
+
30
+ ```
31
+ my_app/
32
+ ├── Gemfile
33
+ ├── config.ru
34
+ ├── config/
35
+ │ ├── database.yml
36
+ │ └── environment.rb
37
+ ├── app/
38
+ │ ├── api.rb # Main Sinatra app
39
+ │ ├── routes/
40
+ │ │ ├── orders.rb
41
+ │ │ ├── users.rb
42
+ │ │ └── health.rb
43
+ │ ├── models/
44
+ │ │ ├── order.rb
45
+ │ │ └── user.rb
46
+ │ ├── services/
47
+ │ │ └── orders/
48
+ │ │ └── create_service.rb
49
+ │ └── helpers/
50
+ │ ├── auth_helper.rb
51
+ │ └── json_helper.rb
52
+ ├── db/
53
+ │ └── migrate/
54
+ ├── test/
55
+ │ ├── test_helper.rb
56
+ │ ├── routes/
57
+ │ │ └── orders_test.rb
58
+ │ └── services/
59
+ │ └── orders/
60
+ │ └── create_service_test.rb
61
+ └── Rakefile
62
+ ```
63
+
64
+ ```ruby
65
+ # config.ru
66
+ require_relative "config/environment"
67
+ run MyApp::Api
68
+ ```
69
+
70
+ ```ruby
71
+ # config/environment.rb
72
+ require "bundler/setup"
73
+ Bundler.require(:default, ENV.fetch("RACK_ENV", "development").to_sym)
74
+
75
+ require "sinatra/base"
76
+ require "sinatra/json"
77
+ require "sinatra/activerecord"
78
+
79
+ # Load app files
80
+ Dir[File.join(__dir__, "..", "app", "models", "*.rb")].each { |f| require f }
81
+ Dir[File.join(__dir__, "..", "app", "services", "**", "*.rb")].each { |f| require f }
82
+ Dir[File.join(__dir__, "..", "app", "helpers", "*.rb")].each { |f| require f }
83
+
84
+ require_relative "../app/api"
85
+ Dir[File.join(__dir__, "..", "app", "routes", "*.rb")].each { |f| require f }
86
+ ```
87
+
88
+ ```ruby
89
+ # app/api.rb
90
+ module MyApp
91
+ class Api < Sinatra::Base
92
+ register Sinatra::ActiveRecordExtension
93
+
94
+ # Configuration
95
+ configure do
96
+ set :database_file, "config/database.yml"
97
+ set :show_exceptions, false
98
+ end
99
+
100
+ configure :development do
101
+ set :show_exceptions, :after_handler
102
+ end
103
+
104
+ # Middleware
105
+ use Rack::JSONBodyParser
106
+
107
+ # Global helpers
108
+ helpers AuthHelper
109
+ helpers JsonHelper
110
+
111
+ # Error handling
112
+ error ActiveRecord::RecordNotFound do
113
+ halt 404, json_error("Not found")
114
+ end
115
+
116
+ error ActiveRecord::RecordInvalid do |e|
117
+ halt 422, json_error("Validation failed", details: e.record.errors.full_messages)
118
+ end
119
+
120
+ error do |e|
121
+ logger.error("#{e.class}: #{e.message}")
122
+ halt 500, json_error("Internal server error")
123
+ end
124
+ end
125
+ end
126
+ ```
127
+
128
+ ```ruby
129
+ # app/helpers/auth_helper.rb
130
+ module AuthHelper
131
+ def authenticate!
132
+ token = request.env["HTTP_AUTHORIZATION"]&.delete_prefix("Bearer ")
133
+ halt 401, json_error("Unauthorized") unless token
134
+
135
+ @current_user = User.find_by_api_token(token)
136
+ halt 401, json_error("Invalid token") unless @current_user
137
+ end
138
+
139
+ def current_user
140
+ @current_user
141
+ end
142
+ end
143
+
144
+ # app/helpers/json_helper.rb
145
+ module JsonHelper
146
+ def json_response(data, status: 200)
147
+ content_type :json
148
+ halt status, data.to_json
149
+ end
150
+
151
+ def json_error(message, status: nil, details: nil)
152
+ content_type :json
153
+ body = { error: message }
154
+ body[:details] = details if details
155
+ body.to_json
156
+ end
157
+ end
158
+ ```
159
+
160
+ ```ruby
161
+ # app/routes/orders.rb
162
+ module MyApp
163
+ class Api
164
+ # Routes grouped by resource
165
+ before "/orders*" do
166
+ authenticate!
167
+ end
168
+
169
+ get "/orders" do
170
+ orders = current_user.orders.order(created_at: :desc)
171
+ json_response(orders: orders.map(&:as_json))
172
+ end
173
+
174
+ get "/orders/:id" do
175
+ order = current_user.orders.find(params[:id])
176
+ json_response(order: order.as_json)
177
+ end
178
+
179
+ post "/orders" do
180
+ result = Orders::CreateService.call(parsed_body, current_user)
181
+
182
+ if result.success?
183
+ json_response({ order: result.order.as_json }, status: 201)
184
+ else
185
+ halt 422, json_error("Creation failed", details: result.errors)
186
+ end
187
+ end
188
+
189
+ delete "/orders/:id" do
190
+ order = current_user.orders.find(params[:id])
191
+ order.destroy!
192
+ json_response({ deleted: true })
193
+ end
194
+
195
+ private
196
+
197
+ def parsed_body
198
+ JSON.parse(request.body.read, symbolize_names: true)
199
+ rescue JSON::ParserError
200
+ halt 400, json_error("Invalid JSON")
201
+ end
202
+ end
203
+ end
204
+ ```
205
+
206
+ ## Why This Is Good
207
+
208
+ - **Modular `Sinatra::Base` subclass.** The app is a class, not a script. It can be tested, mounted in Rack, and composed with other apps.
209
+ - **Routes in separate files.** Each resource has its own file. Adding a new resource means adding a new file, not editing a growing monolith.
210
+ - **Extracted helpers.** Auth and JSON helpers are reusable modules, not inline code in every route.
211
+ - **Centralized error handling.** `error ActiveRecord::RecordNotFound` handles 404s globally. No `begin/rescue` in every route.
212
+ - **Same service object pattern as Rails.** `Orders::CreateService.call(params, user)` works identically whether it's called from a Sinatra route or a Rails controller.
213
+
214
+ ## Anti-Pattern
215
+
216
+ A single-file Sinatra app that grows into a 500-line monster:
217
+
218
+ ```ruby
219
+ # BAD: Everything in one file
220
+ require "sinatra"
221
+ require "json"
222
+
223
+ # 50 lines of config
224
+ # 30 lines of helpers
225
+ # 100 lines of order routes
226
+ # 100 lines of user routes
227
+ # 80 lines of auth routes
228
+ # 50 lines of error handling
229
+ # 90 lines of inline business logic
230
+ ```
231
+
232
+ ## When To Apply
233
+
234
+ - **Every Sinatra app beyond a prototype.** The modular structure takes 10 minutes to set up and prevents every future headache.
235
+ - **API services.** Sinatra is excellent for focused, single-purpose APIs (webhook receivers, microservices, lightweight proxies).
236
+ - **When Rails is too heavy.** Sinatra boots in milliseconds, has minimal dependencies, and is perfect for small services.
237
+
238
+ ## When NOT To Apply
239
+
240
+ - **If you need forms, views, sessions, mailers, background jobs, and admin panels.** Use Rails. Sinatra can do all of these but you'll end up rebuilding half of Rails.
241
+ - **If the app will grow to 50+ routes.** Sinatra's simplicity becomes a liability at scale. Consider Rails or Hanami.