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.
Files changed (280) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +27 -1
  3. data/CHANGELOG.md +19 -0
  4. data/README.md +213 -43
  5. data/agents/codebase-analyzer.md +88 -0
  6. data/agents/codebase-pattern-finder.md +83 -0
  7. data/agents/documentation-researcher.md +59 -0
  8. data/agents/thoughts-analyzer.md +102 -0
  9. data/agents/web-search-researcher.md +71 -0
  10. data/anima-core.gemspec +3 -0
  11. data/app/channels/session_channel.rb +195 -45
  12. data/app/decorators/user_message_decorator.rb +16 -5
  13. data/app/jobs/agent_request_job.rb +55 -2
  14. data/app/jobs/analytical_brain_job.rb +33 -0
  15. data/app/jobs/count_event_tokens_job.rb +15 -4
  16. data/app/models/concerns/event/broadcasting.rb +81 -0
  17. data/app/models/event.rb +20 -1
  18. data/app/models/goal.rb +91 -0
  19. data/app/models/session.rb +366 -21
  20. data/config/application.rb +2 -0
  21. data/config/initializers/event_subscribers.rb +0 -1
  22. data/config/routes.rb +0 -6
  23. data/db/migrate/20260313010000_add_status_to_events.rb +8 -0
  24. data/db/migrate/20260313020000_add_processing_to_sessions.rb +7 -0
  25. data/db/migrate/20260314075248_add_subagent_support_to_sessions.rb +6 -0
  26. data/db/migrate/20260314112417_add_granted_tools_to_sessions.rb +5 -0
  27. data/db/migrate/20260314140000_add_name_to_sessions.rb +7 -0
  28. data/db/migrate/20260314150000_add_viewport_event_ids_to_sessions.rb +7 -0
  29. data/db/migrate/20260315100000_add_active_skills_to_sessions.rb +7 -0
  30. data/db/migrate/20260315140843_create_goals.rb +16 -0
  31. data/db/migrate/20260315144837_add_completed_at_to_goals.rb +5 -0
  32. data/db/migrate/20260315191105_add_active_workflow_to_sessions.rb +5 -0
  33. data/lib/agent_loop.rb +65 -6
  34. data/lib/agents/definition.rb +116 -0
  35. data/lib/agents/registry.rb +106 -0
  36. data/lib/analytical_brain/runner.rb +276 -0
  37. data/lib/analytical_brain/tools/activate_skill.rb +52 -0
  38. data/lib/analytical_brain/tools/deactivate_skill.rb +43 -0
  39. data/lib/analytical_brain/tools/deactivate_workflow.rb +34 -0
  40. data/lib/analytical_brain/tools/everything_is_ready.rb +28 -0
  41. data/lib/analytical_brain/tools/finish_goal.rb +62 -0
  42. data/lib/analytical_brain/tools/read_workflow.rb +58 -0
  43. data/lib/analytical_brain/tools/rename_session.rb +63 -0
  44. data/lib/analytical_brain/tools/set_goal.rb +60 -0
  45. data/lib/analytical_brain/tools/update_goal.rb +60 -0
  46. data/lib/analytical_brain.rb +23 -0
  47. data/lib/anima/cli/mcp/secrets.rb +76 -0
  48. data/lib/anima/cli/mcp.rb +197 -0
  49. data/lib/anima/cli.rb +5 -40
  50. data/lib/anima/installer.rb +168 -0
  51. data/lib/anima/settings.rb +226 -0
  52. data/lib/anima/version.rb +1 -1
  53. data/lib/anima.rb +9 -0
  54. data/lib/credential_store.rb +103 -0
  55. data/lib/environment_probe.rb +232 -0
  56. data/lib/events/subscribers/persister.rb +1 -0
  57. data/lib/events/user_message.rb +17 -0
  58. data/lib/llm/client.rb +29 -10
  59. data/lib/mcp/client_manager.rb +86 -0
  60. data/lib/mcp/config.rb +213 -0
  61. data/lib/mcp/health_check.rb +77 -0
  62. data/lib/mcp/secrets.rb +73 -0
  63. data/lib/mcp/stdio_transport.rb +206 -0
  64. data/lib/providers/anthropic.rb +11 -20
  65. data/lib/shell_session.rb +11 -10
  66. data/lib/skills/definition.rb +97 -0
  67. data/lib/skills/registry.rb +105 -0
  68. data/lib/tools/edit.rb +226 -0
  69. data/lib/tools/mcp_tool.rb +114 -0
  70. data/lib/tools/read.rb +151 -0
  71. data/lib/tools/registry.rb +14 -12
  72. data/lib/tools/request_feature.rb +121 -0
  73. data/lib/tools/return_result.rb +81 -0
  74. data/lib/tools/spawn_specialist.rb +109 -0
  75. data/lib/tools/spawn_subagent.rb +111 -0
  76. data/lib/tools/subagent_prompts.rb +12 -0
  77. data/lib/tools/web_get.rb +8 -9
  78. data/lib/tools/write.rb +86 -0
  79. data/lib/tui/app.rb +985 -26
  80. data/lib/tui/cable_client.rb +69 -31
  81. data/lib/tui/message_store.rb +103 -8
  82. data/lib/tui/screens/chat.rb +293 -45
  83. data/lib/workflows/definition.rb +97 -0
  84. data/lib/workflows/registry.rb +89 -0
  85. data/skills/activerecord/SKILL.md +255 -0
  86. data/skills/activerecord/examples/associations/association_extensions.rb +298 -0
  87. data/skills/activerecord/examples/associations/basic_associations.rb +118 -0
  88. data/skills/activerecord/examples/associations/counter_caches.rb +215 -0
  89. data/skills/activerecord/examples/associations/polymorphic_associations.rb +217 -0
  90. data/skills/activerecord/examples/associations/self_referential.rb +302 -0
  91. data/skills/activerecord/examples/associations/through_associations.rb +203 -0
  92. data/skills/activerecord/examples/basics/crud_operations.rb +209 -0
  93. data/skills/activerecord/examples/basics/dirty_tracking.rb +218 -0
  94. data/skills/activerecord/examples/basics/inheritance.rb +377 -0
  95. data/skills/activerecord/examples/basics/type_casting.rb +317 -0
  96. data/skills/activerecord/examples/callbacks/alternatives_to_callbacks.rb +447 -0
  97. data/skills/activerecord/examples/callbacks/conditional_callbacks.rb +353 -0
  98. data/skills/activerecord/examples/callbacks/lifecycle_callbacks.rb +280 -0
  99. data/skills/activerecord/examples/callbacks/transaction_callbacks.rb +340 -0
  100. data/skills/activerecord/examples/migrations/indexes_and_constraints.rb +337 -0
  101. data/skills/activerecord/examples/migrations/reversible_patterns.rb +403 -0
  102. data/skills/activerecord/examples/migrations/safe_patterns.rb +420 -0
  103. data/skills/activerecord/examples/migrations/schema_changes.rb +277 -0
  104. data/skills/activerecord/examples/querying/batch_processing.rb +226 -0
  105. data/skills/activerecord/examples/querying/eager_loading.rb +259 -0
  106. data/skills/activerecord/examples/querying/finder_methods.rb +170 -0
  107. data/skills/activerecord/examples/querying/optimization.rb +275 -0
  108. data/skills/activerecord/examples/querying/scopes.rb +260 -0
  109. data/skills/activerecord/examples/validations/built_in_validators.rb +277 -0
  110. data/skills/activerecord/examples/validations/conditional_validations.rb +288 -0
  111. data/skills/activerecord/examples/validations/custom_validators.rb +381 -0
  112. data/skills/activerecord/examples/validations/database_constraints.rb +432 -0
  113. data/skills/activerecord/examples/validations/validation_contexts.rb +367 -0
  114. data/skills/activerecord/references/associations.md +709 -0
  115. data/skills/activerecord/references/basics.md +622 -0
  116. data/skills/activerecord/references/callbacks.md +738 -0
  117. data/skills/activerecord/references/migrations.md +657 -0
  118. data/skills/activerecord/references/querying.md +655 -0
  119. data/skills/activerecord/references/validations.md +596 -0
  120. data/skills/dragonruby/SKILL.md +250 -0
  121. data/skills/dragonruby/examples/audio/audio_events.rb +55 -0
  122. data/skills/dragonruby/examples/audio/background_music.rb +29 -0
  123. data/skills/dragonruby/examples/audio/crossfade.rb +51 -0
  124. data/skills/dragonruby/examples/audio/music_controls.rb +51 -0
  125. data/skills/dragonruby/examples/audio/sound_effects.rb +30 -0
  126. data/skills/dragonruby/examples/core/coordinate_system.rb +27 -0
  127. data/skills/dragonruby/examples/core/hello_world.rb +24 -0
  128. data/skills/dragonruby/examples/core/labels.rb +22 -0
  129. data/skills/dragonruby/examples/core/sprites.rb +35 -0
  130. data/skills/dragonruby/examples/core/state_management.rb +29 -0
  131. data/skills/dragonruby/examples/distribution/background_pause.rb +42 -0
  132. data/skills/dragonruby/examples/distribution/build_workflow.sh +26 -0
  133. data/skills/dragonruby/examples/distribution/cvars_production.txt +16 -0
  134. data/skills/dragonruby/examples/distribution/game_metadata_hd.txt +23 -0
  135. data/skills/dragonruby/examples/distribution/game_metadata_minimal.txt +9 -0
  136. data/skills/dragonruby/examples/distribution/game_metadata_mobile.txt +31 -0
  137. data/skills/dragonruby/examples/distribution/platform_detection.rb +36 -0
  138. data/skills/dragonruby/examples/distribution/steam_metadata.txt +19 -0
  139. data/skills/dragonruby/examples/entities/collision_detection.rb +43 -0
  140. data/skills/dragonruby/examples/entities/entity_lifecycle.rb +68 -0
  141. data/skills/dragonruby/examples/entities/entity_storage.rb +38 -0
  142. data/skills/dragonruby/examples/entities/factory_methods.rb +45 -0
  143. data/skills/dragonruby/examples/entities/random_spawning.rb +50 -0
  144. data/skills/dragonruby/examples/game-logic/reset_patterns.rb +98 -0
  145. data/skills/dragonruby/examples/game-logic/save_load.rb +101 -0
  146. data/skills/dragonruby/examples/game-logic/scoring.rb +104 -0
  147. data/skills/dragonruby/examples/game-logic/state_transitions.rb +103 -0
  148. data/skills/dragonruby/examples/game-logic/timers.rb +87 -0
  149. data/skills/dragonruby/examples/input/action_triggers.rb +36 -0
  150. data/skills/dragonruby/examples/input/analog_movement.rb +28 -0
  151. data/skills/dragonruby/examples/input/controller_input.rb +28 -0
  152. data/skills/dragonruby/examples/input/directional_input.rb +24 -0
  153. data/skills/dragonruby/examples/input/keyboard_input.rb +28 -0
  154. data/skills/dragonruby/examples/input/mouse_click.rb +26 -0
  155. data/skills/dragonruby/examples/input/movement_with_bounds.rb +22 -0
  156. data/skills/dragonruby/examples/input/normalized_movement.rb +32 -0
  157. data/skills/dragonruby/examples/rendering/frame_animation.rb +32 -0
  158. data/skills/dragonruby/examples/rendering/labels.rb +32 -0
  159. data/skills/dragonruby/examples/rendering/layering.rb +51 -0
  160. data/skills/dragonruby/examples/rendering/solids.rb +61 -0
  161. data/skills/dragonruby/examples/rendering/sprites.rb +33 -0
  162. data/skills/dragonruby/examples/rendering/spritesheet_animation.rb +39 -0
  163. data/skills/dragonruby/examples/scenes/case_dispatch.rb +60 -0
  164. data/skills/dragonruby/examples/scenes/class_based.rb +150 -0
  165. data/skills/dragonruby/examples/scenes/pause_overlay.rb +100 -0
  166. data/skills/dragonruby/examples/scenes/safe_transitions.rb +68 -0
  167. data/skills/dragonruby/examples/scenes/scene_transitions.rb +98 -0
  168. data/skills/dragonruby/examples/scenes/send_dispatch.rb +88 -0
  169. data/skills/dragonruby/references/audio.md +396 -0
  170. data/skills/dragonruby/references/core.md +385 -0
  171. data/skills/dragonruby/references/distribution.md +434 -0
  172. data/skills/dragonruby/references/entities.md +516 -0
  173. data/skills/dragonruby/references/game-logic/persistence.md +386 -0
  174. data/skills/dragonruby/references/game-logic/state.md +389 -0
  175. data/skills/dragonruby/references/input.md +414 -0
  176. data/skills/dragonruby/references/rendering/animation.md +467 -0
  177. data/skills/dragonruby/references/rendering/primitives.md +403 -0
  178. data/skills/dragonruby/references/scenes.md +443 -0
  179. data/skills/draper-decorators/SKILL.md +344 -0
  180. data/skills/draper-decorators/examples/application_decorator.rb +61 -0
  181. data/skills/draper-decorators/examples/decorator_spec.rb +253 -0
  182. data/skills/draper-decorators/examples/model_decorator.rb +152 -0
  183. data/skills/draper-decorators/references/anti-patterns.md +640 -0
  184. data/skills/draper-decorators/references/patterns.md +507 -0
  185. data/skills/draper-decorators/references/testing.md +559 -0
  186. data/skills/gh-issue.md +182 -0
  187. data/skills/mcp-server/SKILL.md +177 -0
  188. data/skills/mcp-server/examples/dynamic_tools.rb +36 -0
  189. data/skills/mcp-server/examples/file_manager_tool.rb +85 -0
  190. data/skills/mcp-server/examples/http_client.rb +48 -0
  191. data/skills/mcp-server/examples/http_server.rb +97 -0
  192. data/skills/mcp-server/examples/rails_integration.rb +88 -0
  193. data/skills/mcp-server/examples/stdio_server.rb +108 -0
  194. data/skills/mcp-server/examples/streaming_client.rb +95 -0
  195. data/skills/mcp-server/references/gotchas.md +183 -0
  196. data/skills/mcp-server/references/prompts.md +98 -0
  197. data/skills/mcp-server/references/resources.md +53 -0
  198. data/skills/mcp-server/references/server.md +140 -0
  199. data/skills/mcp-server/references/tools.md +146 -0
  200. data/skills/mcp-server/references/transport.md +104 -0
  201. data/skills/ratatui-ruby/SKILL.md +315 -0
  202. data/skills/ratatui-ruby/references/core-concepts.md +340 -0
  203. data/skills/ratatui-ruby/references/events.md +387 -0
  204. data/skills/ratatui-ruby/references/frameworks.md +522 -0
  205. data/skills/ratatui-ruby/references/layout.md +423 -0
  206. data/skills/ratatui-ruby/references/styling.md +268 -0
  207. data/skills/ratatui-ruby/references/testing.md +433 -0
  208. data/skills/ratatui-ruby/references/widgets.md +532 -0
  209. data/skills/rspec/SKILL.md +340 -0
  210. data/skills/rspec/examples/core/basic_structure.rb +69 -0
  211. data/skills/rspec/examples/core/configuration.rb +126 -0
  212. data/skills/rspec/examples/core/hooks.rb +126 -0
  213. data/skills/rspec/examples/core/memoized_helpers.rb +139 -0
  214. data/skills/rspec/examples/core/metadata_filtering.rb +144 -0
  215. data/skills/rspec/examples/core/shared_examples.rb +145 -0
  216. data/skills/rspec/examples/factory_bot/associations.rb +314 -0
  217. data/skills/rspec/examples/factory_bot/build_strategies.rb +272 -0
  218. data/skills/rspec/examples/factory_bot/callbacks.rb +320 -0
  219. data/skills/rspec/examples/factory_bot/custom_construction.rb +328 -0
  220. data/skills/rspec/examples/factory_bot/factory_definition.rb +191 -0
  221. data/skills/rspec/examples/factory_bot/inheritance.rb +314 -0
  222. data/skills/rspec/examples/factory_bot/traits.rb +293 -0
  223. data/skills/rspec/examples/factory_bot/transients.rb +229 -0
  224. data/skills/rspec/examples/matchers/change.rb +115 -0
  225. data/skills/rspec/examples/matchers/collections.rb +154 -0
  226. data/skills/rspec/examples/matchers/comparisons.rb +79 -0
  227. data/skills/rspec/examples/matchers/composing.rb +155 -0
  228. data/skills/rspec/examples/matchers/custom_matchers.rb +197 -0
  229. data/skills/rspec/examples/matchers/equality.rb +58 -0
  230. data/skills/rspec/examples/matchers/errors.rb +136 -0
  231. data/skills/rspec/examples/matchers/output.rb +103 -0
  232. data/skills/rspec/examples/matchers/predicates.rb +87 -0
  233. data/skills/rspec/examples/matchers/truthiness.rb +101 -0
  234. data/skills/rspec/examples/matchers/types.rb +82 -0
  235. data/skills/rspec/examples/matchers/yield.rb +147 -0
  236. data/skills/rspec/examples/mocks/any_instance.rb +172 -0
  237. data/skills/rspec/examples/mocks/argument_matchers.rb +206 -0
  238. data/skills/rspec/examples/mocks/constants.rb +177 -0
  239. data/skills/rspec/examples/mocks/doubles.rb +139 -0
  240. data/skills/rspec/examples/mocks/expectations.rb +137 -0
  241. data/skills/rspec/examples/mocks/message_chains.rb +173 -0
  242. data/skills/rspec/examples/mocks/ordering.rb +144 -0
  243. data/skills/rspec/examples/mocks/receive_counts.rb +181 -0
  244. data/skills/rspec/examples/mocks/responses.rb +223 -0
  245. data/skills/rspec/examples/mocks/spies.rb +149 -0
  246. data/skills/rspec/examples/mocks/stubbing.rb +133 -0
  247. data/skills/rspec/examples/rails/channels.rb +250 -0
  248. data/skills/rspec/examples/rails/controller_specs.rb +302 -0
  249. data/skills/rspec/examples/rails/helper_specs.rb +245 -0
  250. data/skills/rspec/examples/rails/job_specs.rb +256 -0
  251. data/skills/rspec/examples/rails/mailer_specs.rb +228 -0
  252. data/skills/rspec/examples/rails/matchers.rb +374 -0
  253. data/skills/rspec/examples/rails/model_specs.rb +193 -0
  254. data/skills/rspec/examples/rails/request_specs.rb +275 -0
  255. data/skills/rspec/examples/rails/routing_specs.rb +276 -0
  256. data/skills/rspec/examples/rails/system_specs.rb +294 -0
  257. data/skills/rspec/examples/rails/transactions.rb +254 -0
  258. data/skills/rspec/examples/rails/view_specs.rb +252 -0
  259. data/skills/rspec/references/core.md +816 -0
  260. data/skills/rspec/references/factory_bot.md +641 -0
  261. data/skills/rspec/references/matchers.md +516 -0
  262. data/skills/rspec/references/mocks.md +381 -0
  263. data/skills/rspec/references/rails.md +528 -0
  264. data/templates/soul.md +40 -0
  265. data/workflows/commit.md +45 -0
  266. data/workflows/create_handoff.md +98 -0
  267. data/workflows/create_note.md +82 -0
  268. data/workflows/create_plan.md +457 -0
  269. data/workflows/decompose_ticket.md +109 -0
  270. data/workflows/feature.md +91 -0
  271. data/workflows/implement_plan.md +87 -0
  272. data/workflows/iterate_plan.md +247 -0
  273. data/workflows/research_codebase.md +210 -0
  274. data/workflows/resume_handoff.md +217 -0
  275. data/workflows/review_pr.md +320 -0
  276. data/workflows/thoughts_init.md +71 -0
  277. data/workflows/validate_plan.md +166 -0
  278. metadata +290 -3
  279. data/app/controllers/api/sessions_controller.rb +0 -25
  280. data/lib/events/subscribers/action_cable_bridge.rb +0 -59
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+
5
+ module Mcp
6
+ # Probes an MCP server to verify connectivity and count available tools.
7
+ # Used by the CLI +list+ command to show server health status.
8
+ #
9
+ # @example
10
+ # result = Mcp::HealthCheck.call(name: "sentry", url: "https://mcp.sentry.dev/mcp", headers: {})
11
+ # result #=> { status: :connected, tools: 5 }
12
+ class HealthCheck
13
+ # Health check probe timeout in seconds. Balances responsiveness
14
+ # (CLI shouldn't hang) vs. giving slow servers a fair chance.
15
+ TIMEOUT = 5
16
+
17
+ # @param server [Hash] interpolated server config with symbol keys
18
+ # (+:name+, +:url+/+:command+, and +:transport+)
19
+ # @return [Hash] +{ status: :connected, tools: Integer }+ or
20
+ # +{ status: :failed, error: String }+
21
+ def self.call(server)
22
+ new(server).call
23
+ end
24
+
25
+ def initialize(server)
26
+ @server = server
27
+ @stdio_transport = nil
28
+ end
29
+
30
+ def call
31
+ Timeout.timeout(TIMEOUT) { check }
32
+ rescue Timeout::Error
33
+ {status: :failed, error: "connection timeout"}
34
+ rescue KeyError => key_error
35
+ {status: :failed, error: "missing credential #{key_error.message}"}
36
+ rescue => error
37
+ {status: :failed, error: error.message}
38
+ end
39
+
40
+ private
41
+
42
+ def check
43
+ transport = @server[:transport]
44
+
45
+ case transport
46
+ when "http" then check_http
47
+ when "stdio" then check_stdio
48
+ else {status: :failed, error: "unknown transport '#{transport}'"}
49
+ end
50
+ end
51
+
52
+ def check_http
53
+ require "mcp"
54
+
55
+ transport = MCP::Client::HTTP.new(url: @server[:url], headers: @server[:headers] || {})
56
+ client = MCP::Client.new(transport: transport)
57
+ tool_count = client.tools.size
58
+ {status: :connected, tools: tool_count}
59
+ end
60
+
61
+ def check_stdio
62
+ require "mcp"
63
+ require_relative "stdio_transport"
64
+
65
+ @stdio_transport = StdioTransport.new(
66
+ command: @server[:command],
67
+ args: @server[:args] || [],
68
+ env: @server[:env] || {}
69
+ )
70
+ client = MCP::Client.new(transport: @stdio_transport)
71
+ tool_count = client.tools.size
72
+ {status: :connected, tools: tool_count}
73
+ ensure
74
+ @stdio_transport&.shutdown
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mcp
4
+ # CRUD operations for MCP server secrets stored in Rails encrypted credentials.
5
+ # Secrets live under the +mcp+ namespace in the credentials file:
6
+ #
7
+ # mcp:
8
+ # linear_api_key: "sk-xxx"
9
+ # mythonix_api_key: "Bearer tok-yyy"
10
+ #
11
+ # Referenced in mcp.toml via +${credential:key_name}+ syntax, resolved at
12
+ # runtime by {Mcp::Config#interpolate_credentials}.
13
+ #
14
+ # @example Storing a secret
15
+ # Mcp::Secrets.set("linear_api_key", "sk-xxx")
16
+ #
17
+ # @example Retrieving a secret
18
+ # Mcp::Secrets.get("linear_api_key") #=> "sk-xxx"
19
+ class Secrets
20
+ NAMESPACE = "mcp"
21
+
22
+ # Keys must be interpolatable via ${credential:key_name} in mcp.toml.
23
+ VALID_KEY_PATTERN = /\A\w+\z/
24
+
25
+ class << self
26
+ # Stores a secret in encrypted credentials.
27
+ #
28
+ # @param key [String] secret identifier (e.g. "linear_api_key")
29
+ # @param value [String] secret value
30
+ # @return [void]
31
+ # @raise [ArgumentError] if key contains characters that cannot be
32
+ # referenced via +${credential:key_name}+ syntax
33
+ def set(key, value)
34
+ validate_key!(key)
35
+ CredentialStore.write(NAMESPACE, key => value)
36
+ end
37
+
38
+ # Retrieves a secret from encrypted credentials.
39
+ #
40
+ # @param key [String] secret identifier
41
+ # @return [String, nil] secret value or nil if not found
42
+ def get(key)
43
+ CredentialStore.read(NAMESPACE, key)
44
+ end
45
+
46
+ # Lists all stored MCP secret keys (not values).
47
+ #
48
+ # @return [Array<String>] secret names
49
+ def list
50
+ CredentialStore.list(NAMESPACE)
51
+ end
52
+
53
+ # Removes a secret from encrypted credentials.
54
+ #
55
+ # @param key [String] secret identifier to remove
56
+ # @return [void]
57
+ def remove(key)
58
+ CredentialStore.remove(NAMESPACE, key)
59
+ end
60
+
61
+ private
62
+
63
+ # @raise [ArgumentError] if key is not interpolatable
64
+ def validate_key!(key)
65
+ return if key.match?(VALID_KEY_PATTERN)
66
+
67
+ raise ArgumentError,
68
+ "invalid secret key '#{key}' — use only letters, numbers, and underscores " \
69
+ "(must match ${credential:key_name} syntax)"
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "mcp"
5
+ require "open3"
6
+ require "timeout"
7
+
8
+ module Mcp
9
+ # Client-side stdio transport for MCP servers that communicate via
10
+ # JSON-RPC over stdin/stdout. Conforms to the MCP SDK transport contract
11
+ # (+send_request(request:)+ → Hash) so it plugs into {MCP::Client}
12
+ # identically to the built-in HTTP transport.
13
+ #
14
+ # Spawns the server process lazily on first request. If the process
15
+ # crashes, the next request automatically respawns it. Thread-safe
16
+ # via a mutex around the entire request/response cycle.
17
+ #
18
+ # @example
19
+ # transport = Mcp::StdioTransport.new(command: "linear-toon-mcp")
20
+ # client = MCP::Client.new(transport: transport)
21
+ # client.tools # spawns process, sends tools/list, returns tools
22
+ #
23
+ # @see MCP::Client::HTTP the built-in HTTP transport this mirrors
24
+ class StdioTransport
25
+ # Seconds to wait for graceful SIGTERM shutdown before escalating to SIGKILL.
26
+ GRACEFUL_SHUTDOWN_TIMEOUT = 2
27
+
28
+ # @param command [String] executable to spawn (resolved via $PATH)
29
+ # @param args [Array<String>] command-line arguments for the server process
30
+ # @param env [Hash<String, String>] environment variables merged into
31
+ # the child process's inherited environment
32
+ def initialize(command:, args: [], env: {})
33
+ @command = command
34
+ @args = args
35
+ @env = env
36
+ @mutex = Mutex.new
37
+ @stdin = nil
38
+ @stdout = nil
39
+ @wait_thread = nil
40
+ end
41
+
42
+ # Sends a JSON-RPC request and returns the parsed response.
43
+ # Spawns the server process on first call. If the process died
44
+ # since the last call, respawns automatically.
45
+ #
46
+ # @param request [Hash] complete JSON-RPC request object with
47
+ # +:jsonrpc+, +:id+, +:method+, and optional +:params+ keys
48
+ # @return [Hash] parsed JSON-RPC response (string keys)
49
+ # @raise [MCP::Client::RequestHandlerError] on transport-level errors
50
+ # (process crash, invalid JSON, timeout, command not found)
51
+ def send_request(request:)
52
+ @mutex.synchronize do
53
+ perform_request(request)
54
+ end
55
+ end
56
+
57
+ # Terminates the server process and releases resources.
58
+ # Safe to call multiple times — subsequent calls are no-ops.
59
+ def shutdown
60
+ @mutex.synchronize { stop_process }
61
+ self.class.unregister(self)
62
+ end
63
+
64
+ # --- Class-level instance tracking for at_exit cleanup ---
65
+
66
+ @instances = []
67
+ @instances_mutex = Mutex.new
68
+
69
+ class << self
70
+ # @api private
71
+ def register(instance)
72
+ @instances_mutex.synchronize { @instances << instance }
73
+ end
74
+
75
+ # @api private
76
+ def unregister(instance)
77
+ @instances_mutex.synchronize { @instances.delete(instance) }
78
+ end
79
+
80
+ # Shuts down all tracked instances. Called automatically via +at_exit+.
81
+ def cleanup_all
82
+ @instances_mutex.synchronize do
83
+ @instances.each { |instance| instance.send(:stop_process) }
84
+ @instances.clear
85
+ end
86
+ end
87
+ end
88
+
89
+ at_exit { Mcp::StdioTransport.cleanup_all }
90
+
91
+ private
92
+
93
+ def perform_request(request)
94
+ ensure_running
95
+ write_request(request)
96
+ read_response(request)
97
+ rescue Errno::EPIPE, IOError => error
98
+ stop_process
99
+ raise_transport_error("Server process crashed: #{error.message}", request, error)
100
+ rescue JSON::ParserError => error
101
+ stop_process
102
+ raise_transport_error("Invalid JSON from server: #{error.message}", request, error)
103
+ rescue Timeout::Error
104
+ stop_process
105
+ raise_transport_error("No response within #{Anima::Settings.mcp_response_timeout}s", request)
106
+ end
107
+
108
+ def ensure_running
109
+ return if alive?
110
+
111
+ spawn_process
112
+ end
113
+
114
+ def alive?
115
+ @wait_thread&.alive? || false
116
+ end
117
+
118
+ def spawn_process
119
+ @stdin, @stdout, @wait_thread = Open3.popen2(@env, @command, *@args)
120
+ @stdin.set_encoding("UTF-8")
121
+ @stdout.set_encoding("UTF-8")
122
+ self.class.register(self)
123
+ rescue Errno::ENOENT => error
124
+ raise_transport_error("Command not found: #{@command}", {}, error)
125
+ end
126
+
127
+ def write_request(request)
128
+ @stdin.puts(JSON.generate(request))
129
+ @stdin.flush
130
+ end
131
+
132
+ # Reads lines from stdout until a JSON-RPC response matching the
133
+ # request ID is found. Notifications (messages without a matching id)
134
+ # are silently skipped — the MCP protocol allows servers to emit
135
+ # them at any time.
136
+ def read_response(request)
137
+ request_id = (request[:id] || request["id"]).to_s
138
+
139
+ Timeout.timeout(Anima::Settings.mcp_response_timeout) do
140
+ loop do
141
+ line = @stdout.gets
142
+ raise IOError, "Server process closed stdout" if line.nil?
143
+
144
+ parsed = JSON.parse(line)
145
+ unless parsed.is_a?(Hash)
146
+ raise JSON::ParserError, "Expected JSON object, got #{parsed.class}"
147
+ end
148
+
149
+ return parsed if parsed["id"].to_s == request_id
150
+ end
151
+ end
152
+ end
153
+
154
+ def stop_process
155
+ close_pipes
156
+ terminate_process
157
+ @stdin = nil
158
+ @stdout = nil
159
+ @wait_thread = nil
160
+ end
161
+
162
+ def close_pipes
163
+ @stdin&.close rescue IOError # rubocop:disable Style/RescueModifier
164
+ @stdout&.close rescue IOError # rubocop:disable Style/RescueModifier
165
+ end
166
+
167
+ # Sends SIGTERM and waits up to 2 seconds for the process to exit.
168
+ # Falls back to SIGKILL if the process does not terminate in time.
169
+ def terminate_process
170
+ return unless @wait_thread
171
+
172
+ pid = @wait_thread.pid
173
+ begin
174
+ Process.kill("TERM", pid)
175
+ rescue Errno::ESRCH, Errno::EPERM
176
+ return
177
+ end
178
+
179
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + GRACEFUL_SHUTDOWN_TIMEOUT
180
+ loop do
181
+ _, status = Process.wait2(pid, Process::WNOHANG)
182
+ break if status
183
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
184
+ Process.kill("KILL", pid) rescue Errno::ESRCH # rubocop:disable Style/RescueModifier
185
+ Process.wait(pid) rescue Errno::ECHILD # rubocop:disable Style/RescueModifier
186
+ break
187
+ end
188
+ sleep 0.05
189
+ end
190
+ rescue Errno::ECHILD, Errno::ESRCH
191
+ # Already reaped
192
+ end
193
+
194
+ def raise_transport_error(message, request, original_error = nil)
195
+ method = request[:method] || request["method"]
196
+ params = request[:params] || request["params"]
197
+
198
+ raise MCP::Client::RequestHandlerError.new(
199
+ message,
200
+ {method: method, params: params},
201
+ error_type: :internal_error,
202
+ original_error: original_error
203
+ )
204
+ end
205
+ end
206
+ end
@@ -7,13 +7,11 @@ module Providers
7
7
  include HTTParty
