rails-ai-context 5.32.2 → 5.34.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 (203) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1491 -0
  3. data/CONTEXT.md +27 -3
  4. data/README.md +16 -12
  5. data/Rakefile +6 -0
  6. data/app/controllers/rails_ai_context/mcp_controller.rb +112 -84
  7. data/config/routes.rb +2 -3
  8. data/docs/ARCHITECTURE.md +7 -5
  9. data/docs/CLI.md +79 -6
  10. data/docs/COMPATIBILITY.md +75 -1
  11. data/docs/CONFIGURATION.md +30 -11
  12. data/docs/CUSTOM_TOOLS.md +2 -1
  13. data/docs/FAQ.md +16 -6
  14. data/docs/GUIDE.md +117 -71
  15. data/docs/INTROSPECTORS.md +10 -8
  16. data/docs/QUICKSTART.md +3 -3
  17. data/docs/RECIPES.md +1 -1
  18. data/docs/SECURITY.md +109 -25
  19. data/docs/SETUP.md +30 -6
  20. data/docs/STANDALONE.md +10 -3
  21. data/docs/TOOLS.md +152 -34
  22. data/docs/TROUBLESHOOTING.md +90 -12
  23. data/docs/adr/0003-shared-tool-cache-semantics.md +8 -0
  24. data/docs/adr/0005-workspace-mcp-entries.md +39 -0
  25. data/exe/rails-ai-context +733 -104
  26. data/lib/generators/rails_ai_context/install/install_generator.rb +341 -125
  27. data/lib/rails_ai_context/app_kind.rb +18 -5
  28. data/lib/rails_ai_context/ast_cache.rb +7 -0
  29. data/lib/rails_ai_context/base_mixins.rb +8 -8
  30. data/lib/rails_ai_context/boot_manager.rb +22 -3
  31. data/lib/rails_ai_context/change_watch.rb +14 -11
  32. data/lib/rails_ai_context/cli/app_root.rb +401 -0
  33. data/lib/rails_ai_context/cli/entry_boot.rb +58 -22
  34. data/lib/rails_ai_context/cli/tool_runner.rb +47 -21
  35. data/lib/rails_ai_context/code_reloader.rb +115 -1
  36. data/lib/rails_ai_context/concern_membership.rb +3 -1
  37. data/lib/rails_ai_context/concern_paths.rb +8 -3
  38. data/lib/rails_ai_context/configuration.rb +41 -9
  39. data/lib/rails_ai_context/context_file_report.rb +19 -2
  40. data/lib/rails_ai_context/database_file.rb +26 -0
  41. data/lib/rails_ai_context/doctor.rb +1122 -184
  42. data/lib/rails_ai_context/engine.rb +18 -1
  43. data/lib/rails_ai_context/erb_source.rb +19 -0
  44. data/lib/rails_ai_context/executable.rb +21 -0
  45. data/lib/rails_ai_context/facts_formatter.rb +5 -2
  46. data/lib/rails_ai_context/file_walk.rb +61 -10
  47. data/lib/rails_ai_context/fingerprinter/snapshot.rb +234 -0
  48. data/lib/rails_ai_context/fingerprinter.rb +40 -12
  49. data/lib/rails_ai_context/gem_lock.rb +186 -27
  50. data/lib/rails_ai_context/git_ignore.rb +22 -0
  51. data/lib/rails_ai_context/install/ai_tool.rb +31 -5
  52. data/lib/rails_ai_context/install/cleanup.rb +21 -0
  53. data/lib/rails_ai_context/install/program.rb +209 -35
  54. data/lib/rails_ai_context/install/selection_record.rb +258 -97
  55. data/lib/rails_ai_context/install/surface.rb +6 -1
  56. data/lib/rails_ai_context/install/validation_hook.rb +282 -0
  57. data/lib/rails_ai_context/install/workspace.rb +166 -0
  58. data/lib/rails_ai_context/install_mode.rb +71 -13
  59. data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +1 -1
  60. data/lib/rails_ai_context/introspectors/action_presence.rb +11 -6
  61. data/lib/rails_ai_context/introspectors/action_source.rb +182 -0
  62. data/lib/rails_ai_context/introspectors/action_text_introspector.rb +1 -2
  63. data/lib/rails_ai_context/introspectors/active_record_settings.rb +1 -1
  64. data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +1 -3
  65. data/lib/rails_ai_context/introspectors/active_support_introspector.rb +3 -3
  66. data/lib/rails_ai_context/introspectors/admin_resources.rb +1 -1
  67. data/lib/rails_ai_context/introspectors/anyway_configs.rb +1 -1
  68. data/lib/rails_ai_context/introspectors/api_introspector.rb +3 -3
  69. data/lib/rails_ai_context/introspectors/auth_introspector.rb +15 -10
  70. data/lib/rails_ai_context/introspectors/call_site_expansion.rb +24 -1
  71. data/lib/rails_ai_context/introspectors/component_introspector.rb +3 -2
  72. data/lib/rails_ai_context/introspectors/controller_introspector.rb +387 -60
  73. data/lib/rails_ai_context/introspectors/convention_introspector.rb +1 -1
  74. data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +15 -3
  75. data/lib/rails_ai_context/introspectors/devops_introspector.rb +3 -1
  76. data/lib/rails_ai_context/introspectors/eager_load.rb +201 -9
  77. data/lib/rails_ai_context/introspectors/engine_introspector.rb +1 -1
  78. data/lib/rails_ai_context/introspectors/env_config_introspector.rb +1 -1
  79. data/lib/rails_ai_context/introspectors/env_references.rb +10 -1
  80. data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +1 -1
  81. data/lib/rails_ai_context/introspectors/gem_introspector.rb +23 -10
  82. data/lib/rails_ai_context/introspectors/grape_endpoints.rb +1 -1
  83. data/lib/rails_ai_context/introspectors/habtm_join_tables.rb +1 -1
  84. data/lib/rails_ai_context/introspectors/helper_names.rb +1 -1
  85. data/lib/rails_ai_context/introspectors/i18n_introspector.rb +3 -3
  86. data/lib/rails_ai_context/introspectors/job_introspector.rb +8 -6
  87. data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +12 -8
  88. data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +78 -4
  89. data/lib/rails_ai_context/introspectors/listeners/config_assignment_listener.rb +1 -0
  90. data/lib/rails_ai_context/introspectors/listeners/env_access_listener.rb +10 -1
  91. data/lib/rails_ai_context/introspectors/listeners/foreign_evals_listener.rb +61 -0
  92. data/lib/rails_ai_context/introspectors/listeners/macros_listener.rb +13 -4
  93. data/lib/rails_ai_context/introspectors/listeners/migration_dsl_listener.rb +2 -1
  94. data/lib/rails_ai_context/introspectors/listeners/migration_replay_listener.rb +3 -2
  95. data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +29 -8
  96. data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +2 -2
  97. data/lib/rails_ai_context/introspectors/listeners/scopes_listener.rb +88 -12
  98. data/lib/rails_ai_context/introspectors/middleware_introspector.rb +2 -2
  99. data/lib/rails_ai_context/introspectors/migration_introspector.rb +27 -0
  100. data/lib/rails_ai_context/introspectors/migration_replay/helpers.rb +1 -1
  101. data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +83 -27
  102. data/lib/rails_ai_context/introspectors/migration_replay.rb +15 -14
  103. data/lib/rails_ai_context/introspectors/model_introspector.rb +231 -23
  104. data/lib/rails_ai_context/introspectors/n_plus_one_scan.rb +620 -0
  105. data/lib/rails_ai_context/introspectors/performance_introspector.rb +11 -174
  106. data/lib/rails_ai_context/introspectors/route_introspector.rb +10 -1
  107. data/lib/rails_ai_context/introspectors/schema_conventions.rb +23 -3
  108. data/lib/rails_ai_context/introspectors/schema_introspector.rb +66 -16
  109. data/lib/rails_ai_context/introspectors/schema_reader.rb +4 -3
  110. data/lib/rails_ai_context/introspectors/seeds_introspector.rb +1 -1
  111. data/lib/rails_ai_context/introspectors/source_scan.rb +39 -9
  112. data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +15 -6
  113. data/lib/rails_ai_context/introspectors/test_introspector.rb +1 -1
  114. data/lib/rails_ai_context/introspectors/turbo_introspector.rb +131 -12
  115. data/lib/rails_ai_context/introspectors/view_introspector.rb +4 -4
  116. data/lib/rails_ai_context/introspectors/view_template_introspector.rb +15 -1
  117. data/lib/rails_ai_context/legacy_cleanup.rb +17 -4
  118. data/lib/rails_ai_context/live_reload.rb +20 -20
  119. data/lib/rails_ai_context/mcp_config_generator.rb +916 -109
  120. data/lib/rails_ai_context/mcp_edge.rb +182 -32
  121. data/lib/rails_ai_context/middleware.rb +5 -1
  122. data/lib/rails_ai_context/migration_status.rb +88 -25
  123. data/lib/rails_ai_context/output_guard.rb +48 -2
  124. data/lib/rails_ai_context/package_json.rb +1 -1
  125. data/lib/rails_ai_context/path_resolver.rb +46 -13
  126. data/lib/rails_ai_context/payload.rb +92 -8
  127. data/lib/rails_ai_context/pending_migrations.rb +5 -1
  128. data/lib/rails_ai_context/portable_path.rb +3 -2
  129. data/lib/rails_ai_context/presets.rb +15 -11
  130. data/lib/rails_ai_context/redaction.rb +262 -51
  131. data/lib/rails_ai_context/resources.rb +55 -20
  132. data/lib/rails_ai_context/route_coverage.rb +8 -0
  133. data/lib/rails_ai_context/safe_file.rb +14 -1
  134. data/lib/rails_ai_context/safe_path.rb +101 -4
  135. data/lib/rails_ai_context/schema_adapter.rb +8 -0
  136. data/lib/rails_ai_context/schema_version.rb +9 -0
  137. data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +88 -33
  138. data/lib/rails_ai_context/serializers/compact_serializer_helper.rb +36 -34
  139. data/lib/rails_ai_context/serializers/context_file_serializer.rb +21 -29
  140. data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +8 -6
  141. data/lib/rails_ai_context/serializers/copilot_serializer.rb +2 -7
  142. data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +19 -16
  143. data/lib/rails_ai_context/serializers/guide_examples.rb +142 -0
  144. data/lib/rails_ai_context/serializers/markdown_serializer.rb +146 -30
  145. data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +38 -15
  146. data/lib/rails_ai_context/serializers/section_facts.rb +44 -1
  147. data/lib/rails_ai_context/serializers/section_marker_writer.rb +94 -12
  148. data/lib/rails_ai_context/serializers/stack_overview_helper.rb +24 -12
  149. data/lib/rails_ai_context/serializers/tool_guide_helper.rb +244 -129
  150. data/lib/rails_ai_context/server.rb +304 -71
  151. data/lib/rails_ai_context/tasks/rails_ai_context.rake +447 -372
  152. data/lib/rails_ai_context/tools/analyze_feature.rb +72 -18
  153. data/lib/rails_ai_context/tools/argument_schema.rb +49 -0
  154. data/lib/rails_ai_context/tools/base_tool.rb +367 -46
  155. data/lib/rails_ai_context/tools/brakeman_guard.rb +24 -0
  156. data/lib/rails_ai_context/tools/dependency_graph.rb +63 -28
  157. data/lib/rails_ai_context/tools/diagnose.rb +218 -11
  158. data/lib/rails_ai_context/tools/generate_test.rb +339 -39
  159. data/lib/rails_ai_context/tools/get_callbacks.rb +24 -13
  160. data/lib/rails_ai_context/tools/get_component_catalog.rb +1 -1
  161. data/lib/rails_ai_context/tools/get_concern.rb +91 -27
  162. data/lib/rails_ai_context/tools/get_config.rb +13 -4
  163. data/lib/rails_ai_context/tools/get_context.rb +71 -14
  164. data/lib/rails_ai_context/tools/get_controllers.rb +179 -44
  165. data/lib/rails_ai_context/tools/get_conventions.rb +28 -6
  166. data/lib/rails_ai_context/tools/get_edit_context.rb +27 -14
  167. data/lib/rails_ai_context/tools/get_env.rb +18 -9
  168. data/lib/rails_ai_context/tools/get_frontend_stack.rb +3 -1
  169. data/lib/rails_ai_context/tools/get_gems.rb +1 -1
  170. data/lib/rails_ai_context/tools/get_helper_methods.rb +10 -6
  171. data/lib/rails_ai_context/tools/get_i18n.rb +1 -1
  172. data/lib/rails_ai_context/tools/get_job_pattern.rb +3 -2
  173. data/lib/rails_ai_context/tools/get_mailers.rb +3 -3
  174. data/lib/rails_ai_context/tools/get_model_details.rb +30 -10
  175. data/lib/rails_ai_context/tools/get_partial_interface.rb +242 -23
  176. data/lib/rails_ai_context/tools/get_routes.rb +29 -8
  177. data/lib/rails_ai_context/tools/get_schema.rb +52 -19
  178. data/lib/rails_ai_context/tools/get_service_pattern.rb +24 -10
  179. data/lib/rails_ai_context/tools/get_stimulus.rb +37 -1
  180. data/lib/rails_ai_context/tools/get_test_info.rb +18 -4
  181. data/lib/rails_ai_context/tools/get_turbo_map.rb +221 -58
  182. data/lib/rails_ai_context/tools/get_view.rb +14 -14
  183. data/lib/rails_ai_context/tools/migration_advisor.rb +355 -48
  184. data/lib/rails_ai_context/tools/onboard.rb +52 -8
  185. data/lib/rails_ai_context/tools/performance_check.rb +5 -3
  186. data/lib/rails_ai_context/tools/query.rb +914 -86
  187. data/lib/rails_ai_context/tools/read_logs.rb +69 -15
  188. data/lib/rails_ai_context/tools/review_changes.rb +215 -53
  189. data/lib/rails_ai_context/tools/runtime_info.rb +51 -1
  190. data/lib/rails_ai_context/tools/safe_call.rb +35 -14
  191. data/lib/rails_ai_context/tools/search_code.rb +243 -67
  192. data/lib/rails_ai_context/tools/search_docs.rb +4 -4
  193. data/lib/rails_ai_context/tools/security_scan.rb +143 -33
  194. data/lib/rails_ai_context/tools/session_context.rb +39 -5
  195. data/lib/rails_ai_context/tools/validate.rb +259 -40
  196. data/lib/rails_ai_context/tools/validate_semantics.rb +130 -56
  197. data/lib/rails_ai_context/version.rb +1 -1
  198. data/lib/rails_ai_context/vfs.rb +67 -40
  199. data/lib/rails_ai_context/view_file.rb +41 -3
  200. data/lib/rails_ai_context/watcher.rb +51 -13
  201. data/lib/rails_ai_context.rb +21 -0
  202. data/server.json +1 -1
  203. metadata +14 -1
