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,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+ require_relative "teammate"
6
+
7
+ module RubynCode
8
+ module Teams
9
+ # CRUD manager for teammates backed by SQLite.
10
+ #
11
+ # Provides lifecycle management for agent teammates: spawning,
12
+ # listing, status updates, and removal.
13
+ class Manager
14
+ # @param db [DB::Connection] the database connection
15
+ # @param mailbox [Mailbox] the team mailbox for inter-agent messaging
16
+ def initialize(db, mailbox:)
17
+ @db = db
18
+ @mailbox = mailbox
19
+ ensure_table!
20
+ end
21
+
22
+ # Creates a new teammate record.
23
+ #
24
+ # @param name [String] unique teammate name
25
+ # @param role [String] the teammate's role description
26
+ # @param persona [String, nil] optional persona prompt
27
+ # @param model [String, nil] optional LLM model override
28
+ # @return [Teammate] the newly created teammate
29
+ # @raise [Error] if a teammate with the given name already exists
30
+ def spawn(name:, role:, persona: nil, model: nil)
31
+ existing = get(name)
32
+ raise Error, "Teammate '#{name}' already exists" if existing
33
+
34
+ id = SecureRandom.uuid
35
+ now = Time.now.utc.iso8601
36
+ metadata_json = JSON.generate({})
37
+
38
+ @db.execute(
39
+ <<~SQL,
40
+ INSERT INTO teammates (id, name, role, persona, model, status, metadata, created_at)
41
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
42
+ SQL
43
+ [id, name, role, persona, model, "idle", metadata_json, now]
44
+ )
45
+
46
+ Teammate.new(
47
+ id: id,
48
+ name: name,
49
+ role: role,
50
+ persona: persona,
51
+ model: model,
52
+ status: "idle",
53
+ metadata: {},
54
+ created_at: now
55
+ )
56
+ end
57
+
58
+ # Returns all teammates.
59
+ #
60
+ # @return [Array<Teammate>]
61
+ def list
62
+ rows = @db.query("SELECT * FROM teammates ORDER BY created_at ASC").to_a
63
+ rows.map { |row| row_to_teammate(row) }
64
+ end
65
+
66
+ # Finds a teammate by name.
67
+ #
68
+ # @param name [String]
69
+ # @return [Teammate, nil]
70
+ def get(name)
71
+ rows = @db.query("SELECT * FROM teammates WHERE name = ? LIMIT 1", [name]).to_a
72
+ return nil if rows.empty?
73
+
74
+ row_to_teammate(rows.first)
75
+ end
76
+
77
+ # Updates a teammate's status.
78
+ #
79
+ # @param name [String]
80
+ # @param status [String] one of "idle", "active", "offline"
81
+ # @return [void]
82
+ # @raise [ArgumentError] if the status is invalid
83
+ # @raise [Error] if the teammate is not found
84
+ def update_status(name, status)
85
+ unless VALID_STATUSES.include?(status)
86
+ raise ArgumentError, "Invalid status '#{status}'. Must be one of: #{VALID_STATUSES.join(', ')}"
87
+ end
88
+
89
+ teammate = get(name)
90
+ raise Error, "Teammate '#{name}' not found" unless teammate
91
+
92
+ @db.execute(
93
+ "UPDATE teammates SET status = ? WHERE name = ?",
94
+ [status, name]
95
+ )
96
+ end
97
+
98
+ # Removes a teammate by name.
99
+ #
100
+ # @param name [String]
101
+ # @return [void]
102
+ # @raise [Error] if the teammate is not found
103
+ def remove(name)
104
+ teammate = get(name)
105
+ raise Error, "Teammate '#{name}' not found" unless teammate
106
+
107
+ @db.execute("DELETE FROM teammates WHERE name = ?", [name])
108
+ end
109
+
110
+ # Returns all teammates with status "active".
111
+ #
112
+ # @return [Array<Teammate>]
113
+ def active_teammates
114
+ rows = @db.query(
115
+ "SELECT * FROM teammates WHERE status = ? ORDER BY created_at ASC",
116
+ ["active"]
117
+ ).to_a
118
+ rows.map { |row| row_to_teammate(row) }
119
+ end
120
+
121
+ private
122
+
123
+ # Converts a database row hash to a Teammate value object.
124
+ #
125
+ # @param row [Hash]
126
+ # @return [Teammate]
127
+ def row_to_teammate(row)
128
+ metadata = parse_metadata(row["metadata"])
129
+
130
+ Teammate.new(
131
+ id: row["id"],
132
+ name: row["name"],
133
+ role: row["role"],
134
+ persona: row["persona"],
135
+ model: row["model"],
136
+ status: row["status"],
137
+ metadata: metadata,
138
+ created_at: row["created_at"]
139
+ )
140
+ end
141
+
142
+ # Safely parses JSON metadata, returning an empty hash on failure.
143
+ #
144
+ # @param raw [String, nil]
145
+ # @return [Hash]
146
+ def parse_metadata(raw)
147
+ return {} if raw.nil? || raw.empty?
148
+
149
+ JSON.parse(raw, symbolize_names: true)
150
+ rescue JSON::ParserError
151
+ {}
152
+ end
153
+
154
+ # Creates the teammates table if it does not already exist.
155
+ def ensure_table!
156
+ @db.execute(<<~SQL)
157
+ CREATE TABLE IF NOT EXISTS teammates (
158
+ id TEXT PRIMARY KEY,
159
+ name TEXT NOT NULL UNIQUE,
160
+ role TEXT NOT NULL,
161
+ persona TEXT,
162
+ model TEXT,
163
+ status TEXT NOT NULL DEFAULT 'idle',
164
+ metadata TEXT NOT NULL DEFAULT '{}',
165
+ created_at TEXT NOT NULL
166
+ )
167
+ SQL
168
+
169
+ @db.execute(<<~SQL)
170
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_teammates_name ON teammates (name)
171
+ SQL
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module Teams
5
+ # Immutable value object representing a teammate in an agent team.
6
+ #
7
+ # Status values: "idle", "active", "offline"
8
+ VALID_STATUSES = %w[idle active offline].freeze
9
+
10
+ Teammate = Data.define(
11
+ :id, :name, :role, :persona, :model, :status, :metadata, :created_at
12
+ ) do
13
+
14
+ # @return [Boolean]
15
+ def idle? = status == "idle"
16
+
17
+ # @return [Boolean]
18
+ def active? = status == "active"
19
+
20
+ # @return [Boolean]
21
+ def offline? = status == "offline"
22
+
23
+ # @return [Hash]
24
+ def to_h
25
+ {
26
+ id: id,
27
+ name: name,
28
+ role: role,
29
+ persona: persona,
30
+ model: model,
31
+ status: status,
32
+ metadata: metadata,
33
+ created_at: created_at
34
+ }
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require_relative "registry"
5
+
6
+ module RubynCode
7
+ module Tools
8
+ class BackgroundRun < Base
9
+ TOOL_NAME = "background_run"
10
+ DESCRIPTION = "Run a command in the background (test suites, builds, deploys). " \
11
+ "Returns immediately with a job ID. Results are delivered automatically " \
12
+ "before your next LLM call."
13
+ PARAMETERS = {
14
+ command: {
15
+ type: :string,
16
+ description: "The shell command to run in the background",
17
+ required: true
18
+ },
19
+ timeout: {
20
+ type: :integer,
21
+ description: "Timeout in seconds (default: 300)",
22
+ required: false
23
+ }
24
+ }.freeze
25
+ RISK_LEVEL = :execute
26
+
27
+ attr_writer :background_worker
28
+
29
+ def execute(command:, timeout: 300)
30
+ unless @background_worker
31
+ return "Error: Background worker not available. Use bash tool instead."
32
+ end
33
+
34
+ job_id = @background_worker.run(command, timeout: timeout)
35
+ "Background job started: #{job_id}\nCommand: #{command}\nTimeout: #{timeout}s\nResults will appear automatically when complete."
36
+ end
37
+ end
38
+
39
+ Registry.register(BackgroundRun)
40
+ end
41
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module Tools
5
+ class Base
6
+ TOOL_NAME = ""
7
+ DESCRIPTION = ""
8
+ PARAMETERS = {}.freeze
9
+ RISK_LEVEL = :read
10
+ REQUIRES_CONFIRMATION = false
11
+
12
+ class << self
13
+ def tool_name
14
+ const_get(:TOOL_NAME)
15
+ end
16
+
17
+ def description
18
+ const_get(:DESCRIPTION)
19
+ end
20
+
21
+ def parameters
22
+ const_get(:PARAMETERS)
23
+ end
24
+
25
+ def risk_level
26
+ const_get(:RISK_LEVEL)
27
+ end
28
+
29
+ def requires_confirmation?
30
+ const_get(:REQUIRES_CONFIRMATION)
31
+ end
32
+
33
+ def to_schema
34
+ {
35
+ name: tool_name,
36
+ description: description,
37
+ input_schema: Schema.build(parameters)
38
+ }
39
+ end
40
+ end
41
+
42
+ attr_reader :project_root
43
+
44
+ def initialize(project_root:)
45
+ @project_root = File.expand_path(project_root)
46
+ end
47
+
48
+ def execute(**params)
49
+ raise NotImplementedError, "#{self.class}#execute must be implemented"
50
+ end
51
+
52
+ def safe_path(path)
53
+ expanded = if Pathname.new(path).absolute?
54
+ File.expand_path(path)
55
+ else
56
+ File.expand_path(path, project_root)
57
+ end
58
+
59
+ unless expanded.start_with?(project_root)
60
+ raise PermissionDeniedError, "Path traversal denied: #{path} resolves outside project root"
61
+ end
62
+
63
+ expanded
64
+ end
65
+
66
+ def truncate(output, max: 50_000)
67
+ return output if output.nil? || output.length <= max
68
+
69
+ half = max / 2
70
+ "#{output[0, half]}\n\n... [truncated #{output.length - max} characters] ...\n\n#{output[-half, half]}"
71
+ end
72
+
73
+ private
74
+
75
+ def read_file_safely(path)
76
+ resolved = safe_path(path)
77
+ raise Error, "File not found: #{path}" unless File.exist?(resolved)
78
+ raise Error, "Not a file: #{path}" unless File.file?(resolved)
79
+
80
+ resolved
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "timeout"
5
+ require_relative "base"
6
+ require_relative "registry"
7
+
8
+ module RubynCode
9
+ module Tools
10
+ class Bash < Base
11
+ TOOL_NAME = "bash"
12
+ DESCRIPTION = "Runs a shell command in the project directory. Blocks dangerous patterns and scrubs sensitive environment variables."
13
+ PARAMETERS = {
14
+ command: { type: :string, required: true, description: "The shell command to execute" },
15
+ timeout: { type: :integer, required: false, default: 120, description: "Timeout in seconds (default: 120)" }
16
+ }.freeze
17
+ RISK_LEVEL = :execute
18
+ REQUIRES_CONFIRMATION = true
19
+
20
+ def execute(command:, timeout: 120)
21
+ validate_command!(command)
22
+
23
+ env = scrubbed_env
24
+
25
+ stdout, stderr, status = nil
26
+ begin
27
+ Timeout.timeout(timeout) do
28
+ stdout, stderr, status = Open3.capture3(env, command, chdir: project_root)
29
+ end
30
+ rescue Timeout::Error
31
+ raise Error, "Command timed out after #{timeout} seconds: #{command}"
32
+ end
33
+
34
+ output = build_output(stdout, stderr, status)
35
+ output
36
+ end
37
+
38
+ private
39
+
40
+ def validate_command!(command)
41
+ Config::Defaults::DANGEROUS_PATTERNS.each do |pattern|
42
+ if command.include?(pattern)
43
+ raise PermissionDeniedError, "Blocked dangerous command pattern: '#{pattern}'"
44
+ end
45
+ end
46
+ end
47
+
48
+ def scrubbed_env
49
+ env = ENV.to_h.dup
50
+
51
+ env.each_key do |key|
52
+ if Config::Defaults::SCRUB_ENV_VARS.any? { |sensitive| key.upcase.include?(sensitive) }
53
+ env[key] = "[SCRUBBED]"
54
+ end
55
+ end
56
+
57
+ env
58
+ end
59
+
60
+ def build_output(stdout, stderr, status)
61
+ parts = []
62
+
63
+ unless stdout.empty?
64
+ parts << stdout
65
+ end
66
+
67
+ unless stderr.empty?
68
+ parts << "STDERR:\n#{stderr}"
69
+ end
70
+
71
+ unless status.success?
72
+ parts << "Exit code: #{status.exitstatus}"
73
+ end
74
+
75
+ parts.empty? ? "(no output)" : parts.join("\n")
76
+ end
77
+ end
78
+
79
+ Registry.register(Bash)
80
+ end
81
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require_relative "base"
5
+ require_relative "registry"
6
+
7
+ module RubynCode
8
+ module Tools
9
+ class BundleAdd < Base
10
+ TOOL_NAME = "bundle_add"
11
+ DESCRIPTION = "Adds a gem to the Gemfile and installs it via `bundle add`."
12
+ PARAMETERS = {
13
+ gem_name: { type: :string, required: true, description: "Name of the gem to add" },
14
+ version: { type: :string, required: false, description: "Version constraint (e.g. '~> 1.0')" },
15
+ group: { type: :string, required: false, description: "Gemfile group (e.g. 'development', 'test')" }
16
+ }.freeze
17
+ RISK_LEVEL = :execute
18
+ REQUIRES_CONFIRMATION = false
19
+
20
+ def execute(gem_name:, version: nil, group: nil)
21
+ gemfile_path = File.join(project_root, "Gemfile")
22
+
23
+ unless File.exist?(gemfile_path)
24
+ raise Error, "No Gemfile found in project root. Cannot run bundle add."
25
+ end
26
+
27
+ command = build_command(gem_name, version, group)
28
+ stdout, stderr, status = Open3.capture3(command, chdir: project_root)
29
+
30
+ build_output(stdout, stderr, status)
31
+ end
32
+
33
+ private
34
+
35
+ def build_command(gem_name, version, group)
36
+ cmd = "bundle add #{gem_name}"
37
+ cmd += " --version '#{version}'" if version
38
+ cmd += " --group #{group}" if group
39
+ cmd
40
+ end
41
+
42
+ def build_output(stdout, stderr, status)
43
+ parts = []
44
+ parts << stdout unless stdout.empty?
45
+ parts << "STDERR:\n#{stderr}" unless stderr.empty?
46
+ parts << "Exit code: #{status.exitstatus}" unless status.success?
47
+ parts.empty? ? "(no output)" : parts.join("\n")
48
+ end
49
+ end
50
+
51
+ Registry.register(BundleAdd)
52
+ end
53
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require_relative "base"
5
+ require_relative "registry"
6
+
7
+ module RubynCode
8
+ module Tools
9
+ class BundleInstall < Base
10
+ TOOL_NAME = "bundle_install"
11
+ DESCRIPTION = "Runs `bundle install` to install gem dependencies."
12
+ PARAMETERS = {}.freeze
13
+ RISK_LEVEL = :execute
14
+ REQUIRES_CONFIRMATION = false
15
+
16
+ def execute(**_params)
17
+ gemfile_path = File.join(project_root, "Gemfile")
18
+
19
+ unless File.exist?(gemfile_path)
20
+ raise Error, "No Gemfile found in project root. Cannot run bundle install."
21
+ end
22
+
23
+ stdout, stderr, status = Open3.capture3("bundle install", chdir: project_root)
24
+
25
+ build_output(stdout, stderr, status)
26
+ end
27
+
28
+ private
29
+
30
+ def build_output(stdout, stderr, status)
31
+ parts = []
32
+ parts << stdout unless stdout.empty?
33
+ parts << "STDERR:\n#{stderr}" unless stderr.empty?
34
+ parts << "Exit code: #{status.exitstatus}" unless status.success?
35
+ parts.empty? ? "(no output)" : parts.join("\n")
36
+ end
37
+ end
38
+
39
+ Registry.register(BundleInstall)
40
+ end
41
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require_relative "registry"
5
+
6
+ module RubynCode
7
+ module Tools
8
+ class Compact < Base
9
+ TOOL_NAME = "compact"
10
+ DESCRIPTION = "Triggers manual context compaction to reduce conversation size while preserving key information."
11
+ PARAMETERS = {
12
+ focus: { type: :string, required: false, description: "What to focus the summary on (e.g. 'the auth refactor', 'test failures')" }
13
+ }.freeze
14
+ RISK_LEVEL = :read
15
+ REQUIRES_CONFIRMATION = false
16
+
17
+ def initialize(project_root:, context_manager: nil)
18
+ super(project_root: project_root)
19
+ @context_manager = context_manager
20
+ end
21
+
22
+ def execute(focus: nil)
23
+ manager = @context_manager
24
+
25
+ unless manager
26
+ return "Context compaction is not available in this session. " \
27
+ "No context manager was provided."
28
+ end
29
+
30
+ if manager.respond_to?(:compact)
31
+ result = manager.compact(focus: focus)
32
+ format_result(result, focus)
33
+ else
34
+ "Context manager does not support compaction."
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def format_result(result, focus)
41
+ parts = ["Context compacted successfully."]
42
+
43
+ if result.is_a?(Hash)
44
+ parts << "Messages before: #{result[:before]}" if result[:before]
45
+ parts << "Messages after: #{result[:after]}" if result[:after]
46
+ parts << "Tokens saved: ~#{result[:tokens_saved]}" if result[:tokens_saved]
47
+ end
48
+
49
+ parts << "Focus: #{focus}" if focus
50
+
51
+ parts.join("\n")
52
+ end
53
+ end
54
+
55
+ Registry.register(Compact)
56
+ end
57
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require_relative "base"
5
+ require_relative "registry"
6
+
7
+ module RubynCode
8
+ module Tools
9
+ class DbMigrate < Base
10
+ TOOL_NAME = "db_migrate"
11
+ DESCRIPTION = "Runs Rails database migrations (up) or rollback (down)."
12
+ PARAMETERS = {
13
+ direction: { type: :string, required: false, default: "up", enum: %w[up down], description: "Migration direction: 'up' to migrate, 'down' to rollback (default: 'up')" },
14
+ steps: { type: :integer, required: false, description: "Number of steps to rollback (only used with direction 'down')" }
15
+ }.freeze
16
+ RISK_LEVEL = :execute
17
+ REQUIRES_CONFIRMATION = false
18
+
19
+ def execute(direction: "up", steps: nil)
20
+ command = build_command(direction, steps)
21
+ stdout, stderr, status = Open3.capture3(command, chdir: project_root)
22
+
23
+ build_output(stdout, stderr, status)
24
+ end
25
+
26
+ private
27
+
28
+ def build_command(direction, steps)
29
+ case direction
30
+ when "up"
31
+ "bundle exec rails db:migrate"
32
+ when "down"
33
+ cmd = "bundle exec rails db:rollback"
34
+ cmd += " STEP=#{steps.to_i}" if steps && steps.to_i > 0
35
+ cmd
36
+ else
37
+ raise Error, "Invalid direction: #{direction}. Must be 'up' or 'down'."
38
+ end
39
+ end
40
+
41
+ def build_output(stdout, stderr, status)
42
+ parts = []
43
+ parts << stdout unless stdout.empty?
44
+ parts << "STDERR:\n#{stderr}" unless stderr.empty?
45
+ parts << "Exit code: #{status.exitstatus}" unless status.success?
46
+ parts.empty? ? "(no output)" : parts.join("\n")
47
+ end
48
+ end
49
+
50
+ Registry.register(DbMigrate)
51
+ end
52
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require_relative "registry"
5
+
6
+ module RubynCode
7
+ module Tools
8
+ class EditFile < Base
9
+ TOOL_NAME = "edit_file"
10
+ DESCRIPTION = "Performs exact string replacement in a file. Fails if old_text is not found or is ambiguous."
11
+ PARAMETERS = {
12
+ path: { type: :string, required: true, description: "Path to the file to edit" },
13
+ old_text: { type: :string, required: true, description: "The exact text to find and replace" },
14
+ new_text: { type: :string, required: true, description: "The replacement text" },
15
+ replace_all: { type: :boolean, required: false, default: false, description: "Replace all occurrences (default: false)" }
16
+ }.freeze
17
+ RISK_LEVEL = :write
18
+ REQUIRES_CONFIRMATION = false
19
+
20
+ def execute(path:, old_text:, new_text:, replace_all: false)
21
+ resolved = read_file_safely(path)
22
+ content = File.read(resolved)
23
+
24
+ occurrences = content.scan(old_text).length
25
+
26
+ if occurrences.zero?
27
+ raise Error, "old_text not found in #{path}. No changes made."
28
+ end
29
+
30
+ if !replace_all && occurrences > 1
31
+ raise Error, "old_text found #{occurrences} times in #{path}. Use replace_all: true to replace all, or provide a more specific old_text."
32
+ end
33
+
34
+ new_content = if replace_all
35
+ content.gsub(old_text, new_text)
36
+ else
37
+ content.sub(old_text, new_text)
38
+ end
39
+
40
+ File.write(resolved, new_content)
41
+
42
+ replaced_count = replace_all ? occurrences : 1
43
+ "Successfully replaced #{replaced_count} occurrence#{'s' if replaced_count > 1} in #{path}"
44
+ end
45
+ end
46
+
47
+ Registry.register(EditFile)
48
+ end
49
+ end