8
8
 
9
9
  base_uri "https://api.anthropic.com"
10
- default_timeout 30
11
10
 
12
11
  TOKEN_PREFIX = "sk-ant-oat01-"
13
12
  TOKEN_MIN_LENGTH = 80
14
13
  API_VERSION = "2023-06-01"
15
14
  REQUIRED_BETA = "oauth-2025-04-20"
16
- VALIDATION_MODEL = "claude-sonnet-4-20250514"
17
15
 
18
16
  class Error < StandardError; end
19
17
  class AuthenticationError < Error; end
@@ -25,21 +23,11 @@ module Providers
25
23
  class ServerError < TransientError; end
26
24
 
27
25
  class << self
28
- def validate!
29
- token = fetch_token
30
- validate_token_format!(token)
31
- validate_token_api!(token)
32
- true
33
- end
34
-
35
26
  def fetch_token
36
- token = Rails.application.credentials.dig(:anthropic, :subscription_token)
27
+ token = CredentialStore.read("anthropic", "subscription_token")
37
28
  raise AuthenticationError, <<~MSG.strip if token.blank?
38
29
  No Anthropic subscription token found in credentials.
39
- Run: bin/rails credentials:edit
40
- Add:
41
- anthropic:
42
- subscription_token: sk-ant-oat01-YOUR_TOKEN_HERE
30
+ Use the TUI token setup (Ctrl+a → a) to configure your token.
43
31
  MSG