data/exe/rails-ai-context CHANGED
@@ -45,10 +45,58 @@ PREBOOT_GEM_SPECS = preboot_gem_specs.freeze
45
45
  # entry puts those back, so re-registering their specs would tell it not to.
46
46
  PREBOOT_FRAMEWORK_NAMES = preboot_framework_names.freeze
47
47
 
48
+ # A C or POSIX locale names no encoding, so Ruby tags every path it reads -
49
+ # the working directory, $HOME, a glob - binary or US-ASCII, and the first
50
+ # one outside ASCII can be neither compared with nor joined to the gem's own
51
+ # UTF-8 strings. Rails makes UTF-8 the default when it boots; this binary
52
+ # reads paths and files before any boot, so it does the same first, for
53
+ # them and for the arguments it was given - when they are UTF-8. A folder
54
+ # or an argument in another encoding (Latin-1 bytes on disk) keeps the
55
+ # locale's tags, under which it is read byte by byte as before.
56
+ if Encoding.default_external == Encoding::US_ASCII
57
+ utf8 = ->(text) { text.dup.force_encoding(Encoding::UTF_8).valid_encoding? }
58
+ if (utf8.call(Dir.pwd) rescue false) && ARGV.all?(&utf8)
59
+ verbose, $VERBOSE = $VERBOSE, nil
60
+ Encoding.default_external = Encoding::UTF_8
61
+ $VERBOSE = verbose
62
+ ARGV.map! { |arg| arg.dup.force_encoding(Encoding::UTF_8) }
63
+ end
64
+ end
65
+
66
+ # This file, wherever the gem is installed: what a child re-entering this very
67
+ # copy runs when no installed spec names it.
68
+ EXE_PATH = File.expand_path(__FILE__).freeze
69
+
70
+ # The command as typed, for the run a handoff to the app's bundled copy starts.
71
+ ORIGINAL_ARGV = ARGV.dup.freeze
72
+
73
+ # The environment this binary was started in, before an app's boot sets up
74
+ # the app's bundle in it.
75
+ STARTING_ENV = ENV.to_h.freeze
76
+
77
+ # How the person who ran init in a folder of apps names the app a child
78
+ # `context` of it runs in. The child runs at the app's root, and the files
79
+ # it reports - the rm line for legacy files among them - are named from
80
+ # where init was run, so a line pasted there does what it says. A variable
81
+ # rather than a flag: an app whose bundle pins an older gem ignores it.
82
+ PLACE_ENV = "RAILS_AI_CONTEXT_PLACE"
83
+
84
+ # Where a command handed to the app's bundle was typed. The handoff starts
85
+ # that copy at the app's root when it walked there from a folder inside the
86
+ # app or above it, and doctor writes each fix's command to run where it was
87
+ # typed. A variable for the same reason as PLACE_ENV.
88
+ TYPED_IN_ENV = "RAILS_AI_CONTEXT_TYPED_IN"
89
+
90
+ # The first release whose `context` takes --no-mcp-refresh. An older copy
91
+ # refuses a switch it does not know, and it writes no MCP config in
92
+ # `context` either.
93
+ MCP_REFRESH_FLAG_SINCE = "5.33.0"
94
+
48
95
  # After one of these tools' names `--environment` is its filter, never RAILS_ENV: this is
49
96
  # decided before boot, when no tool schema can load. cli_smoke_spec holds it to the schemas.
50
97
  ENVIRONMENT_PARAM_TOOLS = %w[env_config].freeze
51
98
 
99
+ require "shellwords"
52
100
  require "thor"
53
101
 
