rails-ai-context 5.36.7 → 5.36.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +220 -0
  3. data/README.md +6 -0
  4. data/docs/CLI.md +1 -1
  5. data/docs/COMPATIBILITY.md +19 -0
  6. data/docs/CONFIGURATION.md +1 -1
  7. data/docs/GUIDE.md +1 -1
  8. data/docs/PROMPTS.md +3 -3
  9. data/docs/SECURITY.md +37 -2
  10. data/docs/SETUP.md +191 -0
  11. data/docs/STANDALONE.md +2 -2
  12. data/docs/TOOLS.md +5 -5
  13. data/docs/TROUBLESHOOTING.md +6 -2
  14. data/exe/rails-ai-context +53 -20
  15. data/lib/generators/rails_ai_context/install/install_generator.rb +8 -4
  16. data/lib/rails_ai_context/boot_manager.rb +7 -1
  17. data/lib/rails_ai_context/claude_code_settings.rb +1006 -0
  18. data/lib/rails_ai_context/cli/app_root.rb +126 -16
  19. data/lib/rails_ai_context/cli/entry_boot.rb +60 -6
  20. data/lib/rails_ai_context/configuration.rb +116 -34
  21. data/lib/rails_ai_context/doctor.rb +482 -24
  22. data/lib/rails_ai_context/gem_lock.rb +14 -7
  23. data/lib/rails_ai_context/initializer_settings.rb +603 -0
  24. data/lib/rails_ai_context/install/claude_plugin_removal.rb +107 -0
  25. data/lib/rails_ai_context/install/program.rb +133 -6
  26. data/lib/rails_ai_context/install/selection_record.rb +11 -5
  27. data/lib/rails_ai_context/introspectors/env_references.rb +1 -1
  28. data/lib/rails_ai_context/introspectors/gemfile_gems.rb +2 -2
  29. data/lib/rails_ai_context/mcp_config_generator.rb +18 -6
  30. data/lib/rails_ai_context/redaction.rb +749 -26
  31. data/lib/rails_ai_context/resources.rb +15 -0
  32. data/lib/rails_ai_context/safe_path.rb +101 -11
  33. data/lib/rails_ai_context/secret_columns.rb +1 -1
  34. data/lib/rails_ai_context/server.rb +6 -1
  35. data/lib/rails_ai_context/sql_gate.rb +5 -0
  36. data/lib/rails_ai_context/tools/get_env.rb +20 -30
  37. data/lib/rails_ai_context/tools/get_partial_interface.rb +12 -3
  38. data/lib/rails_ai_context/tools/get_view.rb +6 -3
  39. data/lib/rails_ai_context/tools/review_changes.rb +74 -29
  40. data/lib/rails_ai_context/tools/safe_call.rb +5 -0
  41. data/lib/rails_ai_context/tools/search_code.rb +24 -8
  42. data/lib/rails_ai_context/tools/search_docs.rb +2 -1
  43. data/lib/rails_ai_context/update_check.rb +17 -4
  44. data/lib/rails_ai_context/version.rb +1 -1
  45. data/lib/rails_ai_context.rb +26 -0
  46. metadata +4 -1
data/docs/STANDALONE.md CHANGED
@@ -60,7 +60,7 @@ rails-ai-context version # Show version
60
60
  1. **Loads only its boot shim** before the app - the binary requires the two files it needs to boot Rails, then `config/environment.rb`, and requires the gem itself only after the boot returns. Nothing it loads first is a gem an app pins: it reads the Gemfile and `config/boot.rb` with Ruby's own Ripper, not the prism gem, and loads json only once the app's bundle is set up
