rails-ai-context 5.32.2 → 5.33.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 (188) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1287 -0
  3. data/CONTEXT.md +26 -2
  4. data/README.md +13 -9
  5. data/Rakefile +6 -0
  6. data/app/controllers/rails_ai_context/mcp_controller.rb +114 -84
  7. data/docs/ARCHITECTURE.md +6 -4
  8. data/docs/CLI.md +79 -6
  9. data/docs/COMPATIBILITY.md +65 -1
  10. data/docs/CONFIGURATION.md +30 -11
  11. data/docs/CUSTOM_TOOLS.md +2 -1
  12. data/docs/FAQ.md +16 -6
  13. data/docs/GUIDE.md +117 -71
  14. data/docs/INTROSPECTORS.md +8 -7
  15. data/docs/QUICKSTART.md +3 -3
  16. data/docs/RECIPES.md +1 -1
  17. data/docs/SECURITY.md +109 -25
  18. data/docs/SETUP.md +30 -6
  19. data/docs/STANDALONE.md +10 -3
  20. data/docs/TOOLS.md +145 -34
  21. data/docs/TROUBLESHOOTING.md +90 -12
  22. data/docs/adr/0003-shared-tool-cache-semantics.md +8 -0
  23. data/docs/adr/0005-workspace-mcp-entries.md +39 -0
  24. data/exe/rails-ai-context +733 -104
  25. data/lib/generators/rails_ai_context/install/install_generator.rb +341 -125
  26. data/lib/rails_ai_context/ast_cache.rb +7 -0
  27. data/lib/rails_ai_context/base_mixins.rb +8 -8
  28. data/lib/rails_ai_context/boot_manager.rb +22 -3
  29. data/lib/rails_ai_context/change_watch.rb +14 -11
  30. data/lib/rails_ai_context/cli/app_root.rb +401 -0
  31. data/lib/rails_ai_context/cli/entry_boot.rb +53 -20
  32. data/lib/rails_ai_context/cli/tool_runner.rb +47 -21
  33. data/lib/rails_ai_context/code_reloader.rb +115 -1
  34. data/lib/rails_ai_context/concern_membership.rb +3 -1
  35. data/lib/rails_ai_context/concern_paths.rb +8 -3
  36. data/lib/rails_ai_context/configuration.rb +41 -9
  37. data/lib/rails_ai_context/context_file_report.rb +19 -2
  38. data/lib/rails_ai_context/database_file.rb +26 -0
  39. data/lib/rails_ai_context/doctor.rb +1122 -184
  40. data/lib/rails_ai_context/engine.rb +11 -1
  41. data/lib/rails_ai_context/erb_source.rb +19 -0
  42. data/lib/rails_ai_context/executable.rb +21 -0
  43. data/lib/rails_ai_context/facts_formatter.rb +5 -2
  44. data/lib/rails_ai_context/file_walk.rb +61 -10
  45. data/lib/rails_ai_context/fingerprinter/snapshot.rb +234 -0
  46. data/lib/rails_ai_context/fingerprinter.rb +40 -12
  47. data/lib/rails_ai_context/gem_lock.rb +176 -26
  48. data/lib/rails_ai_context/git_ignore.rb +22 -0
  49. data/lib/rails_ai_context/install/ai_tool.rb +31 -5
  50. data/lib/rails_ai_context/install/cleanup.rb +21 -0
  51. data/lib/rails_ai_context/install/program.rb +209 -35
  52. data/lib/rails_ai_context/install/selection_record.rb +258 -97
  53. data/lib/rails_ai_context/install/surface.rb +6 -1
  54. data/lib/rails_ai_context/install/validation_hook.rb +282 -0
  55. data/lib/rails_ai_context/install/workspace.rb +166 -0
  56. data/lib/rails_ai_context/install_mode.rb +71 -13
  57. data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +1 -1
  58. data/lib/rails_ai_context/introspectors/action_presence.rb +11 -6
  59. data/lib/rails_ai_context/introspectors/action_source.rb +182 -0
  60. data/lib/rails_ai_context/introspectors/action_text_introspector.rb +1 -2
  61. data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +1 -3
  62. data/lib/rails_ai_context/introspectors/active_support_introspector.rb +3 -3
  63. data/lib/rails_ai_context/introspectors/admin_resources.rb +1 -1
  64. data/lib/rails_ai_context/introspectors/anyway_configs.rb +1 -1
  65. data/lib/rails_ai_context/introspectors/api_introspector.rb +3 -3
  66. data/lib/rails_ai_context/introspectors/auth_introspector.rb +10 -3
  67. data/lib/rails_ai_context/introspectors/component_introspector.rb +3 -2
  68. data/lib/rails_ai_context/introspectors/controller_introspector.rb +56 -8
  69. data/lib/rails_ai_context/introspectors/convention_introspector.rb +1 -1
  70. data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +14 -2
  71. data/lib/rails_ai_context/introspectors/devops_introspector.rb +3 -1
  72. data/lib/rails_ai_context/introspectors/eager_load.rb +201 -9
  73. data/lib/rails_ai_context/introspectors/engine_introspector.rb +1 -1
  74. data/lib/rails_ai_context/introspectors/env_config_introspector.rb +1 -1
  75. data/lib/rails_ai_context/introspectors/env_references.rb +10 -1
  76. data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +1 -1
  77. data/lib/rails_ai_context/introspectors/gem_introspector.rb +2 -1
  78. data/lib/rails_ai_context/introspectors/grape_endpoints.rb +1 -1
  79. data/lib/rails_ai_context/introspectors/habtm_join_tables.rb +1 -1
  80. data/lib/rails_ai_context/introspectors/helper_names.rb +1 -1
  81. data/lib/rails_ai_context/introspectors/i18n_introspector.rb +2 -2
  82. data/lib/rails_ai_context/introspectors/job_introspector.rb +8 -6
  83. data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +46 -1
  84. data/lib/rails_ai_context/introspectors/listeners/env_access_listener.rb +10 -1
  85. data/lib/rails_ai_context/introspectors/listeners/macros_listener.rb +3 -1
  86. data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +14 -3
  87. data/lib/rails_ai_context/introspectors/listeners/scopes_listener.rb +88 -12
  88. data/lib/rails_ai_context/introspectors/middleware_introspector.rb +2 -2
  89. data/lib/rails_ai_context/introspectors/migration_introspector.rb +27 -0
  90. data/lib/rails_ai_context/introspectors/migration_replay.rb +2 -2
  91. data/lib/rails_ai_context/introspectors/model_introspector.rb +91 -5
  92. data/lib/rails_ai_context/introspectors/n_plus_one_scan.rb +620 -0
  93. data/lib/rails_ai_context/introspectors/performance_introspector.rb +11 -174
  94. data/lib/rails_ai_context/introspectors/route_introspector.rb +10 -1
  95. data/lib/rails_ai_context/introspectors/schema_introspector.rb +57 -4
  96. data/lib/rails_ai_context/introspectors/seeds_introspector.rb +1 -1
  97. data/lib/rails_ai_context/introspectors/source_scan.rb +13 -9
  98. data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +15 -6
  99. data/lib/rails_ai_context/introspectors/turbo_introspector.rb +130 -11
  100. data/lib/rails_ai_context/introspectors/view_introspector.rb +3 -3
  101. data/lib/rails_ai_context/introspectors/view_template_introspector.rb +15 -1
  102. data/lib/rails_ai_context/legacy_cleanup.rb +17 -4
  103. data/lib/rails_ai_context/live_reload.rb +20 -20
  104. data/lib/rails_ai_context/mcp_config_generator.rb +916 -109
  105. data/lib/rails_ai_context/mcp_edge.rb +185 -32
  106. data/lib/rails_ai_context/middleware.rb +5 -1
  107. data/lib/rails_ai_context/migration_status.rb +88 -25
  108. data/lib/rails_ai_context/output_guard.rb +48 -2
  109. data/lib/rails_ai_context/package_json.rb +1 -1
  110. data/lib/rails_ai_context/path_resolver.rb +46 -13
  111. data/lib/rails_ai_context/payload.rb +55 -7
  112. data/lib/rails_ai_context/pending_migrations.rb +5 -1
  113. data/lib/rails_ai_context/portable_path.rb +3 -2
  114. data/lib/rails_ai_context/presets.rb +15 -11
  115. data/lib/rails_ai_context/redaction.rb +254 -51
  116. data/lib/rails_ai_context/resources.rb +55 -20
  117. data/lib/rails_ai_context/route_coverage.rb +8 -0
  118. data/lib/rails_ai_context/safe_file.rb +14 -1
  119. data/lib/rails_ai_context/safe_path.rb +101 -4
  120. data/lib/rails_ai_context/schema_adapter.rb +8 -0
  121. data/lib/rails_ai_context/schema_version.rb +9 -0
  122. data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +86 -32
  123. data/lib/rails_ai_context/serializers/compact_serializer_helper.rb +36 -34
  124. data/lib/rails_ai_context/serializers/context_file_serializer.rb +21 -29
  125. data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +8 -6
  126. data/lib/rails_ai_context/serializers/copilot_serializer.rb +2 -7
  127. data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +17 -15
  128. data/lib/rails_ai_context/serializers/guide_examples.rb +141 -0
  129. data/lib/rails_ai_context/serializers/markdown_serializer.rb +142 -29
  130. data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +36 -14
  131. data/lib/rails_ai_context/serializers/section_facts.rb +35 -1
  132. data/lib/rails_ai_context/serializers/section_marker_writer.rb +94 -12
  133. data/lib/rails_ai_context/serializers/stack_overview_helper.rb +24 -12
  134. data/lib/rails_ai_context/serializers/tool_guide_helper.rb +244 -129
  135. data/lib/rails_ai_context/server.rb +304 -71
  136. data/lib/rails_ai_context/tasks/rails_ai_context.rake +112 -43
  137. data/lib/rails_ai_context/tools/analyze_feature.rb +72 -18
  138. data/lib/rails_ai_context/tools/argument_schema.rb +49 -0
  139. data/lib/rails_ai_context/tools/base_tool.rb +352 -41
  140. data/lib/rails_ai_context/tools/brakeman_guard.rb +24 -0
  141. data/lib/rails_ai_context/tools/dependency_graph.rb +63 -28
  142. data/lib/rails_ai_context/tools/diagnose.rb +218 -11
  143. data/lib/rails_ai_context/tools/generate_test.rb +339 -39
  144. data/lib/rails_ai_context/tools/get_callbacks.rb +24 -13
  145. data/lib/rails_ai_context/tools/get_component_catalog.rb +1 -1
  146. data/lib/rails_ai_context/tools/get_concern.rb +91 -27
  147. data/lib/rails_ai_context/tools/get_config.rb +13 -4
  148. data/lib/rails_ai_context/tools/get_context.rb +71 -14
  149. data/lib/rails_ai_context/tools/get_controllers.rb +157 -40
  150. data/lib/rails_ai_context/tools/get_conventions.rb +26 -4
  151. data/lib/rails_ai_context/tools/get_edit_context.rb +27 -14
  152. data/lib/rails_ai_context/tools/get_env.rb +18 -9
  153. data/lib/rails_ai_context/tools/get_frontend_stack.rb +3 -1
  154. data/lib/rails_ai_context/tools/get_gems.rb +1 -1
  155. data/lib/rails_ai_context/tools/get_helper_methods.rb +9 -5
  156. data/lib/rails_ai_context/tools/get_i18n.rb +1 -1
  157. data/lib/rails_ai_context/tools/get_job_pattern.rb +2 -1
  158. data/lib/rails_ai_context/tools/get_mailers.rb +3 -3
  159. data/lib/rails_ai_context/tools/get_model_details.rb +16 -6
  160. data/lib/rails_ai_context/tools/get_partial_interface.rb +242 -23
  161. data/lib/rails_ai_context/tools/get_routes.rb +19 -6
  162. data/lib/rails_ai_context/tools/get_schema.rb +51 -18
  163. data/lib/rails_ai_context/tools/get_service_pattern.rb +24 -10
  164. data/lib/rails_ai_context/tools/get_stimulus.rb +37 -1
  165. data/lib/rails_ai_context/tools/get_test_info.rb +18 -4
  166. data/lib/rails_ai_context/tools/get_turbo_map.rb +221 -58
  167. data/lib/rails_ai_context/tools/get_view.rb +14 -14
  168. data/lib/rails_ai_context/tools/migration_advisor.rb +355 -48
  169. data/lib/rails_ai_context/tools/onboard.rb +46 -6
  170. data/lib/rails_ai_context/tools/performance_check.rb +4 -2
  171. data/lib/rails_ai_context/tools/query.rb +914 -86
  172. data/lib/rails_ai_context/tools/read_logs.rb +69 -15
  173. data/lib/rails_ai_context/tools/review_changes.rb +204 -43
  174. data/lib/rails_ai_context/tools/runtime_info.rb +51 -1
  175. data/lib/rails_ai_context/tools/safe_call.rb +35 -14
  176. data/lib/rails_ai_context/tools/search_code.rb +243 -67
  177. data/lib/rails_ai_context/tools/search_docs.rb +3 -3
  178. data/lib/rails_ai_context/tools/security_scan.rb +141 -31
  179. data/lib/rails_ai_context/tools/session_context.rb +38 -4
  180. data/lib/rails_ai_context/tools/validate.rb +259 -40
  181. data/lib/rails_ai_context/tools/validate_semantics.rb +129 -55
  182. data/lib/rails_ai_context/version.rb +1 -1
  183. data/lib/rails_ai_context/vfs.rb +67 -40
  184. data/lib/rails_ai_context/view_file.rb +41 -3
  185. data/lib/rails_ai_context/watcher.rb +51 -13
  186. data/lib/rails_ai_context.rb +21 -0
  187. data/server.json +1 -1
  188. metadata +13 -1