54
102
  # Top-level class to avoid conflict with RailsAiContext::CLI module (which contains ToolRunner).
@@ -66,18 +114,69 @@ class RailsAiContextCLI < Thor
66
114
  true
67
115
  end
68
116
 
117
+ # The words Thor is handed: switches behind the command name, and a
118
+ # command's --help turned into `help <command>`.
119
+ def self.normalize_argv(argv)
120
+ help_request(reorder_switches(argv))
121
+ end
122
+
123
+ # Every switch this binary declares, global or per-command, and its type.
124
+ def self.switch_types
125
+ declared = class_options.values + commands.each_value.flat_map { |c| c.options.values }
126
+ declared.each_with_object({}) do |option, table|
127
+ ([ option.switch_name ] + Array(option.aliases)).each { |name| table[name] = option.type }
128
+ end
129
+ end
130
+
131
+ HELP_FLAGS = %w[--help -h].freeze
132
+
133
+ # `<command> --help` asks for that command's help, which Thor only gives
134
+ # as `help <command>`: it read the flag as an argument the command does
135
+ # not take, and `tool` and `preset` as the name to run. After a tool's
136
+ # name the flag is that tool's (`tool schema --help` lists its params).
137
+ def self.help_request(argv)
138
+ command, *rest = argv
139
+ return argv if command.nil? || command.start_with?("-") || command == "help"
140
+
141
+ at = rest.index { |arg| HELP_FLAGS.include?(arg) } or return argv
142
+ return argv if command == "tool" && named_tool?(rest.first(at))
143
+
144
+ [ "help", command ]
145
+ end
146
+
147
+ # Whether these words after `tool` hold a tool name: a word that is
148
+ # neither a switch nor the value a switch before it takes.
149
+ def self.named_tool?(words)
150
+ types = switch_types
151
+ value_next = false
152
+ words.each do |word|
153
+ if value_next
154
+ value_next = false
155
+ elsif word.start_with?("-")
156
+ type = types[word.split("=", 2).first]
157
+ value_next = !word.include?("=") && !type.nil? && type != :boolean
158
+ else
159
+ return true
160
+ end
161
+ end
162
+ false
163
+ end
164
+
165
+ # Thor names the command by its method in an argument error, and
166
+ # `inspect` is defined as inspect_app; the name a user types is the
167
+ # first word of the command's usage.
168
+ def self.handle_argument_error(command, error, args, arity)
169
+ super(command.dup.tap { |public_name| public_name.name = command.usage.split.first }, error, args, arity)
170
+ end
171
+
69
172
  # Thor reads a leading switch as "no command given" and falls back to
70
173
  # `help`, which answers `rails-ai-context --app-path /x doctor` with usage
71
174
  # and exit 0. Move every switch this binary declares
72
175
  # (global or per-command) behind the command name; anything else is left
73
176
  # where it is, so `--help`, `--version` and an unknown switch keep their
74
177
  # own answers.
75
- def self.normalize_argv(argv)
76
- declared = class_options.values + commands.each_value.flat_map { |c| c.options.values }
77
- switches = declared.each_with_object({}) do |option, table|
78
- ([ option.switch_name ] + Array(option.aliases)).each { |name| table[name] = option.type }
79
- end
80
-
178
+ def self.reorder_switches(argv)
179
+ switches = switch_types
81
180
  globals = []
82
181
  rest = argv.dup
83
182
  while (token = rest.first) && token.start_with?("-")
@@ -119,7 +218,7 @@ class RailsAiContextCLI < Thor
119
218
  map %w[--version -v] => :version
120
219
 
121
220
  class_option :app_path, type: :string,
122
- desc: "Path to the Rails app root (default: current directory)"
221
+ desc: "Path to the Rails app root (default: the app the current directory is in, or the one app below it)"
123
222
  class_option :environment, type: :string,
124
223
  desc: "RAILS_ENV to run under (default: the ambient RAILS_ENV or development)"
125
224
 
@@ -173,11 +272,32 @@ class RailsAiContextCLI < Thor
173
272
  exit 1 if runner.error
174
273
  end
175
274
 
275
+ # What Server#start takes; the gem's server cannot be loaded to ask it
276
+ # before the app boots. streamable_http is http's other name there.
277
+ SERVE_TRANSPORTS = %w[stdio http streamable_http].freeze
278
+
176
279
  desc "serve", "Start MCP server (stdio transport)"
177
280
  option :transport, type: :string, default: "stdio", desc: "Transport: stdio or http"
178
281
  option :port, type: :numeric, desc: "HTTP port (only for http transport; default: config http_port, else 6029)"
179
282
  option :no_boot, type: :boolean, default: false, desc: "Skip booting the app; serve static analysis only"
180
283
  def serve
284
+ # Checked before the boot, which can take a minute, and in the words
285
+ # the option is typed in rather than as Ruby symbols.
286
+ unless SERVE_TRANSPORTS.include?(options[:transport])
287
+ $stderr.puts "Error: Unknown transport '#{options[:transport]}'. Use stdio or http."
288
+ exit 1
289
+ end
290
+
291
+ # A stdio client is waiting for the answer to `initialize` and gives up
292
+ # after about 30 seconds; BootManager::STDIO_TIMEOUT says why the boot
293
+ # gets less than that.
294
+ if options[:transport] == "stdio"
295
+ entry_boot
296
+ ::RailsAiContext::BootManager.default_timeout = ::RailsAiContext::BootManager::STDIO_TIMEOUT
297
+ end
298
+
299
+ # Live reload uses `listen` when it is there, standalone included.
300
+ reach_installed_gem("listen")
181
301
  boot_rails!(command: "serve", allow_static: true)
182
302
 
183
303
  transport = options[:transport].to_sym
@@ -190,6 +310,13 @@ class RailsAiContextCLI < Thor
190
310
  desc "context", "Generate AI context files"
191
311
  option :format, type: :string, default: nil, desc: "Format: claude, cursor, copilot, opencode, codex, json, all (default: from config)"
192
312
  option :no_boot, type: :boolean, default: false, desc: "Skip booting the app; serve static analysis only"
313
+ # For the installer, which writes the MCP configs itself just before it
314
+ # runs this at an engine's root.
315
+ option :mcp_refresh, type: :boolean, default: true, hide: true
316
+ # For doctor, which runs this at an engine's root into a copy of the
317
+ # context files to see which a run would rewrite: the files go there,
318
+ # and nothing else is touched - no MCP config, no legacy file.
319
+ option :output_dir, type: :string, hide: true
193
320
  def context
194
321
  # A repo you have just cloned is the case an agent most needs these files
195
322
  # for, and it is the case least likely to boot. Every command that reads
@@ -206,7 +333,8 @@ class RailsAiContextCLI < Thor
206
333
  else
207
334
  cleanup_tools = ::RailsAiContext.configuration.ai_tools
208
335
  end
209
- ::RailsAiContext::LegacyCleanup.prompt_legacy_files(cleanup_tools, root: Dir.pwd)
336
+ output_dir = options[:output_dir]
337
+ ::RailsAiContext::LegacyCleanup.prompt_legacy_files(cleanup_tools, root: Dir.pwd, place: display_place) unless output_dir
210
338
 
211
339
  # An explicit --format names a file, so it is written either way. With
212
340
  # no format and an MCP-only install there is nothing to write, and the
@@ -218,6 +346,13 @@ class RailsAiContextCLI < Thor
218
346
  return
219
347
  end
220
348
 
349
+ # `rails ai:context` brings the selected tools' MCP configs up to date
350
+ # on every run, in the form the install takes now. So does this, or an
351
+ # app taken out of its bundle got context files naming the binary
352
+ # beside configs that still start `bundle exec`.
353
+ refresh_mcp_configs if options[:format].nil? && options[:mcp_refresh] && !output_dir
354
+ ::RailsAiContext.configuration.output_dir = File.expand_path(output_dir) if output_dir
355
+
221
356
  $stderr.puts "Introspecting Rails app..."
222
357
 
223
358
  # An explicit --format overrides the recorded selection.
@@ -244,18 +379,43 @@ class RailsAiContextCLI < Thor
244
379
  desc "watch", "Watch for changes and auto-regenerate context files"
245
380
  option :no_boot, type: :boolean, default: false, desc: "Skip booting the app; serve static analysis only"
246
381
  def watch
382
+ reach_installed_gem("listen")
247
383
  boot_rails!(command: "watch", allow_static: true)
248
- ::RailsAiContext::Watcher.new.start
384
+ ::RailsAiContext::Watcher.new(place: display_place).start
249
385
  end
250
386
 
251
387
  desc "doctor", "Run diagnostic checks and report AI readiness score"
252
388
  option :strict, type: :boolean, default: false, desc: "Exit 1 when any check fails (CI gate)"
389
+ # Declared so the flag every other command takes is refused in words,
390
+ # not with Thor's "was called with arguments"; hidden from the help.
391
+ option :no_boot, type: :boolean, default: false, hide: true
253
392
  def doctor