44
32
  token
45
33
  end
@@ -76,7 +64,8 @@ module Providers
76
64
  response = self.class.post(
77
65
  "/v1/messages",
78
66
  body: body.to_json,
79
- headers: request_headers
67
+ headers: request_headers,
68
+ timeout: Anima::Settings.api_timeout
80
69
  )
81
70
 
82
71
  handle_response(response)
@@ -98,7 +87,8 @@ module Providers
98
87
  response = self.class.post(
99
88
  "/v1/messages/count_tokens",
100
89
  body: body.to_json,
101
- headers: request_headers
90
+ headers: request_headers,
91
+ timeout: Anima::Settings.api_timeout
102
92
  )
103
93
 
104
94
  result = handle_response(response)
@@ -111,11 +101,12 @@ module Providers
111
101
  response = self.class.post(
112
102
  "/v1/messages",
113
103
  body: {
114
- model: VALIDATION_MODEL,
104
+ model: Anima::Settings.model,
115
105
  messages: [{role: "user", content: "Hi"}],
116
106
  max_tokens: 1
117
107
  }.to_json,
118
- headers: request_headers
108
+ headers: request_headers,
109
+ timeout: Anima::Settings.api_timeout
119
110
  )
120
111
 
121
112
  case response.code
@@ -123,7 +114,7 @@ module Providers
123
114
  true