@@ -7,6 +7,9 @@ module RailsAiContext
7
7
  module ToolGuideHelper
8
8
  include CountPhrase
9
9
 
10
+ # A workflow step that runs a tool. A plain string is a step with none.
11
+ WorkflowStep = Struct.new(:text)
12
+
10
13
  # Returns the tool invocation example for a given tool call.
11
14
  # MCP: rails_analyze_feature(feature:"post")
12
15
  # CLI: rails 'ai:tool[analyze_feature]' feature=post
@@ -25,6 +28,36 @@ module RailsAiContext
25
28
  RailsAiContext.configuration.tool_mode
26
29
  end
27
30
 
31
+ # A tool named once in prose: its MCP call, or its command where the
32
+ # app serves no MCP. A pointer in a rules file names the one form to
33
+ # use; the workflows below show both in :mcp mode.
34
+ def tool_ref(mcp_name, mcp_params = nil, cli_params = nil)
35
+ if tool_mode == :cli
36
+ "`#{cli_cmd(RailsAiContext::CLI::ToolRunner.short_name(mcp_name), cli_params)}`"
37
+ else
38
+ "`#{mcp_name}#{"(#{mcp_params})" if mcp_params}`"
39
+ end
40
+ end
41
+
42
+ # "`rails_get_schema` MCP tool", or the command in :cli mode. A
43
+ # parameter follows the name in prose, or goes into the command.
44
+ def tool_named(mcp_name, key = nil, value = nil)
45
+ return tool_ref(mcp_name, nil, ("#{key}=#{value}" if key)) if tool_mode == :cli
46
+
47
+ key ? "`#{mcp_name}` MCP tool with #{param_text(key, value)}" : "`#{mcp_name}` MCP tool"
48
+ end
49
+
50
+ # The tools as a whole, in a sentence: in :cli mode, with how to run one.
51
+ def tools_noun
52
+ tool_mode == :cli ? "introspection tools (`#{cli_cmd("TOOL_NAME", "param=value")}`)" : "MCP tools"
53
+ end
54
+
55
+ # A parameter as the mode passes it: detail:"summary" to an MCP tool,
56
+ # detail=summary on the command line.
57
+ def param_text(key, value)
58
+ tool_mode == :cli ? "#{key}=#{value}" : "#{key}:\"#{value}\""
59
+ end
60
+
28
61
  # True when the app runs in API-only mode (no view layer) - used to swap
29
62
  # the view-editing workflow for an API-focused one in the generated guide.
30
63
  # Falls back to false (the view workflow) when the includer has no
@@ -38,9 +71,28 @@ module RailsAiContext
38
71
  RailsAiContext::Payload.architecture(ctx).include?("api_only")
39
72
  end
40
73
 
41
- # Derived from BaseTool.registered_tools - the single source of truth for tool count.
74
+ # The tools the server serves: the built-ins skip_tools leaves, then
75
+ # the custom ones. Every count and list in the guide reads this, so none
76
+ # names a tool tools/list lacks. Resolved once per generation run.
77
+ def exposed_tools
78
+ RailsAiContext::RunCache.fetch(:exposed_tools) { RailsAiContext::Server.exposed_tools }
79
+ end
80
+
42
81
  def tool_count
43
- RailsAiContext::Server.builtin_tools.size
82
+ exposed_tools.size
83
+ end
84
+
85
+ # Whether the server serves the tool. The guide names no other: a step,
86
+ # rule or pointer whose tool skip_tools removed is left out, or said
87
+ # without it, so nothing sends the AI to a tool tools/list lacks.
88
+ def served?(mcp_name)
89
+ @served_tool_names ||= exposed_tools.to_set { |tool| RailsAiContext::Server.tool_label(tool) }
90
+ @served_tool_names.include?(mcp_name)
91
+ end
92
+
93
+ # The named tools the server serves, each as tool_ref gives it.
94
+ def served_refs(*mcp_names)
95
+ mcp_names.select { |name| served?(name) }.map { |name| tool_ref(name) }
44
96
  end
45
97
 
46
98
  def tools_header
@@ -53,7 +105,7 @@ module RailsAiContext
53
105
  [
54
106
  "This project has #{count_phrase(tool_count, "introspection tool")}. **MANDATORY - use these instead of reading files.**",
55
107
  "They return ground truth from the running app: real schema, real associations, real filters - not guesses.",
56
- "Read files ONLY when you are about to Edit them.",
108
+ "Run one with `#{cli_cmd("TOOL_NAME", "param=value")}`. Read files ONLY when you are about to Edit them.",
57
109
  ""
58
110
  ]
59
111
  else
@@ -80,7 +132,7 @@ module RailsAiContext
80
132
  "",
81
133
  "1. **Verify before you write.** Never reference a column, association, route, helper, method, class, partial, or gem you have NOT verified in THIS project via a tool call in THIS turn. If it's not verified here, verify it now. Never invent names that \"sound right.\"",
82
134
  "2. **Mark every assumption.** If you must proceed without verification, prefix the relevant output with `[ASSUMPTION]` and state what you're assuming and why. Silent assumptions are forbidden. \"I'd need to check X first\" is a valid and preferred answer.",
83
- "3. **Training data describes average Rails. This app isn't average.** When something feels \"obviously\" like standard Rails, query anyway. Factories vs fixtures? Pundit vs CanCan? Devise vs has_secure_password? Check `rails_get_conventions` and `rails_get_gems` BEFORE scaffolding anything.",
135
+ "3. **Training data describes average Rails. This app isn't average.** When something feels \"obviously\" like standard Rails, query anyway. Factories vs fixtures? Pundit vs CanCan? Devise vs has_secure_password? #{stack_check_text} BEFORE scaffolding anything.",
84
136
  "4. **Check the inheritance chain before every edit.** Before writing a controller action: inherited `before_action` filters and ancestor classes. Before writing a model method: concerns, includes, STI parents. Inheritance is never flat.",
85
137
  "5. **Empty tool output is information, not permission.** \"0 callers found,\" \"no validations,\" or a missing model is a signal to investigate or confirm with the user - not a license to proceed on guesses. Follow `_Next:` hints.",
86
138
  "6. **Stale context lies. Re-query after writes.** After any edit, tool output from earlier in this turn may be wrong. Re-query the affected tool before the next write.",
@@ -88,148 +140,176 @@ module RailsAiContext
88
140
  ]