393
+ if options[:no_boot]
394
+ $stderr.puts "Error: doctor diagnoses the app's boot, so it cannot skip it. Run it without --no-boot."
395
+ exit 1
396
+ end
397
+
398
+ # The live reload check asks for `listen` as serve would find it.
399
+ reach_installed_gem("listen")
400
+ # An engine's root boots no app of its own, so doctor diagnoses the
401
+ # dummy app its bin/rails boots, and reads the install at the root.
402
+ resolution = resolve_app_root!
403
+ # Where this was typed, a folder inside the app included, where each
404
+ # command a fix gives runs. A run handed to the app's bundle started at
405
+ # the app's root, and is told.
406
+ typed = STARTING_ENV[TYPED_IN_ENV] || resolution.cwd
407
+ if (dummy = app_root.dummy_app(Dir.pwd))
408
+ # Named from the root found, when that was typed inside it.
409
+ root = resolution.root
410
+ shown_from = root && (typed == root || typed.start_with?("#{root}/")) ? root : typed
411
+ $stderr.puts "[rails-ai-context] an engine's root boots no app: diagnosing its dummy app at #{app_root.display(dummy, shown_from)}/"
412
+ Dir.chdir(dummy)
413
+ end
254
414
  # Diagnosing a boot failure needs the app, so doctor never serves the
255
415
  # static tier; it does read a source-only tree, to name what is missing.
256
416
  boot_rails!(command: "doctor", allow_source_only: true,
257
417
  boot_failure_hint: " doctor needs a bootable app; fix the boot error above first.")
258
- result = ::RailsAiContext::Doctor.new.run
418
+ result = ::RailsAiContext::Doctor.new(from: typed).run
259
419
  # The report is the command's product - it belongs on stdout so
260
420
  # `doctor > report.txt` captures it. stderr stays for errors only.
261
421
  puts ::RailsAiContext::Doctor.report_lines(result)
@@ -274,45 +434,47 @@ class RailsAiContextCLI < Thor
274
434
  option :mcp_only, type: :boolean, default: false,
275
435
  desc: "Write the MCP config and nothing else: no CLAUDE.md, AGENTS.md, rules files or .ai-context.json"
276
436
  def init
277
- unless entry_boot.app_present?(Dir.pwd, allow_source_only: true)
278
- $stderr.puts "Error: No Rails app found in #{Dir.pwd}"
279
- $stderr.puts "Run this command from your Rails app root directory."
280
- exit 1
281
- end
437
+ resolution = resolve_app_root!(workspace: true, hand_off: false)
438
+ return init_workspace(resolution.base, resolution.below) if resolution.workspace?
439
+
440
+ refuse_no_app! unless entry_boot.app_present?(Dir.pwd, allow_source_only: true)
282
441
 
283
442
  require_install_files!
443
+ program = ::RailsAiContext::Install::Program
284
444
 
285
- # --- Read previous selection (before prompts) ---
445
+ # --- Every question first, the previous selection read before them ---
286
446
  previous_tools = read_previous_ai_tools
287
-
288
- # --- Prompts (no Rails needed) ---
289
- ai_tools = prompt_ai_tools
447
+ ai_tools = prompt_ai_tools(current: previous_tools)
290
448
  setup = prompt_setup
291
449
  tool_mode = setup.tool_mode
292
450
  context_files = setup.context_files
293
-
294
- # --- Cleanup removed tools ---
295
- cleanup_removed_tools(previous_tools, ai_tools) if previous_tools&.any?
296
-
297
- # --- One-time v5.0.0 legacy UI-pattern files cleanup prompt ---
298
- ::RailsAiContext::LegacyCleanup.prompt_legacy_files(ai_tools, root: Dir.pwd) if context_files
299
-
300
- # --- Record the selection (YAML, plus the initializer line) ---
451
+ dropped = program.ask_removed_tools(program_surface, previous: previous_tools, selected: ai_tools)
452
+ unwired = tool_mode == :cli ? program.ask_mcp_removal(program_surface, root: Dir.pwd) : []
453
+ # One-time v5.0.0 legacy UI-pattern files cleanup prompt
454
+ ::RailsAiContext::LegacyCleanup.prompt_legacy_files(ai_tools, root: Dir.pwd, place: display_place) if context_files
455
+
456
+ # --- Boot, then write ---
457
+ # Nothing is written before the app's bundle is set up: the MCP configs
458
+ # and the cleanup read and write JSON, and done first they loaded this
459
+ # gem's newest json, which the app's pinned copy then loaded over. A
460
+ # boot that fails falls back to the static tier rather than abandoning
461
+ # the setup, since setup is exactly what you run on a repo you have
462
+ # just cloned. An MCP-only setup needs nothing from the app, so it boots
463
+ # nothing; nor does a run whose app bundles its own copy of the gem,
464
+ # which writes the context files in a child of its own.
465
+ bundled = bundled_copy_for_context if context_files
466
+ boot_rails!(command: "init", allow_static: true) if context_files && !bundled
467
+
468
+ program.remove_tools(program_surface, dropped, selected: ai_tools, previous: previous_tools, root: Dir.pwd)
469
+ program.remove_mcp_entries(program_surface, unwired, root: Dir.pwd)
301
470
  record_selection(ai_tools, tool_mode, context_files)
302
-
303
- # --- Write per-tool MCP config files (MCP mode only) ---
304
471
  write_mcp_configs(ai_tools, tool_mode)
305
-
306
- # --- Add .ai-context.json to .gitignore ---
307
472
  add_to_gitignore(context_files)
308
473
 
309
- # --- Boot Rails, load config, generate context ---
310
- # The config files are already on disk by now, so failing here leaves the
311
- # app half set up and calls the whole thing a failure. Setup is exactly
312
- # what you run on a repo you have just cloned.
313
- boot_rails!(command: "init", allow_static: true)
314
-
315
- if context_files
474
+ if bundled
475
+ generate_bundled_context(bundled)
476
+ elsif context_files
477
+ configure_answers!(ai_tools, tool_mode, context_files)
316
478
  $stderr.puts ""
317
479
  $stderr.puts "Generating AI context files..."
318
480
  print_context_result(::RailsAiContext.generate_context(format: ai_tools))
@@ -342,7 +504,9 @@ class RailsAiContextCLI < Thor
342
504
  boot_rails!(command: "facts", allow_static: true)
343
505
 
344
506
  context = ::RailsAiContext.introspect
345
- puts ::RailsAiContext::FactsFormatter.render(context, inspect_hint: "rails-ai-context inspect")
507
+ puts ::RailsAiContext::FactsFormatter.render(
508
+ context, full_json: "Run `#{own_command('inspect')}` for the full introspection as JSON."
509
+ )
346
510
  end
347
511
 
348
512
  desc "version", "Print version"
@@ -361,12 +525,59 @@ class RailsAiContextCLI < Thor
361
525
  lambda { |prompt|
362
526
  $stderr.print "#{prompt} "
363
527
  $stdin.gets&.strip
364
- }
528
+ },
529
+ display_place
365
530
  )
366
531
  end
367
532
 
368
- def prompt_ai_tools
369
- ::RailsAiContext::Install::Program.select_ai_tools(program_surface)
533
+ # The root this run works in as the person who typed the command names
534
+ # it, when they typed it outside that root (`init --app-path X` in X's
535
+ # parent, a workspace named from elsewhere): the files it reports are
536
+ # named from there, `X/.mcp.json`, since a bare `.mcp.json` reads as one
537
+ # in the directory they are in. A typed --app-path is used as typed, the
538
+ # way the commands it prints repeat it. Nil when they stand in the root
539
+ # or below.
540
+ #
541
+ # A folder of apps is the root of the run that sets it up, even when it
542
+ # holds one app, which the resolution names as its root: the folder's
543
+ # own files were named as that app's.
544
+ def display_place
545
+ result = @app_root_result or return nil
546
+ root = @workspace_dir || result.root || result.base
547
+ cwd = result.cwd
548
+ return nil if root.nil? || cwd.nil? || cwd.start_with?("#{root}/")
549
+ return inherited_place if cwd == root
550
+
551
+ typed = @app_path_override || options[:app_path]
552
+ result.explicit && typed ? typed.delete_suffix("/") : app_root.display(root, cwd)
553
+ end
554
+
555
+ # The place a folder's init gave this run, a child it started at one of
556
+ # its apps; nil for any other.
557
+ def inherited_place
558
+ place = ENV[PLACE_ENV]
559
+ place unless place.nil? || place.empty? || @workspace_dir
560
+ end
561
+
562
+ # How the person who typed the command names a root this run writes
563
+ # under: the run's own as display_place says, a workspace's app from
564
+ # where they stand.
565
+ def place_for(root)
566
+ root == (@workspace_dir || Dir.pwd) ? display_place : app_root.display(root, Dir.pwd)
567
+ end
568
+
569
+ def prompt_ai_tools(current: nil)
570
+ ::RailsAiContext::Install::Program.select_ai_tools(program_surface, current: current)
571
+ end
572
+
573
+ # The answers, over the configuration the boot loaded from the files as
574
+ # they stood before this run: the context files come out in the mode
575
+ # just chosen, not the one the app had.
576
+ def configure_answers!(ai_tools, tool_mode, context_files)
577
+ config = ::RailsAiContext.configuration
578
+ config.ai_tools = ai_tools
579
+ config.tool_mode = tool_mode
580
+ config.context_files = context_files
370
581
  end
371
582
 
372
583
  def prompt_setup
@@ -377,42 +588,257 @@ class RailsAiContextCLI < Thor
377
588
  ::RailsAiContext::Install::Program.select_setup(program_surface)
378
589
  end
