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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1491 -0
- data/CONTEXT.md +27 -3
- data/README.md +16 -12
- data/Rakefile +6 -0
- data/app/controllers/rails_ai_context/mcp_controller.rb +112 -84
- data/config/routes.rb +2 -3
- data/docs/ARCHITECTURE.md +7 -5
- data/docs/CLI.md +79 -6
- data/docs/COMPATIBILITY.md +75 -1
- data/docs/CONFIGURATION.md +30 -11
- data/docs/CUSTOM_TOOLS.md +2 -1
- data/docs/FAQ.md +16 -6
- data/docs/GUIDE.md +117 -71
- data/docs/INTROSPECTORS.md +10 -8
- data/docs/QUICKSTART.md +3 -3
- data/docs/RECIPES.md +1 -1
- data/docs/SECURITY.md +109 -25
- data/docs/SETUP.md +30 -6
- data/docs/STANDALONE.md +10 -3
- data/docs/TOOLS.md +152 -34
- data/docs/TROUBLESHOOTING.md +90 -12
- data/docs/adr/0003-shared-tool-cache-semantics.md +8 -0
- data/docs/adr/0005-workspace-mcp-entries.md +39 -0
- data/exe/rails-ai-context +733 -104
- data/lib/generators/rails_ai_context/install/install_generator.rb +341 -125
- data/lib/rails_ai_context/app_kind.rb +18 -5
- data/lib/rails_ai_context/ast_cache.rb +7 -0
- data/lib/rails_ai_context/base_mixins.rb +8 -8
- data/lib/rails_ai_context/boot_manager.rb +22 -3
- data/lib/rails_ai_context/change_watch.rb +14 -11
- data/lib/rails_ai_context/cli/app_root.rb +401 -0
- data/lib/rails_ai_context/cli/entry_boot.rb +58 -22
- data/lib/rails_ai_context/cli/tool_runner.rb +47 -21
- data/lib/rails_ai_context/code_reloader.rb +115 -1
- data/lib/rails_ai_context/concern_membership.rb +3 -1
- data/lib/rails_ai_context/concern_paths.rb +8 -3
- data/lib/rails_ai_context/configuration.rb +41 -9
- data/lib/rails_ai_context/context_file_report.rb +19 -2
- data/lib/rails_ai_context/database_file.rb +26 -0
- data/lib/rails_ai_context/doctor.rb +1122 -184
- data/lib/rails_ai_context/engine.rb +18 -1
- data/lib/rails_ai_context/erb_source.rb +19 -0
- data/lib/rails_ai_context/executable.rb +21 -0
- data/lib/rails_ai_context/facts_formatter.rb +5 -2
- data/lib/rails_ai_context/file_walk.rb +61 -10
- data/lib/rails_ai_context/fingerprinter/snapshot.rb +234 -0
- data/lib/rails_ai_context/fingerprinter.rb +40 -12
- data/lib/rails_ai_context/gem_lock.rb +186 -27
- data/lib/rails_ai_context/git_ignore.rb +22 -0
- data/lib/rails_ai_context/install/ai_tool.rb +31 -5
- data/lib/rails_ai_context/install/cleanup.rb +21 -0
- data/lib/rails_ai_context/install/program.rb +209 -35
- data/lib/rails_ai_context/install/selection_record.rb +258 -97
- data/lib/rails_ai_context/install/surface.rb +6 -1
- data/lib/rails_ai_context/install/validation_hook.rb +282 -0
- data/lib/rails_ai_context/install/workspace.rb +166 -0
- data/lib/rails_ai_context/install_mode.rb +71 -13
- data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/action_presence.rb +11 -6
- data/lib/rails_ai_context/introspectors/action_source.rb +182 -0
- data/lib/rails_ai_context/introspectors/action_text_introspector.rb +1 -2
- data/lib/rails_ai_context/introspectors/active_record_settings.rb +1 -1
- data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +1 -3
- data/lib/rails_ai_context/introspectors/active_support_introspector.rb +3 -3
- data/lib/rails_ai_context/introspectors/admin_resources.rb +1 -1
- data/lib/rails_ai_context/introspectors/anyway_configs.rb +1 -1
- data/lib/rails_ai_context/introspectors/api_introspector.rb +3 -3
- data/lib/rails_ai_context/introspectors/auth_introspector.rb +15 -10
- data/lib/rails_ai_context/introspectors/call_site_expansion.rb +24 -1
- data/lib/rails_ai_context/introspectors/component_introspector.rb +3 -2
- data/lib/rails_ai_context/introspectors/controller_introspector.rb +387 -60
- data/lib/rails_ai_context/introspectors/convention_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +15 -3
- data/lib/rails_ai_context/introspectors/devops_introspector.rb +3 -1
- data/lib/rails_ai_context/introspectors/eager_load.rb +201 -9
- data/lib/rails_ai_context/introspectors/engine_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/env_config_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/env_references.rb +10 -1
- data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/gem_introspector.rb +23 -10
- data/lib/rails_ai_context/introspectors/grape_endpoints.rb +1 -1
- data/lib/rails_ai_context/introspectors/habtm_join_tables.rb +1 -1
- data/lib/rails_ai_context/introspectors/helper_names.rb +1 -1
- data/lib/rails_ai_context/introspectors/i18n_introspector.rb +3 -3
- data/lib/rails_ai_context/introspectors/job_introspector.rb +8 -6
- data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +12 -8
- data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +78 -4
- data/lib/rails_ai_context/introspectors/listeners/config_assignment_listener.rb +1 -0
- data/lib/rails_ai_context/introspectors/listeners/env_access_listener.rb +10 -1
- data/lib/rails_ai_context/introspectors/listeners/foreign_evals_listener.rb +61 -0
- data/lib/rails_ai_context/introspectors/listeners/macros_listener.rb +13 -4
- data/lib/rails_ai_context/introspectors/listeners/migration_dsl_listener.rb +2 -1
- data/lib/rails_ai_context/introspectors/listeners/migration_replay_listener.rb +3 -2
- data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +29 -8
- data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +2 -2
- data/lib/rails_ai_context/introspectors/listeners/scopes_listener.rb +88 -12
- data/lib/rails_ai_context/introspectors/middleware_introspector.rb +2 -2
- data/lib/rails_ai_context/introspectors/migration_introspector.rb +27 -0
- data/lib/rails_ai_context/introspectors/migration_replay/helpers.rb +1 -1
- data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +83 -27
- data/lib/rails_ai_context/introspectors/migration_replay.rb +15 -14
- data/lib/rails_ai_context/introspectors/model_introspector.rb +231 -23
- data/lib/rails_ai_context/introspectors/n_plus_one_scan.rb +620 -0
- data/lib/rails_ai_context/introspectors/performance_introspector.rb +11 -174
- data/lib/rails_ai_context/introspectors/route_introspector.rb +10 -1
- data/lib/rails_ai_context/introspectors/schema_conventions.rb +23 -3
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +66 -16
- data/lib/rails_ai_context/introspectors/schema_reader.rb +4 -3
- data/lib/rails_ai_context/introspectors/seeds_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/source_scan.rb +39 -9
- data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +15 -6
- data/lib/rails_ai_context/introspectors/test_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/turbo_introspector.rb +131 -12
- data/lib/rails_ai_context/introspectors/view_introspector.rb +4 -4
- data/lib/rails_ai_context/introspectors/view_template_introspector.rb +15 -1
- data/lib/rails_ai_context/legacy_cleanup.rb +17 -4
- data/lib/rails_ai_context/live_reload.rb +20 -20
- data/lib/rails_ai_context/mcp_config_generator.rb +916 -109
- data/lib/rails_ai_context/mcp_edge.rb +182 -32
- data/lib/rails_ai_context/middleware.rb +5 -1
- data/lib/rails_ai_context/migration_status.rb +88 -25
- data/lib/rails_ai_context/output_guard.rb +48 -2
- data/lib/rails_ai_context/package_json.rb +1 -1
- data/lib/rails_ai_context/path_resolver.rb +46 -13
- data/lib/rails_ai_context/payload.rb +92 -8
- data/lib/rails_ai_context/pending_migrations.rb +5 -1
- data/lib/rails_ai_context/portable_path.rb +3 -2
- data/lib/rails_ai_context/presets.rb +15 -11
- data/lib/rails_ai_context/redaction.rb +262 -51
- data/lib/rails_ai_context/resources.rb +55 -20
- data/lib/rails_ai_context/route_coverage.rb +8 -0
- data/lib/rails_ai_context/safe_file.rb +14 -1
- data/lib/rails_ai_context/safe_path.rb +101 -4
- data/lib/rails_ai_context/schema_adapter.rb +8 -0
- data/lib/rails_ai_context/schema_version.rb +9 -0
- data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +88 -33
- data/lib/rails_ai_context/serializers/compact_serializer_helper.rb +36 -34
- data/lib/rails_ai_context/serializers/context_file_serializer.rb +21 -29
- data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +8 -6
- data/lib/rails_ai_context/serializers/copilot_serializer.rb +2 -7
- data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +19 -16
- data/lib/rails_ai_context/serializers/guide_examples.rb +142 -0
- data/lib/rails_ai_context/serializers/markdown_serializer.rb +146 -30
- data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +38 -15
- data/lib/rails_ai_context/serializers/section_facts.rb +44 -1
- data/lib/rails_ai_context/serializers/section_marker_writer.rb +94 -12
- data/lib/rails_ai_context/serializers/stack_overview_helper.rb +24 -12
- data/lib/rails_ai_context/serializers/tool_guide_helper.rb +244 -129
- data/lib/rails_ai_context/server.rb +304 -71
- data/lib/rails_ai_context/tasks/rails_ai_context.rake +447 -372
- data/lib/rails_ai_context/tools/analyze_feature.rb +72 -18
- data/lib/rails_ai_context/tools/argument_schema.rb +49 -0
- data/lib/rails_ai_context/tools/base_tool.rb +367 -46
- data/lib/rails_ai_context/tools/brakeman_guard.rb +24 -0
- data/lib/rails_ai_context/tools/dependency_graph.rb +63 -28
- data/lib/rails_ai_context/tools/diagnose.rb +218 -11
- data/lib/rails_ai_context/tools/generate_test.rb +339 -39
- data/lib/rails_ai_context/tools/get_callbacks.rb +24 -13
- data/lib/rails_ai_context/tools/get_component_catalog.rb +1 -1
- data/lib/rails_ai_context/tools/get_concern.rb +91 -27
- data/lib/rails_ai_context/tools/get_config.rb +13 -4
- data/lib/rails_ai_context/tools/get_context.rb +71 -14
- data/lib/rails_ai_context/tools/get_controllers.rb +179 -44
- data/lib/rails_ai_context/tools/get_conventions.rb +28 -6
- data/lib/rails_ai_context/tools/get_edit_context.rb +27 -14
- data/lib/rails_ai_context/tools/get_env.rb +18 -9
- data/lib/rails_ai_context/tools/get_frontend_stack.rb +3 -1
- data/lib/rails_ai_context/tools/get_gems.rb +1 -1
- data/lib/rails_ai_context/tools/get_helper_methods.rb +10 -6
- data/lib/rails_ai_context/tools/get_i18n.rb +1 -1
- data/lib/rails_ai_context/tools/get_job_pattern.rb +3 -2
- data/lib/rails_ai_context/tools/get_mailers.rb +3 -3
- data/lib/rails_ai_context/tools/get_model_details.rb +30 -10
- data/lib/rails_ai_context/tools/get_partial_interface.rb +242 -23
- data/lib/rails_ai_context/tools/get_routes.rb +29 -8
- data/lib/rails_ai_context/tools/get_schema.rb +52 -19
- data/lib/rails_ai_context/tools/get_service_pattern.rb +24 -10
- data/lib/rails_ai_context/tools/get_stimulus.rb +37 -1
- data/lib/rails_ai_context/tools/get_test_info.rb +18 -4
- data/lib/rails_ai_context/tools/get_turbo_map.rb +221 -58
- data/lib/rails_ai_context/tools/get_view.rb +14 -14
- data/lib/rails_ai_context/tools/migration_advisor.rb +355 -48
- data/lib/rails_ai_context/tools/onboard.rb +52 -8
- data/lib/rails_ai_context/tools/performance_check.rb +5 -3
- data/lib/rails_ai_context/tools/query.rb +914 -86
- data/lib/rails_ai_context/tools/read_logs.rb +69 -15
- data/lib/rails_ai_context/tools/review_changes.rb +215 -53
- data/lib/rails_ai_context/tools/runtime_info.rb +51 -1
- data/lib/rails_ai_context/tools/safe_call.rb +35 -14
- data/lib/rails_ai_context/tools/search_code.rb +243 -67
- data/lib/rails_ai_context/tools/search_docs.rb +4 -4
- data/lib/rails_ai_context/tools/security_scan.rb +143 -33
- data/lib/rails_ai_context/tools/session_context.rb +39 -5
- data/lib/rails_ai_context/tools/validate.rb +259 -40
- data/lib/rails_ai_context/tools/validate_semantics.rb +130 -56
- data/lib/rails_ai_context/version.rb +1 -1
- data/lib/rails_ai_context/vfs.rb +67 -40
- data/lib/rails_ai_context/view_file.rb +41 -3
- data/lib/rails_ai_context/watcher.rb +51 -13
- data/lib/rails_ai_context.rb +21 -0
- data/server.json +1 -1
- 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
|
-
|
|
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(
|
|
56
|
-
rescue SystemCallError, IOError => e
|
|
57
|
-
|
|
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
|
-
#
|
|
74
|
-
#
|
|
75
|
-
#
|
|
76
|
-
#
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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,
|
|
675
|
+
def merge_json(path, config, entries)
|
|
91
676
|
FileUtils.mkdir_p(File.dirname(path))
|
|
92
677
|
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
#
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
155
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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.#{
|
|
165
|
-
|
|
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
|
-
# ---
|
|
877
|
+
# --- Which config serves an app ---
|
|
180
878
|
|
|
181
|
-
#
|
|
182
|
-
#
|
|
183
|
-
#
|
|
184
|
-
#
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
196
|
-
#
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
996
|
+
warn.call(path, e.message)
|
|
218
997
|
end
|
|
219
998
|
end
|
|
220
999
|
cleaned
|
|
221
1000
|
end
|
|
222
1001
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
|
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
|
-
|
|
230
|
-
|
|
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 =
|
|
245
|
-
|
|
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
|
-
|
|
248
|
-
#
|
|
249
|
-
|
|
250
|
-
|
|
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 +
|
|
1062
|
+
RailsAiContext::SafeFile.atomic_write(path, bom + new_content + newline)
|
|
256
1063
|
end
|
|
257
1064
|
true
|
|
258
1065
|
end
|