89
141
  end
90
142
 
143
+ # Rule 3's check before scaffolding: the conventions and gems tools the
144
+ # server serves, else the app's own files.
145
+ def stack_check_text
146
+ refs = served_refs("rails_get_conventions", "rails_get_gems")
147
+ refs.any? ? "Check #{refs.join(" and ")}" : "Check the Gemfile and the code that already does it"
148
+ end
149
+
91
150
  def tools_detail_guidance
92
- detail_param = tool_mode == :cli ? "detail=summary" : "detail:\"summary\""
93
- context_tool = tool_mode == :cli ? cli_cmd("context") : "rails_get_context"
94
- analyze_tool = tool_mode == :cli ? cli_cmd("analyze_feature") : "rails_analyze_feature"
95
- [
151
+ lines = [
96
152
  "### detail parameter - ALWAYS start with summary",
97
153
  "",
98
- "Individual lookup tools accept `#{detail_param}`. Use the right level:",
154
+ "Individual lookup tools accept `#{param_text("detail", "summary")}`. Use the right level:",
99
155
  "- **summary** - first call, orient yourself (table list, model names, route overview)",
100
156
  "- **standard** - working detail (columns with types, associations, action source) - DEFAULT",
101
157
  "- **full** - only when you need indexes, foreign keys, code snippets, or complete content",
102
158
  "",
103
159
  "Pattern: summary to find the target → standard to understand it → full only if needed.",
104
- "",
105
- "**Do NOT pass `detail` to composite tools** - `#{context_tool}` and `#{analyze_tool}` do not accept it and will return an error.",
106
160
  ""
107
161
  ]
162
+ composites = served_refs("rails_get_context", "rails_analyze_feature")
163
+ if composites.any?
164
+ lines << "**Do NOT pass `detail` to composite tools** - #{composites.join(" and ")} #{composites.size > 1 ? "do" : "does"} not accept it and will return an error."
165
+ lines << ""
166
+ end
167
+ lines
108
168
  end
109
169
 
110
170
  def tools_power_tool_section
111
- [
112
- "### Start here - composite tools save multiple calls",
113
- "",
114
- "**New to this project?** Get a full walkthrough first:",
115
- tool_call("rails_onboard(detail:\"standard\")", cli_cmd("onboard", "detail=standard")),
116
- "",
117
- "**`get_context` is your power tool** - bundles schema + model + controller + routes + views in ONE call:",
118
- tool_call("rails_get_context(controller:\"PostsController\", action:\"create\")", cli_cmd("context", "controller=PostsController action=create")),
119
- tool_call("rails_get_context(model:\"Post\")", cli_cmd("context", "model=Post")),
120
- tool_call("rails_get_context(feature:\"post\")", cli_cmd("context", "feature=post")),
121
- "",
122
- "**`analyze_feature` for broad discovery** - scans all layers (models, controllers, routes, services, jobs, views, tests):",
123
- tool_call("rails_analyze_feature(feature:\"authentication\")", cli_cmd("analyze_feature", "feature=authentication")),
124
- "",
125
- "Use individual tools only when you need deeper detail on a specific layer.",
126
- ""
127
- ]
171
+ ex = guide_examples
172
+ lines = []
173
+ if served?("rails_onboard")
174
+ lines.push("**New to this project?** Get a full walkthrough first:",
175
+ tool_call("rails_onboard(detail:\"standard\")", cli_cmd("onboard", "detail=standard")), "")
176
+ end
177
+ if served?("rails_get_context")
178
+ lines.push("**#{tool_ref("rails_get_context")} is your power tool** - bundles schema + model + controller + routes + views in ONE call:",
179
+ tool_call("rails_get_context(controller:\"#{ex.controller}\", action:\"#{ex.action}\")", cli_cmd("context", "controller=#{ex.controller} action=#{ex.action}")),
180
+ tool_call("rails_get_context(model:\"#{ex.model}\")", cli_cmd("context", "model=#{ex.model}")),
181
+ tool_call("rails_get_context(feature:\"#{ex.feature}\")", cli_cmd("context", "feature=#{ex.feature}")), "")
182
+ end
183
+ if served?("rails_analyze_feature")
184
+ lines.push("**#{tool_ref("rails_analyze_feature")} for broad discovery** - scans all layers (models, controllers, routes, services, jobs, views, tests):",
185
+ tool_call("rails_analyze_feature(feature:\"authentication\")", cli_cmd("analyze_feature", "feature=authentication")), "")
186
+ end
187
+ return [] if lines.empty?
188
+
189
+ composite = served?("rails_get_context") || served?("rails_analyze_feature")
190
+ lines.push("Use individual tools only when you need deeper detail on a specific layer.", "") if composite
191
+ [ composite ? "### Start here - composite tools save multiple calls" : "### Start here", "", *lines ]
192
+ end
193
+
194
+ # The step, or nil when the server does not serve its tool.
195
+ def tool_step(mcp_name, mcp_params, cli_short, cli_params, note = nil)
196
+ return nil unless served?(mcp_name)
197
+
198
+ WorkflowStep.new([ tool_call_inline(mcp_name, mcp_params, cli_short, cli_params), note ].compact.join(" - "))
199
+ end
200
+
201
+ # A titled workflow numbered over the steps left. One left with no tool
202
+ # at all says only "read the file and edit it", so it goes too.
203
+ def workflow(title, *steps)
204
+ return [] unless steps.any?(WorkflowStep)
205
+
206
+ texts = steps.compact.map { |step| step.is_a?(WorkflowStep) ? step.text : step }
207
+ [ title, *texts.each_with_index.map { |text, i| "#{i + 1}. #{text}" }, "" ]
208
+ end
209
+
210
+ # A one-call example under its title, or nothing when its tool is not served.
211
+ def tool_example(title, mcp_name, mcp_call, cli_short, cli_params)
212
+ served?(mcp_name) ? [ title, tool_call(mcp_call, cli_cmd(cli_short, cli_params)), "" ] : []
128
213
  end
129
214
 
130
215
  def tools_workflow_section
131
- [
132
- "### Step-by-step workflows (follow this order)",
133
- "",
216
+ ex = guide_examples
217
+ lines = workflow(
134
218
  "**Modify a model** (add field, change validation, add scope):",
135
- "1. #{tool_call_inline("rails_get_context", "model:\"Post\"", "context", "model=Post")} - schema + associations + validations in one call",
136
- "2. Read the model file, make your edit",
137
- "3. #{tool_call_inline("rails_migration_advisor", "action:\"add_column\", table:\"posts\", column:\"rating\", type:\"integer\"", "migration_advisor", "action=add_column table=posts column=rating type=integer")} - if schema change needed",
138
- "4. #{tool_call_inline("rails_validate", "files:[\"app/models/post.rb\"], level:\"rails\"", "validate", "files=app/models/post.rb level=rails")} - EVERY time after editing",
139
- "5. #{tool_call_inline("rails_generate_test", "model:\"Post\"", "generate_test", "model=Post")} - generate tests matching project patterns",
140
- "",
219
+ tool_step("rails_get_context", "model:\"#{ex.model}\"", "context", "model=#{ex.model}", "schema + associations + validations in one call") ||
220
+ tool_step("rails_get_model_details", "model:\"#{ex.model}\"", "model_details", "model=#{ex.model}", "associations, validations, scopes and enums"),
221
+ "Read the model file, make your edit",
222
+ tool_step("rails_migration_advisor", "action:\"add_column\", table:\"#{ex.table}\", column:\"rating\", type:\"integer\"", "migration_advisor",
223
+ "action=add_column table=#{ex.table} column=rating type=integer", "if schema change needed"),
224
+ tool_step("rails_validate", "files:[\"#{ex.model_file}\"], level:\"rails\"", "validate", "files=#{ex.model_file} level=rails", "EVERY time after editing"),
225
+ tool_step("rails_generate_test", "model:\"#{ex.model}\"", "generate_test", "model=#{ex.model}", "generate tests matching project patterns")
226
+ )
227
+ lines += workflow(
141
228
  "**Fix a controller bug:**",
142
- "1. #{tool_call_inline("rails_get_context", "controller:\"PostsController\", action:\"create\"", "context", "controller=PostsController action=create")} - action source + routes + views + model",
143
- "2. Read the controller file, make your fix",
144
- "3. #{tool_call_inline("rails_validate", "files:[\"app/controllers/posts_controller.rb\"], level:\"rails\"", "validate", "files=app/controllers/posts_controller.rb level=rails")}",
145
- ""
146
- ] + (api_only? ? api_endpoint_workflow_lines : view_workflow_lines) + [
147
- "**Trace a method:**",
148
- tool_call("rails_search_code(pattern:\"publishable?\", match_type:\"trace\")", cli_cmd("search_code", "pattern=\"publishable?\" match_type=trace")),
149
- "",
150
- "**Debug an error (one call - gathers context + git + logs + fix):**",
151
- tool_call("rails_diagnose(error:\"NoMethodError: undefined method `foo` for nil\", file:\"app/models/post.rb\")", cli_cmd("diagnose", "error=\"NoMethodError: undefined method foo\" file=app/models/post.rb")),
152
- "",
153
- "**Review changes before merging:**",
154
- tool_call("rails_review_changes(ref:\"main\")", cli_cmd("review_changes", "ref=main")),
155
- "",
156
- "**Generate tests matching project patterns:**",
157
- tool_call("rails_generate_test(model:\"Post\")", cli_cmd("generate_test", "model=Post")),
158
- ""
159
- ]
229
+ tool_step("rails_get_context", "controller:\"#{ex.controller}\", action:\"#{ex.action}\"", "context",
230
+ "controller=#{ex.controller} action=#{ex.action}", "action source + routes + views + model") ||
231
+ tool_step("rails_get_controllers", "controller:\"#{ex.controller}\", action:\"#{ex.action}\"", "controllers",
232
+ "controller=#{ex.controller} action=#{ex.action}", "action source + inherited filters + render map"),
233
+ "Read the controller file, make your fix",
234
+ tool_step("rails_validate", "files:[\"#{ex.controller_file}\"], level:\"rails\"", "validate", "files=#{ex.controller_file} level=rails")
235
+ )
236
+ lines += api_only? ? api_endpoint_workflow_lines : view_workflow_lines
237
+ lines += tool_example("**Trace a method:**", "rails_search_code",
238
+ "rails_search_code(pattern:\"#{ex.method_name}\", match_type:\"trace\")", "search_code", "pattern=\"#{ex.method_name}\" match_type=trace")
239
+ # Single quotes, as Ruby 3.4 prints the name: a backtick would close the code span.
240
+ lines += tool_example("**Debug an error (one call - gathers context + git + logs + fix):**", "rails_diagnose",
241
+ "rails_diagnose(error:\"NoMethodError: undefined method 'foo' for nil\", file:\"#{ex.model_file}\")",
242
+ "diagnose", "error=\"NoMethodError: undefined method 'foo' for nil\" file=#{ex.model_file}")
243
+ lines += tool_example("**Review changes before merging:**", "rails_review_changes", "rails_review_changes(ref:\"main\")", "review_changes", "ref=main")
244
+ lines += tool_example("**Generate tests matching project patterns:**", "rails_generate_test",
245
+ "rails_generate_test(model:\"#{ex.model}\")", "generate_test", "model=#{ex.model}")
246
+ return [] if lines.empty?
247
+
248
+ [ "### Step-by-step workflows (follow this order)", "", *lines ]
160
249
  end