379
590
 
380
- def read_previous_ai_tools
381
- ::RailsAiContext::Install::SelectionRecord.read(root: Dir.pwd)
591
+ def read_previous_ai_tools(root: Dir.pwd)
592
+ ::RailsAiContext::Install::SelectionRecord.read(root: root)
382
593
  end
383
594
 
384
- def cleanup_removed_tools(previous, current)
595
+ def cleanup_removed_tools(previous, current, root: Dir.pwd, app_roots: [ root ])
385
596
  ::RailsAiContext::Install::Program.cleanup_removed_tools(
386
- program_surface, previous: previous, selected: current, root: Dir.pwd
597
+ program_surface, previous: previous, selected: current, root: root, app_roots: app_roots
387
598
  )
388
599
  end
389
600
 
390
- def add_to_gitignore(context_files = true)
391
- ::RailsAiContext::Install::Program.mark_gitignore(program_surface, root: Dir.pwd,
392
- context_files: context_files)
601
+ # A workspace's apps each have a .gitignore, and so may the folder.
602
+ def add_to_gitignore(context_files = true, root: Dir.pwd)
603
+ surface = program_surface.dup.tap { |copy| copy.place = place_for(root) }
604
+ ::RailsAiContext::Install::Program.mark_gitignore(surface, root: root, context_files: context_files)
393
605
  end
394
606
 
395
- def record_selection(ai_tools, tool_mode, context_files = true)
607
+ def record_selection(ai_tools, tool_mode, context_files = true, root: Dir.pwd)
396
608
  result = ::RailsAiContext::Install::SelectionRecord.write(
397
- ai_tools, root: Dir.pwd,
609
+ ai_tools, root: root,
398
610
  extra_yaml: { "tool_mode" => tool_mode.to_s, "context_files" => context_files }
399
611
  )
400
612
  record = ::RailsAiContext::Install::SelectionRecord
401
- conflicts = [
402
- record.conflict_message(:context_files, record.write_context_files(context_files, root: Dir.pwd)),
403
- record.conflict_message(:tool_mode, record.write_tool_mode(tool_mode, root: Dir.pwd))
404
- ].compact
613
+ place = place_for(root)
614
+ lines = { context_files: record.write_context_files(context_files, root: root),
615
+ tool_mode: record.write_tool_mode(tool_mode, root: root) }
616
+ conflicts = lines.filter_map { |key, status| record.conflict_message(key, status, place: place) }
617
+ # The initializer is reported once, whichever of its three lines moved:
618
+ # a mode switch that rewrote it said nothing.
619
+ if lines.values.any? { |status| %i[updated inserted].include?(status) } && %i[unchanged absent].include?(result[:initializer])
620
+ result = result.merge(initializer: :updated)
621
+ end
405
622
 
406
- (record.messages(result) + conflicts).each do |level, text|
623
+ (record.messages(result, place: place) + conflicts).each do |level, text|
407
624
  $stderr.puts(level == :warn ? "Warning: #{text}" : text)
408
625
  end
409
626
  end
410
627
 
411
- def write_mcp_configs(ai_tools, tool_mode)
412
- require_relative "../lib/rails_ai_context/mcp_config_generator"
413
- ::RailsAiContext::Install::Program.write_mcp_configs(
414
- program_surface, tools: ai_tools, tool_mode: tool_mode, root: Dir.pwd
415
- )
628
+ # Only for a selection the app recorded: with none, `rails ai:context`
629
+ # asks first, and this asks nothing, so it writes no config either.
630
+ def refresh_mcp_configs
631
+ config = ::RailsAiContext.configuration
632
+ return unless config.tool_mode == :mcp && config.ai_tools&.any?
633
+
634
+ tools = ::RailsAiContext::Install::Program.own_config_tools(config.ai_tools, root: Dir.pwd)
635
+ write_mcp_configs(tools, :mcp) if tools.any?
636
+ end
637
+
638
+ # The install form is the app's own: under bundle exec the process's
639
+ # bundle can be another app's (`init --app-path ../b` run from a).
640
+ def write_mcp_configs(ai_tools, tool_mode, root: Dir.pwd, servers: nil)
641
+ standalone = servers ? nil : ::RailsAiContext::InstallMode.standalone?(root: root)
642
+ in_starting_env do
643
+ ::RailsAiContext::Install::Program.write_mcp_configs(
644
+ program_surface, tools: ai_tools, tool_mode: tool_mode, root: root, servers: servers, standalone: standalone
645
+ )
646
+ end
647
+ end
648
+
649
+ # Codex's config copies PATH and the gem variables out of this process
650
+ # for the server Codex starts, so they are copied as the binary was
651
+ # started. The app's boot sets up its bundle in this process, putting
652
+ # GEM_HOME at the bundle's path and the bundle's bin directory first on
653
+ # PATH, which no shell running the binary outside that bundle has.
654
+ def in_starting_env
655
+ now = ENV.to_h
656
+ ENV.replace(STARTING_ENV)
657
+ yield
658
+ ensure
659
+ ENV.replace(now) if now
660
+ end
661
+
662
+ # A folder of apps, set up as a whole. The AI tools are chosen once; each
663
+ # app gets its own .rails-ai-context.yml and context files, since its
664
+ # server reads them from its own root; and this folder's MCP configs get
665
+ # one server per app, since a client reads them from the folder it opened
666
+ # and from nowhere below it.
667
+ def init_workspace(dir, roots)
668
+ # Before anything is said: the folder's own files are named from it.
669
+ @workspace_dir = dir
670
+ require_install_files!
671
+ apps = ::RailsAiContext::Install::Workspace.apps(dir, roots)
672
+
673
+ $stderr.puts "#{dir} is no Rails app; it holds #{apps.size == 1 ? 'one' : apps.size} below it. Setting up:"
674
+ apps.each { |app| $stderr.puts " #{app.path}/" }
675
+ warn_ruby_mismatch(apps)
676
+
677
+ # Before the prompts, as in an app: what each app recorded last time.
678
+ previous = apps.flat_map { |app| read_previous_ai_tools(root: app.root) || [] }.uniq
679
+
680
+ ai_tools = prompt_ai_tools(current: previous)
681
+ setup = prompt_setup
682
+ tool_mode = setup.tool_mode
683
+ context_files = setup.context_files
684
+
685
+ cleanup_removed_tools(previous, ai_tools, root: dir, app_roots: apps.map(&:root)) if previous.any?
686
+ if tool_mode == :cli
687
+ program = ::RailsAiContext::Install::Program
688
+ program.remove_mcp_entries(program_surface, program.ask_mcp_removal(program_surface, root: dir), root: dir)
689
+ end
690
+
691
+ apps.each do |app|
692
+ $stderr.puts ""
693
+ $stderr.puts "#{app.path}/:"
694
+ record_selection(ai_tools, tool_mode, context_files, root: app.root)
695
+ add_to_gitignore(context_files, root: app.root)
696
+ end
697
+
698
+ # JSON and TOML hold UTF-8 alone, so an app whose folder is named in
699
+ # another encoding cannot be named there: left out, the others' entries
700
+ # are still written, where its name failed the whole file.
701
+ configured, unnamed = apps.partition do |app|
702
+ [ app.server.app_path, app.server.gemfile ].compact.all? { |path| path.dup.force_encoding(Encoding::UTF_8).valid_encoding? }
703
+ end
704
+ unnamed.each do |app|
705
+ $stderr.puts ""
706
+ $stderr.puts "Warning: #{app.path}/ is left out of this folder's MCP configs: its folder name is not UTF-8, which " \
707
+ "JSON and TOML require. Rename it, or run `rails-ai-context init` inside it for a config of its own."
708
+ end
709
+
710
+ $stderr.puts ""
711
+ write_mcp_configs(ai_tools, tool_mode, root: dir, servers: configured.map(&:server))
712
+ # This folder's own .gitignore, when it has one: the Codex config here
713
+ # embeds this machine's PATH and GEM_HOME too.
714
+ add_to_gitignore(false, root: dir)
715
+
716
+ if context_files
717
+ failed = apps.reject { |app| generate_app_context(app) }
718
+ else
719
+ failed = []
720
+ $stderr.puts ""
721
+ $stderr.puts "MCP-only setup: no context files written."
722
+ end
723
+
724
+ show_workspace_instructions(dir, apps, ai_tools, tool_mode, context_files, configured: configured)
725
+ return if failed.empty?
726
+
727
+ $stderr.puts "Error: no context files for #{failed.map { |app| "#{app.path}/" }.join(', ')} - see the output above. " \
728
+ "Retry with `#{app_root.command_line([ "rails-ai-context", "--app-path", app_root.option_path(app_root.display(failed.first.root, Dir.pwd)), "context" ])}`."
729
+ exit 1
730
+ end
731
+
732
+ # A client opened here starts every app's server under the Ruby this
733
+ # folder resolves to, and the context files below are generated the same
734
+ # way; an app that declares another may not boot under it.
735
+ def warn_ruby_mismatch(apps)
736
+ running = RUBY_VERSION.split(".")
737
+ apps.each do |app|
738
+ declared = ::RailsAiContext::GemLock.for(app.root).ruby_version.to_s[/\A\d+(?:\.\d+){1,2}/] or next
739
+ next if declared.split(".") == running.first(declared.count(".") + 1)
740
+
741
+ $stderr.puts "Warning: #{app.path}/ declares Ruby #{declared}; this folder runs #{RUBY_VERSION}."
742
+ $stderr.puts " Its server here runs under #{RUBY_VERSION} too, so it may answer from source alone, and a " \
743
+ "bundle exec entry may not start. `rails-ai-context init` inside #{app.path}/ gives it a config of its own."
744
+ end
745
+ end
746
+
747
+ # One process boots one app, so each app's context files come from a
748
+ # child running the install that app's server runs: its bundle's copy of
749
+ # the gem for an in-Gemfile app, this copy for a standalone one. The
750
+ # child prompts for its own legacy files, so this process does not, and
751
+ # names them, as every file it reports, from where this one was run.
752
+ def generate_app_context(app)
753
+ $stderr.puts ""
754
+ $stderr.puts "Generating AI context files for #{app.path}/..."
755
+
756
+ flags = []
757
+ flags << "--no-boot" if options[:no_boot]
758
+ flags.push("--environment", options[:environment]) if options[:environment]
759
+ if app.standalone
760
+ env, command, bundled = own_binary
761
+ command = [ *command, "context", *flags ]
762
+ else
763
+ env = { "BUNDLE_GEMFILE" => app.gemfile }
764
+ command = [ "bundle", "exec", "rails-ai-context", "context", *flags ]
765
+ end
766
+ env = env.merge(PLACE_ENV => app_root.display(app.root, Dir.pwd))
767
+
768
+ # Otherwise a parent run under bundle exec hands the child the
769
+ # environment it was started with, the user's own Bundler settings
770
+ # included, not its bundle.
771
+ run = -> { system(env, *command, chdir: app.root) }
772
+ original = !bundled && defined?(::Bundler) && ::Bundler.respond_to?(:with_original_env)
773
+ ok = original ? ::Bundler.with_original_env(&run) : run.call
774
+ $stderr.puts "Error: could not run `#{command.first}` - is it installed and on PATH?" if ok.nil?
775
+ ok == true
776
+ end
777
+
778
+ # This copy of the gem, at this version, as a child runs it.
779
+ #
780
+ # Inside a bundle (a checkout's, or a folder's own Gemfile that lists the
781
+ # gem) the child keeps this process's environment: its dependencies may
782
+ # be installed nowhere else, as under a vendored BUNDLE_PATH. The bundle's
783
+ # Gemfile is pinned, since the child starts in another directory.
784
+ # Otherwise it is the installed spec, activated the way its RubyGems
785
+ # wrapper does it (a wrapper found on PATH may load another version), or
786
+ # this file with its own lib, as a checkout runs it.
787
+ #
788
+ # @return [Array(Hash, Array<String>, Boolean)] the child's environment,
789
+ # its command, and whether it keeps this process's bundle
790
+ def own_binary
791
+ from_lib = [ RbConfig.ruby, "-I", File.expand_path("../lib", __dir__), EXE_PATH ]
792
+ if ENV["BUNDLE_BIN_PATH"] && defined?(::Bundler) && ::Bundler.respond_to?(:default_gemfile)
793
+ return [ { "BUNDLE_GEMFILE" => ::Bundler.default_gemfile.to_s }, from_lib, true ]
794
+ end
795
+
796
+ spec = PREBOOT_GEM_SPECS["rails-ai-context"]
797
+ return [ {}, from_lib, false ] unless spec
798
+
799
+ [ {}, [ RbConfig.ruby, "-e", 'load Gem.activate_bin_path("rails-ai-context", "rails-ai-context", ARGV.shift)',
800
+ "= #{spec.version}" ], false ]
801
+ end
802
+
803
+ def show_workspace_instructions(dir, apps, ai_tools, tool_mode, context_files, configured: apps)
804
+ $stderr.puts ""
805
+ $stderr.puts "=" * 50
806
+ $stderr.puts " rails-ai-context initialized for #{apps.size == 1 ? '1 app' : "#{apps.size} apps"}"
807
+ $stderr.puts "=" * 50
808
+ $stderr.puts ""
809
+ $stderr.puts "Your setup:"
810
+ where = dir == Dir.pwd ? "here" : "in #{app_root.display(dir, Dir.pwd).delete_suffix('/')}/"
811
+ ::RailsAiContext::Install::AiTool.all.each do |tool|
812
+ next unless ai_tools.include?(tool.key)
813
+
814
+ # The MCP config is this folder's, so it leaves the per-app list.
815
+ files = context_files ? "#{tool.files_label(mcp: false)} in each app" : "no context files"
816
+ files = "#{tool.mcp_config[:path]} #{where}; #{files}" if tool_mode == :mcp
817
+ $stderr.puts " #{tool.name.ljust(16)} -> #{files}"
818
+ end
819
+ if tool_mode == :mcp && configured.any?
820
+ $stderr.puts ""
821
+ $stderr.puts "MCP servers, one per app:"
822
+ width = configured.map { |app| app.server_name.size }.max
823
+ configured.each do |app|
824
+ how = if app.standalone then ""
825
+ elsif app.server.gemfile then " (bundle exec, BUNDLE_GEMFILE=#{app.server.gemfile})"
826
+ else " (bundle exec)"
827
+ end
828
+ $stderr.puts " #{app.server_name.ljust(width)} -> #{app.path}/#{how}"
829
+ end
830
+ end
831
+ example = configured.first || apps.first
832
+ first = app_root.command_line([ app_root.option_path(app_root.display(example.root, Dir.pwd)) ])
833
+ $stderr.puts ""
834
+ $stderr.puts "Commands, from here:"
835
+ $stderr.puts " rails-ai-context --app-path #{first} context # Regenerate that app's context files" if context_files
836
+ $stderr.puts " rails-ai-context --app-path #{first} tool NAME # Run a tool against it"
837
+ $stderr.puts " rails-ai-context --app-path #{first} doctor # Check its AI readiness"
838
+ $stderr.puts ""
839
+ $stderr.puts "Config: .rails-ai-context.yml in each app (edit anytime, no restart needed)"
840
+ $stderr.puts "Opening one app on its own? Run `rails-ai-context init` inside it."
841
+ $stderr.puts ""
416
842
  end
