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
@@ -1,10 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
3
  require "fileutils"
5
- require "securerandom"
6
4
  require_relative "install_mode"
7
5
 
6
+ # json and strscan are gems an app's bundle pins, and the install loads this
7
+ # file before the app boots: required here, our copies loaded first and the
8
+ # app's then loaded over them ("already initialized constant"), leaving a
9
+ # mixed library. Each loads when first named, a rescue clause naming
10
+ # JSON::ParserError included.
11
+ autoload :JSON, "json"
12
+ autoload :StringScanner, "strscan"
13
+
8
14
  module RailsAiContext
9
15
  # Generates per-tool MCP config files so each AI tool auto-discovers the MCP server.
10
16
  #
@@ -14,11 +20,74 @@ module RailsAiContext
14
20
  # VS Code → .vscode/mcp.json (servers key)
15
21
  # OpenCode → opencode.json (mcp key, type: "local", command as array)
16
22
  # Codex CLI → .codex/config.toml (TOML, [mcp_servers.NAME] section)
23
+ #
24
+ # An app's own configs carry one server, rails-ai-context. A workspace's
25
+ # (a folder of apps, Install::Workspace) carry one per app, each named
26
+ # rails-ai-context-<app> and pointed at its app with --app-path.
17
27
  class McpConfigGenerator
18
28
  TOOL_CONFIGS = Install::AiTool.mcp_configs_by_key.freeze
19
29
 
20
30
  SERVER_NAME = "rails-ai-context"
21
31
 
32
+ # Every name this gem writes a server under: the app's own, and the
33
+ # workspace form. The names are the gem's: removal takes all of them, so
34
+ # dropping an AI tool leaves no workspace entry behind.
35
+ OWN_NAME = /#{Regexp.escape(SERVER_NAME)}(?:-[A-Za-z0-9_-]+)?/
36
+ OWN_SERVER_NAME = /\A#{OWN_NAME}\z/
37
+
38
+ # The name the server announces, where an entry sets one. A variable
39
+ # rather than a flag: an app whose bundle pins an older gem ignores it
40
+ # and starts, where an unknown flag would stop it (ADR-0005).
41
+ SERVER_NAME_ENV = "RAILS_AI_CONTEXT_SERVER_NAME"
42
+
43
+ # The byte order mark some editors put at the head of a JSON file.
44
+ BOM = "\uFEFF"
45
+
46
+ # On Windows a gem's executables are .bat files (rails-ai-context.bat,
47
+ # bundle.bat), which a client that starts its server without a shell -
48
+ # Node's child_process.spawn in Claude Code, Codex's Rust Command - does
49
+ # not run. cmd.exe is a program every client starts, and it runs them.
50
+ WINDOWS_SHELL = %w[cmd /c].freeze
51
+
52
+ # One server entry. app_path and gemfile are relative to the folder the
53
+ # config sits in, because the file may be committed and an absolute path
54
+ # holds only on the machine that wrote it. `folder` is how a tool's config
55
+ # names that folder (AiTool's folder_variable), where the tool has a name
56
+ # for it; elsewhere the path stays relative, read from the folder the tool
57
+ # starts the server in.
58
+ #
59
+ # `announce` is the name the server gives the client, where it differs
60
+ # from the entry's: VS Code names every tool after the announced name and
61
+ # keeps 13 characters of it, so a workspace server puts its app first.
62
+ Server = Struct.new(:name, :standalone, :app_path, :gemfile, :announce, keyword_init: true) do
63
+ # In-Gemfile installs go through the CLI binary because it quarantines
64
+ # app boot output away from stdout starting before Bundler.require; the
65
+ # rake task can only quarantine from the environment task onward.
66
+ def argv(folder = nil)
67
+ argv = standalone ? %w[rails-ai-context serve] : %w[bundle exec rails-ai-context serve]
68
+ argv = [ *argv, "--app-path", anchored(app_path, folder) ] if app_path
69
+ McpConfigGenerator.platform_argv(argv)
70
+ end
71
+
72
+ # bundle exec looks for a Gemfile from where it starts, upward, and a
73
+ # workspace entry starts in the folder above its app.
74
+ def env(folder = nil)
75
+ env = {}
76
+ env["BUNDLE_GEMFILE"] = anchored(gemfile, folder) if !standalone && gemfile
77
+ env[SERVER_NAME_ENV] = announce if announce
78
+ env
79
+ end
80
+
81
+ private
82
+
83
+ # A relative path that starts with a dash would be read as an option.
84
+ def anchored(path, folder)
85
+ return "#{folder}/#{path}" if folder
86
+
87
+ path.start_with?("-") ? "./#{path}" : path
88
+ end
89
+ end
90
+
22
91
  # @param tools [Array<Symbol>] selected AI tool keys (e.g. [:claude, :cursor])
23
92
  # @param output_dir [String] project root path
24
93
  # @param standalone [Boolean, nil] true writes bare `rails-ai-context`
@@ -28,20 +97,29 @@ module RailsAiContext
28
97
  # generator) converges on identical config content for the same app
29
98
  # instead of rewriting each other's command form on alternate runs.
30
99
  # @param tool_mode [Symbol] :mcp or :cli
31
- def initialize(tools:, output_dir:, standalone: nil, tool_mode: :mcp)
100
+ # @param servers [Array<Server>, nil] the entries to write; nil writes the
101
+ # app's own single server, in the install mode `standalone` names.
102
+ def initialize(tools:, output_dir:, standalone: nil, tool_mode: :mcp, servers: nil)
32
103
  @tools = Array(tools).map(&:to_sym)
33
- @output_dir = output_dir
34
- @standalone = standalone.nil? ? InstallMode.standalone? : standalone
104
+ @output_dir = output_dir.to_s
35
105
  @tool_mode = tool_mode
106
+ @servers = servers || [
107
+ Server.new(name: SERVER_NAME, standalone: standalone.nil? ? InstallMode.standalone? : standalone)
108
+ ]
36
109
  end
37
110
 
38
- # @return [Hash] { written: [paths], skipped: [paths], failed: [paths] }
111
+ # @return [Hash] { written: [paths], skipped: [paths], failed: [paths],
112
+ # reasons: { path => why it failed }, notes: { path => an entry kept
113
+ # in place of the gem's } }, the reasons and notes for the caller to
114
+ # say in its own voice
39
115
  def call
40
- return { written: [], skipped: [], failed: [] } if @tool_mode == :cli
116
+ return { written: [], skipped: [], failed: [], reasons: {}, notes: {} } if @tool_mode == :cli
41
117
 
42
118
  written = []
43
119
  skipped = []
44
120
  failed = []
121
+ reasons = {}
122
+ @notes = {}
45
123
 
46
124
  @tools.each do |tool|
47
125
  config = TOOL_CONFIGS[tool]
@@ -52,9 +130,9 @@ module RailsAiContext
52
130
  # unreadable or unwritable config is one tool's auto-discovery lost,
53
131
  # not a reason to abandon the install.
54
132
  result = begin
55
- generate_for(tool, path, config)
56
- rescue SystemCallError, IOError => e
57
- RailsAiContext.log_warn "[rails-ai-context] could not write #{config[:path]}: #{e.message}"
133
+ generate_for(path, config)
134
+ rescue SystemCallError, IOError, JSON::JSONError, ShapeError => e
135
+ reasons[path] = e.message
58
136
  :failed