161
250
 
162
251
  # HTML/Hotwire apps get the view-editing workflow.
163
252
  def view_workflow_lines
164
- [
253
+ ex = guide_examples
254
+ workflow(
165
255
  "**Build or modify a view:**",
166
- "1. #{tool_call_inline("rails_get_view", "controller:\"posts\"", "view", "controller=posts")} - existing templates, partials, Stimulus refs",
167
- "2. #{tool_call_inline("rails_get_partial_interface", "partial:\"shared/status_badge\"", "partial_interface", "partial=shared/status_badge")} - partial locals contract",
168
- "3. #{tool_call_inline("rails_get_component_catalog", "component:\"Button\"", "component_catalog", "component=Button")} - ViewComponent/Phlex props, slots, previews",
169
- "4. Read the view file, make your edit",
170
- "5. #{tool_call_inline("rails_validate", "files:[\"app/views/posts/index.html.erb\"]", "validate", "files=app/views/posts/index.html.erb")}",
171
- ""
172
- ]
256
+ tool_step("rails_get_view", "controller:\"#{ex.view_controller}\"", "view", "controller=#{ex.view_controller}", "existing templates, partials, Stimulus refs"),
257
+ tool_step("rails_get_partial_interface", "partial:\"#{ex.partial}\"", "partial_interface", "partial=#{ex.partial}", "partial locals contract"),
258
+ tool_step("rails_get_component_catalog", "component:\"#{ex.component}\"", "component_catalog", "component=#{ex.component}", "ViewComponent/Phlex props, slots, previews"),
259
+ "Read the view file, make your edit",
260
+ tool_step("rails_validate", "files:[\"#{ex.view_file}\"]", "validate", "files=#{ex.view_file}")
261
+ )
173
262
  end