417
843
 
418
844
  def show_standalone_instructions(ai_tools, tool_mode, context_files = true)
@@ -424,20 +850,27 @@ class RailsAiContextCLI < Thor
424
850
  $stderr.puts "Your setup:"
425
851
  ::RailsAiContext::Install::AiTool.all.each do |tool|
426
852
  next unless ai_tools.include?(tool.key)
427
- $stderr.puts " #{tool.name.ljust(16)} -> #{context_files ? tool.files : 'MCP config only'}"
853
+ $stderr.puts " #{tool.name.ljust(16)} -> #{context_files ? tool.files_label(mcp: tool_mode == :mcp) : 'MCP config only'}"
428
854
  end
429
855
  unless context_files
430
856
  $stderr.puts ""
431
857
  $stderr.puts " Left alone on purpose: CLAUDE.md, AGENTS.md, the rules directories and .ai-context.json."
432
858
  end
859
+ # Set up through --app-path from elsewhere, the commands name the app
860
+ # the same way, or they would read the folder they are run in.
861
+ result = @app_root_result
862
+ typed = @app_path_override || options[:app_path]
863
+ binary = "rails-ai-context"
864
+ binary = app_root.command_line([ "rails-ai-context", "--app-path", app_root.option_path(typed) ]) if result&.explicit && typed && result.root != result.cwd
433
865
  $stderr.puts ""
434
866
  $stderr.puts "Commands:"
435
- $stderr.puts " rails-ai-context context # Regenerate context files" if context_files
436
- $stderr.puts " rails-ai-context tool NAME # Run any of the #{RailsAiContext::CountPhrase.call(builtin_tool_count, "tool")}"
867
+ $stderr.puts " #{binary} context # Regenerate context files" if context_files
868
+ tools = served_tool_count
869
+ $stderr.puts " #{binary} tool NAME # Run any of the #{RailsAiContext::CountPhrase.call(tools, "tool")}"
437
870
  if tool_mode == :mcp
438
- $stderr.puts " rails-ai-context serve # Start MCP server"
871
+ $stderr.puts " #{binary} serve # Start MCP server"
439
872
  end
440
- $stderr.puts " rails-ai-context doctor # Check AI readiness"
873
+ $stderr.puts " #{binary} doctor # Check AI readiness"
441
874
  $stderr.puts ""
442
875
  if tool_mode == :mcp
443
876
  $stderr.puts "MCP auto-discovery:"
@@ -448,17 +881,27 @@ class RailsAiContextCLI < Thor
448
881
  $stderr.puts " AI agents can run `rails-ai-context tool schema table=users` directly."
449
882
  end
450
883
  $stderr.puts ""
451
- $stderr.puts "Config: .rails-ai-context.yml (edit anytime, no restart needed)"
884
+ config = display_place ? File.join(display_place, ".rails-ai-context.yml") : ".rails-ai-context.yml"
885
+ $stderr.puts "Config: #{config} (edit anytime, no restart needed)"
452
886
  $stderr.puts ""
453
887
  end
454
888
 
