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