174
263
 
175
264
  # API-only apps have no view layer - swap in a workflow for modifying
176
265
  # a JSON/XML response instead.
177
266
  def api_endpoint_workflow_lines
178
- [
267
+ ex = guide_examples
268
+ workflow(
179
269
  "**Modify a JSON endpoint** (add/change a serialized field, adjust status codes):",
180
- "1. #{tool_call_inline("rails_get_controllers", "controller:\"PostsController\", action:\"create\"", "controllers", "controller=PostsController action=create")} - action source + strong params + render map",
181
- "2. #{tool_call_inline("rails_get_model_details", "model:\"Post\"", "model_details", "model=Post")} - schema + associations + validations backing the response",
182
- "3. Read the controller file, make your edit",
183
- "4. #{tool_call_inline("rails_validate", "files:[\"app/controllers/posts_controller.rb\"], level:\"rails\"", "validate", "files=app/controllers/posts_controller.rb level=rails")}",
184
- ""
185
- ]
270
+ tool_step("rails_get_controllers", "controller:\"#{ex.controller}\", action:\"#{ex.action}\"", "controllers",
271
+ "controller=#{ex.controller} action=#{ex.action}", "action source + strong params + render map"),
272
+ tool_step("rails_get_model_details", "model:\"#{ex.model}\"", "model_details", "model=#{ex.model}", "schema + associations + validations backing the response"),
273
+ "Read the controller file, make your edit",
274
+ tool_step("rails_validate", "files:[\"#{ex.controller_file}\"], level:\"rails\"", "validate", "files=#{ex.controller_file} level=rails")
275
+ )
186
276
  end