455
- def builtin_tool_count
456
- ::RailsAiContext::Server.builtin_tools.size
889
+ # What the server serves, skip_tools and custom_tools applied. An
890
+ # MCP-only setup boots nothing, so the gem is loaded here for the count,
891
+ # once every file is written. Without a boot the custom tools, app
892
+ # classes, cannot load, so each entry counts as one.
893
+ def served_tool_count
894
+ return ::RailsAiContext::Server.exposed_tools.size if defined?(::RailsAiContext::Server)
895
+
896
+ entry_boot.require_gem_without_app!
897
+ ::RailsAiContext::Configuration.auto_load!(Dir.pwd)
898
+ config = ::RailsAiContext.configuration
899
+ ::RailsAiContext::Server.active_tools(config).size + config.custom_tools.size
457
900
  end
458
901
 
459
902
  def print_context_result(result)
460
903
  style = ::RailsAiContext::ContextFileReport.style(:plain)
461
- ::RailsAiContext::ContextFileReport.each_line(result, style) do |_bucket, text|
904
+ ::RailsAiContext::ContextFileReport.each_line(result, style, root: Dir.pwd, place: display_place) do |_bucket, text|
462
905
  $stderr.puts " #{text}"
463
906
  end
464
907
  end
@@ -470,9 +913,21 @@ class RailsAiContextCLI < Thor
470
913
  # otherwise load the gem standalone and list from that. Only execution
471
914
  # (running a specific tool) genuinely needs a live app.
472
915
  def print_tool_list(no_boot: false)
473
- if !no_boot && entry_boot.app_present?(Dir.pwd) && try_boot_for_list
474
- puts ::RailsAiContext::CLI::ToolRunner.tool_list
475
- return
916
+ @no_boot = true if no_boot
917
+ # No app is no refusal here: the app-less listing is the answer.
918
+ resolve_app_root!(required: false)
919
+ if entry_boot.app_present?(Dir.pwd, allow_source_only: true)
920
+ outcome = enter_app(command: "tool", allow_static: true, no_boot: no_boot, allow_source_only: true)
921
+ unless outcome.tier == :absent
922
+ # The list still comes; a boot failure's whole report, meant for a
923
+ # command that stops on it, would read as this command failing.
924
+ if outcome.kind == :boot_failed
925
+ $stderr.puts "[rails-ai-context] App boot failed (#{outcome.reason}); " \
926
+ "tools answer from source, and custom tools may be missing from this list."
927
+ end
928
+ puts ::RailsAiContext::CLI::ToolRunner.tool_list
929
+ return
930
+ end
476
931
  end
477
932
 
478
933
  begin
@@ -480,8 +935,7 @@ class RailsAiContextCLI < Thor
480
935
  # Boot is what normally loads .rails-ai-context.yml, so without it the
481
936
  # listing took the gem's defaults and advertised tools the app has
482
937
  # configured off - which the very next call then refuses by name.
483
- # The app under analysis, which --app-path may have moved.
484
- ::RailsAiContext::Configuration.auto_load!(File.expand_path(@app_path_override || options[:app_path] || Dir.pwd))
938
+ ::RailsAiContext::Configuration.auto_load!(Dir.pwd)
485
939
  puts ::RailsAiContext::CLI::ToolRunner.tool_list
486
940
  puts ""
487
941
  puts "Run inside a Rails app to execute tools - static source analysis works even when the app can't boot (see --no-boot)."
@@ -491,26 +945,13 @@ class RailsAiContextCLI < Thor
491
945
  if entry_boot.app_present?(Dir.pwd, allow_source_only: true)
492
946
  $stderr.puts "Error: the tool list needs this gem loaded, and it failed: #{e.message}"
493
947
  else
494
- $stderr.puts "Error: No Rails app found in #{Dir.pwd}"
495
- $stderr.puts "Run this command from your Rails app root directory."
496
948
  $stderr.puts " (gem also failed to load standalone: #{e.message})" if ENV["DEBUG"]
949
+ refuse_no_app!
497
950
  end
498
951
  exit 1
499
952
  end
500
953
  end
501
954
 
502
- # Attempts the normal boot path, catching the `exit 1` boot_rails! raises
503
- # on a missing app or a failed boot (both come through as SystemExit).
504
- # Returns true only when the app booted successfully; other `tool`
505
- # subcommands don't have a fallback so they call boot_rails! directly and
506
- # let that exit propagate.
507
- def try_boot_for_list
508
- boot_rails!(command: "tool")
509
- true
510
- rescue SystemExit
511
- false
512
- end
513
-
514
955
  # Pull a value-taking flag (--flag VALUE or --flag=VALUE) out of the
515
956
  # pass-through args that stop_on_unknown_option! leaves after the tool
516
957
  # name. Returns the value, or nil when the flag isn't present.
@@ -532,16 +973,24 @@ class RailsAiContextCLI < Thor
532
973
  # than being recognised by name.
533
974
  def boot_rails!(command:, allow_static: false, no_boot: false, allow_source_only: allow_static,
534
975
  boot_failure_hint: nil)
535
- app_path_opt = @app_path_override || options[:app_path]
976
+ outcome = enter_app(command: command, allow_static: allow_static, no_boot: no_boot,
977
+ allow_source_only: allow_source_only)
978
+ outcome.messages.each { |line| $stderr.puts line }
979
+ return unless outcome.tier == :absent
980
+
981
+ $stderr.puts boot_failure_hint if boot_failure_hint && outcome.reason
982
+ $stderr.puts " #{@app_above_hint}" if @app_above_hint && !outcome.reason
983
+ exit 1
984
+ end
985
+
986
+ # The app this command reads, booted or read from source as the command
987
+ # allows. Prints nothing: the outcome carries the lines to say.
988
+ def enter_app(command:, allow_static:, no_boot:, allow_source_only:)
989
+ # Read by the hand-off, which resolving the root may start: `tool`
990
+ # takes --no-boot after the tool's name, where Thor never sees it.
991
+ @no_boot = true if no_boot
992
+ resolve_app_root!
536
993
  environment_opt = @environment_override || options[:environment]
537
- app_root = File.expand_path(app_path_opt || Dir.pwd)
538
- if app_path_opt && !Dir.exist?(app_root)
539
- $stderr.puts "Error: No such directory: #{app_root}"
540
- exit 1
541
- end
542
- # Relative paths inside the app (log/, tmp/, sqlite files) assume the
543
- # process cwd is the app root, so honor --app-path by moving there.
544
- Dir.chdir(app_root) if app_path_opt
545
994
  # Apps read RAILS_ENV in config/boot.rb, before anything the gem can
546
995
  # rescue, so the default is only reachable if it is set here.
547
996
  # The chain is Rails' own (`Rails.env`): RAILS_ENV, then RACK_ENV, then
@@ -551,15 +1000,195 @@ class RailsAiContextCLI < Thor
551
1000
 
552
1001
  # A command that can serve the static tier reads a source-only tree
553
1002
  # instead of refusing it.
554
- outcome = entry_boot.call(root: Dir.pwd, allow_static: allow_static, no_boot: no_boot || options[:no_boot],
555
- allow_source_only: allow_source_only, command: command)
556
- outcome.messages.each { |line| $stderr.puts line }
557
- return unless outcome.tier == :absent
1003
+ entry_boot.call(root: Dir.pwd, allow_static: allow_static, no_boot: no_boot || options[:no_boot],
1004
+ allow_source_only: allow_source_only, command: command, doctor: doctor_command, shown: shown_root)
1005
+ end
558
1006
 
