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,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module Tools
5
+ class Executor
6
+ attr_reader :project_root
7
+ attr_accessor :llm_client, :background_worker, :on_agent_status, :db
8
+
9
+ def initialize(project_root:)
10
+ @project_root = File.expand_path(project_root)
11
+ @injections = {}
12
+ Registry.load_all!
13
+ end
14
+
15
+ def execute(tool_name, params)
16
+ tool_class = Registry.get(tool_name)
17
+ tool = tool_class.new(project_root: project_root)
18
+
19
+ # Inject dependencies for tools that need them
20
+ inject_dependencies(tool, tool_name)
21
+
22
+ symbolized = params.transform_keys(&:to_sym)
23
+ result = tool.execute(**symbolized)
24
+ tool.truncate(result.to_s)
25
+ rescue ToolNotFoundError => e
26
+ error_result("Tool error: #{e.message}")
27
+ rescue PermissionDeniedError => e
28
+ error_result("Permission denied: #{e.message}")
29
+ rescue NotImplementedError => e
30
+ error_result("Not implemented: #{e.message}")
31
+ rescue Error => e
32
+ error_result("Error: #{e.message}")
33
+ rescue StandardError => e
34
+ error_result("Unexpected error in #{tool_name}: #{e.class}: #{e.message}")
35
+ end
36
+
37
+ def tool_definitions
38
+ Registry.tool_definitions
39
+ end
40
+
41
+ private
42
+
43
+ def inject_dependencies(tool, tool_name)
44
+ case tool_name
45
+ when "spawn_agent"
46
+ tool.llm_client = @llm_client if tool.respond_to?(:llm_client=)
47
+ tool.on_status = @on_agent_status if tool.respond_to?(:on_status=)
48
+ when "spawn_teammate"
49
+ tool.llm_client = @llm_client if tool.respond_to?(:llm_client=)
50
+ tool.on_status = @on_agent_status if tool.respond_to?(:on_status=)
51
+ tool.db = @db if tool.respond_to?(:db=)
52
+ when "background_run"
53
+ tool.background_worker = @background_worker if tool.respond_to?(:background_worker=)
54
+ end
55
+ end
56
+
57
+ def error_result(message)
58
+ message
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,97 @@
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 GitCommit < Base
10
+ TOOL_NAME = "git_commit"
11
+ DESCRIPTION = "Stage files and create a git commit. Specify files to stage or use 'all' to stage everything."
12
+ PARAMETERS = {
13
+ message: { type: :string, required: true, description: "The commit message" },
14
+ files: { type: :string, required: false, default: "all", description: "Space-separated file paths to stage, or 'all' to stage everything (git add -A)" }
15
+ }.freeze
16
+ RISK_LEVEL = :write
17
+ REQUIRES_CONFIRMATION = true
18
+
19
+ def execute(message:, files: "all")
20
+ validate_git_repo!
21
+ validate_message!(message)
22
+
23
+ stage_files(files)
24
+ commit_output = create_commit(message)
25
+
26
+ commit_output
27
+ end
28
+
29
+ private
30
+
31
+ def validate_git_repo!
32
+ _, _, status = Open3.capture3("git", "rev-parse", "--is-inside-work-tree", chdir: project_root)
33
+ unless status.success?
34
+ raise Error, "Not a git repository: #{project_root}"
35
+ end
36
+ end
37
+
38
+ def validate_message!(message)
39
+ if message.nil? || message.strip.empty?
40
+ raise Error, "Commit message cannot be empty"
41
+ end
42
+ end
43
+
44
+ def stage_files(files)
45
+ if files.strip.downcase == "all"
46
+ stdout, stderr, status = Open3.capture3("git", "add", "-A", chdir: project_root)
47
+ else
48
+ file_list = files.split(/\s+/).reject(&:empty?)
49
+ if file_list.empty?
50
+ raise Error, "No files specified to stage"
51
+ end
52
+
53
+ stdout, stderr, status = Open3.capture3("git", "add", "--", *file_list, chdir: project_root)
54
+ end
55
+
56
+ unless status.success?
57
+ raise Error, "Failed to stage files: #{stderr.strip}"
58
+ end
59
+ end
60
+
61
+ def create_commit(message)
62
+ stdout, stderr, status = Open3.capture3("git", "commit", "-m", message, chdir: project_root)
63
+
64
+ unless status.success?
65
+ if stderr.include?("nothing to commit")
66
+ return "Nothing to commit — working tree is clean."
67
+ end
68
+
69
+ raise Error, "Commit failed: #{stderr.strip}"
70
+ end
71
+
72
+ # Extract the commit hash from the output
73
+ commit_hash = extract_commit_hash
74
+ branch = current_branch
75
+
76
+ lines = ["Committed on branch: #{branch}"]
77
+ lines << "Commit: #{commit_hash}" if commit_hash
78
+ lines << ""
79
+ lines << stdout.strip
80
+
81
+ lines.join("\n")
82
+ end
83
+
84
+ def extract_commit_hash
85
+ stdout, _, status = Open3.capture3("git", "rev-parse", "--short", "HEAD", chdir: project_root)
86
+ status.success? ? stdout.strip : nil
87
+ end
88
+
89
+ def current_branch
90
+ stdout, _, status = Open3.capture3("git", "branch", "--show-current", chdir: project_root)
91
+ status.success? && !stdout.strip.empty? ? stdout.strip : "HEAD (detached)"
92
+ end
93
+ end
94
+
95
+ Registry.register(GitCommit)
96
+ end
97
+ end
@@ -0,0 +1,61 @@
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 GitDiff < Base
10
+ TOOL_NAME = "git_diff"
11
+ DESCRIPTION = "Show git diff for staged, unstaged, or between branches/commits."
12
+ PARAMETERS = {
13
+ target: { type: :string, required: false, default: "unstaged", description: 'What to diff: "staged", "unstaged", or a branch/commit ref (default: "unstaged")' }
14
+ }.freeze
15
+ RISK_LEVEL = :read
16
+ REQUIRES_CONFIRMATION = false
17
+
18
+ MAX_DIFF_LENGTH = 80_000
19
+
20
+ def execute(target: "unstaged")
21
+ validate_git_repo!
22
+
23
+ cmd = build_diff_command(target.to_s.strip)
24
+ stdout, stderr, status = Open3.capture3(*cmd, chdir: project_root)
25
+
26
+ unless status.success?
27
+ raise Error, "git diff failed: #{stderr.strip}"
28
+ end
29
+
30
+ if stdout.strip.empty?
31
+ "No differences found for target: #{target}"
32
+ else
33
+ header = "git diff (#{target}):\n\n"
34
+ truncate("#{header}#{stdout}", max: MAX_DIFF_LENGTH)
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def validate_git_repo!
41
+ _, _, status = Open3.capture3("git", "rev-parse", "--is-inside-work-tree", chdir: project_root)
42
+ unless status.success?
43
+ raise Error, "Not a git repository: #{project_root}"
44
+ end
45
+ end
46
+
47
+ def build_diff_command(target)
48
+ case target.downcase
49
+ when "staged", "cached"
50
+ %w[git diff --cached]
51
+ when "unstaged", ""
52
+ %w[git diff]
53
+ else
54
+ ["git", "diff", target]
55
+ end
56
+ end
57
+ end
58
+
59
+ Registry.register(GitDiff)
60
+ end
61
+ end
@@ -0,0 +1,59 @@
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 GitLog < Base
10
+ TOOL_NAME = "git_log"
11
+ DESCRIPTION = "Show recent git commit history."
12
+ PARAMETERS = {
13
+ count: { type: :integer, required: false, default: 20, description: "Number of commits to show (default: 20)" },
14
+ branch: { type: :string, required: false, description: "Branch name to show log for (default: current branch)" }
15
+ }.freeze
16
+ RISK_LEVEL = :read
17
+ REQUIRES_CONFIRMATION = false
18
+
19
+ def execute(count: 20, branch: nil)
20
+ validate_git_repo!
21
+
22
+ count = [[count.to_i, 1].max, 200].min
23
+
24
+ cmd = ["git", "log", "--oneline", "-#{count}"]
25
+ cmd << branch unless branch.nil? || branch.strip.empty?
26
+
27
+ stdout, stderr, status = Open3.capture3(*cmd, chdir: project_root)
28
+
29
+ unless status.success?
30
+ raise Error, "git log failed: #{stderr.strip}"
31
+ end
32
+
33
+ if stdout.strip.empty?
34
+ "No commits found."
35
+ else
36
+ current = current_branch
37
+ header = "Commit history#{branch ? " (#{branch})" : " (#{current})"}:\n\n"
38
+ truncate("#{header}#{stdout}", max: 50_000)
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def validate_git_repo!
45
+ _, _, status = Open3.capture3("git", "rev-parse", "--is-inside-work-tree", chdir: project_root)
46
+ unless status.success?
47
+ raise Error, "Not a git repository: #{project_root}"
48
+ end
49
+ end
50
+
51
+ def current_branch
52
+ stdout, _, status = Open3.capture3("git", "branch", "--show-current", chdir: project_root)
53
+ status.success? && !stdout.strip.empty? ? stdout.strip : "HEAD (detached)"
54
+ end
55
+ end
56
+
57
+ Registry.register(GitLog)
58
+ end
59
+ end
@@ -0,0 +1,59 @@
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 GitStatus < Base
10
+ TOOL_NAME = "git_status"
11
+ DESCRIPTION = "Show the current git status — modified, staged, and untracked files."
12
+ PARAMETERS = {}.freeze
13
+ RISK_LEVEL = :read
14
+ REQUIRES_CONFIRMATION = false
15
+
16
+ def execute(**_params)
17
+ validate_git_repo!
18
+
19
+ branch = current_branch
20
+ status_output = git_status
21
+
22
+ lines = ["Branch: #{branch}\n"]
23
+
24
+ if status_output.strip.empty?
25
+ lines << "Working tree is clean — nothing to commit."
26
+ else
27
+ lines << status_output
28
+ end
29
+
30
+ lines.join("\n")
31
+ end
32
+
33
+ private
34
+
35
+ def validate_git_repo!
36
+ _, _, status = Open3.capture3("git", "rev-parse", "--is-inside-work-tree", chdir: project_root)
37
+ unless status.success?
38
+ raise Error, "Not a git repository: #{project_root}"
39
+ end
40
+ end
41
+
42
+ def current_branch
43
+ stdout, _, status = Open3.capture3("git", "branch", "--show-current", chdir: project_root)
44
+ status.success? && !stdout.strip.empty? ? stdout.strip : "HEAD (detached)"
45
+ end
46
+
47
+ def git_status
48
+ stdout, stderr, status = Open3.capture3("git", "status", "--short", chdir: project_root)
49
+ unless status.success?
50
+ raise Error, "git status failed: #{stderr.strip}"
51
+ end
52
+
53
+ stdout
54
+ end
55
+ end
56
+
57
+ Registry.register(GitStatus)
58
+ end
59
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require_relative "registry"
5
+
6
+ module RubynCode
7
+ module Tools
8
+ class Glob < Base
9
+ TOOL_NAME = "glob"
10
+ DESCRIPTION = "File pattern matching. Returns sorted list of file paths matching the glob pattern."
11
+ PARAMETERS = {
12
+ pattern: { type: :string, required: true, description: "Glob pattern (e.g. '**/*.rb', 'app/**/*.erb')" },
13
+ path: { type: :string, required: false, description: "Directory to search in (defaults to project root)" }
14
+ }.freeze
15
+ RISK_LEVEL = :read
16
+ REQUIRES_CONFIRMATION = false
17
+
18
+ def execute(pattern:, path: nil)
19
+ search_dir = path ? safe_path(path) : project_root
20
+
21
+ unless File.directory?(search_dir)
22
+ raise Error, "Directory not found: #{path || project_root}"
23
+ end
24
+
25
+ full_pattern = File.join(search_dir, pattern)
26
+ matches = Dir.glob(full_pattern, File::FNM_DOTMATCH).sort
27
+
28
+ matches
29
+ .select { |f| File.file?(f) }
30
+ .reject { |f| File.basename(f).start_with?(".") && File.basename(f) == "." || File.basename(f) == ".." }
31
+ .map { |f| relative_to_root(f) }
32
+ .join("\n")
33
+ end
34
+
35
+ private
36
+
37
+ def relative_to_root(absolute_path)
38
+ absolute_path.delete_prefix("#{project_root}/")
39
+ end
40
+ end
41
+
42
+ Registry.register(Glob)
43
+ end
44
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require_relative "registry"
5
+
6
+ module RubynCode
7
+ module Tools
8
+ class Grep < Base
9
+ TOOL_NAME = "grep"
10
+ DESCRIPTION = "Searches file contents using regular expressions. Returns matching lines with file paths and line numbers."
11
+ PARAMETERS = {
12
+ pattern: { type: :string, required: true, description: "Regular expression pattern to search for" },
13
+ path: { type: :string, required: false, description: "File or directory to search in (defaults to project root)" },
14
+ glob_filter: { type: :string, required: false, description: "Glob pattern to filter files (e.g. '*.rb')" },
15
+ max_results: { type: :integer, required: false, default: 50, description: "Maximum number of matching lines to return" }
16
+ }.freeze
17
+ RISK_LEVEL = :read
18
+ REQUIRES_CONFIRMATION = false
19
+
20
+ def execute(pattern:, path: nil, glob_filter: nil, max_results: 50)
21
+ search_path = path ? safe_path(path) : project_root
22
+ regex = Regexp.new(pattern)
23
+
24
+ files = collect_files(search_path, glob_filter)
25
+ results = []
26
+
27
+ files.each do |file|
28
+ break if results.length >= max_results
29
+
30
+ search_file(file, regex, results, max_results)
31
+ end
32
+
33
+ return "No matches found for pattern: #{pattern}" if results.empty?
34
+
35
+ results.join("\n")
36
+ end
37
+
38
+ private
39
+
40
+ def collect_files(search_path, glob_filter)
41
+ if File.file?(search_path)
42
+ [search_path]
43
+ elsif File.directory?(search_path)
44
+ glob_pattern = glob_filter || "**/*"
45
+ Dir.glob(File.join(search_path, glob_pattern))
46
+ .select { |f| File.file?(f) }
47
+ .reject { |f| binary_file?(f) }
48
+ .sort
49
+ else
50
+ raise Error, "Path not found: #{search_path}"
51
+ end
52
+ end
53
+
54
+ def search_file(file, regex, results, max_results)
55
+ File.foreach(file).with_index(1) do |line, line_num|
56
+ break if results.length >= max_results
57
+
58
+ if line.match?(regex)
59
+ relative = file.delete_prefix("#{project_root}/")
60
+ results << "#{relative}:#{line_num}: #{line.chomp}"
61
+ end
62
+ end
63
+ rescue ArgumentError, Encoding::InvalidByteSequenceError
64
+ # Skip files with encoding issues
65
+ end
66
+
67
+ def binary_file?(path)
68
+ return true if path.match?(/\.(png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|pdf|zip|gz|tar|so|dylib|o|a)\z/i)
69
+
70
+ sample = File.read(path, 512)
71
+ return false if sample.nil?
72
+
73
+ sample.bytes.any? { |b| b.zero? }
74
+ rescue StandardError
75
+ true
76
+ end
77
+ end
78
+
79
+ Registry.register(Grep)
80
+ end
81
+ 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 LoadSkill < Base
9
+ TOOL_NAME = "load_skill"
10
+ DESCRIPTION = "Loads a skill document into the conversation context. Use /skill-name or provide the skill name."
11
+ PARAMETERS = {
12
+ name: { type: :string, required: true, description: "Name of the skill to load" }
13
+ }.freeze
14
+ RISK_LEVEL = :read
15
+ REQUIRES_CONFIRMATION = false
16
+
17
+ def initialize(project_root:, skill_loader: nil)
18
+ super(project_root: project_root)
19
+ @skill_loader = skill_loader
20
+ end
21
+
22
+ def execute(name:)
23
+ loader = @skill_loader || default_loader
24
+ loader.load(name)
25
+ end
26
+
27
+ private
28
+
29
+ def default_loader
30
+ skills_dirs = [
31
+ File.join(project_root, ".rubyn", "skills"),
32
+ File.join(Dir.home, ".rubyn", "skills")
33
+ ]
34
+ catalog = Skills::Catalog.new(skills_dirs)
35
+ Skills::Loader.new(catalog)
36
+ end
37
+ end
38
+
39
+ Registry.register(LoadSkill)
40
+ end
41
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require_relative "registry"
5
+
6
+ module RubynCode
7
+ module Tools
8
+ class MemorySearch < Base
9
+ TOOL_NAME = "memory_search"
10
+ DESCRIPTION = "Searches project memories using full-text search. " \
11
+ "Returns relevant memories including code patterns, user preferences, " \
12
+ "project conventions, error resolutions, and past decisions."
13
+ PARAMETERS = {
14
+ query: { type: :string, required: true, description: "Search query for finding relevant memories" },
15
+ tier: { type: :string, required: false, description: "Filter by memory tier: short, medium, or long" },
16
+ category: { type: :string, required: false, description: "Filter by category: code_pattern, user_preference, project_convention, error_resolution, or decision" },
17
+ limit: { type: :integer, required: false, description: "Maximum number of results to return (default 10)" }
18
+ }.freeze
19
+ RISK_LEVEL = :read
20
+ REQUIRES_CONFIRMATION = false
21
+
22
+ # @param project_root [String]
23
+ # @param memory_search [Memory::Search] injected search instance
24
+ def initialize(project_root:, memory_search: nil)
25
+ super(project_root: project_root)
26
+ @memory_search = memory_search
27
+ end
28
+
29
+ # @param query [String]
30
+ # @param tier [String, nil]
31
+ # @param category [String, nil]
32
+ # @param limit [Integer, nil]
33
+ # @return [String] formatted search results
34
+ def execute(query:, tier: nil, category: nil, limit: 10)
35
+ search = @memory_search || resolve_memory_search
36
+ results = search.search(query, tier: tier, category: category, limit: limit.to_i)
37
+
38
+ return "No memories found for query: #{query}" if results.empty?
39
+
40
+ format_results(results)
41
+ end
42
+
43
+ private
44
+
45
+ # Formats an array of MemoryRecord into a human-readable string.
46
+ #
47
+ # @param records [Array<Memory::MemoryRecord>]
48
+ # @return [String]
49
+ def format_results(records)
50
+ lines = ["Found #{records.size} memor#{records.size == 1 ? 'y' : 'ies'}:\n"]
51
+
52
+ records.each_with_index do |record, idx|
53
+ lines << "--- Memory #{idx + 1} ---"
54
+ lines << "ID: #{record.id}"
55
+ lines << "Tier: #{record.tier} | Category: #{record.category || 'none'}"
56
+ lines << "Relevance: #{format('%.2f', record.relevance_score)} | Accessed: #{record.access_count} times"
57
+ lines << "Created: #{record.created_at}"
58
+ lines << ""
59
+ lines << record.content
60
+ lines << ""
61
+ end
62
+
63
+ lines.join("\n")
64
+ end
65
+
66
+ # Lazily resolves a Memory::Search instance from the project root.
67
+ #
68
+ # @return [Memory::Search]
69
+ def resolve_memory_search
70
+ db = DB::Connection.instance
71
+ Memory::Search.new(db, project_path: project_root)
72
+ end
73
+ end
74
+
75
+ Registry.register(MemorySearch)
76
+ end
77
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require_relative "registry"
5
+
6
+ module RubynCode
7
+ module Tools
8
+ class MemoryWrite < Base
9
+ TOOL_NAME = "memory_write"
10
+ DESCRIPTION = "Writes a new memory to the project memory store. " \
11
+ "Use this to persist code patterns, user preferences, project conventions, " \
12
+ "error resolutions, or architectural decisions for future reference."
13
+ PARAMETERS = {
14
+ content: { type: :string, required: true, description: "The memory content to store" },
15
+ tier: { type: :string, required: false, description: "Memory retention tier: short, medium (default), or long" },
16
+ category: { type: :string, required: false, description: "Category: code_pattern, user_preference, project_convention, error_resolution, or decision" }
17
+ }.freeze
18
+ RISK_LEVEL = :read # Memory is internal — no user approval needed
19
+
20
+ # @param project_root [String]
21
+ # @param memory_store [Memory::Store] injected store instance
22
+ def initialize(project_root:, memory_store: nil)
23
+ super(project_root: project_root)
24
+ @memory_store = memory_store
25
+ end
26
+
27
+ # @param content [String]
28
+ # @param tier [String] defaults to "medium"
29
+ # @param category [String, nil]
30
+ # @return [String] confirmation message
31
+ def execute(content:, tier: "medium", category: nil)
32
+ store = @memory_store || resolve_memory_store
33
+ record = store.write(content: content, tier: tier, category: category)
34
+
35
+ "Memory saved (ID: #{record.id}, tier: #{record.tier}" \
36
+ "#{record.category ? ", category: #{record.category}" : ''})."
37
+ end
38
+
39
+ private
40
+
41
+ # Lazily resolves a Memory::Store instance from the project root.
42
+ #
43
+ # @return [Memory::Store]
44
+ def resolve_memory_store
45
+ db = DB::Connection.instance
46
+ Memory::Store.new(db, project_path: project_root)
47
+ end
48
+ end
49
+
50
+ Registry.register(MemoryWrite)
51
+ end
52
+ end
@@ -0,0 +1,54 @@
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 RailsGenerate < Base
10
+ TOOL_NAME = "rails_generate"
11
+ DESCRIPTION = "Runs a Rails generator command. Validates that the project is a Rails application."
12
+ PARAMETERS = {
13
+ generator: { type: :string, required: true, description: "Generator name (e.g. 'model', 'controller', 'migration')" },
14
+ args: { type: :string, required: true, description: "Arguments for the generator (e.g. 'User name:string email:string')" }
15
+ }.freeze
16
+ RISK_LEVEL = :execute
17
+ REQUIRES_CONFIRMATION = false
18
+
19
+ def execute(generator:, args:)
20
+ validate_rails_project!
21
+
22
+ command = "bundle exec rails generate #{generator} #{args}"
23
+ stdout, stderr, status = Open3.capture3(command, chdir: project_root)
24
+
25
+ build_output(stdout, stderr, status)
26
+ end
27
+
28
+ private
29
+
30
+ def validate_rails_project!
31
+ gemfile_path = File.join(project_root, "Gemfile")
32
+
33
+ unless File.exist?(gemfile_path)
34
+ raise Error, "No Gemfile found. This does not appear to be a Ruby project."
35
+ end
36
+
37
+ gemfile_content = File.read(gemfile_path)
38
+ unless gemfile_content.match?(/['"]rails['"]/)
39
+ raise Error, "Gemfile does not include Rails. This does not appear to be a Rails project."
40
+ end
41
+ end
42
+
43
+ def build_output(stdout, stderr, status)
44
+ parts = []
45
+ parts << stdout unless stdout.empty?
46
+ parts << "STDERR:\n#{stderr}" unless stderr.empty?
47
+ parts << "Exit code: #{status.exitstatus}" unless status.success?
48
+ parts.empty? ? "(no output)" : parts.join("\n")
49
+ end
50
+ end
51
+
52
+ Registry.register(RailsGenerate)
53
+ end
54
+ end