187
277
 
188
278
  def tools_antipatterns_section
189
- search_tool = tool_mode == :cli ? cli_cmd("search_code") : "rails_search_code"
190
- validate_tool = tool_mode == :cli ? cli_cmd("validate") : "rails_validate"
191
- [
192
- "### Common mistakes - avoid these",
193
- "",
194
- "- **Don't read #{schema_dump_path}** - use `get_schema`. It adds [indexed]/[unique] hints you'd miss.",
195
- "- **Don't read model files for reference** - use `get_model_details`. It resolves concerns, inherited methods, and implicit belongs_to validations.",
196
- "- **Prefer `#{search_tool}` over Grep** for method tracing and cross-layer search. It excludes sensitive files, supports `match_type:\"trace\"`, and paginates.",
197
- "- **Don't call tools without a target** - `get_model_details()` without `model:` returns a paginated list, not an error. Always specify what you want.",
198
- "- **Don't skip validation** - run `#{validate_tool}` after EVERY edit. It catches syntax errors AND Rails-specific issues (missing partials, bad column refs).",
199
- "- **Don't ignore cross-references** - tool responses include `_Next:` hints suggesting the best follow-up call. Follow them.",
200
- "- **Don't call `detail:\"full\"` first** - start with `summary` to find your target, then drill in. Full responses bury the signal.",
201
- ""
202
- ]
279
+ lines = [ "### Common mistakes - avoid these", "" ]
280
+ lines << "- **Don't read #{schema_dump_path}** - use #{tool_ref("rails_get_schema")}. It adds [indexed]/[unique] hints you'd miss." if served?("rails_get_schema")
281
+ if served?("rails_get_model_details")
282
+ lines << "- **Don't read model files for reference** - use #{tool_ref("rails_get_model_details")}. It resolves concerns, inherited methods, and implicit belongs_to validations."
283
+ end
284
+ if served?("rails_search_code")
285
+ lines << "- **Prefer #{tool_ref("rails_search_code")} over Grep** for method tracing and cross-layer search. It excludes sensitive files, supports `#{param_text("match_type", "trace")}`, and paginates."
286
+ end
287
+ lines << if served?("rails_get_model_details")
288
+ "- **Don't call tools without a target** - #{tool_ref("rails_get_model_details", "")} without `#{tool_mode == :cli ? "model=" : "model:"}` returns a paginated list, not an error. Always specify what you want."
289
+ else
290
+ "- **Don't call tools without a target** - a lookup without one returns a paginated list, not an error. Always specify what you want."
291
+ end
292
+ if served?("rails_validate")
293
+ lines << "- **Don't skip validation** - run #{tool_ref("rails_validate")} after EVERY edit. It catches syntax errors AND Rails-specific issues (missing partials, bad column refs)."
294
+ end
295
+ lines << "- **Don't ignore cross-references** - tool responses include `_Next:` hints suggesting the best follow-up call. Follow them."
296
+ lines << "- **Don't call `#{param_text("detail", "full")}` first** - start with `summary` to find your target, then drill in. Full responses bury the signal."
297
+ lines << ""
203
298
  end