59
137
  end
60
138
 
@@ -65,109 +143,729 @@ module RailsAiContext
65
143
  end
66
144
  end
67
145
 
68
- { written: written, skipped: skipped, failed: failed }
146
+ { written: written, skipped: skipped, failed: failed, reasons: reasons, notes: @notes }
147
+ end
148
+
149
+ # The entries a write puts under a JSON config's servers key, by name,
150
+ # as it writes them: what a config it leaves as it is needs by hand.
151
+ def json_entries(config)
152
+ @servers.to_h { |server| [ server.name, json_entry(server, config) ] }
153
+ end
154
+
155
+ # A config that parses but holds no object to merge into, at the top or
156
+ # under its servers key: replacing it would drop what it holds.
157
+ class ShapeError < StandardError; end
158
+
159
+ # A JSON config its client cannot read: one that reads JSON refuses the
160
+ # comments and trailing commas a JSONC reader passes over.
161
+ class UnreadableError < StandardError; end
162
+
163
+ # A JSON config in another encoding: JSON is UTF-8, so the fix is the
164
+ # file's encoding, not an entry added to it.
165
+ class NotUtf8Error < StandardError; end
166
+
167
+ # Codex reads its config as TOML, which is UTF-8, and refuses one that
168
+ # is not ("stream did not contain valid UTF-8"), starting no server.
169
+ TOML_NOT_UTF8 = "Codex CLI cannot read it: it is not UTF-8, which TOML is"
170
+
171
+ # Codex's config read without a TOML parser, which the gem does not
172
+ # depend on. The file is split into its statements - table headers and
173
+ # key/value pairs, each with the full key path TOML gives it - so a
174
+ # server is found however its name is spelled, quoted or not, whether as
175
+ # a table, an inline table or dotted keys; everything else in the file is
176
+ # copied through untouched.
177
+ module Toml
178
+ # One statement: a :table or :array_table header, or a :pair. path is
179
+ # the full key path - a pair's is its table's and its own dotted key -
180
+ # nil for a header this cannot read and for every pair after it. first
181
+ # and last are the lines it spans; a pair's value is its text.
182
+ Statement = Struct.new(:kind, :path, :first, :last, :value)
183
+
184
+ # Everything the file says about one server: its [mcp_servers.<name>]
185
+ # tables, as line ranges; every other statement that defines part of
186
+ # it - an inline table, dotted keys from outside its table, a sub-table
187
+ # away from it, mcp_servers itself as an inline table; and its command
188
+ # line and env, read from all of them.
189
+ Server = Struct.new(:name, :sections, :elsewhere, :argv, :env, :twice) do
190
+ # Its one table, or none, is all there is of it: the merge can
191
+ # replace it where it stands, or add it.
192
+ def rewritable?
193
+ elsewhere.empty?
194
+ end
195
+
196
+ # Defined twice over, which Codex refuses to read: a second table, or
197
+ # an inline table or dotted keys beside its table.
198
+ def twice?
199
+ twice
200
+ end
201
+ end
202
+
203
+ SERVERS = "mcp_servers"
204
+ BARE_KEY = /[A-Za-z0-9_-]+/
205
+ BASIC = /"(?:[^"\\\n]|\\.)*"/
206
+ LITERAL = /'[^'\n]*'/
207
+ MULTILINE_BASIC = /"""(?:[^"\\]|\\.|"(?!""))*"""(?:""?)?/m
208
+ MULTILINE_LITERAL = /'''(?:[^']|'(?!''))*'''(?:''?)?/m
209
+
210
+ module_function
211
+
212
+ # The file's statements, in order.
213
+ def statements(lines)
214
+ text = lines.join
215
+ # Where each line starts, in bytes, as StringScanner counts.
216
+ starts = lines.each_with_object([ 0 ]) { |line, offsets| offsets << offsets.last + line.bytesize }
217
+ line_at = ->(pos) { (starts.bsearch_index { |offset| offset > pos } || lines.size) - 1 }
218
+ scanner = StringScanner.new(text)
219
+ table = []
220
+ found = []
221
+ until scanner.eos?
222
+ next if scanner.skip(/[ \t\r\n]+|#[^\n]*/)
223
+
224
+ start = scanner.pos
225
+ if scanner.skip(/\[\[/) || scanner.skip(/\[/)
226
+ kind = text.byteslice(start, 2) == "[[" ? :array_table : :table
227
+ path = key_path(scanner)
228
+ path = nil unless path && scanner.skip(kind == :table ? /[ \t]*\]/ : /[ \t]*\]\]/)
229
+ table = path
230
+ found << Statement.new(kind, path, line_at.call(start), line_at.call(start), nil)
231
+ elsif (key = key_path(scanner)) && scanner.skip(/[ \t]*=[ \t]*/)
232
+ from = scanner.pos
233
+ skip_value(scanner)
234
+ found << Statement.new(:pair, table && table + key, line_at.call(start), line_at.call([ scanner.pos - 1, start ].max),
235
+ text.byteslice(from...scanner.pos))
236
+ end
237
+ # The rest of the line: a comment, or what could not be read.
238
+ scanner.skip(/[^\n]*/)
239
+ end
240
+ found
241
+ end
242
+
243
+ # A dotted key, its parts bare or quoted: nil where there is none.
244
+ def key_path(scanner)
245
+ path = []
246
+ loop do
247
+ scanner.skip(/[ \t]*/)
248
+ part = if (bare = scanner.scan(BARE_KEY)) then bare
249
+ elsif (basic = scanner.scan(BASIC)) then unescape(basic)
250
+ elsif (literal = scanner.scan(LITERAL)) then literal[1..-2]
251
+ end
252
+ return nil unless part
253
+
254
+ path << part
255
+ scanner.skip(/[ \t]*/)
256
+ return path unless scanner.skip(/\./)
257
+ end
258
+ end
259
+
260
+ # Past one value, however many lines its strings, arrays and inline
261
+ # tables span; to the end of its line, not over it.
262
+ def skip_value(scanner)
263
+ depth = 0
264
+ until scanner.eos?
265
+ if scanner.skip(MULTILINE_BASIC) || scanner.skip(MULTILINE_LITERAL) || scanner.skip(BASIC) || scanner.skip(LITERAL)
266
+ next
267
+ elsif scanner.skip(/[\[{]/)
268
+ depth += 1
269
+ elsif scanner.skip(/[\]}]/)
270
+ depth -= 1
271
+ elsif scanner.skip(/#[^\n]*/)
272
+ next
273
+ elsif scanner.check(/\r?\n/)
274
+ break if depth <= 0
275
+
276
+ scanner.skip(/\r?\n/)
277
+ elsif !scanner.skip(/[^"'\[\]{}#\r\n]+/)
278
+ scanner.getch
279
+ end
280
+ end
281
+ end
282
+
283
+ # A value as data, as far as a server's entry needs one: a string, an
284
+ # array, an inline table; nil for any other scalar.
285
+ def value(text)
286
+ parse_value(StringScanner.new(text))
287
+ end
288
+
289
+ def parse_value(scanner)
290
+ scanner.skip(/(?:[ \t\r\n]+|#[^\n]*)*/)
291
+ if scanner.skip(/\{/)
292
+ items(scanner, "}", {}) do |table|
293
+ path = key_path(scanner) or next
294
+ next unless scanner.skip(/[ \t]*=/)
295
+
296
+ *outer, last = path
297
+ holder = outer.inject(table) { |hash, key| hash[key].is_a?(Hash) ? hash[key] : (hash[key] = {}) }
298
+ holder[last] = parse_value(scanner)
299
+ end
300
+ elsif scanner.skip(/\[/)
301
+ items(scanner, "]", []) { |array| array << parse_value(scanner) }
302
+ elsif (text = scanner.scan(MULTILINE_BASIC) || scanner.scan(MULTILINE_LITERAL))
303
+ # Escapes and line-ending backslashes left as they are: no entry
304
+ # the gem reads spans lines.
305
+ text[3..-4].sub(/\A\r?\n/, "")
306
+ elsif (text = scanner.scan(BASIC))
307
+ unescape(text)
308
+ elsif (text = scanner.scan(LITERAL))
309
+ text[1..-2]
310
+ else
311
+ scanner.skip(/[^,\]}\s#]*/)
312
+ nil
313
+ end
314
+ end
315
+
316
+ # The items of an array or inline table, each read by the block, up to
317
+ # its closing bracket. One that cannot be read ends it.
318
+ def items(scanner, close, into)
319
+ loop do
320
+ scanner.skip(/(?:[ \t\r\n,]+|#[^\n]*)*/)
321
+ break if scanner.eos? || scanner.skip(/#{Regexp.escape(close)}/)
322
+
323
+ at = scanner.pos
324
+ yield into
325
+ break if scanner.pos == at
326
+ end
327
+ into
328
+ end
329
+
330
+ # The tables of the servers `keep` accepts, by name, as [line range,
331
+ # name]. A table runs to the next header that is not one of its own
332
+ # sub-tables; blank and comment lines just above that header stay out
333
+ # of it, since a comment over a header belongs to the header.
334
+ def sections(lines, found = statements(lines), &keep)
335
+ headers = found.reject { |statement| statement.kind == :pair }
336
+ headers.each_with_index.filter_map do |header, at|
337
+ next unless header.kind == :table && server_path?(header.path) && keep.call(header.path[1])
338
+
339
+ after = headers[(at + 1)..].find { |other| !under?(other.path, header.path) }
340
+ finish = after ? after.first : lines.size
341
+ finish -= 1 while finish > header.first + 1 && trivia?(lines[finish - 1])
342
+ [ header.first...finish, header.path[1] ]
343
+ end
344
+ end
345
+
346
+ # The tables of the gem's own servers, by the rule own_entry? keeps:
347
+ # [line range, name] each.
348
+ def own_sections(lines, found = statements(lines))
349
+ sections(lines, found) { |name| name.match?(OWN_SERVER_NAME) }.select do |range, name|
350
+ McpConfigGenerator.own_entry?(name, argv(lines, range, found))
351
+ end
352
+ end
353
+
354
+ # Each server `keep` accepts, by name, as the whole file defines it.
355
+ def servers(lines, found = statements(lines), &keep)
356
+ tables = sections(lines, found, &keep)
357
+ whole = found.find { |statement| statement.kind == :pair && statement.path == [ SERVERS ] }
358
+ whole_value = whole && value(whole.value)
359
+ names = found.filter_map { |statement| statement.path[1] if statement.path&.size.to_i >= 2 && statement.path[0] == SERVERS }
360
+ names = (names + (whole_value.is_a?(Hash) ? whole_value.keys : [])).uniq
361
+
362
+ names.select(&keep).map do |name|
363
+ ranges = tables.filter_map { |range, table_name| range if table_name == name }
364
+ defining = found.select { |statement| statement.path && statement.path.size >= 2 && statement.path.first(2) == [ SERVERS, name ] }
365
+ elsewhere = defining.reject { |statement| ranges.any? { |range| range.cover?(statement.first) } }
366
+ elsewhere << whole if whole_value.is_a?(Hash) && whole_value.key?(name)
367
+
368
+ data = entry_data(defining)
369
+ data = whole_value[name].merge(data) if whole_value.is_a?(Hash) && whole_value[name].is_a?(Hash)
370
+ env = data["env"].is_a?(Hash) ? data["env"].filter_map { |key, item| [ key, item ] if item.is_a?(String) }.to_h : {}
371
+ Server.new(name, ranges, elsewhere, [ *data["command"], *data["args"] ].grep(String), env, twice?(ranges, elsewhere))
372
+ end
373
+ end
374
+
375
+ # A second table, a second inline table, or a table beside an inline
376
+ # table or dotted keys from outside it: what no TOML reader accepts.
377
+ def twice?(ranges, elsewhere)
378
+ inline = elsewhere.count { |statement| statement.kind == :pair && statement.path.size <= 2 }
379
+ dotted = elsewhere.any? { |statement| statement.kind == :pair && statement.path.size > 2 }
380
+ detached = elsewhere.any? { |statement| statement.kind != :pair }
381
+ ranges.size + inline + (dotted ? 1 : 0) > 1 || (inline.positive? && (dotted || detached))
382
+ end
383
+
384
+ # Why no [mcp_servers.<name>] table can be added to the file: when
385
+ # mcp_servers itself is an inline table or an array of tables. nil
386
+ # when one can.
387
+ def closed(found)
388
+ return unless found.any? { |statement| statement.path == [ SERVERS ] && statement.kind != :table }
389
+
390
+ "its #{SERVERS} is an inline table or an array of tables, beside which no [#{SERVERS}.<name>] table can go"
391
+ end
392
+
393
+ # One server's keys as data, from the pairs that define them.
394
+ def entry_data(defining)
395
+ defining.select { |statement| statement.kind == :pair }.each_with_object({}) do |statement, data|
396
+ keys = statement.path.drop(2)
397
+ item = value(statement.value)
398
+ next data.merge!(item) { |_, mine, theirs| mine || theirs } if keys.empty? && item.is_a?(Hash)
399
+ next if keys.empty?
400
+
401
+ *outer, last = keys
402
+ holder = outer.inject(data) { |hash, key| hash[key].is_a?(Hash) ? hash[key] : (hash[key] = {}) }
403
+ holder[last] = item unless holder.key?(last)
404
+ end
405
+ end
406
+
407
+ # One section's command line, its `command` and `args`, read from the
408
+ # table itself and not its sub-tables.
409
+ def argv(lines, range, found = statements(lines))
410
+ data = section_data(lines, range, found)
411
+ [ *data["command"], *data["args"] ].grep(String)
412
+ end
413
+
414
+ # One sub-table of a section, `[mcp_servers.<name>.<key>]` or the
415
+ # section's own `<key>.<item> = ...`, as each item's string value.
416
+ def sub_table(lines, range, _name, key, found = statements(lines))
417
+ table = section_data(lines, range, found)[key]
418
+ return {} unless table.is_a?(Hash)
419
+
420
+ table.filter_map { |item, values| [ item, Array(values).grep(String).first ] if Array(values).grep(String).any? }.to_h
421
+ end
422
+
423
+ def section_data(lines, range, found)
424
+ header = found.find { |statement| statement.kind == :table && statement.first == range.begin && server_path?(statement.path) }
425
+ return {} unless header
426
+
427
+ entry_data(found.select { |statement| range.cover?(statement.first) && under?(statement.path, header.path) })
428
+ end
429
+
430
+ # The strings at the start of a value: a basic ("...") or literal
431
+ # ('...') string, or an array of them however a formatter wrapped it,
432
+ # with comments and a trailing comma.
433
+ def strings(text)
434
+ Array(value(text)).grep(String)
435
+ end
436
+
437
+ # TOML's basic-string escapes are JSON's, but for \U and \e. A file that
438
+ # is not UTF-8 is read as bytes, and json reads a string out of those as
439
+ # UTF-8 it is not, which the first pattern matched against it raised on:
440
+ # such a string stays the bytes it is.
441
+ def unescape(basic)
442
+ text = JSON.parse(basic)
443
+ text.is_a?(String) && text.valid_encoding? ? text : basic[1..-2]
444
+ rescue JSON::ParserError
445
+ basic[1..-2]
446
+ end
447
+
448
+ def server_path?(path)
449
+ path&.size == 2 && path[0] == SERVERS
450
+ end
451
+
452
+ # Whether path is a key or table inside the table at `table`.
453
+ def under?(path, table)
454
+ !path.nil? && path.size > table.size && path.first(table.size) == table
455
+ end
456
+
457
+ def trivia?(line)
458
+ stripped = line.strip
459
+ stripped.empty? || stripped.start_with?("#")
460
+ end
461
+ end
462
+
463
+ # The directory an entry's --app-path names, read the way its tool reads
464
+ # it: the tool's name for its config's folder is that folder, and a bare
465
+ # relative path starts there too. Any other variable (${HOME},
466
+ # ${userHome}) is the tool's to expand, so such a path names no app this
467
+ # can judge.
468
+ #
469
+ # @return [String, nil] absolute, nil when the entry names no app
470
+ def self.entry_app_root(argv, folder, dir)
471
+ argv = argv.map(&:to_s)
472
+ at = argv.index("--app-path")
473
+ path = at ? argv[at + 1] : argv.find { |arg| arg.start_with?("--app-path=") }&.delete_prefix("--app-path=")
474
+ return nil if path.nil? || path.empty?
475
+
476
+ entry_path(path, folder, dir)
477
+ end
478
+
479
+ # A path an entry holds (its --app-path, its BUNDLE_GEMFILE), read the
480
+ # way entry_app_root reads one.
481
+ #
482
+ # @return [String, nil] absolute, nil when another variable is the tool's to expand
483
+ def self.entry_path(path, folder, dir)
484
+ path = dir + path.delete_prefix(folder) if folder && path.start_with?(folder)
485
+ return nil if path.include?("$")
486
+
487
+ File.expand_path(path, dir)
488
+ end
489
+
490
+ # Whether this platform's clients start a gem's executable only through
491
+ # `cmd /c`: Windows', where it is a .bat file.
492
+ def self.windows_shell?
493
+ Gem.win_platform?
494
+ end
495
+
496
+ # The command line as the platform's clients can start it.
497
+ def self.platform_argv(argv)
498
+ windows_shell? ? [ *WINDOWS_SHELL, *argv ] : argv
499
+ end
500
+
501
+ # The command line an entry runs, inside a `cmd /c` it was given on
502
+ # Windows (`cmd`, `cmd.exe`, `C:\Windows\System32\cmd.exe`).
503
+ def self.unwrapped_argv(argv)
504
+ argv = Array(argv).map(&:to_s)
505
+ return argv unless argv.size > 2 && File.basename(argv[0].tr("\\", "/"), ".*").casecmp?("cmd") && argv[1].casecmp?("/c")
506
+
507
+ argv[2..]
508
+ end
509
+
510
+ # A JSON entry's command line: OpenCode's command array, or command plus args.
511
+ def self.json_argv(entry)
512
+ entry.is_a?(Hash) ? [ *entry["command"], *entry["args"] ] : []
513
+ end
514
+
515
+ # Whether an entry is the gem's: the app's own by its name and a command,
516
+ # which every version has written in one form or another, and a
517
+ # workspace one by its name and by running the gem's server. An entry
518
+ # with no command - an HTTP one, `rails-ai-context-prod` or the bare
519
+ # name - is whoever made it's: the gem writes none.
520
+ def self.own_entry?(name, argv)
521
+ argv = argv.map(&:to_s)
522
+ return !argv.empty? if name == SERVER_NAME
523
+ return false unless name.match?(OWN_SERVER_NAME)
524
+
525
+ at = argv.index("rails-ai-context")
526
+ !at.nil? && argv[at + 1] == "serve"
527
+ end
528
+
529
+ # A config's text past an editor's byte order mark, and the mark, which
530
+ # is the file's and goes back at its head when it is written.
531
+ def self.split_bom(text)
532
+ return [ text, "".dup.force_encoding(text.encoding) ] unless text.b.start_with?(BOM.b)
533
+
534
+ [ text.byteslice(BOM.bytesize..).force_encoding(text.encoding), BOM.dup.force_encoding(text.encoding) ]
535
+ end
536
+
537
+ # A JSON config's text and byte order mark, and why it cannot be read
538
+ # as JSON at all: JSON is UTF-8.
539
+ #
540
+ # @return [Array(String, String, String)] text, mark, problem (nil when none)
541
+ def self.json_text(path)
542
+ text, bom = split_bom(RailsAiContext::SafeFile.read_text(path))
543
+ [ text, bom, text.encoding == Encoding::BINARY ? "it is not UTF-8, which JSON is" : nil ]
544
+ end
545
+
546
+ # A JSON config as data, read as its client reads it (config, its
547
+ # TOOL_CONFIGS entry). One that is not UTF-8 raises as one that does not
548
+ # parse.
549
+ def self.read_json(path, config = nil)
550
+ text, _, problem = json_text(path)
551
+ raise JSON::ParserError, problem if problem
552
+
553
+ parse_json_text(text, config)
554
+ end
555
+
556
+ # A config's JSON read the way its client reads it. A JSONC client - VS
557
+ # Code, OpenCode - passes over comments and trailing commas; a JSON one -
558
+ # Claude Code, Cursor - refuses the whole file, which raises
559
+ # UnreadableError: such a file is no config of the client's, however this
560
+ # parser reads it. Without a config it is read as JSONC.
561
+ #
562
+ # json 2 passed over comments unasked (2.10 on with a deprecation
563
+ # warning) and json 3 refuses one unless told, so the same file read
564
+ # differently by which json the process had: comments are allowed
565
+ # outright, and looked for here. A json before 2.9 knows no trailing
566
+ # commas and ignores the option, refusing them still.
567
+ def self.parse_json_text(text, config = nil)
568
+ if config && config[:jsonc] == false && (refused = jsonc_only(text))
569
+ raise UnreadableError, "#{config[:client]} cannot read it: it holds #{refused}, which JSON does not allow"
570
+ end
571
+
572
+ JSON.parse(text, allow_comments: true, allow_trailing_comma: true)
573
+ end
574
+
575
+ # What a JSONC reader passes over and a JSON one refuses, as the text
576
+ # holds it: comments, a trailing comma, or both. nil for neither.
577
+ def self.jsonc_only(text)
578
+ found = [ ("comments" if json_comments?(text)), ("a trailing comma" if json_trailing_comma?(text)) ].compact
579
+ found.join(" and ") unless found.empty?
580
+ end
581
+
582
+ COMMENTS_PROBLEM = "it holds comments, which writing it back as JSON would drop"
583
+
584
+ # Where a JSON parser stopped, when it says (json 2.10 on), and the
585
+ # trailing comma a json before 2.9 stops at, when the file has one; the
586
+ # parser's own words can run to the whole file. json 3 refuses the
587
+ # comments json 2 passed over, so a file holding them is named for them
588
+ # whichever parser read it.
589
+ def self.parse_problem(error, text = nil)
590
+ return error.message if error.is_a?(UnreadableError)
591
+ return COMMENTS_PROBLEM if text && json_comments?(text)
592
+
593
+ where = error.message[/line \d+,? column \d+/]
594
+ "it does not parse as JSON#{" at #{where}" if where}#{' (a trailing comma?)' if text && json_trailing_comma?(text)}"
595
+ end
596
+
597
+ # Whether JSON text closes an array or object right after a comma.
598
+ # Strings are matched whole, as for comments.
599
+ def self.json_trailing_comma?(text)
600
+ text.scan(/"(?:[^"\\]|\\.)*"|,\s*[\]}]/).any? { |token| !token.start_with?('"') }
601
+ end
602
+
603
+ # Whether JSON text holds a comment, which JSON.parse passes over and a
604
+ # rewrite would drop. Strings are matched whole, so a URL's // is none.
605
+ def self.json_comments?(text)
606
+ text.scan(%r{"(?:[^"\\]|\\.)*"|//|/\*}).any? { |token| !token.start_with?('"') }
607
+ end
608
+
609
+ # A Codex config's servers under the gem's names, read the way the merge
610
+ # reads them, and why no server table can be added to it (nil when one
611
+ # can).
612
+ #
613
+ # @return [Array(Array<Toml::Server>, String)]
614
+ def self.toml_servers(path)
615
+ lines = split_bom(RailsAiContext::SafeFile.read_text(path)).first.lines
616
+ found = Toml.statements(lines)
617
+ [ Toml.servers(lines, found) { |name| name.match?(OWN_SERVER_NAME) }, Toml.closed(found) ]
618
+ end
619
+
620
+ # Why the gem's entries in a Codex config stay as they are.
621
+ def self.unwritable_toml(names)
622
+ "it sets #{names.join(', ')} as an inline table or with dotted keys, which the install does not rewrite"
69
623
  end
70
624
 
71
625
  private
72
626
 
73
- # The root key comes from the AiTool table, the same table self.remove
74
- # reads. OpenCode takes the whole command as one array; the other JSON
75
- # tools take command plus args, with type left off - stdio is inferred
76
- # from command being present.
77
- def generate_for(_tool, path, config)
78
- return merge_toml(path, build_codex_section) if config[:format] == :codex_toml
79
-
80
- entry = if config[:format] == :opencode_json
81
- { "type" => "local", "command" => server_command }
82
- else
83
- { "command" => server_command.first, "args" => server_command[1..] }
627
+ # What a write drops besides replacing its own entries, from the entries
628
+ # of ours already there (name => the app root each names). An app's own
629
+ # write drops nothing. A workspace's drops the bare entry, which would
630
+ # serve the folder itself, and an entry under a name it gives an app in
631
+ # this folder, when that app is gone or now goes by another name. Every
632
+ # other entry stays as somebody's own: a second one named by hand, one
633
+ # naming an app outside the folder, or one only another machine has.
634
+ def stale_names(existing)
635
+ return [] unless workspace_write?
636
+
637
+ names = @servers.map(&:name)
638
+ roots = @servers.map { |server| RailsAiContext::SafePath.canonical(File.expand_path(server.app_path, @output_dir)) }
639
+ inside = "#{@output_dir.delete_suffix('/')}/"
640
+ existing.filter_map do |name, app_root|
641
+ next name if name == SERVER_NAME
642
+ next if names.include?(name) || app_root.nil? || !app_root.start_with?(inside)
643
+ next unless Install::Workspace.generated_name?(name, app_root.delete_prefix(inside))
644
+
645
+ name if !File.directory?(app_root) || roots.include?(RailsAiContext::SafePath.canonical(app_root))
84
646
  end
85
- merge_json(path, config[:root_key], entry)
647
+ end
648
+
649
+ # A workspace's write: per-app servers, with the app's own bare one gone.
650
+ def workspace_write?
651
+ @servers.none? { |server| server.name == SERVER_NAME }
652
+ end
653
+
654
+ # The root key comes from the AiTool table, the same table self.remove
655
+ # reads. OpenCode takes the whole command as one array and calls its
656
+ # environment `environment`; the other JSON tools take command plus args
657
+ # and `env`, with type left off - stdio is inferred from command.
658
+ def generate_for(path, config)
659
+ return merge_toml(path) if config[:format] == :codex_toml
660
+
661
+ merge_json(path, config, json_entries(config))
662
+ end
663
+
664
+ def json_entry(server, config)
665
+ argv = server.argv(config[:folder_variable])
666
+ env = server.env(config[:folder_variable])
667
+ opencode = config[:format] == :opencode_json
668
+ entry = opencode ? { "type" => "local", "command" => argv } : { "command" => argv.first, "args" => argv[1..] }
669
+ entry[opencode ? "environment" : "env"] = env unless env.empty?
670
+ entry
86
671
  end
87
672
 
88
673
  # --- JSON merge logic ---
89
674
 
90
- def merge_json(path, root_key, entry)
675
+ def merge_json(path, config, entries)
91
676
  FileUtils.mkdir_p(File.dirname(path))
92
677
 
93
- if File.exist?(path)
94
- existing = begin
95
- JSON.parse(File.read(path))
96
- rescue JSON::ParserError
97
- {}
98
- end
99
- existing[root_key] ||= {}
678
+ text, bom, problem = File.exist?(path) ? self.class.json_text(path) : [ nil, "", nil ]
679
+ raise NotUtf8Error, problem if problem
100
680
 
101
- if existing[root_key][SERVER_NAME] == entry
102
- return :skipped
103
- end
681
+ # An empty file holds nothing to lose.
682
+ data = text.nil? || text.strip.empty? ? {} : parse_json(text, config)
683
+ raise ShapeError, "it is JSON but not an object" unless data.is_a?(Hash)
104
684
 
105
- existing[root_key][SERVER_NAME] = entry
106
- RailsAiContext::SafeFile.atomic_write(path, JSON.pretty_generate(existing) + "\n")
107
- else
108
- content = JSON.pretty_generate({ root_key => { SERVER_NAME => entry } }) + "\n"
109
- RailsAiContext::SafeFile.atomic_write(path, content)
685
+ root_key = config[:root_key]
686
+ data[root_key] ||= {}
687
+ servers = data[root_key]
688
+ raise ShapeError, %("#{root_key}" is not an object) unless servers.is_a?(Hash)
689
+
690
+ ours = servers.filter_map do |name, entry|
691
+ argv = self.class.json_argv(entry)
692
+ [ name, self.class.entry_app_root(argv, config[:folder_variable], @output_dir) ] if self.class.own_entry?(name, argv)
110
693
  end
694
+ # An entry under one of this write's names that is not the gem's - the
695
+ # HTTP one SETUP.md describes - is kept in place of the gem's.
696
+ kept = entries.keys.select { |name| servers.key?(name) && !self.class.own_entry?(name, self.class.json_argv(servers[name])) }
697
+ note_kept(path, kept)
698
+ entries = entries.except(*kept)
699
+ stale = stale_names(ours.to_h)
700
+ return :skipped if text && stale.empty? && entries.all? { |name, entry| servers[name] == entry }
701
+ # JSON.parse passes over comments, and the file written back would
702
+ # have none.
703
+ raise ShapeError, COMMENTS_PROBLEM if text && self.class.json_comments?(text)
111
704
 
705
+ stale.each { |name| servers.delete(name) }
706
+ servers.merge!(entries)
707
+ RailsAiContext::SafeFile.atomic_write(path, bom + JSON.pretty_generate(data) + "\n")
112
708
  :written
709
+ rescue NotUtf8Error => e
710
+ # The entry added by hand would sit in a file JSON still cannot read.
711
+ raise ShapeError, "#{e.message}, so it is left as it is. Save it as UTF-8, and the next run merges into it"
712
+ rescue UnreadableError => e
713
+ # Adding the entry by hand would leave a file its client still cannot read.
714
+ raise ShapeError, "#{e.message}, so it is left as it is. Make it plain JSON by hand, and the next run merges into it"
715
+ rescue ShapeError => e
716
+ raise ShapeError, "#{e.message}, so it is left as it is. Add #{JSON.generate(config[:root_key] => entries)} to it by hand"
113
717
  end
114
718
 
115
- # --- TOML merge logic ---
719
+ # A file JSON cannot parse is never replaced: a fresh file would drop
720
+ # everything somebody wrote there.
721
+ def parse_json(text, config)
722
+ self.class.parse_json_text(text, config)
723
+ rescue JSON::ParserError => e
724
+ raise ShapeError, self.class.parse_problem(e, text)
725
+ end
726
+
727
+ def note_kept(path, kept)
728
+ return if kept.empty?
729
+
730
+ @notes[path] = "kept #{kept.join(', ')}, which #{kept.one? ? 'runs' : 'run'} something other than the gem's " \
731
+ "server (an HTTP entry?), in place of the gem's"
732
+ end
116
733
 
117
- TOML_SECTION_HEADER = "[mcp_servers.#{SERVER_NAME}]"
118
- # Matches the [mcp_servers.rails-ai-context] section and any sub-sections
119
- # like [mcp_servers.rails-ai-context.env], stopping at a non-sub-section header.
120
- TOML_SECTION_REGEX = /
121
- ^\[mcp_servers\.rails-ai-context\]\s*\n # main section header
122
- (?:(?!\n\[(?!mcp_servers\.rails-ai-context\.))[^\n]*\n)* # lines until non-sub-section
123
- (?:(?!\n?\[(?!mcp_servers\.rails-ai-context\.))[^\n]+)? # optional last line without \n
124
- /mx
734
+ # --- TOML merge logic ---
125
735
 
126
- def merge_toml(path, section)
736
+ # Each server's section is replaced where it stands, a stale one is
737
+ # dropped, and a new one goes at the end after a blank line.
738
+ def merge_toml(path)
127
739
  FileUtils.mkdir_p(File.dirname(path))
740
+ content, bom = self.class.split_bom(File.exist?(path) ? RailsAiContext::SafeFile.read_text(path) : +"")
741
+ lines = content.lines
742
+ # A file kept with Windows line endings keeps them, and one that is not
743
+ # UTF-8 gets the new sections as bytes beside its own.
744
+ newline = content.include?("\r\n") ? "\r\n" : "\n"
745
+ by_name = @servers.to_h do |server|
746
+ section = toml_section(server).gsub("\n", newline)
747
+ [ server.name, content.encoding == Encoding::BINARY ? section.b : section ]
748
+ end
128
749
 
129
- if File.exist?(path)
130
- content = File.read(path)
750
+ found = Toml.statements(lines)
751
+ own_name = ->(name) { name.match?(OWN_SERVER_NAME) }
752
+ named = Toml.sections(lines, found, &own_name)
753
+ ours = Toml.own_sections(lines, found).map do |range, name|
754
+ [ name, self.class.entry_app_root(Toml.argv(lines, range, found), nil, @output_dir) ]
755
+ end
756
+ # A server of this write's spelled any way but one table - an inline
757
+ # table, dotted keys - is never written beside: a table next to it
758
+ # declares the server twice, which Codex refuses to read.
759
+ unwritable = Toml.servers(lines, found, &own_name).select { |server| by_name.key?(server.name) && !server.rewritable? }
760
+ check_toml_shape(found, unwritable)
761
+ # A section or entry under one of this write's names that is not the
762
+ # gem's is kept in place of the gem's, which would be a second table of
763
+ # one name, and that does not parse.
764
+ kept = named.map(&:last).select { |name| by_name.key?(name) } - ours.map(&:first) + unwritable.map(&:name)
765
+ note_kept(path, kept.uniq)
766
+ # Written all the same, as it always was: the bytes are the user's.
767
+ @notes[path] = [ @notes[path], TOML_NOT_UTF8 ].compact.join("; ") if content.encoding == Encoding::BINARY
768
+ by_name = by_name.except(*kept)
769
+ drop = stale_names(ours.to_h)
770
+ starts = named.select { |_, name| by_name.key?(name) || drop.include?(name) }
771
+ .to_h { |range, name| [ range.begin, [ range, name ] ] }
131
772
 
132
- if content.include?(TOML_SECTION_HEADER)
133
- new_content = content.sub(TOML_SECTION_REGEX, section)
134
- if new_content == content
135
- return :skipped
136
- end
137
- RailsAiContext::SafeFile.atomic_write(path, new_content)
773
+ out = []
774
+ written = {}
775
+ dropped = false
776
+ index = 0
777
+ while index < lines.size
778
+ range, name = starts[index]
779
+ if range.nil?
780
+ out << lines[index]
781
+ index += 1
782
+ elsif by_name.key?(name) && !written.key?(name)
783
+ out << by_name.fetch(name)
784
+ written[name] = true
785
+ index = range.end
138
786
  else
139
- # Append our section
140
- separator = content.end_with?("\n") ? "\n" : "\n\n"
141
- RailsAiContext::SafeFile.atomic_write(path, content + separator + section)
787
+ # A dropped section takes the blank line under it when the one above
788
+ # is blank too, so the gap it leaves does not double. A comment above
789
+ # it stays: it may be a key of the table before, set aside by hand.
790
+ index = range.end
791
+ index += 1 if (out.empty? || out.last.strip.empty?) && lines[index]&.strip&.empty?
792
+ dropped = true
142
793
  end
143
- else
144
- RailsAiContext::SafeFile.atomic_write(path, section)
145
794
  end
146
795
 
796
+ new_content = out.join.force_encoding(content.encoding)
797
+ # A section dropped from the end leaves no blank line behind it.
798
+ new_content = new_content.sub(/(?:\r?\n)+\z/, newline) if dropped && !new_content.empty?
799
+ by_name.each do |name, section|
800
+ next if written.key?(name)
801
+
802
+ separator = if new_content.empty? then ""
803
+ elsif new_content.end_with?(newline * 2) then ""
804
+ elsif new_content.end_with?(newline) then newline
805
+ else newline * 2
806
+ end
807
+ new_content = new_content + separator + section
808
+ end
809
+
810
+ return :skipped if File.exist?(path) && new_content == content
811
+
812
+ RailsAiContext::SafeFile.atomic_write(path, bom + new_content)
147
813
  :written
148
814
  end
149
815
 
150
- def build_codex_section
151
- lines = []
152
- lines << "[mcp_servers.#{SERVER_NAME}]"
816
+ # A Codex config the merge would leave unreadable, or could not keep
817
+ # current, is left as it is: one where mcp_servers is a single inline
818
+ # table, one that already declares a server of this write twice, and one
819
+ # that sets the gem's entry in a form the merge does not rewrite. What
820
+ # is left after that - someone else's entry under the gem's name - is
821
+ # kept in its place.
822
+ def check_toml_shape(found, unwritable)
823
+ if (closed = Toml.closed(found))
824
+ raise ShapeError, "#{closed}, so it is left as it is. Write it as [mcp_servers.<name>] tables by hand"
825
+ end
826
+
827
+ twice = unwritable.select(&:twice?)
828
+ if twice.any?
829
+ raise ShapeError, "it declares #{twice.map(&:name).join(', ')} twice, which Codex refuses to read, " \
830
+ "so it is left as it is. Delete all but one by hand"
831
+ end
153
832
 
154
- cmd = server_command
155
- lines << "command = #{cmd.first.inspect}"
156
- lines << "args = #{cmd[1..].inspect}"
833
+ own = unwritable.select { |server| self.class.own_entry?(server.name, server.argv) }
834
+ return if own.empty?
157
835
 
158
- # Codex CLI env_clear()s the process environment. Capture the current Ruby
159
- # environment so the MCP server can find gems regardless of version manager
160
- # (rbenv, rvm, asdf, mise, or system Ruby).
161
- env_vars = ruby_env_snapshot
162
- unless env_vars.empty?
836
+ it = own.one? ? "it" : "them"
837
+ tables = own.one? ? "a [mcp_servers.#{own.first.name}] table" : "[mcp_servers.<name>] tables"
838
+ raise ShapeError, "#{self.class.unwritable_toml(own.map(&:name))}, so it is left as it is. Write #{it} as " \
839
+ "#{tables} by hand, or delete #{it}, and the next run keeps #{it} current"
840
+ end
841
+
842
+ def toml_section(server)
843
+ argv = server.argv
844
+ lines = [ "[mcp_servers.#{server.name}]" ]
845
+ lines << "command = #{toml_string(argv.first)}"
846
+ lines << "args = [#{argv[1..].map { |arg| toml_string(arg) }.join(', ')}]"
847
+
848
+ # Codex CLI env_clear()s the process environment. Capture the current
849
+ # Ruby environment so the MCP server can find gems regardless of version
850
+ # manager (rbenv, rvm, asdf, mise, or system Ruby).
851
+ env = ruby_env_snapshot.merge(server.env)
852
+ unless env.empty?
163
853
  lines << ""
164
- lines << "[mcp_servers.#{SERVER_NAME}.env]"
165
- env_vars.each { |k, v| lines << "#{k} = #{v.inspect}" }
854
+ lines << "[mcp_servers.#{server.name}.env]"
855
+ env.each { |key, value| lines << "#{key} = #{toml_string(value)}" }
166
856
  end
167
857
 
168
858
  lines.join("\n") + "\n"
169
859
  end
170
860
 
861
+ # A TOML basic string. JSON's string escapes are a subset of TOML's, where
862
+ # Ruby's #inspect writes `\#{` and `\e`, which TOML rejects. ENV hands
863
+ # back bytes tagged BINARY where the locale names no encoding, so they are
864
+ # read as the UTF-8 they almost always are.
865
+ def toml_string(value)
866
+ JSON.generate(value.to_s.dup.force_encoding(Encoding::UTF_8).scrub)
867
+ end
868
+
171
869
  # Snapshot environment variables needed for Ruby/Bundler to work.
172
870
  # Only captures vars that are actually set - works with any version manager.
173
871
  RUBY_ENV_KEYS = %w[PATH GEM_HOME GEM_PATH GEM_ROOT RUBY_VERSION BUNDLE_PATH].freeze
@@ -176,29 +874,111 @@ module RailsAiContext
176
874
  ENV.slice(*RUBY_ENV_KEYS).reject { |_, value| value.empty? }
177
875
  end
178
876
 
179
- # --- Shared helpers ---
877
+ # --- Which config serves an app ---
180
878
 
181
- # How the server is invoked, for every config format. In-Gemfile installs
182
- # go through the CLI binary because it quarantines app boot output away
183
- # from stdout starting before Bundler.require; the rake task can only
184
- # quarantine from the environment task onward.
185
- def server_command
186
- if @standalone
187
- [ "rails-ai-context", "serve" ]
188
- else
189
- [ "bundle", "exec", "rails-ai-context", "serve" ]
879
+ # The config file that serves the app at app_root for one AI tool: the
880
+ # app's own, else a workspace's one or two levels up (the depth a
881
+ # workspace finds its apps at) whose entry names the app by --app-path.
882
+ #
883
+ # @return [String, nil] the path, nil when no config serves the app
884
+ def self.serving_config(app_root, tool)
885
+ config = TOOL_CONFIGS[tool.to_sym] or return nil
886
+ own = File.join(app_root.to_s, config[:path])
887
+ return own if File.exist?(own)
888
+
889
+ target = RailsAiContext::SafePath.canonical(app_root.to_s)
890
+ dir = app_root.to_s
891
+ 2.times do
892
+ parent = File.dirname(dir)
893
+ break if parent == dir
894
+
895
+ dir = parent
896
+ path = File.join(dir, config[:path])
897
+ next unless File.file?(path)
898
+ return path if app_roots_in(path, tool, dir).any? { |root| RailsAiContext::SafePath.canonical(root) == target }
899
+ end
900
+ nil
901
+ end
902
+
903
+ # A config one or two levels up that names the gem's servers but cannot
904
+ # be read as JSON: a workspace's, which may or may not serve the app.
905
+ #
906
+ # @return [String, nil] the path
907
+ def self.unreadable_config_above(app_root, tool)
908
+ config = TOOL_CONFIGS[tool.to_sym] or return nil
909
+ return nil if config[:format] == :codex_toml
910
+
911
+ dir = app_root.to_s
912
+ 2.times do
913
+ parent = File.dirname(dir)
914
+ break if parent == dir
915
+
916
+ dir = parent
917
+ path = File.join(dir, config[:path])
918
+ next unless File.file?(path) && File.binread(path).include?(SERVER_NAME)
919
+
920
+ begin
921
+ read_json(path, config)
922
+ rescue JSON::ParserError, UnreadableError
923
+ return path
924
+ end
925
+ end
926
+ nil
927
+ rescue SystemCallError, IOError
928
+ nil
929
+ end
930
+
931
+ # The entries under the gem's names in one config file, read the way the
932
+ # install reads the file: each one's name, command line and environment,
933
+ # and whether it is the gem's own by own_entry?'s rule. An unreadable
934
+ # file raises as the readers below do.
935
+ #
936
+ # @return [Array<Hash>] { name:, argv:, env:, own: } each
937
+ def self.named_entries(path, tool)
938
+ config = TOOL_CONFIGS.fetch(tool.to_sym)
939
+ if config[:format] == :codex_toml
940
+ return toml_servers(path).first.map do |server|
941
+ { name: server.name, argv: server.argv, env: server.env, own: own_entry?(server.name, server.argv) }
942
+ end
190
943
  end
944
+
945
+ data = read_json(path, config)
946
+ servers = data.is_a?(Hash) ? data[config[:root_key]] : nil
947
+ return [] unless servers.is_a?(Hash)
948
+
949
+ # OpenCode calls its environment `environment`, as json_entry writes it.
950
+ env_key = config[:format] == :opencode_json ? "environment" : "env"
951
+ servers.filter_map do |name, entry|
952
+ next unless name.match?(OWN_SERVER_NAME)
953
+
954
+ argv = json_argv(entry).map(&:to_s)
955
+ env = entry[env_key] if entry.is_a?(Hash)
956
+ { name: name, argv: argv, env: env.is_a?(Hash) ? env.transform_values(&:to_s) : {}, own: own_entry?(name, argv) }
957
+ end
958
+ end
959
+
960
+ # The app every server of ours in one config file names.
961
+ def self.app_roots_in(path, tool, dir)
962
+ folder = TOOL_CONFIGS.fetch(tool.to_sym)[:folder_variable]
963
+ named_entries(path, tool).filter_map { |entry| entry_app_root(entry[:argv], folder, dir) if entry[:own] }
964
+ rescue SystemCallError, IOError, JSON::ParserError, UnreadableError
965
+ []
191
966
  end
967
+ private_class_method :app_roots_in
192
968
 
193
969
  # --- Merge-safe removal ---
194
970
 
195
- # Removes only the rails-ai-context entry from each tool's MCP config file,
196
- # preserving other servers. Deletes the file only if no other entries remain.
971
+ # Removes every rails-ai-context entry - the app's own and each workspace
972
+ # one - from each tool's MCP config file, preserving other servers.
973
+ # Deletes the file only if no other entries remain.
197
974
  #
198
975
  # @param tools [Array<Symbol>] tool keys to remove MCP entries from
199
976
  # @param output_dir [String] project root path
977
+ # @param warn [#call, nil] called with a config's path and why it was left
978
+ # as it is, for the caller to say in its own voice; nil logs it
200
979
  # @return [Array<String>] paths that were modified or deleted
201
- def self.remove(tools:, output_dir:)
980
+ def self.remove(tools:, output_dir:, warn: nil)
981
+ warn ||= ->(path, reason) { RailsAiContext.log_warn("[rails-ai-context] could not update #{path}: #{reason}") }
202
982
  cleaned = []
203
983
  Array(tools).map(&:to_sym).each do |tool|
204
984
  config = TOOL_CONFIGS[tool]
@@ -208,51 +988,78 @@ module RailsAiContext
208
988
  next unless File.exist?(path)
209
989
 
210
990
  begin
211
- if config[:format] == :codex_toml
212
- cleaned << path if remove_toml_entry(path)
213
- else
214
- cleaned << path if remove_json_entry(path, config[:root_key])
991
+ left = config[:format] == :codex_toml ? remove_toml_entry(path) : remove_json_entry(path, config)
992
+ if left == true then cleaned << path
993
+ elsif left then warn.call(path, left)
215
994
  end
216
995
  rescue SystemCallError, IOError => e
217
- RailsAiContext.log_warn "[rails-ai-context] could not update #{config[:path]}: #{e.message}"
996
+ warn.call(path, e.message)
218
997
  end
219
998
  end
220
999
  cleaned
221
1000
  end
222
1001
 
223
- def self.remove_json_entry(path, root_key)
224
- data = JSON.parse(File.read(path))
225
- return false unless data.dig(root_key, SERVER_NAME)
1002
+ # @return [true, String, nil] true when the entries went, the reason they
1003
+ # stay when the file cannot be written back faithfully, nil when it
1004
+ # holds none of the gem's
1005
+ def self.remove_json_entry(path, config)
1006
+ root_key = config[:root_key]
1007
+ text, bom, problem = json_text(path)
1008
+ # A file that cannot be read says so only when it names the gem.
1009
+ unreadable = ->(why) { "#{why}, so it is left as it is. Remove its rails-ai-context entries by hand" if text.include?(SERVER_NAME) }
1010
+ return unreadable.call(problem) if problem
226
1011
 
227
- data[root_key].delete(SERVER_NAME)
1012
+ data = begin
1013
+ parse_json_text(text, config)
1014
+ rescue JSON::ParserError, UnreadableError => e
1015
+ return unreadable.call(parse_problem(e, text))
1016
+ end
1017
+ servers = data.is_a?(Hash) ? data[root_key] : nil
1018
+ return nil unless servers.is_a?(Hash)
228
1019
 
229
- if data[root_key].empty?
230
- data.delete(root_key)
1020
+ own = servers.select { |name, entry| own_entry?(name, json_argv(entry)) }.keys
1021
+ return nil if own.empty?
1022
+ if json_comments?(text)
1023
+ return "#{COMMENTS_PROBLEM}, so it is left as it is. " \
1024
+ "Remove #{own.join(', ')} from it by hand"
231
1025
  end
232
1026
 
1027
+ own.each { |name| servers.delete(name) }
1028
+ data.delete(root_key) if servers.empty?
1029
+
233
1030
  if data.empty?
234
1031
  File.delete(path)
235
1032
  else
236
- RailsAiContext::SafeFile.atomic_write(path, JSON.pretty_generate(data) + "\n")
1033
+ RailsAiContext::SafeFile.atomic_write(path, bom + JSON.pretty_generate(data) + "\n")
237
1034
  end
238
1035
  true
239
- rescue JSON::ParserError
240
- false
241
1036
  end
242
1037
 
1038
+ # @return [true, String, nil] as remove_json_entry
243
1039
  def self.remove_toml_entry(path)
244
- content = File.read(path)
245
- return false unless content.include?(TOML_SECTION_HEADER)
1040
+ content, bom = split_bom(RailsAiContext::SafeFile.read_text(path))
1041
+ lines = content.lines
1042
+ found = Toml.statements(lines)
1043
+ # Taking its table out would leave the rest of an entry spelled
1044
+ # another way, which is no entry Codex can start.
1045
+ held = Toml.servers(lines, found) { |name| name.match?(OWN_SERVER_NAME) }
1046
+ .select { |server| !server.rewritable? && own_entry?(server.name, server.argv) }
1047
+ return "#{unwritable_toml(held.map(&:name))}, so it is left as it is. Remove #{held.one? ? 'it' : 'them'} by hand" if held.any?
1048
+
1049
+ sections = Toml.own_sections(lines, found)
1050
+ return nil if sections.empty?
246
1051
 
247
- new_content = content.sub(TOML_SECTION_REGEX, "")
248
- # Clean up extra blank lines left behind
249
- new_content.gsub!(/\n{3,}/, "\n\n")
250
- new_content.strip!
1052
+ # A comment above a section stays: it may be a key of the table before,
1053
+ # set aside by hand.
1054
+ sections.reverse_each { |range, _| lines.slice!(range) }
1055
+ # Clean up extra blank lines left behind, in the file's own line ending
1056
+ newline = content.include?("\r\n") ? "\r\n" : "\n"
1057
+ new_content = lines.join.force_encoding(content.encoding).gsub(/(?:\r?\n){3,}/, newline * 2).strip
251
1058
 
252
1059
  if new_content.empty?
253
1060
  File.delete(path)
254
1061
  else
255
- RailsAiContext::SafeFile.atomic_write(path, new_content + "\n")
1062
+ RailsAiContext::SafeFile.atomic_write(path, bom + new_content + newline)
256
1063
  end
257
1064
  true
258
1065
  end