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,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
|
data/lib/mcp/secrets.rb
ADDED
|
@@ -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
|
data/lib/providers/anthropic.rb
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
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 <
|
|
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(
|
|
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 #{
|
|
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 #{
|
|
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
|
-
|
|
277
|
+
max_bytes = @max_output_bytes
|
|
278
|
+
return output if output.bytesize <= max_bytes
|
|
278
279
|
|
|
279
|
-
output.byteslice(0,
|
|
280
|
+
output.byteslice(0, max_bytes)
|
|
280
281
|
.force_encoding("UTF-8")
|
|
281
282
|
.scrub +
|
|
282
|
-
"\n\n[Truncated: output exceeded #{
|
|
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
|