204
299
 
205
300
  def tools_rules_section
206
- case tool_mode
207
- when :cli
208
- [
209
- "### Rules",
210
- "",
211
- "1. **Use composite tools first** - `#{cli_cmd("context")}` and `#{cli_cmd("analyze_feature")}` before individual tools",
212
- "2. **NEVER read reference files** - #{schema_dump_path}, config/routes.rb, model files, test files - tools are better",
213
- "3. **Prefer `#{cli_cmd("search_code")}`** for tracing and cross-layer search - standard search tools are fine for simple targeted lookups",
214
- "4. **Read files ONLY to Edit them** - not for reference",
215
- "5. **Validate EVERY edit** - `#{cli_cmd("validate", "files=... level=rails")}`",
216
- "6. **Follow _Next:_ hints** - tool responses suggest the best follow-up call",
217
- ""
218
- ]
219
- else
220
- [
221
- "### Rules",
222
- "",
223
- "1. **Use composite tools first** - `rails_get_context` and `rails_analyze_feature` before individual tools",
224
- "2. **NEVER read reference files** - #{schema_dump_path}, config/routes.rb, model files, test files - tools are better",
225
- "3. **Prefer `rails_search_code`** for tracing and cross-layer search - standard search tools are fine for simple targeted lookups",
226
- "4. **Read files ONLY to Edit them** - not for reference",
227
- "5. **Validate EVERY edit** - `rails_validate(files:[...], level:\"rails\")`",
228
- "6. **Follow _Next:_ hints** - tool responses suggest the best follow-up call",
229
- "7. If MCP tools are not connected, use CLI: `#{cli_cmd("TOOL_NAME", "param=value")}`",
230
- ""
231
- ]
301
+ rules = []
302
+ composites = served_refs("rails_get_context", "rails_analyze_feature")
303
+ rules << "**Use composite tools first** - #{composites.join(" and ")} before individual tools" if composites.any?
304
+ rules << "**NEVER read reference files** - #{schema_dump_path}, config/routes.rb, model files, test files - tools are better"
305
+ if served?("rails_search_code")
306
+ rules << "**Prefer #{tool_ref("rails_search_code")}** for tracing and cross-layer search - standard search tools are fine for simple targeted lookups"
232
307
  end
308
+ rules << "**Read files ONLY to Edit them** - not for reference"
309
+ rules << "**Validate EVERY edit** - #{tool_ref("rails_validate", 'files:[...], level:"rails"', "files=... level=rails")}" if served?("rails_validate")
310
+ rules << "**Follow _Next:_ hints** - tool responses suggest the best follow-up call"
311
+ rules << "If MCP tools are not connected, use CLI: `#{cli_cmd("TOOL_NAME", "param=value")}`" unless tool_mode == :cli
312
+ [ "### Rules", "", *rules.each_with_index.map { |rule, i| "#{i + 1}. #{rule}" }, "" ]
233
313
  end
234
314
 
235
315
  def tools_table
@@ -238,12 +318,16 @@ module RailsAiContext
238
318
  lines
