anima-core 0.2.1 → 1.0.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/.reek.yml +27 -1
- data/CHANGELOG.md +19 -0
- data/README.md +213 -43
- data/agents/codebase-analyzer.md +88 -0
- data/agents/codebase-pattern-finder.md +83 -0
- data/agents/documentation-researcher.md +59 -0
- data/agents/thoughts-analyzer.md +102 -0
- data/agents/web-search-researcher.md +71 -0
- data/anima-core.gemspec +3 -0
- data/app/channels/session_channel.rb +195 -45
- data/app/decorators/user_message_decorator.rb +16 -5
- data/app/jobs/agent_request_job.rb +55 -2
- data/app/jobs/analytical_brain_job.rb +33 -0
- data/app/jobs/count_event_tokens_job.rb +15 -4
- data/app/models/concerns/event/broadcasting.rb +81 -0
- data/app/models/event.rb +20 -1
- data/app/models/goal.rb +91 -0
- data/app/models/session.rb +366 -21
- data/config/application.rb +2 -0
- data/config/initializers/event_subscribers.rb +0 -1
- data/config/routes.rb +0 -6
- data/db/migrate/20260313010000_add_status_to_events.rb +8 -0
- data/db/migrate/20260313020000_add_processing_to_sessions.rb +7 -0
- data/db/migrate/20260314075248_add_subagent_support_to_sessions.rb +6 -0
- data/db/migrate/20260314112417_add_granted_tools_to_sessions.rb +5 -0
- data/db/migrate/20260314140000_add_name_to_sessions.rb +7 -0
- data/db/migrate/20260314150000_add_viewport_event_ids_to_sessions.rb +7 -0
- data/db/migrate/20260315100000_add_active_skills_to_sessions.rb +7 -0
- data/db/migrate/20260315140843_create_goals.rb +16 -0
- data/db/migrate/20260315144837_add_completed_at_to_goals.rb +5 -0
- data/db/migrate/20260315191105_add_active_workflow_to_sessions.rb +5 -0
- data/lib/agent_loop.rb +65 -6
- data/lib/agents/definition.rb +116 -0
- data/lib/agents/registry.rb +106 -0
- data/lib/analytical_brain/runner.rb +276 -0
- data/lib/analytical_brain/tools/activate_skill.rb +52 -0
- data/lib/analytical_brain/tools/deactivate_skill.rb +43 -0
- data/lib/analytical_brain/tools/deactivate_workflow.rb +34 -0
- data/lib/analytical_brain/tools/everything_is_ready.rb +28 -0
- data/lib/analytical_brain/tools/finish_goal.rb +62 -0
- data/lib/analytical_brain/tools/read_workflow.rb +58 -0
- data/lib/analytical_brain/tools/rename_session.rb +63 -0
- data/lib/analytical_brain/tools/set_goal.rb +60 -0
- data/lib/analytical_brain/tools/update_goal.rb +60 -0
- data/lib/analytical_brain.rb +23 -0
- data/lib/anima/cli/mcp/secrets.rb +76 -0
- data/lib/anima/cli/mcp.rb +197 -0
- data/lib/anima/cli.rb +5 -40
- data/lib/anima/installer.rb +168 -0
- data/lib/anima/settings.rb +226 -0
- data/lib/anima/version.rb +1 -1
- data/lib/anima.rb +9 -0
- data/lib/credential_store.rb +103 -0
- data/lib/environment_probe.rb +232 -0
- data/lib/events/subscribers/persister.rb +1 -0
- data/lib/events/user_message.rb +17 -0
- data/lib/llm/client.rb +29 -10
- data/lib/mcp/client_manager.rb +86 -0
- data/lib/mcp/config.rb +213 -0
- data/lib/mcp/health_check.rb +77 -0
- data/lib/mcp/secrets.rb +73 -0
- data/lib/mcp/stdio_transport.rb +206 -0
- data/lib/providers/anthropic.rb +11 -20
- data/lib/shell_session.rb +11 -10
- data/lib/skills/definition.rb +97 -0
- data/lib/skills/registry.rb +105 -0
- data/lib/tools/edit.rb +226 -0
- data/lib/tools/mcp_tool.rb +114 -0
- data/lib/tools/read.rb +151 -0
- data/lib/tools/registry.rb +14 -12
- data/lib/tools/request_feature.rb +121 -0
- data/lib/tools/return_result.rb +81 -0
- data/lib/tools/spawn_specialist.rb +109 -0
- data/lib/tools/spawn_subagent.rb +111 -0
- data/lib/tools/subagent_prompts.rb +12 -0
- data/lib/tools/web_get.rb +8 -9
- data/lib/tools/write.rb +86 -0
- data/lib/tui/app.rb +985 -26
- data/lib/tui/cable_client.rb +69 -31
- data/lib/tui/message_store.rb +103 -8
- data/lib/tui/screens/chat.rb +293 -45
- data/lib/workflows/definition.rb +97 -0
- data/lib/workflows/registry.rb +89 -0
- data/skills/activerecord/SKILL.md +255 -0
- data/skills/activerecord/examples/associations/association_extensions.rb +298 -0
- data/skills/activerecord/examples/associations/basic_associations.rb +118 -0
- data/skills/activerecord/examples/associations/counter_caches.rb +215 -0
- data/skills/activerecord/examples/associations/polymorphic_associations.rb +217 -0
- data/skills/activerecord/examples/associations/self_referential.rb +302 -0
- data/skills/activerecord/examples/associations/through_associations.rb +203 -0
- data/skills/activerecord/examples/basics/crud_operations.rb +209 -0
- data/skills/activerecord/examples/basics/dirty_tracking.rb +218 -0
- data/skills/activerecord/examples/basics/inheritance.rb +377 -0
- data/skills/activerecord/examples/basics/type_casting.rb +317 -0
- data/skills/activerecord/examples/callbacks/alternatives_to_callbacks.rb +447 -0
- data/skills/activerecord/examples/callbacks/conditional_callbacks.rb +353 -0
- data/skills/activerecord/examples/callbacks/lifecycle_callbacks.rb +280 -0
- data/skills/activerecord/examples/callbacks/transaction_callbacks.rb +340 -0
- data/skills/activerecord/examples/migrations/indexes_and_constraints.rb +337 -0
- data/skills/activerecord/examples/migrations/reversible_patterns.rb +403 -0
- data/skills/activerecord/examples/migrations/safe_patterns.rb +420 -0
- data/skills/activerecord/examples/migrations/schema_changes.rb +277 -0
- data/skills/activerecord/examples/querying/batch_processing.rb +226 -0
- data/skills/activerecord/examples/querying/eager_loading.rb +259 -0
- data/skills/activerecord/examples/querying/finder_methods.rb +170 -0
- data/skills/activerecord/examples/querying/optimization.rb +275 -0
- data/skills/activerecord/examples/querying/scopes.rb +260 -0
- data/skills/activerecord/examples/validations/built_in_validators.rb +277 -0
- data/skills/activerecord/examples/validations/conditional_validations.rb +288 -0
- data/skills/activerecord/examples/validations/custom_validators.rb +381 -0
- data/skills/activerecord/examples/validations/database_constraints.rb +432 -0
- data/skills/activerecord/examples/validations/validation_contexts.rb +367 -0
- data/skills/activerecord/references/associations.md +709 -0
- data/skills/activerecord/references/basics.md +622 -0
- data/skills/activerecord/references/callbacks.md +738 -0
- data/skills/activerecord/references/migrations.md +657 -0
- data/skills/activerecord/references/querying.md +655 -0
- data/skills/activerecord/references/validations.md +596 -0
- data/skills/dragonruby/SKILL.md +250 -0
- data/skills/dragonruby/examples/audio/audio_events.rb +55 -0
- data/skills/dragonruby/examples/audio/background_music.rb +29 -0
- data/skills/dragonruby/examples/audio/crossfade.rb +51 -0
- data/skills/dragonruby/examples/audio/music_controls.rb +51 -0
- data/skills/dragonruby/examples/audio/sound_effects.rb +30 -0
- data/skills/dragonruby/examples/core/coordinate_system.rb +27 -0
- data/skills/dragonruby/examples/core/hello_world.rb +24 -0
- data/skills/dragonruby/examples/core/labels.rb +22 -0
- data/skills/dragonruby/examples/core/sprites.rb +35 -0
- data/skills/dragonruby/examples/core/state_management.rb +29 -0
- data/skills/dragonruby/examples/distribution/background_pause.rb +42 -0
- data/skills/dragonruby/examples/distribution/build_workflow.sh +26 -0
- data/skills/dragonruby/examples/distribution/cvars_production.txt +16 -0
- data/skills/dragonruby/examples/distribution/game_metadata_hd.txt +23 -0
- data/skills/dragonruby/examples/distribution/game_metadata_minimal.txt +9 -0
- data/skills/dragonruby/examples/distribution/game_metadata_mobile.txt +31 -0
- data/skills/dragonruby/examples/distribution/platform_detection.rb +36 -0
- data/skills/dragonruby/examples/distribution/steam_metadata.txt +19 -0
- data/skills/dragonruby/examples/entities/collision_detection.rb +43 -0
- data/skills/dragonruby/examples/entities/entity_lifecycle.rb +68 -0
- data/skills/dragonruby/examples/entities/entity_storage.rb +38 -0
- data/skills/dragonruby/examples/entities/factory_methods.rb +45 -0
- data/skills/dragonruby/examples/entities/random_spawning.rb +50 -0
- data/skills/dragonruby/examples/game-logic/reset_patterns.rb +98 -0
- data/skills/dragonruby/examples/game-logic/save_load.rb +101 -0
- data/skills/dragonruby/examples/game-logic/scoring.rb +104 -0
- data/skills/dragonruby/examples/game-logic/state_transitions.rb +103 -0
- data/skills/dragonruby/examples/game-logic/timers.rb +87 -0
- data/skills/dragonruby/examples/input/action_triggers.rb +36 -0
- data/skills/dragonruby/examples/input/analog_movement.rb +28 -0
- data/skills/dragonruby/examples/input/controller_input.rb +28 -0
- data/skills/dragonruby/examples/input/directional_input.rb +24 -0
- data/skills/dragonruby/examples/input/keyboard_input.rb +28 -0
- data/skills/dragonruby/examples/input/mouse_click.rb +26 -0
- data/skills/dragonruby/examples/input/movement_with_bounds.rb +22 -0
- data/skills/dragonruby/examples/input/normalized_movement.rb +32 -0
- data/skills/dragonruby/examples/rendering/frame_animation.rb +32 -0
- data/skills/dragonruby/examples/rendering/labels.rb +32 -0
- data/skills/dragonruby/examples/rendering/layering.rb +51 -0
- data/skills/dragonruby/examples/rendering/solids.rb +61 -0
- data/skills/dragonruby/examples/rendering/sprites.rb +33 -0
- data/skills/dragonruby/examples/rendering/spritesheet_animation.rb +39 -0
- data/skills/dragonruby/examples/scenes/case_dispatch.rb +60 -0
- data/skills/dragonruby/examples/scenes/class_based.rb +150 -0
- data/skills/dragonruby/examples/scenes/pause_overlay.rb +100 -0
- data/skills/dragonruby/examples/scenes/safe_transitions.rb +68 -0
- data/skills/dragonruby/examples/scenes/scene_transitions.rb +98 -0
- data/skills/dragonruby/examples/scenes/send_dispatch.rb +88 -0
- data/skills/dragonruby/references/audio.md +396 -0
- data/skills/dragonruby/references/core.md +385 -0
- data/skills/dragonruby/references/distribution.md +434 -0
- data/skills/dragonruby/references/entities.md +516 -0
- data/skills/dragonruby/references/game-logic/persistence.md +386 -0
- data/skills/dragonruby/references/game-logic/state.md +389 -0
- data/skills/dragonruby/references/input.md +414 -0
- data/skills/dragonruby/references/rendering/animation.md +467 -0
- data/skills/dragonruby/references/rendering/primitives.md +403 -0
- data/skills/dragonruby/references/scenes.md +443 -0
- data/skills/draper-decorators/SKILL.md +344 -0
- data/skills/draper-decorators/examples/application_decorator.rb +61 -0
- data/skills/draper-decorators/examples/decorator_spec.rb +253 -0
- data/skills/draper-decorators/examples/model_decorator.rb +152 -0
- data/skills/draper-decorators/references/anti-patterns.md +640 -0
- data/skills/draper-decorators/references/patterns.md +507 -0
- data/skills/draper-decorators/references/testing.md +559 -0
- data/skills/gh-issue.md +182 -0
- data/skills/mcp-server/SKILL.md +177 -0
- data/skills/mcp-server/examples/dynamic_tools.rb +36 -0
- data/skills/mcp-server/examples/file_manager_tool.rb +85 -0
- data/skills/mcp-server/examples/http_client.rb +48 -0
- data/skills/mcp-server/examples/http_server.rb +97 -0
- data/skills/mcp-server/examples/rails_integration.rb +88 -0
- data/skills/mcp-server/examples/stdio_server.rb +108 -0
- data/skills/mcp-server/examples/streaming_client.rb +95 -0
- data/skills/mcp-server/references/gotchas.md +183 -0
- data/skills/mcp-server/references/prompts.md +98 -0
- data/skills/mcp-server/references/resources.md +53 -0
- data/skills/mcp-server/references/server.md +140 -0
- data/skills/mcp-server/references/tools.md +146 -0
- data/skills/mcp-server/references/transport.md +104 -0
- data/skills/ratatui-ruby/SKILL.md +315 -0
- data/skills/ratatui-ruby/references/core-concepts.md +340 -0
- data/skills/ratatui-ruby/references/events.md +387 -0
- data/skills/ratatui-ruby/references/frameworks.md +522 -0
- data/skills/ratatui-ruby/references/layout.md +423 -0
- data/skills/ratatui-ruby/references/styling.md +268 -0
- data/skills/ratatui-ruby/references/testing.md +433 -0
- data/skills/ratatui-ruby/references/widgets.md +532 -0
- data/skills/rspec/SKILL.md +340 -0
- data/skills/rspec/examples/core/basic_structure.rb +69 -0
- data/skills/rspec/examples/core/configuration.rb +126 -0
- data/skills/rspec/examples/core/hooks.rb +126 -0
- data/skills/rspec/examples/core/memoized_helpers.rb +139 -0
- data/skills/rspec/examples/core/metadata_filtering.rb +144 -0
- data/skills/rspec/examples/core/shared_examples.rb +145 -0
- data/skills/rspec/examples/factory_bot/associations.rb +314 -0
- data/skills/rspec/examples/factory_bot/build_strategies.rb +272 -0
- data/skills/rspec/examples/factory_bot/callbacks.rb +320 -0
- data/skills/rspec/examples/factory_bot/custom_construction.rb +328 -0
- data/skills/rspec/examples/factory_bot/factory_definition.rb +191 -0
- data/skills/rspec/examples/factory_bot/inheritance.rb +314 -0
- data/skills/rspec/examples/factory_bot/traits.rb +293 -0
- data/skills/rspec/examples/factory_bot/transients.rb +229 -0
- data/skills/rspec/examples/matchers/change.rb +115 -0
- data/skills/rspec/examples/matchers/collections.rb +154 -0
- data/skills/rspec/examples/matchers/comparisons.rb +79 -0
- data/skills/rspec/examples/matchers/composing.rb +155 -0
- data/skills/rspec/examples/matchers/custom_matchers.rb +197 -0
- data/skills/rspec/examples/matchers/equality.rb +58 -0
- data/skills/rspec/examples/matchers/errors.rb +136 -0
- data/skills/rspec/examples/matchers/output.rb +103 -0
- data/skills/rspec/examples/matchers/predicates.rb +87 -0
- data/skills/rspec/examples/matchers/truthiness.rb +101 -0
- data/skills/rspec/examples/matchers/types.rb +82 -0
- data/skills/rspec/examples/matchers/yield.rb +147 -0
- data/skills/rspec/examples/mocks/any_instance.rb +172 -0
- data/skills/rspec/examples/mocks/argument_matchers.rb +206 -0
- data/skills/rspec/examples/mocks/constants.rb +177 -0
- data/skills/rspec/examples/mocks/doubles.rb +139 -0
- data/skills/rspec/examples/mocks/expectations.rb +137 -0
- data/skills/rspec/examples/mocks/message_chains.rb +173 -0
- data/skills/rspec/examples/mocks/ordering.rb +144 -0
- data/skills/rspec/examples/mocks/receive_counts.rb +181 -0
- data/skills/rspec/examples/mocks/responses.rb +223 -0
- data/skills/rspec/examples/mocks/spies.rb +149 -0
- data/skills/rspec/examples/mocks/stubbing.rb +133 -0
- data/skills/rspec/examples/rails/channels.rb +250 -0
- data/skills/rspec/examples/rails/controller_specs.rb +302 -0
- data/skills/rspec/examples/rails/helper_specs.rb +245 -0
- data/skills/rspec/examples/rails/job_specs.rb +256 -0
- data/skills/rspec/examples/rails/mailer_specs.rb +228 -0
- data/skills/rspec/examples/rails/matchers.rb +374 -0
- data/skills/rspec/examples/rails/model_specs.rb +193 -0
- data/skills/rspec/examples/rails/request_specs.rb +275 -0
- data/skills/rspec/examples/rails/routing_specs.rb +276 -0
- data/skills/rspec/examples/rails/system_specs.rb +294 -0
- data/skills/rspec/examples/rails/transactions.rb +254 -0
- data/skills/rspec/examples/rails/view_specs.rb +252 -0
- data/skills/rspec/references/core.md +816 -0
- data/skills/rspec/references/factory_bot.md +641 -0
- data/skills/rspec/references/matchers.md +516 -0
- data/skills/rspec/references/mocks.md +381 -0
- data/skills/rspec/references/rails.md +528 -0
- data/templates/soul.md +40 -0
- data/workflows/commit.md +45 -0
- data/workflows/create_handoff.md +98 -0
- data/workflows/create_note.md +82 -0
- data/workflows/create_plan.md +457 -0
- data/workflows/decompose_ticket.md +109 -0
- data/workflows/feature.md +91 -0
- data/workflows/implement_plan.md +87 -0
- data/workflows/iterate_plan.md +247 -0
- data/workflows/research_codebase.md +210 -0
- data/workflows/resume_handoff.md +217 -0
- data/workflows/review_pr.md +320 -0
- data/workflows/thoughts_init.md +71 -0
- data/workflows/validate_plan.md +166 -0
- metadata +290 -3
- data/app/controllers/api/sessions_controller.rb +0 -25
- data/lib/events/subscribers/action_cable_bridge.rb +0 -59
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "etc"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "timeout"
|
|
6
|
+
require "json"
|
|
7
|
+
require "pathname"
|
|
8
|
+
require "uri"
|
|
9
|
+
|
|
10
|
+
# Probes the shell environment and assembles a lightweight metadata block
|
|
11
|
+
# for injection into the system prompt. Gives the agent awareness of its
|
|
12
|
+
# working directory, OS, Git status, and nearby project files — without
|
|
13
|
+
# loading any file content.
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# EnvironmentProbe.to_prompt("/home/user/projects/my-app")
|
|
17
|
+
# # => "## Environment\n\nOS: Arch Linux (pacman, yay)\n..."
|
|
18
|
+
class EnvironmentProbe
|
|
19
|
+
# Assembles the environment context block for a given working directory.
|
|
20
|
+
#
|
|
21
|
+
# @param pwd [String, nil] current working directory
|
|
22
|
+
# @return [String, nil] Markdown-formatted environment block, or nil when pwd is unknown
|
|
23
|
+
def self.to_prompt(pwd)
|
|
24
|
+
new(pwd).to_prompt
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @param pwd [String, nil] current working directory
|
|
28
|
+
def initialize(pwd)
|
|
29
|
+
@pwd = pwd
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @return [String, nil] Markdown-formatted environment block
|
|
33
|
+
def to_prompt
|
|
34
|
+
return unless @pwd
|
|
35
|
+
|
|
36
|
+
sections = [os_section, working_directory_section, project_files_section].compact
|
|
37
|
+
return if sections.empty?
|
|
38
|
+
|
|
39
|
+
"## Environment\n\n#{sections.join("\n\n")}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
# @return [String] OS name with package manager hint
|
|
45
|
+
def os_section
|
|
46
|
+
sysname = Etc.uname[:sysname]
|
|
47
|
+
"OS: #{format_os(sysname)}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @param sysname [String] kernel name from uname (e.g. "Linux", "Darwin")
|
|
51
|
+
# @return [String] human-readable OS description
|
|
52
|
+
def format_os(sysname)
|
|
53
|
+
case sysname
|
|
54
|
+
when "Linux"
|
|
55
|
+
distro = detect_linux_distro || "Linux"
|
|
56
|
+
pkg = detect_package_manager
|
|
57
|
+
pkg ? "#{distro} (#{pkg})" : distro
|
|
58
|
+
when "Darwin"
|
|
59
|
+
"macOS (Homebrew)"
|
|
60
|
+
else
|
|
61
|
+
sysname
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Reads PRETTY_NAME from /etc/os-release.
|
|
66
|
+
#
|
|
67
|
+
# @return [String, nil] distro name, or nil on non-Linux / missing file
|
|
68
|
+
def detect_linux_distro
|
|
69
|
+
return unless File.exist?("/etc/os-release")
|
|
70
|
+
|
|
71
|
+
File.foreach("/etc/os-release") do |line|
|
|
72
|
+
if line.start_with?("PRETTY_NAME=")
|
|
73
|
+
return line.split("=", 2).last.strip.delete('"')
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Returns the primary package manager(s) for the current system.
|
|
80
|
+
# Arch-based systems list both pacman and yay when present;
|
|
81
|
+
# other families return the first match.
|
|
82
|
+
#
|
|
83
|
+
# @return [String, nil] comma-separated package manager names
|
|
84
|
+
def detect_package_manager
|
|
85
|
+
managers = []
|
|
86
|
+
managers << "pacman" if File.exist?("/usr/bin/pacman")
|
|
87
|
+
managers << "yay" if File.exist?("/usr/bin/yay")
|
|
88
|
+
return managers.join(", ") if managers.any?
|
|
89
|
+
|
|
90
|
+
return "apt" if File.exist?("/usr/bin/apt")
|
|
91
|
+
return "dnf" if File.exist?("/usr/bin/dnf")
|
|
92
|
+
return "Homebrew" if File.exist?("/opt/homebrew/bin/brew") || File.exist?("/usr/local/bin/brew")
|
|
93
|
+
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# @return [String] CWD line plus optional Git metadata
|
|
98
|
+
def working_directory_section
|
|
99
|
+
lines = ["CWD: #{@pwd}"]
|
|
100
|
+
append_git_lines(lines)
|
|
101
|
+
lines.join("\n")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Appends Git metadata lines (remote, branch, PR) to the output array.
|
|
105
|
+
#
|
|
106
|
+
# @param lines [Array<String>] accumulator for output lines
|
|
107
|
+
# @return [void]
|
|
108
|
+
def append_git_lines(lines)
|
|
109
|
+
git = detect_git
|
|
110
|
+
return unless git
|
|
111
|
+
|
|
112
|
+
remote = git[:remote]
|
|
113
|
+
branch = git[:branch]
|
|
114
|
+
pr_number = git[:pr_number]
|
|
115
|
+
|
|
116
|
+
lines << "Git: #{git[:repo_name]} (#{remote})" if remote
|
|
117
|
+
lines << "Branch: #{branch}" if branch
|
|
118
|
+
lines << "PR: ##{pr_number} (#{git[:pr_state]})" if pr_number
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Detects Git repo metadata: remote, branch, and open PR.
|
|
122
|
+
#
|
|
123
|
+
# @return [Hash{Symbol => String}, nil] keys: :remote, :repo_name, :branch,
|
|
124
|
+
# and optionally :pr_number (Integer) and :pr_state (String); nil when not in a repo
|
|
125
|
+
def detect_git
|
|
126
|
+
_, status = Open3.capture2("git", "-C", @pwd, "rev-parse", "--is-inside-work-tree")
|
|
127
|
+
return unless status.success?
|
|
128
|
+
|
|
129
|
+
info = {}
|
|
130
|
+
detect_git_remote(info)
|
|
131
|
+
detect_git_branch(info)
|
|
132
|
+
info
|
|
133
|
+
rescue Errno::ENOENT
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Populates :remote and :repo_name on the info hash.
|
|
138
|
+
def detect_git_remote(info)
|
|
139
|
+
remote, = Open3.capture2("git", "-C", @pwd, "remote", "get-url", "origin")
|
|
140
|
+
remote = remote.strip
|
|
141
|
+
return unless remote.present?
|
|
142
|
+
|
|
143
|
+
info[:remote] = remote
|
|
144
|
+
info[:repo_name] = extract_repo_name(remote)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Populates :branch, :pr_number, and :pr_state on the info hash.
|
|
148
|
+
def detect_git_branch(info)
|
|
149
|
+
branch, = Open3.capture2("git", "-C", @pwd, "rev-parse", "--abbrev-ref", "HEAD")
|
|
150
|
+
branch = branch.strip
|
|
151
|
+
return unless branch.present?
|
|
152
|
+
|
|
153
|
+
info[:branch] = branch
|
|
154
|
+
pr = detect_pr(branch)
|
|
155
|
+
info.merge!(pr) if pr
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Extracts owner/repo from a Git remote URL.
|
|
159
|
+
#
|
|
160
|
+
# @param remote_url [String] SSH or HTTPS remote URL
|
|
161
|
+
# @return [String] "owner/repo" path, or the raw URL when parsing fails
|
|
162
|
+
def extract_repo_name(remote_url)
|
|
163
|
+
path = if remote_url.match?(%r{\A\w+://})
|
|
164
|
+
URI.parse(remote_url).path
|
|
165
|
+
else
|
|
166
|
+
# SSH format: git@host:owner/repo.git
|
|
167
|
+
remote_url.split(":").last
|
|
168
|
+
end
|
|
169
|
+
path.delete_prefix("/").delete_suffix(".git")
|
|
170
|
+
rescue URI::InvalidURIError
|
|
171
|
+
remote_url
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Queries GitHub for an open PR on the given branch via the gh CLI.
|
|
175
|
+
#
|
|
176
|
+
# @param branch [String] branch name
|
|
177
|
+
# @return [Hash, nil] with :pr_number and :pr_state, or nil
|
|
178
|
+
# @note Returns nil on timeout, missing gh CLI, or JSON parse errors
|
|
179
|
+
def detect_pr(branch)
|
|
180
|
+
Timeout.timeout(Anima::Settings.web_request_timeout) do
|
|
181
|
+
output, status = Open3.capture2(
|
|
182
|
+
"gh", "pr", "list", "--head", branch,
|
|
183
|
+
"--json", "number,state", "--limit", "1",
|
|
184
|
+
chdir: @pwd
|
|
185
|
+
)
|
|
186
|
+
return unless status.success?
|
|
187
|
+
|
|
188
|
+
pr = JSON.parse(output).first
|
|
189
|
+
return unless pr
|
|
190
|
+
|
|
191
|
+
{pr_number: pr["number"], pr_state: pr["state"].downcase}
|
|
192
|
+
end
|
|
193
|
+
rescue Timeout::Error, Errno::ENOENT, JSON::ParserError
|
|
194
|
+
nil
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Scans for well-known project files up to a configurable depth.
|
|
198
|
+
#
|
|
199
|
+
# @return [String, nil] project files section, or nil when none found
|
|
200
|
+
def project_files_section
|
|
201
|
+
found = scan_project_files
|
|
202
|
+
return if found.empty?
|
|
203
|
+
|
|
204
|
+
header = "Project files that may contain useful context:"
|
|
205
|
+
entries = found.map { |path| "- #{path}" }
|
|
206
|
+
[header, *entries, "Use read_file to examine these when needed."].join("\n")
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Scans the working directory for whitelisted filenames.
|
|
210
|
+
#
|
|
211
|
+
# @return [Array<String>] sorted relative paths
|
|
212
|
+
def scan_project_files
|
|
213
|
+
base = Pathname.new(@pwd)
|
|
214
|
+
|
|
215
|
+
glob_patterns.flat_map { |pattern| Dir.glob(pattern) }
|
|
216
|
+
.map { |full_path| Pathname.new(full_path).relative_path_from(base).to_s }
|
|
217
|
+
.sort
|
|
218
|
+
.uniq
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Builds glob patterns for each whitelisted filename at each depth level.
|
|
222
|
+
#
|
|
223
|
+
# @return [Array<String>] glob patterns
|
|
224
|
+
def glob_patterns
|
|
225
|
+
whitelist = Anima::Settings.project_files_whitelist
|
|
226
|
+
max_depth = Anima::Settings.project_files_max_depth
|
|
227
|
+
|
|
228
|
+
whitelist.product((0..max_depth).to_a).map do |filename, depth|
|
|
229
|
+
File.join(@pwd, Array.new(depth, "*"), filename)
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
@@ -42,6 +42,7 @@ module Events
|
|
|
42
42
|
target_session.events.create!(
|
|
43
43
|
event_type: event_type,
|
|
44
44
|
payload: payload,
|
|
45
|
+
status: payload[:status],
|
|
45
46
|
tool_use_id: payload[:tool_use_id],
|
|
46
47
|
timestamp: payload[:timestamp] || Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)
|
|
47
48
|
)
|
data/lib/events/user_message.rb
CHANGED
|
@@ -4,8 +4,25 @@ module Events
|
|
|
4
4
|
class UserMessage < Base
|
|
5
5
|
TYPE = "user_message"
|
|
6
6
|
|
|
7
|
+
# @return [String, nil] "pending" when queued during active processing, nil otherwise
|
|
8
|
+
attr_reader :status
|
|
9
|
+
|
|
10
|
+
# @param content [String] message text
|
|
11
|
+
# @param session_id [Integer, nil] session identifier
|
|
12
|
+
# @param status [String, nil] "pending" when queued during active agent processing
|
|
13
|
+
def initialize(content:, session_id: nil, status: nil)
|
|
14
|
+
super(content: content, session_id: session_id)
|
|
15
|
+
@status = status
|
|
16
|
+
end
|
|
17
|
+
|
|
7
18
|
def type
|
|
8
19
|
TYPE
|
|
9
20
|
end
|
|
21
|
+
|
|
22
|
+
def to_h
|
|
23
|
+
h = super
|
|
24
|
+
h[:status] = status if status
|
|
25
|
+
h
|
|
26
|
+
end
|
|
10
27
|
end
|
|
11
28
|
end
|
data/lib/llm/client.rb
CHANGED
|
@@ -15,10 +15,6 @@ module LLM
|
|
|
15
15
|
# registry.register(Tools::WebGet)
|
|
16
16
|
# client.chat_with_tools(messages, registry: registry, session_id: session.id)
|
|
17
17
|
class Client
|
|
18
|
-
DEFAULT_MODEL = "claude-sonnet-4-20250514"
|
|
19
|
-
DEFAULT_MAX_TOKENS = 8192
|
|
20
|
-
MAX_TOOL_ROUNDS = 25
|
|
21
|
-
|
|
22
18
|
# @return [Providers::Anthropic] the underlying API provider
|
|
23
19
|
attr_reader :provider
|
|
24
20
|
|
|
@@ -28,14 +24,16 @@ module LLM
|
|
|
28
24
|
# @return [Integer] maximum tokens in the response
|
|
29
25
|
attr_reader :max_tokens
|
|
30
26
|
|
|
31
|
-
# @param model [String] Anthropic model identifier
|
|
32
|
-
# @param max_tokens [Integer] maximum tokens in the response
|
|
27
|
+
# @param model [String] Anthropic model identifier (default from Settings)
|
|
28
|
+
# @param max_tokens [Integer] maximum tokens in the response (default from Settings)
|
|
33
29
|
# @param provider [Providers::Anthropic, nil] injectable provider instance;
|
|
34
30
|
# defaults to a new {Providers::Anthropic} using credentials
|
|
35
|
-
|
|
31
|
+
# @param logger [Logger, nil] optional logger for tool call tracing
|
|
32
|
+
def initialize(model: Anima::Settings.model, max_tokens: Anima::Settings.max_tokens, provider: nil, logger: nil)
|
|
36
33
|
@provider = build_provider(provider)
|
|
37
34
|
@model = model
|
|
38
35
|
@max_tokens = max_tokens
|
|
36
|
+
@logger = logger
|
|
39
37
|
end
|
|
40
38
|
|
|
41
39
|
# Send messages to the LLM and return the assistant's text response.
|
|
@@ -75,8 +73,9 @@ module LLM
|
|
|
75
73
|
|
|
76
74
|
loop do
|
|
77
75
|
rounds += 1
|
|
78
|
-
|
|
79
|
-
|
|
76
|
+
max_rounds = Anima::Settings.max_tool_rounds
|
|
77
|
+
if rounds > max_rounds
|
|
78
|
+
return "[Tool loop exceeded #{max_rounds} rounds — halting]"
|
|
80
79
|
end
|
|
81
80
|
|
|
82
81
|
response = provider.create_message(
|
|
@@ -87,6 +86,8 @@ module LLM
|
|
|
87
86
|
**options
|
|
88
87
|
)
|
|
89
88
|
|
|
89
|
+
log(:debug, "stop_reason=#{response["stop_reason"]} content_types=#{(response["content"] || []).map { |b| b["type"] }.join(",")}")
|
|
90
|
+
|
|
90
91
|
if response["stop_reason"] == "tool_use"
|
|
91
92
|
tool_results = execute_tools(response, registry, session_id)
|
|
92
93
|
|
|
@@ -132,18 +133,30 @@ module LLM
|
|
|
132
133
|
end
|
|
133
134
|
end
|
|
134
135
|
|
|
136
|
+
# Executes a single tool and always returns a tool_result — even if
|
|
137
|
+
# the tool raises. The LLM requires every tool_use to have a matching
|
|
138
|
+
# tool_result; a missing result breaks the conversation permanently.
|
|
135
139
|
def execute_single_tool(tool_use, registry, session_id)
|
|
136
140
|
name = tool_use["name"]
|
|
137
141
|
id = tool_use["id"]
|
|
138
142
|
input = tool_use["input"] || {}
|
|
139
143
|
|
|
144
|
+
log(:debug, "tool_call: #{name}(#{input.to_json})")
|
|
145
|
+
|
|
140
146
|
Events::Bus.emit(Events::ToolCall.new(
|
|
141
147
|
content: "Calling #{name}", tool_name: name,
|
|
142
148
|
tool_input: input, tool_use_id: id, session_id: session_id
|
|
143
149
|
))
|
|
144
150
|
|
|
145
|
-
result =
|
|
151
|
+
result = begin
|
|
152
|
+
registry.execute(name, input)
|
|
153
|
+
rescue => error
|
|
154
|
+
Rails.logger.error("Tool #{name} raised #{error.class}: #{error.message}")
|
|
155
|
+
{error: "#{error.class}: #{error.message}"}
|
|
156
|
+
end
|
|
157
|
+
|
|
146
158
|
result_content = format_tool_result(result)
|
|
159
|
+
log(:debug, "tool_result: #{name} → #{result_content.to_s.truncate(200)}")
|
|
147
160
|
|
|
148
161
|
Events::Bus.emit(Events::ToolResponse.new(
|
|
149
162
|
content: result_content, tool_name: name, tool_use_id: id,
|
|
@@ -154,6 +167,12 @@ module LLM
|
|
|
154
167
|
{type: "tool_result", tool_use_id: id, content: result_content}
|
|
155
168
|
end
|
|
156
169
|
|
|
170
|
+
def log(level, message)
|
|
171
|
+
return unless @logger
|
|
172
|
+
|
|
173
|
+
@logger.public_send(level, message)
|
|
174
|
+
end
|
|
175
|
+
|
|
157
176
|
def format_tool_result(result)
|
|
158
177
|
result.is_a?(Hash) ? result.to_json : result.to_s
|
|
159
178
|
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mcp"
|
|
4
|
+
|
|
5
|
+
module Mcp
|
|
6
|
+
# Manages MCP client connections and registers their tools with
|
|
7
|
+
# {Tools::Registry}. Each configured server (HTTP or stdio) gets
|
|
8
|
+
# a dedicated {MCP::Client} instance. Tool lists are fetched once
|
|
9
|
+
# during registration and cached in the registry — subsequent LLM
|
|
10
|
+
# turns reuse the same tool set without re-querying servers.
|
|
11
|
+
#
|
|
12
|
+
# Connection failures are logged and skipped — a misconfigured or
|
|
13
|
+
# unavailable server does not prevent other servers or built-in
|
|
14
|
+
# tools from working.
|
|
15
|
+
#
|
|
16
|
+
# @example
|
|
17
|
+
# manager = Mcp::ClientManager.new
|
|
18
|
+
# manager.register_tools(registry)
|
|
19
|
+
class ClientManager
|
|
20
|
+
# @param config [Mcp::Config] injectable config for testing
|
|
21
|
+
def initialize(config: Config.new(logger: Rails.logger))
|
|
22
|
+
@config = config
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Connects to all configured MCP servers and registers their tools
|
|
26
|
+
# in the given registry. Returns warnings for servers that failed
|
|
27
|
+
# to load so the caller can surface them to the user.
|
|
28
|
+
#
|
|
29
|
+
# @param registry [Tools::Registry] the registry to add tools to
|
|
30
|
+
# @return [Array<String>] warning messages for servers that failed
|
|
31
|
+
def register_tools(registry)
|
|
32
|
+
warnings = []
|
|
33
|
+
register_transport_tools(@config.http_servers, registry, warnings) { |server| build_http_client(server) }
|
|
34
|
+
register_transport_tools(@config.stdio_servers, registry, warnings) { |server| build_stdio_client(server) }
|
|
35
|
+
@config.warnings + warnings
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
# Iterates server configs, builds a client for each via the block,
|
|
41
|
+
# and registers the server's tools. Failures are logged and collected.
|
|
42
|
+
#
|
|
43
|
+
# @param servers [Array<Hash>] server configs from {Mcp::Config}
|
|
44
|
+
# @param registry [Tools::Registry] registry to register tools in
|
|
45
|
+
# @param warnings [Array<String>] collects failure messages
|
|
46
|
+
# @yield [server] block that builds an {MCP::Client} for the server
|
|
47
|
+
def register_transport_tools(servers, registry, warnings)
|
|
48
|
+
servers.each do |server|
|
|
49
|
+
client = yield(server)
|
|
50
|
+
register_server_tools(server[:name], client, registry)
|
|
51
|
+
rescue => error
|
|
52
|
+
message = "MCP: failed to load tools from #{server[:name]}: #{error.message}"
|
|
53
|
+
Rails.logger.warn(message)
|
|
54
|
+
warnings << message
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Fetches tools from an MCP client and registers them with
|
|
59
|
+
# namespaced names in the registry.
|
|
60
|
+
#
|
|
61
|
+
# @param server_name [String] server name for tool namespacing
|
|
62
|
+
# @param client [MCP::Client] connected MCP client
|
|
63
|
+
# @param registry [Tools::Registry] registry to register tools in
|
|
64
|
+
def register_server_tools(server_name, client, registry)
|
|
65
|
+
count = client.tools.map { |mcp_tool|
|
|
66
|
+
Tools::McpTool.new(server_name: server_name, mcp_client: client, mcp_tool: mcp_tool)
|
|
67
|
+
}.each { |wrapper| registry.register(wrapper) }.size
|
|
68
|
+
|
|
69
|
+
Rails.logger.info("MCP: registered #{count} tools from #{server_name}")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @param server [Hash] server config with +:url+ and +:headers+
|
|
73
|
+
# @return [MCP::Client]
|
|
74
|
+
def build_http_client(server)
|
|
75
|
+
transport = MCP::Client::HTTP.new(url: server[:url], headers: server[:headers])
|
|
76
|
+
MCP::Client.new(transport: transport)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @param server [Hash] server config with +:command+, +:args+, +:env+
|
|
80
|
+
# @return [MCP::Client]
|
|
81
|
+
def build_stdio_client(server)
|
|
82
|
+
transport = StdioTransport.new(command: server[:command], args: server[:args], env: server[:env])
|
|
83
|
+
MCP::Client.new(transport: transport)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
data/lib/mcp/config.rb
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "toml-rb"
|
|
5
|
+
|
|
6
|
+
module Mcp
|
|
7
|
+
# Reads and writes MCP server configuration from a TOML file at
|
|
8
|
+
# {DEFAULT_PATH}. Supports HTTP and stdio transports. Secrets stored
|
|
9
|
+
# in Rails encrypted credentials are interpolated via
|
|
10
|
+
# +${credential:key_name}+ syntax in any string value.
|
|
11
|
+
#
|
|
12
|
+
# @example Config file format (~/.anima/mcp.toml)
|
|
13
|
+
# [servers.mythonix]
|
|
14
|
+
# transport = "http"
|
|
15
|
+
# url = "http://localhost:3000/mcp/v2"
|
|
16
|
+
#
|
|
17
|
+
# [servers.linear]
|
|
18
|
+
# transport = "http"
|
|
19
|
+
# url = "https://mcp.linear.app/mcp"
|
|
20
|
+
# headers = { Authorization = "Bearer ${credential:linear_api_key}" }
|
|
21
|
+
#
|
|
22
|
+
# [servers.filesystem]
|
|
23
|
+
# transport = "stdio"
|
|
24
|
+
# command = "mcp-server-filesystem"
|
|
25
|
+
# args = ["--root", "/workspace"]
|
|
26
|
+
class Config
|
|
27
|
+
DEFAULT_PATH = File.expand_path("~/.anima/mcp.toml")
|
|
28
|
+
|
|
29
|
+
# Pattern matching `${credential:key_name}` for credential interpolation.
|
|
30
|
+
CREDENTIAL_PATTERN = /\$\{credential:(\w+)\}/
|
|
31
|
+
|
|
32
|
+
# Bare TOML keys: letters, digits, hyphens, underscores.
|
|
33
|
+
VALID_NAME_PATTERN = /\A[A-Za-z0-9_-]+\z/
|
|
34
|
+
|
|
35
|
+
# Warnings accumulated during parsing (missing credentials, invalid entries).
|
|
36
|
+
# @return [Array<String>]
|
|
37
|
+
attr_reader :warnings
|
|
38
|
+
|
|
39
|
+
# @param path [String] path to the TOML config file
|
|
40
|
+
# @param logger [#warn, nil] optional logger for warning output
|
|
41
|
+
def initialize(path: DEFAULT_PATH, logger: nil)
|
|
42
|
+
@path = path
|
|
43
|
+
@logger = logger
|
|
44
|
+
@warnings = []
|
|
45
|
+
@config_cache = nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Returns all configured servers with raw (pre-interpolation) settings.
|
|
49
|
+
# Intended for display in CLI commands where showing literal
|
|
50
|
+
# +${credential:...}+ placeholders is more useful than resolved values.
|
|
51
|
+
#
|
|
52
|
+
# @return [Array<Hash>] servers with string keys from TOML plus +"name"+
|
|
53
|
+
def all_servers
|
|
54
|
+
servers = load_config["servers"] || {}
|
|
55
|
+
servers.map { |name, settings| settings.merge("name" => name) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Adds a server entry to the configuration file.
|
|
59
|
+
# Creates the file and parent directories if they don't exist.
|
|
60
|
+
#
|
|
61
|
+
# @param name [String] unique server identifier (letters, digits, hyphens, underscores)
|
|
62
|
+
# @param settings [Hash<String, Object>] server configuration (transport, url/command, etc.)
|
|
63
|
+
# @raise [ArgumentError] if name is invalid or already exists
|
|
64
|
+
def add_server(name, settings)
|
|
65
|
+
validate_name!(name)
|
|
66
|
+
config = load_config
|
|
67
|
+
servers = config["servers"] ||= {}
|
|
68
|
+
|
|
69
|
+
raise ArgumentError, "server '#{name}' already exists" if servers.key?(name)
|
|
70
|
+
|
|
71
|
+
servers[name] = settings
|
|
72
|
+
write_config(config)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Removes a server entry from the configuration file.
|
|
76
|
+
#
|
|
77
|
+
# @param name [String] server identifier to remove
|
|
78
|
+
# @raise [ArgumentError] if server name not found
|
|
79
|
+
def remove_server(name)
|
|
80
|
+
config = load_config
|
|
81
|
+
servers = config["servers"] || {}
|
|
82
|
+
|
|
83
|
+
raise ArgumentError, "server '#{name}' not found" unless servers.key?(name)
|
|
84
|
+
|
|
85
|
+
servers.delete(name)
|
|
86
|
+
write_config(config)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Returns HTTP server configurations from the config file.
|
|
90
|
+
#
|
|
91
|
+
# @return [Array<Hash>] server configs with +:name+, +:url+, +:headers+ keys
|
|
92
|
+
def http_servers
|
|
93
|
+
servers_by_transport("http") do |name, settings|
|
|
94
|
+
url = settings["url"]
|
|
95
|
+
unless url
|
|
96
|
+
warn_and_skip("server '#{name}' has transport=http but no url")
|
|
97
|
+
next
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
{
|
|
101
|
+
name: name,
|
|
102
|
+
url: interpolate_credentials(url),
|
|
103
|
+
headers: interpolate_hash_values(settings["headers"] || {})
|
|
104
|
+
}
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Returns stdio server configurations from the config file.
|
|
109
|
+
#
|
|
110
|
+
# @return [Array<Hash>] server configs with +:name+, +:command+, +:args+, +:env+ keys
|
|
111
|
+
def stdio_servers
|
|
112
|
+
servers_by_transport("stdio") do |name, settings|
|
|
113
|
+
command = settings["command"]
|
|
114
|
+
unless command
|
|
115
|
+
warn_and_skip("server '#{name}' has transport=stdio but no command")
|
|
116
|
+
next
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
{
|
|
120
|
+
name: name,
|
|
121
|
+
command: interpolate_credentials(command),
|
|
122
|
+
args: (settings["args"] || []).map { |arg| interpolate_credentials(arg) },
|
|
123
|
+
env: interpolate_hash_values(settings["env"] || {})
|
|
124
|
+
}
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
# Reads the TOML config file, returning an empty hash when missing.
|
|
131
|
+
# Result is cached for the lifetime of this Config instance; mutating
|
|
132
|
+
# callers (+add_server+, +remove_server+) invalidate the cache on write.
|
|
133
|
+
#
|
|
134
|
+
# @return [Hash] parsed TOML config
|
|
135
|
+
def load_config
|
|
136
|
+
@config_cache ||= if File.exist?(@path)
|
|
137
|
+
TomlRB.load_file(@path)
|
|
138
|
+
else
|
|
139
|
+
{}
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Serializes config hash to TOML and writes to disk.
|
|
144
|
+
# Creates parent directories if needed. Sets restrictive permissions
|
|
145
|
+
# since config may contain API keys or auth headers.
|
|
146
|
+
def write_config(config)
|
|
147
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
148
|
+
File.write(@path, TomlRB.dump(config))
|
|
149
|
+
File.chmod(0o600, @path)
|
|
150
|
+
@config_cache = nil
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# @raise [ArgumentError] if name contains characters invalid for TOML bare keys
|
|
154
|
+
def validate_name!(name)
|
|
155
|
+
return if name.match?(VALID_NAME_PATTERN)
|
|
156
|
+
|
|
157
|
+
raise ArgumentError,
|
|
158
|
+
"invalid server name '#{name}' — use only letters, numbers, hyphens, and underscores"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Iterates servers matching a given transport type, yielding each
|
|
162
|
+
# for transport-specific parsing. Servers referencing missing
|
|
163
|
+
# credentials are skipped with a warning — one bad server config
|
|
164
|
+
# must not prevent others from loading.
|
|
165
|
+
#
|
|
166
|
+
# @param transport [String] transport type to filter by ("http", "stdio")
|
|
167
|
+
# @yield [name, settings] block that returns a parsed server hash or nil
|
|
168
|
+
# @return [Array<Hash>] parsed server configs
|
|
169
|
+
def servers_by_transport(transport)
|
|
170
|
+
servers = load_config["servers"] || {}
|
|
171
|
+
|
|
172
|
+
servers.filter_map do |name, settings|
|
|
173
|
+
next unless settings["transport"] == transport
|
|
174
|
+
|
|
175
|
+
yield(name, settings)
|
|
176
|
+
rescue KeyError => error
|
|
177
|
+
warn_and_skip("server '#{name}' references missing credential #{error.message}")
|
|
178
|
+
nil
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Logs a warning and collects it for the caller to surface.
|
|
183
|
+
def warn_and_skip(detail)
|
|
184
|
+
message = "MCP: #{detail} — skipping"
|
|
185
|
+
@logger&.warn(message)
|
|
186
|
+
@warnings << message
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Replaces +${credential:key_name}+ placeholders with values from
|
|
190
|
+
# Rails encrypted credentials via {Mcp::Secrets}.
|
|
191
|
+
#
|
|
192
|
+
# @param value [String] string potentially containing placeholders
|
|
193
|
+
# @return [String] interpolated string
|
|
194
|
+
# @raise [KeyError] if a referenced credential is not stored
|
|
195
|
+
def interpolate_credentials(value)
|
|
196
|
+
Anima.boot_rails!
|
|
197
|
+
require_relative "secrets"
|
|
198
|
+
|
|
199
|
+
value.gsub(CREDENTIAL_PATTERN) do
|
|
200
|
+
key = ::Regexp.last_match(1)
|
|
201
|
+
Secrets.get(key) || raise(KeyError, key)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Interpolates credentials in all values of a string hash.
|
|
206
|
+
#
|
|
207
|
+
# @param hash [Hash<String, String>] key-value pairs with potential placeholders
|
|
208
|
+
# @return [Hash<String, String>] hash with interpolated values
|
|
209
|
+
def interpolate_hash_values(hash)
|
|
210
|
+
hash.transform_values { |value| interpolate_credentials(value) }
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|