559
- $stderr.puts boot_failure_hint if boot_failure_hint && outcome.reason
1007
+ # Which app this command reads, decided once and before anything reads
1008
+ # Dir.pwd, then made the process cwd: relative paths inside the app (log/,
1009
+ # tmp/, sqlite files, every install helper's root) assume it. Without
1010
+ # this, init wrote half its files where it was run and half into
1011
+ # --app-path, and `tool --list --app-path X` never booted X.
1012
+ #
1013
+ # `required: false` leaves the current directory in place when there is
1014
+ # no single app, for the one command that answers without one.
1015
+ # `workspace: true` is init's: a folder of apps is set up as a whole, so
1016
+ # that answer comes back with the process left where it stands.
1017
+ # `hand_off: false` is init's too: it asks its questions in this copy.
1018
+ def resolve_app_root!(required: true, workspace: false, hand_off: true)
1019
+ return @app_root_result if defined?(@app_root_result)
1020
+
1021
+ cwd = Dir.pwd
1022
+ result = @app_root_result = app_root.resolve(cwd: cwd, app_path: @app_path_override || options[:app_path])
1023
+ return result if workspace && result.workspace?
1024
+
1025
+ if result.explicit
1026
+ unless Dir.exist?(result.root)
1027
+ $stderr.puts "Error: No such directory: #{result.root}"
1028
+ hint = app_root.relative_path_hint(@app_path_override || options[:app_path], cwd)
1029
+ $stderr.puts " #{hint}" if hint
1030
+ exit 1
1031
+ end
1032
+ if result.below.any? && required
1033
+ app_root.several_apps(result, cwd, ARGV).each { |line| $stderr.puts line }
1034
+ exit 1
1035
+ end
1036
+ @app_above_hint = app_root.app_above_hint(result.root, cwd) unless entry_boot.app_present?(result.root, allow_source_only: true)
1037
+ elsif result.root.nil?
1038
+ return result unless required && result.below.any?
1039
+
1040
+ app_root.several_apps(result, cwd, ARGV).each { |line| $stderr.puts line }
1041
+ exit 1
1042
+ end
1043
+
1044
+ # Bundler reads a relative BUNDLE_GEMFILE (a workspace entry names an
1045
+ # app's Gemfile that way) against the cwd. bundle exec hands its child an
1046
+ # absolute one, a direct run does not, so it is pinned before the cwd
1047
+ # moves, for this process's boot and every child it starts.
1048
+ gemfile = ENV["BUNDLE_GEMFILE"]
1049
+ ENV["BUNDLE_GEMFILE"] = File.expand_path(gemfile, cwd) if gemfile && !gemfile.empty?
1050
+ Dir.chdir(result.root) unless result.root == cwd
1051
+ # stderr only: stdout is the protocol channel for serve.
1052
+ $stderr.puts app_root.notice(result, cwd) if result.walked
1053
+ # Which bundle an app boots against matters only where there is one.
1054
+ warning = app_root.bundle_warning(result.root, cwd) if entry_boot.app_present?(result.root, allow_source_only: true)
1055
+ $stderr.puts warning if warning
1056
+ hand_off_to_bundle!(result.root, cwd) if hand_off
1057
+ result
1058
+ end
1059
+
1060
+ # The app's bundle carries its own copy of the gem: the command runs as
1061
+ # `bundle exec rails-ai-context` would run it, from where it was typed,
1062
+ # so one copy answers - the one the app's MCP configs start. When bundle
1063
+ # cannot be started this copy carries on.
1064
+ #
1065
+ # Only a run that boots the app hands off, since booting runs the app's
1066
+ # bundle anyway. One that promises to run nothing of the app's -
1067
+ # --no-boot, an MCP-only init - keeps to this copy: bundle exec would
1068
+ # evaluate the app's Gemfile and start whatever copy of the gem the app
1069
+ # names, on a repo just cloned code nobody has read. Nor does a bundle
1070
+ # not installed yet, where bundle exec would only fail and this copy
1071
+ # answers from source.
1072
+ #
1073
+ # A root found by walking from where the command was typed is where the
1074
+ # command runs from: this copy reads every relative argument from there,
1075
+ # and a released copy does not walk at all.
1076
+ def hand_off_to_bundle!(root, cwd)
1077
+ return if options[:no_boot] || @no_boot || options[:mcp_only]
1078
+
1079
+ handoff = app_root.bundled_handoff(root) or return
1080
+ return unless app_root.bundle_ready?(handoff.env, root)
1081
+
1082
+ bundle = Gem.bin_path("bundler", "bundle")
1083
+ $stderr.puts "[rails-ai-context] This app's bundle has rails-ai-context #{handoff.version}; " \
1084
+ "running that copy, as bundle exec does."
1085
+ exec(handoff.env.merge(TYPED_IN_ENV => cwd), RbConfig.ruby, bundle, "exec", "rails-ai-context", *ORIGINAL_ARGV,
1086
+ chdir: @app_root_result&.walked ? root : cwd)
1087
+ rescue Gem::Exception, SystemCallError
1088
+ nil
1089
+ end
1090
+
1091
+ # init asks its questions in this copy and hands only the context files
1092
+ # to the app's bundled copy, once they are asked for: an MCP-only setup
1093
+ # then runs nothing of the app's, flag or answer.
1094
+ def bundled_copy_for_context
1095
+ return nil if options[:no_boot]
1096
+
1097
+ handoff = app_root.bundled_handoff(Dir.pwd) or return nil
1098
+ app_root.bundle_ready?(handoff.env, Dir.pwd) ? handoff : nil
1099
+ end
1100
+
1101
+ def generate_bundled_context(handoff)
1102
+ $stderr.puts ""
1103
+ $stderr.puts "This app's bundle has rails-ai-context #{handoff.version}; generating the context files with that copy, " \
1104
+ "as bundle exec does..."
1105
+ bundle = Gem.bin_path("bundler", "bundle")
1106
+ flags = options[:environment] ? [ "--environment", options[:environment] ] : []
1107
+ # This run has just written the MCP configs, Codex's from the
1108
+ # environment the binary was started in; a `context` that refreshed
1109
+ # them would write that one again from the bundle's.
1110
+ flags << "--no-mcp-refresh" if refresh_flag_known?(handoff.version)
1111
+ # The child names its files as display_place does here: X/CLAUDE.md for `init --app-path X`.
1112
+ env = display_place ? handoff.env.merge(PLACE_ENV => display_place) : handoff.env
1113
+ ok = system(env, RbConfig.ruby, bundle, "exec", "rails-ai-context", "context", *flags, chdir: Dir.pwd)
1114
+ return if ok
1115
+
1116
+ $stderr.puts "Error: the bundled copy could not write the context files - see the output above. " \
1117
+ "Retry with `#{own_command('context')}`."
1118
+ exit 1
1119
+ end
1120
+
1121
+ def refresh_flag_known?(version)
1122
+ Gem::Version.new(version.to_s) >= Gem::Version.new(MCP_REFRESH_FLAG_SINCE)
1123
+ rescue ArgumentError
1124
+ false
1125
+ end
1126
+
1127
+ # Another command for this app as this one was typed: from where it was
1128
+ # typed, with the --app-path it named, under bundle exec when it ran so.
1129
+ # A run the binary handed to the app's bundle was typed without it, and
1130
+ # typed again it is handed over again. Read from the environment the
1131
+ # binary was started in: the app's boot runs bundler/setup, which sets
1132
+ # BUNDLE_BIN_PATH in a standalone run too.
1133
+ def own_command(*args)
1134
+ result = @app_root_result
1135
+ typed = @app_path_override || options[:app_path]
1136
+ typed_bundled = STARTING_ENV["BUNDLE_BIN_PATH"] && !STARTING_ENV[app_root::HANDOFF_ENV]
1137
+ words = typed_bundled ? %w[bundle exec rails-ai-context] : %w[rails-ai-context]
1138
+ words += [ "--app-path", app_root.option_path(typed) ] if result&.explicit && typed && result.root != result.cwd
1139
+ app_root.command_line([ *words, *args ])
1140
+ end
1141
+
1142
+ def doctor_command
1143
+ own_command("doctor")
1144
+ end
1145
+
1146
+ # The root as an error names it: a typed --app-path as typed, the way the
1147
+ # hint under it does, where Dir.pwd spells it through any symlink
1148
+ # (/private/var on macOS).
1149
+ def shown_root
1150
+ result = @app_root_result
1151
+ result&.explicit ? result.root : Dir.pwd
1152
+ end
1153
+
1154
+ def refuse_no_app!
1155
+ $stderr.puts "Error: No Rails app found in #{shown_root}"
1156
+ $stderr.puts entry_boot::NO_APP_HINT
1157
+ $stderr.puts " #{@app_above_hint}" if @app_above_hint
560
1158
  exit 1
561
1159
  end
562
1160
 
1161
+ # Stdlib only, so it is safe to load before the app boots.
1162
+ def app_root
1163
+ entry_boot
1164
+ require_relative "../lib/rails_ai_context/cli/app_root"
1165
+ ::RailsAiContext::CLI::AppRoot
1166
+ end
1167
+
1168
+ # A gem this binary uses when it is there - `listen`, for watch and for
1169
+ # serve's live reload - is no dependency of it, so the binstub never
1170
+ # activated it, and the app's bundle hides every gem the app does not
1171
+ # lock: a standalone install could not see a listen installed beside it.
1172
+ # It is reached the way this gem's own dependencies are, found among the
1173
+ # installed gems before the boot and put back behind the app's bundle
1174
+ # after it, so a copy the app locks still wins. Under bundle exec the
1175
+ # bundle decides.
1176
+ def reach_installed_gem(name)
1177
+ return if ENV["BUNDLE_BIN_PATH"]
1178
+
1179
+ found = [ Gem::Specification.find_by_name(name) ]
1180
+ found.each do |spec|
1181
+ spec.runtime_dependencies.each { |dep| found << dep.to_spec unless found.any? { |had| had.name == dep.name } }
1182
+ end
1183
+ # A default gem loads from Ruby's own directories already, and the
1184
+ # binstub's stash leaves them out the same way.
1185
+ found.reject!(&:default_gem?)
1186
+ found.each { |spec| spec.full_require_paths.each { |path| $LOAD_PATH << path unless $LOAD_PATH.include?(path) } }
1187
+ entry_boot.preboot_gem_specs = found.to_h { |spec| [ spec.name, spec ] }.merge(entry_boot.preboot_gem_specs)
1188
+ rescue Gem::LoadError
1189
+ nil
1190
+ end
1191
+
563
1192
  # Stdlib only, so it is safe to load before the app boots.
564
1193
  def entry_boot
565
1194
  @entry_boot ||= begin
@@ -583,7 +1212,7 @@ class RailsAiContextCLI < Thor
583
1212
  def require_install_files!
584
1213
  %w[
585
1214
  gem_lock install/ai_tool install/selection_record install/cleanup
586
- install/surface install/program mcp_config_generator legacy_cleanup
1215
+ install/surface install/program mcp_config_generator install/workspace legacy_cleanup
587
1216
  ].each { |file| require_relative "../lib/rails_ai_context/#{file}" }
588
1217
 
589
1218
  # Both are defined in the entry file, which is the thing that must not