239
319
  end
240
320
 
241
- # Every registered tool declares its own row, so the guide lists what
242
- # the registry holds and nothing else.
321
+ # One row per tool the server serves, in the guide's order: a built-in
322
+ # declares its own, and a custom tool, which declares none, follows
323
+ # with the first sentence of its description.
243
324
  def tool_rows
244
- RailsAiContext::Tools::BaseTool.registered_tools
245
- .filter_map { |tool| [ tool, tool.guide_row ] if tool.guide_row }
246
- .sort_by { |_tool, row| row.order }
325
+ builtin = exposed_tools.filter_map do |tool|
326
+ row = tool.guide_row if tool.respond_to?(:guide_row)
327
+ [ tool, row ] if row
328
+ end
329
+ custom = (exposed_tools - builtin.map(&:first)).map { |tool| [ tool, custom_tool_row(tool) ] }
330
+ builtin.sort_by { |_tool, row| row.order } + custom
247
331
  end
248
332
 
249
333
  def build_tools_table(include_mcp:)
@@ -276,6 +360,21 @@ module RailsAiContext
276
360
  lines
277
361
  end
278
362
 
363
+ # What a tools rule file adds to a root file that carries the guide: the
364
+ # detail levels and the table. Claude Code loads CLAUDE.md and its rules
365
+ # on every request, as Copilot does its instructions, so a whole guide in
366
+ # both put the guide and the protocol in front of the AI twice.
367
+ def render_tools_reference(root_file)
368
+ [
369
+ "## Tools (#{tool_count}) - Reference",
370
+ "",
371
+ "How to use them - the protocol, the workflows and the rules - is in #{root_file}. This file is the reference.",
372
+ "",
373
+ *tools_detail_guidance,
374
+ *tools_table
375
+ ]
376
+ end
377
+
279
378
  # Compact tool guide for root files (CLAUDE.md, AGENTS.md) that have line limits.
280
379
  # Includes power tools + workflows + rules + dense tool name list (no table).
281
380
  def render_tools_guide_compact
@@ -292,18 +391,35 @@ module RailsAiContext
292
391
  lines
293
392
  end
294
393
 
295
- # Dense one-line-per-tool listing, from the same registry the table uses.
394
+ # Dense one-line listing, from the same rows the table uses: the MCP
395
+ # names, or in :cli mode the names the command takes.
296
396
  def tools_name_list
297
- all_tools = tool_rows.map { |tool, _row| tool.tool_name }
298
- [
299
- "### All #{count_phrase(all_tools.size, "tool")}",
300
- "`#{all_tools.join('` `')}`",
301
- ""
302
- ]
397
+ cli = tool_mode == :cli
398
+ names = tool_rows.map do |tool, _row|
399
+ cli ? RailsAiContext::CLI::ToolRunner.short_name(tool.tool_name) : tool.tool_name
400
+ end
401
+ lines = [ "### All #{count_phrase(names.size, "tool")}" ]
402
+ lines << "Run one as `#{cli_cmd("NAME", "param=value")}`, NAME being one of:" if cli
403
+ lines << "`#{names.join('` `')}`"
404
+ lines << ""
303
405
  end
304
406
 
305
407
  private
306
408
 
409
+ # The model, controller and files the examples name, the app's own
410
+ # where it has them.
411
+ def guide_examples
412
+ @guide_examples ||= GuideExamples.new(respond_to?(:context) ? context : {})
413
+ end
414
+
415
+ # A row for a tool that declares none: its name and the first sentence
416
+ # of its description, which is all a custom MCP::Tool is sure to have.
417
+ def custom_tool_row(tool)
418
+ description = tool.respond_to?(:description_value) ? tool.description_value.to_s : ""
419
+ summary = description.strip.split(/(?<=\.)\s/).first.to_s.gsub(/\s+/, " ").gsub("|", "\\|")
420
+ RailsAiContext::Tools::BaseTool::GuideRow.new(mcp: tool.tool_name, summary: summary.empty? ? "Custom tool" : summary)
421
+ end
422
+
307
423
  # Apps with `config.active_record.schema_format = :sql` dump the schema
308
424
  # to db/structure.sql instead of db/schema.rb; guidance that names the
309
425
  # wrong file sends the AI hunting for a file that does not exist.
@@ -330,23 +446,22 @@ module RailsAiContext
330
446
  # Generate zsh-safe CLI command. In-Gemfile installs go through the rake
331
447
  # task (`rails 'ai:tool[name]'`); standalone installs (gem not in the
332
448
  # host app's Gemfile) have no rake tasks at all, so they use the CLI
333
- # binary directly (`rails-ai-context tool name`).
449
+ # binary directly (`rails-ai-context tool name`), and an engine's root,
450
+ # whose tasks run in its dummy app, uses the binary in its bundle.
334
451
  def cli_cmd(tool_name, params = nil)
335
- cmd = standalone_install? ? "rails-ai-context tool #{tool_name}" : "rails 'ai:tool[#{tool_name}]'"
452
+ cmd = RailsAiContext::InstallMode.tool_command(tool_name, form: install_form)
336
453
  cmd += " #{params}" if params
337
454
  cmd
338
455
  end
339
456
 
340
457
  def serve_cmd
341
- RailsAiContext::InstallMode.command(:serve, standalone: standalone_install?)
458
+ RailsAiContext::InstallMode.command(:serve, form: install_form)
342
459
  end
343
460
 
344
461
  # Delegates to InstallMode (shared with the CLI surfaces), memoized per
345
462
  # serializer instance because cli_cmd runs once per documented tool.
346
- def standalone_install?
347
- return @standalone_install if defined?(@standalone_install)
348
-
349
- @standalone_install = RailsAiContext::InstallMode.standalone?
463
+ def install_form
464
+ @install_form ||= RailsAiContext::InstallMode.form
350
465
  end
351
466
 
352
467
  # Inline tool call for workflow steps (shorter format).