124
115
  when 401
125
116
  raise AuthenticationError,
126
- "Token rejected by Anthropic API (401). Re-run `claude setup-token` and update credentials."
117
+ "Token rejected by Anthropic API (401). Re-run `claude setup-token` and use the TUI token setup (Ctrl+a → a)."
127
118
  when 403
128
119
  raise AuthenticationError,
129
120
  "Token not authorized for API access (403). This credential may be restricted to Claude Code only."
@@ -151,7 +142,7 @@ module Providers
151
142
  raise Error, "Bad request: #{error_message(response)}"
152
143
  when 401
153
144
  raise AuthenticationError,
154
- "Authentication failed (401): #{error_message(response)}. Re-run `claude setup-token` and update credentials."
145
+ "Authentication failed (401): #{error_message(response)}. Re-run `claude setup-token` and use the TUI token setup (Ctrl+a → a)."
155
146
  when 403
156
147
  raise AuthenticationError,
157
148
  "Forbidden (403): #{error_message(response)}"
data/lib/shell_session.rb CHANGED
@@ -16,9 +16,6 @@ require "timeout"
16
16
  # # => {stdout: "/tmp", stderr: "", exit_code: 0}
17
17
  # session.finalize
18
18
  class ShellSession
19
- COMMAND_TIMEOUT = 30
20
- MAX_OUTPUT_BYTES = 100_000
21
-
22
19
  # @return [String, nil] current working directory of the shell process