61
61
  2. **Restores `$LOAD_PATH`** entries that `Bundler.setup` strips (since the gem isn't in the Gemfile). The gem's own dependencies come off the load path for the duration of the boot, so every gem the app locks resolves to the app's pin, and go back behind it afterwards
62
62
  3. **YAML config** - uses `.rails-ai-context.yml` instead of a Ruby initializer
63
- 4. **Steps aside for the app's own copy** - in an app whose bundle carries its own rails-ai-context (another installed version, or a `path:` copy), a command that boots the app runs through that copy, as `bundle exec rails-ai-context` would, and says so on stderr. That is the copy the app's MCP configs start, and two copies in one process would each load half the gem. The bundle is the one `BUNDLE_GEMFILE` names when you set it (a dual boot's `Gemfile.next`), and a command typed in a subdirectory runs from the app root it found. `init` asks its questions itself and has that copy write only the context files; an MCP-only setup, chosen with `--mcp-only` or at the prompt, runs nothing of the app's. So does `--no-boot`: bundle exec would evaluate the app's Gemfile and start whatever copy the app names, which on a repo just cloned is code nobody has read. A bundle not installed yet (`bundle check` fails) is left alone too: its boot fails, and the static tier answers. A copy installed only under a vendored bundle path or from git is not seen from outside the bundle, so there the installed copy runs
63
+ 4. **Steps aside for the app's own copy** - in an app whose bundle carries its own rails-ai-context (another installed version, or a `path:` copy), a command that boots the app runs through that copy, as `bundle exec rails-ai-context` would, and says so on stderr. That is the copy the app's MCP configs start, and two copies in one process would each load half the gem. The bundle is the one `BUNDLE_GEMFILE` names when you set it (a dual boot's `Gemfile.next`), and a command typed in a subdirectory runs from the app root it found. `init` asks its questions itself and has that copy write only the context files; an MCP-only setup, chosen with `--mcp-only` or at the prompt, runs nothing of the app's. So does `--no-boot`: bundle exec would evaluate the app's Gemfile and start whatever copy the app names, which on a repo just cloned is code nobody has read. A bundle not installed yet (`bundle check` fails) is left alone too: its boot fails, and the static tier answers. A copy installed under the bundle's own path (`bundle config set path vendor/bundle`, or a deployment bundle) is handed the run as well. A copy from git is not seen from outside the bundle, so there the installed copy runs and the app's boot loads the bundle's copy over it: in such an app, start the server with `bundle exec rails-ai-context`.
64
64
 
65
65
  This means you get the same 35 tools, same MCP server, same context generation - without touching the project's Gemfile.
66
66
 
@@ -117,7 +117,7 @@ A key the gem does not know warns on stderr and is ignored; the rest of the file
117
117
 
118
118
  ### Precedence
119
119
 
120
- In standalone mode `.rails-ai-context.yml` is the only config source. The gem is not loaded while `config/initializers` runs, so a `config/initializers/rails_ai_context.rb` contributes nothing: the generated file, which guards on `defined?(RailsAiContext) && RailsAiContext.respond_to?(:configure)`, is a silent no-op, and one without that guard raises `NoMethodError` (the binary opens the `RailsAiContext` namespace before the boot, so the constant exists but `configure` is not defined on it) and drops the command into the static tier.
120
+ In standalone mode `.rails-ai-context.yml` is the only config source of a booted run. The gem is not loaded while `config/initializers` runs, so a `config/initializers/rails_ai_context.rb` contributes nothing there: the generated file, which guards on `defined?(RailsAiContext) && RailsAiContext.respond_to?(:configure)`, is a silent no-op, and one without that guard raises `NoMethodError` (the binary opens the `RailsAiContext` namespace before the boot, so the constant exists but `configure` is not defined on it) and drops the command into the static tier. On the static tier the gem reads the literal `sensitive_patterns`, `excluded_paths` and `skip_tools` values such a file writes, without running it ([SECURITY.md](SECURITY.md#configuration)).
121
121
 
122
122
  For in-Gemfile installs both sources apply and merge key by key - see [Precedence](CONFIGURATION.md#precedence).
123
123
 
data/docs/TOOLS.md CHANGED
@@ -77,7 +77,7 @@ Search your codebase with regex, ripgrep acceleration, and sensitive file blocki
77
77
  | Parameter | Type | Default | Description |
78
78
  |:----------|:-----|:--------|:------------|
79
79
  | `pattern` | string | *required* | Regex pattern to search for. With `definition` or `class`, a plain method or constant name matches literally (`valid?`), anything else as a regex (`generate_.*`) |
80
- | `path` | string | - | Subdirectory or file to search (relative to Rails root); a file is searched whatever its type or ignore files say, as ripgrep does, unless it is sensitive |
80
+ | `path` | string | - | Subdirectory or file to search (relative to Rails root); a file is searched whatever its type or ignore files say, as ripgrep does, unless it is sensitive or over `max_file_size` (`File too large`). A path through a link inside the app is searched as the folder it leads to |
81
81
  | `match_type` | enum | `any` | `any`, `definition` (def lines whose method name contains the pattern), `class` (class/module lines whose name contains it), `call`, `trace` |
82
82
  | `file_type` | string | - | Filter by extension (`rb`, `erb`, `js`, etc.) |
83
83
  | `exact_match` | boolean | `false` | Literal, whole-word match. `def reblog?` does not match `def reblog` |
@@ -566,7 +566,7 @@ ActiveSupport surface: concerns registry, deprecators, MessageVerifier/MessageEn
566
566
 
567
567
  #### `env` (was `rails_get_env`)
568
568
 
569
- Environment variables + credentials keys (values are never exposed). Scans `.rb`, `.rake`, ERB views and config YAML under `app`, `config` and `lib`, plus `config.ru`, `db/seeds.rb`, `db/seeds/` and the Ruby scripts in `bin/`. A tag on a YAML comment line is not read: ERB runs it, but what it writes goes with the comment. Config YAML matching `sensitive_patterns` (`config/database.yml`, `config/cable.yml`, `config/storage.yml` and the rest) is read only for the ENV names in its ERB tags, so a default there is listed as present and not read; credentials and keys are never read, and the answer says so. A default is read from a second argument or from a block whose one statement is a literal (`ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" }`). The env Kamal's `config/deploy.yml` gives the app container is listed too: secret names only, and clear values, except one holding a URL or a key-like token, or under a secret-named variable, which shows as hidden, and one an ERB tag sets, which says so. So are the setting keys in the config gem's `config/settings.yml` and `config/settings/<env>.yml` when the bundle has the config gem, and the attributes of each `Anyway::Config` class in `config/configs` or `app/configs` with the env name it reads (`PAYMENT_API_KEY`), never their values. A variable whose call sites pass different defaults is labelled as such rather than with one site's default; `detail:"full"` names each site's. A default is redacted by the rule a source literal gets: a credential format under any name, a URL's password, and under a secret-named variable a value that is not clearly something else, so an address, URL or hostname default prints as written.
569
+ Environment variables + credentials keys (values are never exposed). Scans `.rb`, `.rake`, ERB views and config YAML under `app`, `config` and `lib`, plus `config.ru`, `db/seeds.rb`, `db/seeds/` and the Ruby scripts in `bin/`. A tag on a YAML comment line is not read: ERB runs it, but what it writes goes with the comment. Config YAML matching `sensitive_patterns` (`config/database.yml`, `config/cable.yml`, `config/storage.yml` and the rest) is read only for the ENV names in its ERB tags, so a default there is listed as present and not read; credentials and keys are never read, and the answer says so. A default is read from a second argument or from a block whose one statement is a literal (`ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" }`). The env Kamal's `config/deploy.yml` gives the app container is listed too: secret names only, and clear values, except one holding a URL or a key-like token, or under a secret-named variable, which shows as hidden, and one an ERB tag sets, which says so. A Dockerfile `ENV` or `ARG` value, an `.env.example` value and a default in code read `[FILTERED]` when their shape can carry a secret: a URL whose user part is a key or holds a password, a webhook's address, a token in a query, a key-like run of characters, or a `BUNDLE_<HOST>` gem server credential ([Credential handling](SECURITY.md#credential-handling)). An `.env.example` value in quotes keeps a `#`, and an inline comment is hidden when it holds a credential or one of those shapes. So are the setting keys in the config gem's `config/settings.yml` and `config/settings/<env>.yml` when the bundle has the config gem, and the attributes of each `Anyway::Config` class in `config/configs` or `app/configs` with the env name it reads (`PAYMENT_API_KEY`), never their values. A variable whose call sites pass different defaults is labelled as such rather than with one site's default; `detail:"full"` names each site's. A default is redacted by the rule a source literal gets: a credential format under any name, a URL's password, and under a secret-named variable a value that is not clearly something else, so an address, a hostname or a URL of none of the shapes above prints as written.
570
570
 
571
571
  #### `env_config` (was `rails_get_env_config`)
572
572
 
@@ -782,13 +782,13 @@ Each database other than the primary is reached through a connection handler of
782
782
 
783
783
  > Disabled in production by default.
784
784
 
785
- > Name the columns you need. A query is refused, with a message that says why, when it does one of these: it references a secret column (directly, or through an alias or expression; an `encrypts` column also when `query_allowed_columns` lists it); it serialises a whole row; it carries a column-alias list that renames a wildcard (a `VALUES`-body list such as `(VALUES (1,'a')) AS v(id, name)` is allowed, since it renames no table column); it uses UNION; it calls a session-effecting function, a function that returns a whole table as one value (`table_to_xml`), or any function by a quoted name; it reads a statistics, session or credential relation (`pg_stats`, `pg_stat_activity`, `pg_settings`, `performance_schema`, the `mysql` schema) or a PostgreSQL setting that can hold a password (`primary_conninfo`); it is a `SHOW` with a `WHERE` clause, `SHOW ALL` on PostgreSQL or `EXPLAIN FOR CONNECTION` on MySQL and MariaDB; or it is over 1,000,000 bytes long. `SELECT *` is allowed and its secret columns come back `[FILTERED]`. `SHOW CREATE TABLE` / `SHOW CREATE VIEW` are allowed and their definition text is secret-redacted; other `SHOW CREATE` forms are refused. In the default format a definition is shown whole, as the DDL in a fenced code block with its line breaks kept (a view's character set and collation follow as `name: value` lines); `csv` returns it as one quoted field. When the row limit holds rows back - only when the query really returned more than the cap - the answer says so in every format. With `csv`, a query that returns no rows answers the header row alone.
785
+ > Name the columns you need. A query is refused, with a message that says why, when it does one of these: it references a secret column (directly, or through an alias or expression; an `encrypts` column also when `query_allowed_columns` lists it); it serialises a whole row; it carries a column-alias list that renames a wildcard (a `VALUES`-body list such as `(VALUES (1,'a')) AS v(id, name)` is allowed, since it renames no table column); it uses UNION; it calls a session-effecting function, a function that returns a whole table as one value (`table_to_xml`), or any function by a quoted name; it reads a statistics, session or credential relation (`pg_stats`, `pg_stat_activity`, `pg_settings`, `performance_schema`, the `mysql` schema) or a PostgreSQL setting that can hold a password (`primary_conninfo`); it is a `SHOW` with a `WHERE` clause, `SHOW ALL` on PostgreSQL, or `EXPLAIN FOR CONNECTION` or a `PROCEDURE` clause on MySQL and MariaDB; or it is over 1,000,000 bytes long. `SELECT *` is allowed and its secret columns come back `[FILTERED]`. `SHOW CREATE TABLE` / `SHOW CREATE VIEW` are allowed and their definition text is secret-redacted; other `SHOW CREATE` forms are refused. In the default format a definition is shown whole, as the DDL in a fenced code block with its line breaks kept (a view's character set and collation follow as `name: value` lines); `csv` returns it as one quoted field. When the row limit holds rows back - only when the query really returned more than the cap - the answer says so in every format. With `csv`, a query that returns no rows answers the header row alone.
786
786
 
787
787
  > **Cell rendering by format.** A `markdown` table cell shows `NULL` as `_NULL_`, escapes a pipe (`a\|b`) so it cannot end the cell, renders a line break as the two characters `\n` so it cannot end the row, and shortens a value over 100 characters to `…` (a `SHOW CREATE` definition is not a table cell, so it is never shortened). A `csv` cell is the value itself: `NULL` is an empty field, nothing is escaped beyond RFC 4180 quoting (a field holding a comma, a quote or a line break is wrapped in quotes with its quotes doubled), and nothing is truncated - so a line break stays a real line break inside the quoted field. Binary (non-text) data shows as `[BLOB]` in both.
788
788
 
789
789
  ### `rails_read_logs`
790
790
 
791
- Reverse file tail with level filtering and sensitive data redaction.
791
+ Reverse file tail with level filtering and sensitive data redaction. A value an SQL statement holds inline is filtered when the column it meets is a secret, and so are the values a database error repeats ([Redaction](SECURITY.md#redaction)).
792
792
 
793
793
  | Parameter | Type | Default | Description |
794
794
  |:----------|:-----|:--------|:------------|
@@ -822,7 +822,7 @@ A changed migration lists the tables it affects: the table each schema method ta
822
822
 
823
823
  An app in a subfolder of its repository (a monorepo's `backend/`) is reviewed on its own: its changed files, named from the app root as `rails_validate` takes them, and the commits that touched it.
824
824
 
825
- Each diff passes through the redaction every tool's source does, read as the file it comes from, so a secret a change adds or takes out reads `[FILTERED]`, in a hunk header's line too. A changed file on `sensitive_patterns` (`config/database.yml`, `.env`, a key) is listed with its line counts and none of its lines.
825
+ Each diff line is shown as the line of its side, redacted with the whole of that side the way every tool's source is, so a secret a change adds or takes out reads `[FILTERED]`, and so does a key's body in a hunk that opens inside the key, in a hunk header's line too. A changed file on `sensitive_patterns` (`config/database.yml`, `.env`, a key) is listed with its line counts and none of its lines, and so is one whose diff is over `max_file_size` and a merge conflict's combined diff. The diff is read with the repository's colour, external diff and textconv settings off, and a submodule's change shows as the commits it moves between.
826
826
 
827
827
  | Parameter | Type | Default | Description |
828
828
  |:----------|:-----|:--------|:------------|
@@ -127,6 +127,8 @@ Codex reads `config.toml` as TOML, which is UTF-8, and loads nothing from a file
127
127
  - `can't find executable rails-ai-context for gem rails-ai-context`: the bundle's copy of the gem lists no executable, as a `path:` copy of 5.32.2 or earlier outside a git checkout does. Point the Gemfile at a git checkout or a release.
128
128
  - On Windows, a config that runs `bundle` or `rails-ai-context` itself rather than through `cmd /c`: those are batch files there, which Claude Code, Codex and the other clients that start a server without a shell do not run (`doctor` warns about it). Run the install again; on Windows it writes `cmd /c bundle exec rails-ai-context serve`.
129
129
  - On macOS or Linux, a config that runs `cmd /c ...`: it was written on Windows, and there is no `cmd` here (`doctor` fails it). Run the install again on this machine, and keep the MCP configs out of git if the team works on both.
130
+ - The Claude Code plugin's server connects with no tools: it tells the session why (no gem for its Ruby, an older one, a Ruby too old, several apps or none) and what to run; `claude --debug` shows the same line as its stderr.
131
+ - The Claude Code plugin's server is listed as failed in `/mcp`, and `claude --debug` shows a `Bundler::GemNotFound` trace in its stderr: Claude Code was started from `bundle exec`, or from a shell holding the `RUBYOPT` or `BUNDLER_SETUP` it sets, for a bundle not installed for the plugin's Ruby, which fails before the plugin runs. Start Claude Code outside `bundle exec`, or unset those two where a shell profile sets them. After a failed start Claude Code skips the server in new sessions for about 15 minutes, so reconnect it in `/mcp` once the cause is fixed.
130
132
 
131
133
  Check for Bundler/Ruby path issues:
132
134
 
@@ -148,8 +150,10 @@ command gives it 60. Start the service, or, for an app that is healthy but
148
150
  slow to boot, raise both limits in the server's MCP entry: the boot's with
149
151
  `RAILS_AI_CONTEXT_BOOT_TIMEOUT` (seconds) in its `env`, and the client's with
150
152
  its own setting (Claude Code reads `MCP_TIMEOUT`, in milliseconds). Raising
151
- only the boot's leaves a client that gives up first. `rails ai:serve` has no
152
- static tier, so it exits with the same message instead.
153
+ only the boot's leaves a client that gives up first. The Claude Code plugin's
154
+ entry is the plugin's own: set both in Claude Code's settings `env` (user,
155
+ project or local) or in the shell that starts Claude Code. `rails ai:serve`
156
+ has no static tier, so it exits with the same message instead.
153
157
 
154
158
  ### A server set up for a folder of apps fails to start
155
159
 
data/exe/rails-ai-context CHANGED
@@ -311,12 +311,13 @@ class RailsAiContextCLI < Thor
311
311
  # gets less than that.
312
312
  if options[:transport] == "stdio"
313
313
  entry_boot
314
+ require_relative "../lib/rails_ai_context/boot_manager"
314
315
  ::RailsAiContext::BootManager.default_timeout = ::RailsAiContext::BootManager::STDIO_TIMEOUT
315
316
  end
316
317
 
317
318
  # Live reload uses `listen` when it is there, standalone included.
318
319
  reach_installed_gem("listen")
319
- boot_rails!(command: "serve", allow_static: true)
320
+ boot_rails!(command: "serve", allow_static: true, client: options[:transport] == "stdio")
320
321
 
321
322
  transport = options[:transport].to_sym
322
323
  # No Thor default: it would overwrite the config file's http_port.
@@ -503,7 +504,8 @@ class RailsAiContextCLI < Thor
503
504
  program.remove_mcp_entries(program_surface, unwired, root: Dir.pwd)
504
505
  program.remove_context_files(program_surface, ai_tools, root: Dir.pwd) if stale
505
506
  record_selection(ai_tools, tool_mode, context_files)
506
- write_mcp_configs(ai_tools, tool_mode)
507
+ written = write_mcp_configs(ai_tools, tool_mode)
508
+ held = written[:held] || []
507
509
  add_to_gitignore(context_files)
508
510
 
509
511
  if bundled
@@ -519,7 +521,7 @@ class RailsAiContextCLI < Thor
519
521
  end
520
522
 
521
523
  # --- Instructions ---
522
- show_standalone_instructions(ai_tools, tool_mode, context_files, removed: stale)
524
+ show_standalone_instructions(ai_tools, tool_mode, context_files, removed: stale, held: held, plugin_off: written[:plugin_off])
523
525
  end
524
526
 
525
527
  desc "uninstall", "Remove what rails-ai-context wrote: context files, MCP entries, its config, .gitignore lines and pre-commit hook"
@@ -541,6 +543,9 @@ class RailsAiContextCLI < Thor
541
543
  bundled = app_roots.any? { |app| bundled_app?(app) }
542
544
  where = display_place || (resolution.workspace? ? "this folder" : "this app")
543
545
  $stderr.puts ""
546
+ require_relative "../lib/rails_ai_context/install/claude_plugin_removal"
547
+ ::RailsAiContext::Install::ClaudePluginRemoval.lines(app_roots, cwd: @app_root_result&.cwd || Dir.pwd, workspace: resolution.workspace?)
548
+ .each { |line| $stderr.puts line }
544
549
  if changed
545
550
  $stderr.puts "rails-ai-context is uninstalled from #{where}. To remove the gem itself:"
546
551
  else
@@ -742,8 +747,10 @@ class RailsAiContextCLI < Thor
742
747
  def write_mcp_configs(ai_tools, tool_mode, root: Dir.pwd, servers: nil)
743
748
  standalone = servers ? nil : ::RailsAiContext::InstallMode.standalone?(root: root)
744
749
  in_starting_env do
750
+ there = run_there(root).delete_prefix(" run in ")
745
751
  ::RailsAiContext::Install::Program.write_mcp_configs(
746
- program_surface, tools: ai_tools, tool_mode: tool_mode, root: root, servers: servers, standalone: standalone
752
+ program_surface, tools: ai_tools, tool_mode: tool_mode, root: root, servers: servers, standalone: standalone,
753
+ place: (there unless there.empty?)
747
754
  )
748
755
  end
749
756
  end
@@ -827,7 +834,8 @@ class RailsAiContextCLI < Thor
827
834
  end
828
835
 
829
836
  $stderr.puts ""
830
- write_mcp_configs(ai_tools, tool_mode, root: dir, servers: configured.map(&:server))
837
+ written = write_mcp_configs(ai_tools, tool_mode, root: dir, servers: configured.map(&:server))
838
+ held = written[:held] || []
831
839
  # This folder's own .gitignore, when it has one: the Codex config here
832
840
  # embeds this machine's PATH and GEM_HOME too.
833
841
  add_to_gitignore(false, root: dir)
@@ -845,7 +853,7 @@ class RailsAiContextCLI < Thor
845
853
  $stderr.puts "MCP-only setup: no context files written."
846
854
  end
847
855
 
848
- show_workspace_instructions(dir, apps, ai_tools, tool_mode, context_files, configured: configured)
856
+ show_workspace_instructions(dir, apps, ai_tools, tool_mode, context_files, configured: configured, held: held, plugin_off: written[:plugin_off])
849
857
  return if failed.empty?
850
858
 
851
859
  $stderr.puts "Error: no context files for #{failed.map { |app| "#{app.path}/" }.join(', ')} - see the output above. " \
@@ -927,7 +935,7 @@ class RailsAiContextCLI < Thor
927
935
  "= #{spec.version}" ], false ]
928
936
  end
929
937
 
930
- def show_workspace_instructions(dir, apps, ai_tools, tool_mode, context_files, configured: apps)
938
+ def show_workspace_instructions(dir, apps, ai_tools, tool_mode, context_files, configured: apps, held: [], plugin_off: false)
931
939
  $stderr.puts ""
932
940
  $stderr.puts "=" * 50
933
941
  $stderr.puts " rails-ai-context initialized for #{apps.size == 1 ? '1 app' : "#{apps.size} apps"}"
@@ -940,10 +948,16 @@ class RailsAiContextCLI < Thor
940
948
 
941
949
  # The MCP config is this folder's, so it leaves the per-app list.
942
950
  files = context_files ? "#{tool.files_label(mcp: false)} in each app" : "no context files"
943
- files = "#{tool.mcp_config[:path]} #{where}; #{files}" if tool_mode == :mcp
951
+ # A committed plugin serves where no server was written, and the app's own .mcp.json its own sessions.
952
+ if tool_mode == :mcp
953
+ served = held.include?(tool.key) ? plugin_label(plugin_off) : "#{tool.mcp_config[:path]} #{where}"
954
+ own = configured.one? && held.include?(tool.key) && ::RailsAiContext::Install::Program.app_mcp_json(dir, configured.first.root)
955
+ served += ", and #{configured.first.path}/.mcp.json for sessions started in #{configured.first.path}" if own
956
+ files = "#{served}; #{files}"
957
+ end
944
958
  $stderr.puts " #{tool.name.ljust(16)} -> #{files}"
945
959
  end
946
- if tool_mode == :mcp && configured.any?
960
+ if tool_mode == :mcp && configured.any? && (ai_tools - held).any?
947
961
  $stderr.puts ""
948
962
  $stderr.puts "MCP servers, one per app:"
949
963
  width = configured.map { |app| app.server_name.size }.max
@@ -959,7 +973,8 @@ class RailsAiContextCLI < Thor
959
973
  first = app_root.command_line([ app_root.option_path(app_root.display(example.root, Dir.pwd)) ])
960
974
  if tool_mode == :mcp && configured.any?
961
975
  $stderr.puts ""
962
- say_next_steps(ai_tools)
976
+ # A folder holding one app is one the plugin serves.
977
+ say_next_steps(ai_tools, root: (dir if configured.one?), app: configured.first&.root)
963
978
  end
964
979
  $stderr.puts ""
965
980
  $stderr.puts "Commands, from here:"
@@ -972,18 +987,32 @@ class RailsAiContextCLI < Thor
972
987
  $stderr.puts ""
973
988
  end
974
989
 
990
+ # Where a command has to run, when that is not where this one was typed.
991
+ def run_there(folder)
992
+ cwd = @app_root_result&.cwd or return ""
993
+ return "" if ::RailsAiContext::SafePath.canonical(folder) == ::RailsAiContext::SafePath.canonical(cwd)
994
+
995
+ " run in #{app_root.display(folder, cwd)}"
996
+ end
997
+
975
998
  # Whether the app's bundle starts the gem. An app with no Gemfile has no
976
999
  # bundle at all, which InstallMode, unable to read one, counts as bundled.
977
1000
  def bundled_app?(root)
978
1001
  File.file?(File.join(root.to_s, "Gemfile")) && !::RailsAiContext::InstallMode.standalone?(root: root)
979
1002
  end
980
1003
 
981
- def say_next_steps(ai_tools)
1004
+ def say_next_steps(ai_tools, root: nil, app: root)
982
1005
  $stderr.puts "Next step, before the MCP tools appear:"
983
- ::RailsAiContext::Install::Program.next_steps(ai_tools).each { |line| $stderr.puts " #{line}" }
1006
+ place = root && run_there(root).sub(" run in ", "in ")
1007
+ ::RailsAiContext::Install::Program.next_steps(ai_tools, root: root, place: place.to_s.empty? ? "here" : place, app: app).each { |line| $stderr.puts " #{line}" }
1008
+ end
1009
+
1010
+ # The next step names what serves instead where the committed plugin does not start for this user.
1011
+ def plugin_label(off)
1012
+ off ? "the rails-ai-context plugin (off here: see below)" : "the rails-ai-context plugin"
984
1013
  end
985
1014
 
986
- def show_standalone_instructions(ai_tools, tool_mode, context_files = true, removed: false)
1015
+ def show_standalone_instructions(ai_tools, tool_mode, context_files = true, removed: false, held: [], plugin_off: false)
987
1016
  $stderr.puts ""
988
1017
  $stderr.puts "=" * 50
989
1018
  $stderr.puts " rails-ai-context initialized!"
@@ -992,7 +1021,9 @@ class RailsAiContextCLI < Thor
992
1021
  $stderr.puts "Your setup:"
993
1022
  ::RailsAiContext::Install::AiTool.all.each do |tool|
994
1023
  next unless ai_tools.include?(tool.key)
995
- $stderr.puts " #{tool.name.ljust(16)} -> #{context_files ? tool.files_label(mcp: tool_mode == :mcp) : 'MCP config only'}"
1024
+ files = context_files ? tool.files_label(mcp: tool_mode == :mcp) : "MCP config only"
1025
+ files = context_files ? "#{plugin_label(plugin_off)}; #{files}" : plugin_label(plugin_off) if held.include?(tool.key)
1026
+ $stderr.puts " #{tool.name.ljust(16)} -> #{files}"
996
1027
  end
997
1028
  unless context_files || removed
998
1029
  $stderr.puts ""
@@ -1015,7 +1046,7 @@ class RailsAiContextCLI < Thor
1015
1046
  $stderr.puts " #{binary} doctor # Check AI readiness"
1016
1047
  $stderr.puts ""
1017
1048
  if tool_mode == :mcp
1018
- say_next_steps(ai_tools)
1049
+ say_next_steps(ai_tools, root: Dir.pwd)
1019
1050
  else
1020
1051
  $stderr.puts "CLI tools:"
1021
1052
  $stderr.puts " AI agents can run `rails-ai-context tool schema table=users` directly."
@@ -1112,9 +1143,9 @@ class RailsAiContextCLI < Thor
1112
1143
  # A command that needs more than the tier gives it says so here rather
1113
1144
  # than being recognised by name.
1114
1145
  def boot_rails!(command:, allow_static: false, no_boot: false, allow_source_only: allow_static,
1115
- boot_failure_hint: nil)
1146
+ boot_failure_hint: nil, client: false)
1116
1147
  outcome = enter_app(command: command, allow_static: allow_static, no_boot: no_boot,
1117
- allow_source_only: allow_source_only)
1148
+ allow_source_only: allow_source_only, client: client)
1118
1149
  outcome.messages.each { |line| $stderr.puts line }
1119
1150
  return unless outcome.tier == :absent
1120
1151
 
@@ -1125,7 +1156,7 @@ class RailsAiContextCLI < Thor
1125
1156
 
1126
1157
  # The app this command reads, booted or read from source as the command
1127
1158
  # allows. Prints nothing: the outcome carries the lines to say.
1128
- def enter_app(command:, allow_static:, no_boot:, allow_source_only:)
1159
+ def enter_app(command:, allow_static:, no_boot:, allow_source_only:, client: false)
1129
1160
  # Read by the hand-off, which resolving the root may start: `tool`
1130
1161
  # takes --no-boot after the tool's name, where Thor never sees it.
1131
1162
  @no_boot = true if no_boot
@@ -1141,7 +1172,8 @@ class RailsAiContextCLI < Thor
1141
1172
  # A command that can serve the static tier reads a source-only tree
1142
1173
  # instead of refusing it.
1143
1174
  entry_boot.call(root: Dir.pwd, allow_static: allow_static, no_boot: no_boot || options[:no_boot],
1144
- allow_source_only: allow_source_only, command: command, doctor: doctor_command, shown: shown_root)
1175
+ allow_source_only: allow_source_only, command: command, doctor: doctor_command, shown: shown_root,
1176
+ client: client)
1145
1177
  end
1146
1178
 
1147
1179
  # Which app this command reads, decided once and before anything reads
@@ -1194,6 +1226,7 @@ class RailsAiContextCLI < Thor
1194
1226
  warning = app_root.bundle_warning(result.root, cwd) if entry_boot.app_present?(result.root, allow_source_only: true)
1195
1227
  $stderr.puts warning if warning
1196
1228
  hand_off_to_bundle!(result.root, cwd) if hand_off
1229
+ @start_update_check&.pinned! if app_root.pinned?(result.root)
1197
1230
  result
1198
1231
  end
1199
1232
 
@@ -1377,7 +1410,7 @@ class RailsAiContextCLI < Thor
1377
1410
  %w[
1378
1411
  gem_lock install/ai_tool install/selection_record install/cleanup
1379
1412
  install/surface install/validation_hook install/program install/uninstall mcp_config_generator install/workspace
1380
- legacy_cleanup
1413
+ legacy_cleanup claude_code_settings
1381
1414
  ].each { |file| require_relative "../lib/rails_ai_context/#{file}" }
1382
1415
 
1383
1416
  # Both are defined in the entry file, which is the thing that must not
@@ -100,11 +100,13 @@ module RailsAiContext
100
100
  # itself the generator reads Rails.root's, which is an engine's dummy
101
101
  # app.
102
102
  def create_mcp_config
103
- RailsAiContext::Install::Program.write_mcp_configs(
103
+ written = RailsAiContext::Install::Program.write_mcp_configs(
104
104
  program_surface,
105
105
  tools: @selected_formats, tool_mode: @tool_mode, root: install_root,
106
106
  standalone: RailsAiContext::InstallMode.standalone?(root: install_root)
107
107
  )
108
+ @held = written[:held]
109
+ @plugin_off = written[:plugin_off]
108
110
  program = RailsAiContext::Install::Program
109
111
  unless context_files?
110
112
  @removed_context_files = program.ask_context_removal(program_surface, tools: @selected_formats, root: install_root,
@@ -801,6 +803,8 @@ module RailsAiContext
801
803
  RailsAiContext::Install::AiTool.all.each do |tool|
802
804
  next unless @selected_formats.include?(tool.key)
803
805
  files = context_files? ? tool.files_label(mcp: tool_mode == :mcp) : "MCP config only"
806
+ plugin = @plugin_off ? "the rails-ai-context plugin (off here: see below)" : "the rails-ai-context plugin"
807
+ files = context_files? ? "#{plugin}; #{files}" : plugin if Array(@held).include?(tool.key)
804
808
  say " ✅ #{tool.name.ljust(16)} -> #{files}"
805
809
  end
806
810
  unless context_files? || @removed_context_files
@@ -816,7 +820,7 @@ module RailsAiContext
816
820
  say ""
817
821
  if @tool_mode == :mcp
818
822
  say "Next step, before the MCP tools appear:", :yellow
819
- RailsAiContext::Install::Program.next_steps(@selected_formats).each { |line| say " #{line}" }
823
+ RailsAiContext::Install::Program.next_steps(@selected_formats, root: install_root).each { |line| say " #{line}" }
820
824
  else
821
825
  say "CLI tools:", :yellow
822
826
  example = engine_root ? "bundle exec rails-ai-context tool schema --table users" : "rails 'ai:tool[schema]' table=users"
@@ -825,8 +829,8 @@ module RailsAiContext
825
829
  end
826
830
  say ""
827
831
  show_more_tools
828
- committed = [ ("context files" if context_files?), ("MCP configs" if tool_mode == :mcp) ].compact
829
- say "Commit the #{committed.join(' and ')} so your team benefits!", :green
832
+ committed = [ ("context files" if context_files?), ("MCP configs" if tool_mode == :mcp && (@selected_formats - Array(@held)).any?) ].compact
833
+ say "Commit the #{committed.join(' and ')} so your team benefits!", :green if committed.any?
830
834
  # Only where a Codex config was written, and only while a commit
831
835
  # would still take it along.
832
836
  if tool_mode == :mcp && @selected_formats.include?(:codex) &&
@@ -138,7 +138,13 @@ module RailsAiContext
138
138
 
139
139
  Result.new(status: :failed, error: BootError.new("config/environment.rb loaded but defined no Rails application"))
140
140
  rescue SystemExit => e
141
- Result.new(status: :failed, error: BootExitError.new("App called exit(#{e.status}) during boot"))
141
+ # Bundler in a terminal exits on a missing gem; keeping its error as the cause lets callers name it.
142
+ error = begin
143
+ raise BootExitError, "App called exit(#{e.status}) during boot", cause: e.cause
144
+ rescue BootExitError => raised
145
+ raised
146
+ end
147
+ Result.new(status: :failed, error: error)
142
148
  end
143
149
 
144
150
  # The same three protections for callers that boot through their own