23
20
  attr_reader :pwd
24
21
 
@@ -140,12 +137,14 @@ class ShellSession
140
137
  @stderr_buffer = []
141
138
  @stderr_bytes = 0
142
139
  @stderr_truncated = false
140
+ @max_output_bytes = Anima::Settings.max_output_bytes
143
141
  @stderr_thread = Thread.new do
142
+ max_bytes = @max_output_bytes
144
143
  File.open(@fifo_path, "r") do |fifo|
145
144
  while (line = fifo.gets)
146
145
  cleaned = line.chomp.delete("\r")
147
146
  @stderr_mutex.synchronize do
148
- if @stderr_bytes < MAX_OUTPUT_BYTES
147
+ if @stderr_bytes < max_bytes
149
148
  @stderr_buffer << cleaned
150
149
  @stderr_bytes += cleaned.bytesize
151
150
  else
@@ -172,8 +171,9 @@ class ShellSession
172
171
  def execute_in_pty(command)
173
172
  clear_stderr
174
173
  marker = "__ANIMA_#{SecureRandom.hex(8)}__"
174
+ timeout = Anima::Settings.command_timeout
175
175
 
176
- Timeout.timeout(COMMAND_TIMEOUT) do
176
+ Timeout.timeout(timeout) do
177
177
  # All on one line: run command, capture exit code, ensure newline
178
178
  # before marker so output without trailing newline doesn't merge.
179
179
  @pty_stdin.puts "#{command}; __anima_ec=$?; echo; echo '#{marker}' $__anima_ec"
@@ -190,7 +190,7 @@ class ShellSession
190
190
  end
191
191
  rescue Timeout::Error
192
192
  recover_from_timeout
193
- {error: "Command timed out after #{COMMAND_TIMEOUT} seconds"}
193
+ {error: "Command timed out after #{timeout} seconds"}
194
194
  rescue Errno::EIO
195
195
  @alive = false
196
196
  {error: "Shell session terminated unexpectedly"}
@@ -260,7 +260,7 @@ class ShellSession
260
260
  @stderr_buffer.clear
261
261
  @stderr_bytes = 0
262
262
  @stderr_truncated = false
263
- truncated ? result + "\n\n[Truncated: output exceeded #{MAX_OUTPUT_BYTES} bytes]" : result
263
+ truncated ? result + "\n\n[Truncated: output exceeded #{@max_output_bytes} bytes]" : result
264
264
  end
265
265
  end
266
266
 
@@ -274,12 +274,13 @@ class ShellSession
274
274
  end
275
275
 
276
276
  def truncate(output)
277
- return output if output.bytesize <= MAX_OUTPUT_BYTES
277
+ max_bytes = @max_output_bytes
278
+ return output if output.bytesize <= max_bytes
278
279
 
279
- output.byteslice(0, MAX_OUTPUT_BYTES)
280
+ output.byteslice(0, max_bytes)
280
281
  .force_encoding("UTF-8")
281
282
  .scrub +
282
- "\n\n[Truncated: output exceeded #{MAX_OUTPUT_BYTES} bytes]"
283
+ "\n\n[Truncated: output exceeded #{max_bytes} bytes]"
283
284
  end
284
285
 
285
286
  def shutdown
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Skills
6
+ class InvalidDefinitionError < StandardError; end
7
+
8
+ # A domain knowledge skill parsed from a Markdown definition file.
9
+ # YAML frontmatter holds metadata; the Markdown body is the knowledge
10
+ # content injected into the main agent's system prompt when active.
11
+ #
12
+ # Skills are passive knowledge — they describe WHAT you know, not
13
+ # WHAT to do. The analytical brain activates/deactivates them based
14
+ # on conversation context.
15
+ #
16
+ # @example Skill file format
17
+ # ---
18
+ # name: gh-issue
19
+ # description: "GitHub issue writing with WHAT/WHY/HOW framework."
20
+ # ---
21
+ #
22
+ # # GitHub Issue Writing
23
+ # Write issues with clear rationale...
24
+ class Definition
25
+ # @return [String] unique skill identifier used in activate_skill(name: "...")
26
+ attr_reader :name
27
+
28
+ # @return [String] description shown to the analytical brain for relevance matching
29
+ attr_reader :description
30
+
31
+ # @return [String] knowledge content (Markdown body) injected into system prompt
32
+ attr_reader :content
33
+
34
+ # @return [String] file path this definition was loaded from
35
+ attr_reader :source_path
36
+
37
+ def initialize(name:, description:, content:, source_path: "")
38
+ @name = name
39
+ @description = description
40
+ @content = content
41
+ @source_path = source_path
42
+ end
43
+
44
+ # Parses a Markdown file with YAML frontmatter into a Definition.
45
+ #
46
+ # @param path [String, Pathname] path to the .md file
47
+ # @return [Definition]
48
+ # @raise [InvalidDefinitionError] if required fields are missing or frontmatter is malformed
49
+ def self.from_file(path)
50
+ raw = File.read(path)
51
+ frontmatter, body = parse_frontmatter(raw)
52
+
53
+ validate_required_fields!(frontmatter, path)
54
+
55
+ new(
56
+ name: frontmatter["name"].to_s.strip,
57
+ description: frontmatter["description"].to_s.strip,
58
+ content: body.strip,
59
+ source_path: path.to_s
60
+ )
61
+ end
62
+
63
+ # @param raw [String] raw file content with YAML frontmatter
64
+ # @return [Array(Hash, String)] parsed frontmatter and body text
65
+ # @raise [InvalidDefinitionError] if frontmatter is missing or malformed
66
+ def self.parse_frontmatter(raw)
67
+ # Opening "---" must be followed by a newline (not just whitespace).
68
+ # Non-greedy (.*?\n) captures YAML lines up to the closing "---".
69
+ # Closing "---" may optionally be followed by a newline before the body.
70
+ # The /m flag lets (.*) in the body capture across newlines.
71
+ match = raw.match(/\A---\s*\n(.*?\n)---\s*\n?(.*)\z/m)
72
+ raise InvalidDefinitionError, "Missing YAML frontmatter" unless match
73
+
74
+ frontmatter = YAML.safe_load(match[1])
75
+ raise InvalidDefinitionError, "Frontmatter is not a valid YAML mapping" unless frontmatter.is_a?(Hash)
76
+
77
+ [frontmatter, match[2]]
78
+ end
79
+
80
+ NAME_FORMAT = /\A[a-z0-9][a-z0-9_-]*\z/
81
+
82
+ def self.validate_required_fields!(frontmatter, path)
83
+ %w[name description].each do |field|
84
+ value = frontmatter[field].to_s.strip
85
+ raise InvalidDefinitionError, "Missing required field '#{field}' in #{path}" if value.empty?
86
+ end
87
+
88
+ name = frontmatter["name"].to_s.strip
89
+ unless name.match?(NAME_FORMAT)
90
+ raise InvalidDefinitionError,
91
+ "Invalid skill name '#{name}' in #{path} — must be lowercase alphanumeric with hyphens/underscores"
92
+ end
93
+ end
94
+
95
+ private_class_method :parse_frontmatter, :validate_required_fields!
96
+ end
97
+ end