rubino-agent 0.3.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 +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +115 -0
- data/.rubocop_todo.yml +955 -0
- data/.ruby-version +1 -0
- data/AGENTS.md +97 -0
- data/CHANGELOG.md +344 -0
- data/CONTRIBUTING.md +69 -0
- data/LICENSE +21 -0
- data/README.md +200 -0
- data/Rakefile +8 -0
- data/docs/agents.md +190 -0
- data/docs/api/v1.md +414 -0
- data/docs/architecture.md +177 -0
- data/docs/commands.md +375 -0
- data/docs/configuration.md +590 -0
- data/docs/getting-started.md +143 -0
- data/docs/jobs.md +332 -0
- data/docs/mcp.md +128 -0
- data/docs/memory.md +98 -0
- data/docs/models-and-keys.md +173 -0
- data/docs/oauth-providers.md +145 -0
- data/docs/plugins.md +195 -0
- data/docs/security.md +145 -0
- data/docs/skills.md +322 -0
- data/docs/tools.md +395 -0
- data/docs/troubleshooting.md +73 -0
- data/exe/rubino +9 -0
- data/install.sh +275 -0
- data/lib/rubino/active_skill.rb +50 -0
- data/lib/rubino/agent/agent_registry.rb +120 -0
- data/lib/rubino/agent/backoff_policy.rb +116 -0
- data/lib/rubino/agent/definition.rb +128 -0
- data/lib/rubino/agent/degenerate_recovery.rb +271 -0
- data/lib/rubino/agent/fallback_chain.rb +194 -0
- data/lib/rubino/agent/iteration_budget.rb +50 -0
- data/lib/rubino/agent/loop.rb +617 -0
- data/lib/rubino/agent/model_call_runner.rb +383 -0
- data/lib/rubino/agent/prompts/build.txt +69 -0
- data/lib/rubino/agent/prompts/compaction.txt +20 -0
- data/lib/rubino/agent/prompts/explore.txt +19 -0
- data/lib/rubino/agent/prompts/general.txt +20 -0
- data/lib/rubino/agent/prompts/plan.txt +31 -0
- data/lib/rubino/agent/response_validator.rb +70 -0
- data/lib/rubino/agent/router.rb +65 -0
- data/lib/rubino/agent/runner.rb +195 -0
- data/lib/rubino/agent/tool_executor.rb +402 -0
- data/lib/rubino/agent/truncation_continuation.rb +137 -0
- data/lib/rubino/api/middleware/auth.rb +43 -0
- data/lib/rubino/api/middleware/error_handler.rb +65 -0
- data/lib/rubino/api/middleware/json_parser.rb +100 -0
- data/lib/rubino/api/middleware/observability.rb +59 -0
- data/lib/rubino/api/middleware/rate_limit.rb +136 -0
- data/lib/rubino/api/operations/approvals/decide_operation.rb +49 -0
- data/lib/rubino/api/operations/clarifications/decide_operation.rb +44 -0
- data/lib/rubino/api/operations/cron_jobs/create_operation.rb +46 -0
- data/lib/rubino/api/operations/cron_jobs/delete_operation.rb +36 -0
- data/lib/rubino/api/operations/cron_jobs/list_operation.rb +55 -0
- data/lib/rubino/api/operations/cron_jobs/pause_operation.rb +34 -0
- data/lib/rubino/api/operations/cron_jobs/resume_operation.rb +34 -0
- data/lib/rubino/api/operations/cron_jobs/schedule_validation.rb +30 -0
- data/lib/rubino/api/operations/cron_jobs/show_operation.rb +32 -0
- data/lib/rubino/api/operations/cron_jobs/trigger_operation.rb +38 -0
- data/lib/rubino/api/operations/cron_jobs/update_operation.rb +42 -0
- data/lib/rubino/api/operations/files/read_operation.rb +40 -0
- data/lib/rubino/api/operations/files/upload_operation.rb +175 -0
- data/lib/rubino/api/operations/health_operation.rb +46 -0
- data/lib/rubino/api/operations/memory/delete_operation.rb +32 -0
- data/lib/rubino/api/operations/memory/index_operation.rb +80 -0
- data/lib/rubino/api/operations/memory/stats_operation.rb +28 -0
- data/lib/rubino/api/operations/metrics_operation.rb +18 -0
- data/lib/rubino/api/operations/mode/show_operation.rb +29 -0
- data/lib/rubino/api/operations/mode/update_operation.rb +42 -0
- data/lib/rubino/api/operations/models/list_operation.rb +45 -0
- data/lib/rubino/api/operations/oauth/connections/disconnect_operation.rb +77 -0
- data/lib/rubino/api/operations/oauth/connections/list_operation.rb +36 -0
- data/lib/rubino/api/operations/oauth/providers/callback_operation.rb +82 -0
- data/lib/rubino/api/operations/oauth/providers/connect_operation.rb +44 -0
- data/lib/rubino/api/operations/oauth/providers/list_operation.rb +35 -0
- data/lib/rubino/api/operations/oauth/serializer.rb +21 -0
- data/lib/rubino/api/operations/runs/create_operation.rb +77 -0
- data/lib/rubino/api/operations/runs/events_operation.rb +195 -0
- data/lib/rubino/api/operations/runs/stop_operation.rb +34 -0
- data/lib/rubino/api/operations/sessions/create_operation.rb +46 -0
- data/lib/rubino/api/operations/sessions/delete_operation.rb +33 -0
- data/lib/rubino/api/operations/sessions/index_operation.rb +82 -0
- data/lib/rubino/api/operations/sessions/retry_operation.rb +45 -0
- data/lib/rubino/api/operations/sessions/show_operation.rb +59 -0
- data/lib/rubino/api/operations/sessions/undo_operation.rb +38 -0
- data/lib/rubino/api/operations/skills/list_operation.rb +34 -0
- data/lib/rubino/api/operations/skills/toggle_operation.rb +40 -0
- data/lib/rubino/api/operations/tasks/index_operation.rb +30 -0
- data/lib/rubino/api/operations/tasks/serializer.rb +60 -0
- data/lib/rubino/api/operations/tasks/show_operation.rb +33 -0
- data/lib/rubino/api/operations/tasks/stop_operation.rb +47 -0
- data/lib/rubino/api/request.rb +54 -0
- data/lib/rubino/api/responses.rb +64 -0
- data/lib/rubino/api/router.rb +72 -0
- data/lib/rubino/api/schemas.rb +103 -0
- data/lib/rubino/api/server.rb +102 -0
- data/lib/rubino/api/tls.rb +108 -0
- data/lib/rubino/attachments/classification.rb +16 -0
- data/lib/rubino/attachments/classify.rb +171 -0
- data/lib/rubino/attachments/defang.rb +47 -0
- data/lib/rubino/attachments/policy.rb +36 -0
- data/lib/rubino/attachments/preamble.rb +120 -0
- data/lib/rubino/boot/encryption_key.rb +32 -0
- data/lib/rubino/cli/chat/bang_shell.rb +257 -0
- data/lib/rubino/cli/chat/completion_builder.rb +290 -0
- data/lib/rubino/cli/chat/idle_card_host.rb +69 -0
- data/lib/rubino/cli/chat/image_inbox.rb +168 -0
- data/lib/rubino/cli/chat/session_resolver.rb +176 -0
- data/lib/rubino/cli/chat_command.rb +1674 -0
- data/lib/rubino/cli/commands.rb +250 -0
- data/lib/rubino/cli/config_command.rb +96 -0
- data/lib/rubino/cli/doctor_command.rb +251 -0
- data/lib/rubino/cli/jobs_command.rb +60 -0
- data/lib/rubino/cli/memory_command.rb +135 -0
- data/lib/rubino/cli/onboarding_wizard.rb +207 -0
- data/lib/rubino/cli/server_command.rb +139 -0
- data/lib/rubino/cli/session_command.rb +125 -0
- data/lib/rubino/cli/setup_command.rb +107 -0
- data/lib/rubino/cli/skills_command.rb +85 -0
- data/lib/rubino/cli/tools_command.rb +81 -0
- data/lib/rubino/cli/trust_gate.rb +71 -0
- data/lib/rubino/commands/built_ins.rb +46 -0
- data/lib/rubino/commands/command.rb +116 -0
- data/lib/rubino/commands/executor.rb +550 -0
- data/lib/rubino/commands/handlers/agents.rb +510 -0
- data/lib/rubino/commands/handlers/config.rb +88 -0
- data/lib/rubino/commands/handlers/help.rb +148 -0
- data/lib/rubino/commands/handlers/jobs.rb +71 -0
- data/lib/rubino/commands/handlers/mcp.rb +229 -0
- data/lib/rubino/commands/handlers/memory.rb +200 -0
- data/lib/rubino/commands/handlers/sessions.rb +207 -0
- data/lib/rubino/commands/handlers/skills.rb +195 -0
- data/lib/rubino/commands/handlers/status.rb +211 -0
- data/lib/rubino/commands/loader.rb +90 -0
- data/lib/rubino/config/configuration.rb +455 -0
- data/lib/rubino/config/defaults.rb +569 -0
- data/lib/rubino/config/loader.rb +115 -0
- data/lib/rubino/config/reasoning_prefs.rb +67 -0
- data/lib/rubino/config/writer.rb +72 -0
- data/lib/rubino/context/compressor.rb +149 -0
- data/lib/rubino/context/environment_inspector.rb +176 -0
- data/lib/rubino/context/file_discovery.rb +45 -0
- data/lib/rubino/context/message_boundary.rb +39 -0
- data/lib/rubino/context/prompt_assembler.rb +382 -0
- data/lib/rubino/context/summary_builder.rb +159 -0
- data/lib/rubino/context/token_budget.rb +68 -0
- data/lib/rubino/context/tool_pair_sanitizer.rb +70 -0
- data/lib/rubino/database/connection.rb +77 -0
- data/lib/rubino/database/migrations/001_create_initial_schema.rb +156 -0
- data/lib/rubino/database/migrations/002_create_runs.rb +45 -0
- data/lib/rubino/database/migrations/003_create_skill_states.rb +15 -0
- data/lib/rubino/database/migrations/004_create_cron_jobs.rb +36 -0
- data/lib/rubino/database/migrations/005_create_oauth_connections.rb +27 -0
- data/lib/rubino/database/migrations/006_create_webhook_deliveries.rb +34 -0
- data/lib/rubino/database/migrations/007_create_messages_fts.rb +59 -0
- data/lib/rubino/database/migrations/008_create_memory_facts.rb +75 -0
- data/lib/rubino/database/migrations/009_create_memory_graph.rb +55 -0
- data/lib/rubino/database/migrations/010_add_owner_pid_to_sessions.rb +20 -0
- data/lib/rubino/database/migrator.rb +48 -0
- data/lib/rubino/documents/converters/csv.rb +79 -0
- data/lib/rubino/documents/converters/docx.rb +129 -0
- data/lib/rubino/documents/converters/html.rb +28 -0
- data/lib/rubino/documents/converters/json.rb +35 -0
- data/lib/rubino/documents/converters/pdf.rb +59 -0
- data/lib/rubino/documents/converters/plain.rb +68 -0
- data/lib/rubino/documents/converters/pptx.rb +64 -0
- data/lib/rubino/documents/converters/xlsx.rb +62 -0
- data/lib/rubino/documents/converters/xml.rb +45 -0
- data/lib/rubino/documents/html.rb +71 -0
- data/lib/rubino/documents/registry.rb +68 -0
- data/lib/rubino/documents/table.rb +63 -0
- data/lib/rubino/documents.rb +50 -0
- data/lib/rubino/errors.rb +119 -0
- data/lib/rubino/files/workspace.rb +93 -0
- data/lib/rubino/interaction/cancel_token.rb +43 -0
- data/lib/rubino/interaction/clipboard_image.rb +84 -0
- data/lib/rubino/interaction/event_bus.rb +48 -0
- data/lib/rubino/interaction/events.rb +101 -0
- data/lib/rubino/interaction/image_input.rb +127 -0
- data/lib/rubino/interaction/input_queue.rb +117 -0
- data/lib/rubino/interaction/lifecycle.rb +299 -0
- data/lib/rubino/interaction/probe.rb +65 -0
- data/lib/rubino/interaction/state.rb +56 -0
- data/lib/rubino/jobs/cron_job_repository.rb +75 -0
- data/lib/rubino/jobs/handlers/cleanup_sessions_job.rb +32 -0
- data/lib/rubino/jobs/handlers/compact_session_job.rb +21 -0
- data/lib/rubino/jobs/handlers/distill_skill_job.rb +186 -0
- data/lib/rubino/jobs/handlers/extract_memory_job.rb +37 -0
- data/lib/rubino/jobs/handlers/summarize_session_job.rb +21 -0
- data/lib/rubino/jobs/queue.rb +184 -0
- data/lib/rubino/jobs/registry.rb +45 -0
- data/lib/rubino/jobs/runner.rb +79 -0
- data/lib/rubino/jobs/scheduler.rb +138 -0
- data/lib/rubino/jobs/webhook_delivery.rb +225 -0
- data/lib/rubino/jobs/worker.rb +59 -0
- data/lib/rubino/llm/adapter_factory.rb +47 -0
- data/lib/rubino/llm/adapter_response.rb +65 -0
- data/lib/rubino/llm/auxiliary_client.rb +61 -0
- data/lib/rubino/llm/bedrock_bearer_client.rb +235 -0
- data/lib/rubino/llm/content_builder.rb +55 -0
- data/lib/rubino/llm/credential_check.rb +93 -0
- data/lib/rubino/llm/error_classifier.rb +364 -0
- data/lib/rubino/llm/fake_provider.rb +292 -0
- data/lib/rubino/llm/inline_think_filter.rb +58 -0
- data/lib/rubino/llm/model_catalog.rb +29 -0
- data/lib/rubino/llm/provider_resolver.rb +48 -0
- data/lib/rubino/llm/reasoning_manager.rb +100 -0
- data/lib/rubino/llm/request.rb +56 -0
- data/lib/rubino/llm/ruby_llm_adapter.rb +794 -0
- data/lib/rubino/llm/scenario_loader.rb +68 -0
- data/lib/rubino/llm/scenario_selector.rb +80 -0
- data/lib/rubino/llm/scenarios/agent-creates-cron-failure.yml +29 -0
- data/lib/rubino/llm/scenarios/agent-creates-cron.yml +36 -0
- data/lib/rubino/llm/scenarios/analysis.yml +501 -0
- data/lib/rubino/llm/scenarios/complex-analysis.yml +598 -0
- data/lib/rubino/llm/scenarios/failure.yml +65 -0
- data/lib/rubino/llm/scenarios/happy-path.yml +24 -0
- data/lib/rubino/llm/scenarios/provider-quota-completed.yml +14 -0
- data/lib/rubino/llm/scenarios/wide-table.yml +121 -0
- data/lib/rubino/llm/scenarios/with-approvals.yml +50 -0
- data/lib/rubino/llm/scenarios/with-artifacts.yml +98 -0
- data/lib/rubino/llm/scenarios/with-clarify.yml +32 -0
- data/lib/rubino/llm/scenarios/with-reasoning.yml +175 -0
- data/lib/rubino/llm/scenarios/with-uploads.yml +104 -0
- data/lib/rubino/llm/thinking_support.rb +84 -0
- data/lib/rubino/llm/tool_bridge.rb +89 -0
- data/lib/rubino/logger.rb +99 -0
- data/lib/rubino/mcp/manager.rb +180 -0
- data/lib/rubino/mcp/mcp_tool_wrapper.rb +69 -0
- data/lib/rubino/mcp.rb +57 -0
- data/lib/rubino/memory/backend.rb +104 -0
- data/lib/rubino/memory/backends/default.rb +101 -0
- data/lib/rubino/memory/backends/sqlite.rb +653 -0
- data/lib/rubino/memory/backends.rb +53 -0
- data/lib/rubino/memory/deduplicator.rb +74 -0
- data/lib/rubino/memory/extractor.rb +85 -0
- data/lib/rubino/memory/flusher.rb +31 -0
- data/lib/rubino/memory/retriever.rb +50 -0
- data/lib/rubino/memory/sqlite_extraction_prompt.rb +70 -0
- data/lib/rubino/memory/sqlite_graph.rb +154 -0
- data/lib/rubino/memory/store.rb +228 -0
- data/lib/rubino/memory/threat_scanner.rb +68 -0
- data/lib/rubino/metrics.rb +175 -0
- data/lib/rubino/modes.rb +93 -0
- data/lib/rubino/oauth/connection_repository.rb +95 -0
- data/lib/rubino/oauth/provider/github.rb +75 -0
- data/lib/rubino/oauth/provider/google.rb +59 -0
- data/lib/rubino/oauth/provider.rb +149 -0
- data/lib/rubino/oauth/registry.rb +86 -0
- data/lib/rubino/oauth/token_encryptor.rb +87 -0
- data/lib/rubino/plugins/registry.rb +75 -0
- data/lib/rubino/plugins.rb +86 -0
- data/lib/rubino/run/approval_gate.rb +243 -0
- data/lib/rubino/run/attachment_downloader.rb +166 -0
- data/lib/rubino/run/event_store.rb +74 -0
- data/lib/rubino/run/executor.rb +383 -0
- data/lib/rubino/run/gate_registry.rb +39 -0
- data/lib/rubino/run/recorder.rb +69 -0
- data/lib/rubino/run/repository.rb +118 -0
- data/lib/rubino/run/session_approval_cache.rb +118 -0
- data/lib/rubino/security/allowlist_persister.rb +55 -0
- data/lib/rubino/security/approval_policy.rb +227 -0
- data/lib/rubino/security/command_allowlist.rb +24 -0
- data/lib/rubino/security/dangerous_patterns.rb +118 -0
- data/lib/rubino/security/deny_persister.rb +73 -0
- data/lib/rubino/security/doom_loop_detector.rb +43 -0
- data/lib/rubino/security/hardline_guard.rb +105 -0
- data/lib/rubino/security/pattern_matcher.rb +62 -0
- data/lib/rubino/security/prefix_deriver.rb +124 -0
- data/lib/rubino/security/readonly_commands.rb +211 -0
- data/lib/rubino/session/exporter.rb +101 -0
- data/lib/rubino/session/message.rb +77 -0
- data/lib/rubino/session/repository.rb +295 -0
- data/lib/rubino/session/store.rb +198 -0
- data/lib/rubino/session/summary_store.rb +65 -0
- data/lib/rubino/skills/prompt_index.rb +85 -0
- data/lib/rubino/skills/registry.rb +208 -0
- data/lib/rubino/skills/skill.rb +176 -0
- data/lib/rubino/skills/skill_tool.rb +215 -0
- data/lib/rubino/skills/state_repository.rb +37 -0
- data/lib/rubino/skills/toggle.rb +26 -0
- data/lib/rubino/tools/answer_child_tool.rb +83 -0
- data/lib/rubino/tools/ask_parent_tool.rb +232 -0
- data/lib/rubino/tools/attach_file_tool.rb +120 -0
- data/lib/rubino/tools/background_tasks.rb +520 -0
- data/lib/rubino/tools/base.rb +222 -0
- data/lib/rubino/tools/custom_tool_loader.rb +119 -0
- data/lib/rubino/tools/edit_tool.rb +122 -0
- data/lib/rubino/tools/git_tool.rb +71 -0
- data/lib/rubino/tools/github_tool.rb +233 -0
- data/lib/rubino/tools/glob_tool.rb +69 -0
- data/lib/rubino/tools/grep_tool.rb +206 -0
- data/lib/rubino/tools/memory_tool.rb +184 -0
- data/lib/rubino/tools/multi_edit_tool.rb +110 -0
- data/lib/rubino/tools/patch_tool.rb +260 -0
- data/lib/rubino/tools/probe_tool.rb +175 -0
- data/lib/rubino/tools/question_tool.rb +128 -0
- data/lib/rubino/tools/read_attachment_tool.rb +180 -0
- data/lib/rubino/tools/read_tool.rb +212 -0
- data/lib/rubino/tools/read_tracker.rb +98 -0
- data/lib/rubino/tools/registry.rb +166 -0
- data/lib/rubino/tools/result.rb +113 -0
- data/lib/rubino/tools/ruby_tool.rb +0 -0
- data/lib/rubino/tools/session_search_tool.rb +103 -0
- data/lib/rubino/tools/shell_input_tool.rb +96 -0
- data/lib/rubino/tools/shell_kill_tool.rb +76 -0
- data/lib/rubino/tools/shell_output_tool.rb +72 -0
- data/lib/rubino/tools/shell_registry.rb +158 -0
- data/lib/rubino/tools/shell_tail_tool.rb +118 -0
- data/lib/rubino/tools/shell_tool.rb +330 -0
- data/lib/rubino/tools/steer_tool.rb +118 -0
- data/lib/rubino/tools/subagent_probe.rb +89 -0
- data/lib/rubino/tools/summarize_file_tool.rb +182 -0
- data/lib/rubino/tools/task_result_tool.rb +90 -0
- data/lib/rubino/tools/task_stop_tool.rb +80 -0
- data/lib/rubino/tools/task_tool.rb +622 -0
- data/lib/rubino/tools/test_tool.rb +454 -0
- data/lib/rubino/tools/todo_tool.rb +93 -0
- data/lib/rubino/tools/tool_call_repository.rb +33 -0
- data/lib/rubino/tools/vision_tool.rb +85 -0
- data/lib/rubino/tools/webfetch_tool.rb +153 -0
- data/lib/rubino/tools/websearch_tool.rb +179 -0
- data/lib/rubino/tools/write_tool.rb +61 -0
- data/lib/rubino/trust.rb +88 -0
- data/lib/rubino/ui/api.rb +296 -0
- data/lib/rubino/ui/base.rb +252 -0
- data/lib/rubino/ui/bottom_composer.rb +1599 -0
- data/lib/rubino/ui/cli.rb +1987 -0
- data/lib/rubino/ui/completion_menu.rb +321 -0
- data/lib/rubino/ui/completion_source.rb +284 -0
- data/lib/rubino/ui/escape_reader.rb +169 -0
- data/lib/rubino/ui/indented_io.rb +88 -0
- data/lib/rubino/ui/input_history.rb +108 -0
- data/lib/rubino/ui/live_region.rb +183 -0
- data/lib/rubino/ui/markdown_renderer.rb +506 -0
- data/lib/rubino/ui/notifier.rb +163 -0
- data/lib/rubino/ui/null.rb +195 -0
- data/lib/rubino/ui/paste_store.rb +176 -0
- data/lib/rubino/ui/printer_base.rb +79 -0
- data/lib/rubino/ui/probe_wait_indicator.rb +75 -0
- data/lib/rubino/ui/queued_indicators.rb +66 -0
- data/lib/rubino/ui/status_bar.rb +100 -0
- data/lib/rubino/ui/stdout_proxy.rb +161 -0
- data/lib/rubino/ui/streaming_markdown.rb +186 -0
- data/lib/rubino/ui/subagent_cards.rb +134 -0
- data/lib/rubino/ui/subagent_view.rb +255 -0
- data/lib/rubino/ui.rb +21 -0
- data/lib/rubino/update_check.rb +187 -0
- data/lib/rubino/util/duration.rb +23 -0
- data/lib/rubino/util/hyperlink.rb +105 -0
- data/lib/rubino/util/output.rb +145 -0
- data/lib/rubino/util/secrets_mask.rb +83 -0
- data/lib/rubino/version.rb +5 -0
- data/lib/rubino/workspace.rb +85 -0
- data/lib/rubino-agent.rb +5 -0
- data/lib/rubino.rb +318 -0
- data/mise.toml +2 -0
- data/rubino-agent.gemspec +103 -0
- data/skills/ruby-expert/SKILL.md +67 -0
- data/skills/ruby-expert/references/concurrency.md +357 -0
- data/skills/ruby-expert/references/datetime-and-encoding.md +363 -0
- data/skills/ruby-expert/references/errors-and-types.md +460 -0
- data/skills/ruby-expert/references/gem-authoring.md +459 -0
- data/skills/ruby-expert/references/language-idioms.md +465 -0
- data/skills/ruby-expert/references/metaprogramming.md +339 -0
- data/skills/ruby-expert/references/oo-design.md +553 -0
- data/skills/ruby-expert/references/performance.md +383 -0
- data/skills/ruby-expert/references/rails.md +424 -0
- data/skills/ruby-expert/references/security.md +404 -0
- data/skills/ruby-expert/references/testing.md +473 -0
- data/skills/ruby-expert/references/tooling.md +466 -0
- metadata +856 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Tools
|
|
5
|
+
# Abstract base class for all tools.
|
|
6
|
+
# Each tool must implement: name, description, input_schema, risk_level, call.
|
|
7
|
+
class Base
|
|
8
|
+
# Set by ToolExecutor before each call so long-running tools (shell,
|
|
9
|
+
# http, watchers) can poll for user cancellation. Default is nil — the
|
|
10
|
+
# tool should treat that as "no cancellation possible" and not crash.
|
|
11
|
+
attr_accessor :cancel_token
|
|
12
|
+
|
|
13
|
+
# Session-scoped ReadTracker injected by ToolExecutor. ReadTool
|
|
14
|
+
# registers successful reads; EditTool / MultiEditTool consult it
|
|
15
|
+
# before writing so they can refuse to edit a file the model never
|
|
16
|
+
# opened in this session. Nil-tolerant: tools that don't care just
|
|
17
|
+
# ignore it.
|
|
18
|
+
attr_accessor :read_tracker
|
|
19
|
+
|
|
20
|
+
# Optional Proc, injected by ToolExecutor, that the tool can call with
|
|
21
|
+
# incremental output chunks during a long-running call. ShellTool uses
|
|
22
|
+
# this to stream stdout/stderr lines as the subprocess writes them
|
|
23
|
+
# instead of dumping everything at end-of-command. Nil-tolerant: a
|
|
24
|
+
# tool with no streamable output (read, edit, glob) just ignores it.
|
|
25
|
+
attr_accessor :stream_chunk
|
|
26
|
+
|
|
27
|
+
# Convenience guard so tools don't sprinkle nil-checks at every emit.
|
|
28
|
+
def emit_chunk(text)
|
|
29
|
+
return if text.nil? || text.to_s.empty?
|
|
30
|
+
|
|
31
|
+
@stream_chunk&.call(text.to_s)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# True when the user has requested cancellation. Cheap, lock-protected.
|
|
35
|
+
# Use in tight loops; on true, terminate gracefully and either return
|
|
36
|
+
# an "interrupted" string or raise Rubino::Interrupted.
|
|
37
|
+
def cancellation_requested?
|
|
38
|
+
@cancel_token&.cancelled?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Returns the tool name (used in LLM tool definitions)
|
|
42
|
+
def name
|
|
43
|
+
raise NotImplementedError, "#{self.class}#name not implemented"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The `tools.<key>` config gate that enables/disables this tool. Single
|
|
47
|
+
# source of truth shared with Registry#tool_enabled_in_config? and the
|
|
48
|
+
# `tools` CLI command, so the displayed state always matches the state
|
|
49
|
+
# the registry actually enforces. Defaults to the tool's own name;
|
|
50
|
+
# tools whose config key differs (webfetch/websearch both gate on
|
|
51
|
+
# `tools.web`) override this. Returning a key absent from config means
|
|
52
|
+
# the tool is enabled (opt-out model).
|
|
53
|
+
def config_key
|
|
54
|
+
name
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Returns a description for the LLM
|
|
58
|
+
def description
|
|
59
|
+
raise NotImplementedError, "#{self.class}#description not implemented"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Returns the JSON schema for input parameters
|
|
63
|
+
def input_schema
|
|
64
|
+
raise NotImplementedError, "#{self.class}#input_schema not implemented"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Returns the risk level: :low, :medium, :high
|
|
68
|
+
def risk_level
|
|
69
|
+
:low
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Executes the tool with given arguments, returns output string
|
|
73
|
+
def call(arguments)
|
|
74
|
+
raise NotImplementedError, "#{self.class}#call not implemented"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Returns true if this tool requires user confirmation
|
|
78
|
+
def risky?
|
|
79
|
+
%i[medium high].include?(risk_level)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Returns the tool definition for LLM registration
|
|
83
|
+
def to_tool_definition
|
|
84
|
+
{
|
|
85
|
+
name: name,
|
|
86
|
+
description: description,
|
|
87
|
+
parameters: input_schema
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
protected
|
|
92
|
+
|
|
93
|
+
# Filesystem sandbox for write/edit/delete operations.
|
|
94
|
+
#
|
|
95
|
+
# Defaults to Dir.pwd, overridable via terminal.cwd in config. Mutating
|
|
96
|
+
# tools must call within_workspace? before touching the disk so a prompt
|
|
97
|
+
# injection that asks for `file_path: "/etc/passwd"` is refused at the
|
|
98
|
+
# tool boundary, before the approval prompt even sees the path.
|
|
99
|
+
#
|
|
100
|
+
# The check resolves every symlink with File.realpath before comparing
|
|
101
|
+
# against the workspace root: dropping a `link → /etc` inside the
|
|
102
|
+
# workspace and writing through it used to bypass the boundary because
|
|
103
|
+
# expand_path alone never crosses the symlink. realpath walks the
|
|
104
|
+
# filesystem and gives us the canonical destination, so an in-workspace
|
|
105
|
+
# path that ultimately points outside is rejected like any other escape.
|
|
106
|
+
# For non-existent targets (write-creates-new-file) we resolve the
|
|
107
|
+
# deepest existing ancestor and re-attach the remainder — the new file
|
|
108
|
+
# will land at that ancestor, so the ancestor is what we sandbox.
|
|
109
|
+
#
|
|
110
|
+
# Set tools.workspace_strict=false in config.yml to disable globally
|
|
111
|
+
# (the agent then trusts the model + the approval flow alone).
|
|
112
|
+
# The directory tools sandbox to. Exposed as a class method so the
|
|
113
|
+
# File API operations can root their Workspace at the SAME place
|
|
114
|
+
# (otherwise produced artifacts under this root look like traversal
|
|
115
|
+
# escapes relative to paths_home and the download 422s).
|
|
116
|
+
# The PRIMARY root — terminal.cwd or the launch cwd. Kept as the single
|
|
117
|
+
# source of truth for "the" directory: the @-picker, shell/test cwd, the
|
|
118
|
+
# File API workspace and the attachment downloader all root here so they
|
|
119
|
+
# agree. The write/edit SANDBOX, however, spans every root (see
|
|
120
|
+
# #within_workspace?) so an added dir is also writable.
|
|
121
|
+
def self.workspace_root
|
|
122
|
+
Workspace.primary_root
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Every allowed root (primary + any --add-dir / /add-dir dirs). The
|
|
126
|
+
# sandbox accepts a target under ANY of these.
|
|
127
|
+
def self.workspace_roots
|
|
128
|
+
Workspace.roots
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def workspace_root
|
|
132
|
+
self.class.workspace_root
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def workspace_roots
|
|
136
|
+
self.class.workspace_roots
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def workspace_strict?
|
|
140
|
+
Rubino.configuration.dig("tools", "workspace_strict") != false
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# True when +expanded+ resolves under ANY allowed root. Generalised from
|
|
144
|
+
# the old single-root check so a write/edit/multi_edit under a dir added
|
|
145
|
+
# via --add-dir / /add-dir is accepted, while a path outside every root
|
|
146
|
+
# is still refused. Symlinks are resolved (canonical_path) before the
|
|
147
|
+
# comparison so an in-workspace symlink to /etc can't escape.
|
|
148
|
+
def within_workspace?(expanded)
|
|
149
|
+
return true unless workspace_strict?
|
|
150
|
+
|
|
151
|
+
target_real = canonical_path(expanded)
|
|
152
|
+
return false unless target_real
|
|
153
|
+
|
|
154
|
+
Workspace.canonical_roots.any? do |root_real|
|
|
155
|
+
target_real == root_real ||
|
|
156
|
+
target_real.start_with?("#{root_real}#{File::SEPARATOR}")
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Resolves `path` through every symlink to its canonical destination.
|
|
161
|
+
# When the path doesn't exist yet (create-new-file flow) walks up to
|
|
162
|
+
# the deepest existing ancestor, realpaths that, then re-joins the
|
|
163
|
+
# missing tail. The tail itself can't traverse — expand_path already
|
|
164
|
+
# collapsed `..` segments before we got here.
|
|
165
|
+
def canonical_path(path)
|
|
166
|
+
return nil if path.nil? || path.to_s.empty?
|
|
167
|
+
|
|
168
|
+
expanded = File.expand_path(path.to_s)
|
|
169
|
+
return File.realpath(expanded) if File.exist?(expanded)
|
|
170
|
+
|
|
171
|
+
ancestor = expanded
|
|
172
|
+
tail = []
|
|
173
|
+
until File.exist?(ancestor)
|
|
174
|
+
parent = File.dirname(ancestor)
|
|
175
|
+
break if parent == ancestor
|
|
176
|
+
|
|
177
|
+
tail.unshift(File.basename(ancestor))
|
|
178
|
+
ancestor = parent
|
|
179
|
+
end
|
|
180
|
+
return nil unless File.exist?(ancestor)
|
|
181
|
+
|
|
182
|
+
File.join(File.realpath(ancestor), *tail)
|
|
183
|
+
rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
|
|
184
|
+
nil
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def workspace_violation_message(path)
|
|
188
|
+
roots = workspace_roots
|
|
189
|
+
where = roots.length == 1 ? roots.first : "any allowed root (#{roots.join(", ")})"
|
|
190
|
+
"Error: refusing to access '#{path}' — outside #{where}. " \
|
|
191
|
+
"Set tools.workspace_strict=false in config.yml to disable this check."
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Read-before-edit gate shared by EditTool and MultiEditTool. Refuses the
|
|
195
|
+
# write when the model never read this file in the current session, or
|
|
196
|
+
# read it but the file changed on disk since. Returns nil (proceed) or an
|
|
197
|
+
# error Hash carrying error_code: :stale_read for the model to recover
|
|
198
|
+
# from. No tracker injected → no gate (single-tool unit tests, MCP calls).
|
|
199
|
+
#
|
|
200
|
+
# `verb` is the only token that varies between callers ("edit" /
|
|
201
|
+
# "edits"); the wording is otherwise identical, so it lives here.
|
|
202
|
+
def read_gate_error(expanded, display_path, verb:)
|
|
203
|
+
return nil unless @read_tracker
|
|
204
|
+
|
|
205
|
+
unless @read_tracker.seen?(expanded)
|
|
206
|
+
return { output: "Error: must use the read tool on #{display_path} in this session before editing it. " \
|
|
207
|
+
"Read it first so the #{verb} can verify the surrounding context.",
|
|
208
|
+
error_code: :stale_read }
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
stashed = @read_tracker.mtime_at_read(expanded)
|
|
212
|
+
current = File.mtime(expanded)
|
|
213
|
+
return nil if stashed.nil? || current <= stashed
|
|
214
|
+
|
|
215
|
+
{ output: "Error: #{display_path} changed on disk since the last read " \
|
|
216
|
+
"(read at #{stashed.utc.iso8601}, now #{current.utc.iso8601}). " \
|
|
217
|
+
"Re-read the file before editing so the #{verb} reflect the current contents.",
|
|
218
|
+
error_code: :stale_read }
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Tools
|
|
5
|
+
# Loads user-defined tools from .rubino/tools/ directories.
|
|
6
|
+
# Users can define tools using a simple Ruby DSL.
|
|
7
|
+
#
|
|
8
|
+
# Example tool file (.rubino/tools/my_tool.rb):
|
|
9
|
+
#
|
|
10
|
+
# Rubino.define_tool do
|
|
11
|
+
# name "my_custom_tool"
|
|
12
|
+
# description "Does something custom"
|
|
13
|
+
# input_schema type: "object", properties: { input: { type: "string" } }
|
|
14
|
+
# risk_level :low
|
|
15
|
+
#
|
|
16
|
+
# execute do |args|
|
|
17
|
+
# "Result: #{args['input']}"
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
class CustomToolLoader
|
|
22
|
+
TOOL_GLOB = "*.rb"
|
|
23
|
+
|
|
24
|
+
# HOME-only by design (#44). This loader `load`s arbitrary Ruby, so it
|
|
25
|
+
# must NEVER read from a project's cwd `.rubino/tools` — that would let
|
|
26
|
+
# any directory you start rubino in execute code with zero prompt, the
|
|
27
|
+
# exact foot-gun the folder-trust model exists to prevent. The only
|
|
28
|
+
# allowed source is the user's own config dir under RUBINO_HOME, which is
|
|
29
|
+
# not attacker-controllable by cd-ing into a repo. (Previously the path
|
|
30
|
+
# list led with the cwd `.rubino/tools`; that entry is removed.)
|
|
31
|
+
def self.tool_paths
|
|
32
|
+
[File.join(Rubino.home_path, "tools")]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def initialize(paths: nil)
|
|
36
|
+
@paths = paths || self.class.tool_paths
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Loads all custom tools and registers them
|
|
40
|
+
def load_all!
|
|
41
|
+
loaded = 0
|
|
42
|
+
|
|
43
|
+
@paths.each do |dir|
|
|
44
|
+
expanded = File.expand_path(dir)
|
|
45
|
+
next unless File.directory?(expanded)
|
|
46
|
+
|
|
47
|
+
Dir.glob(File.join(expanded, TOOL_GLOB)).each do |path|
|
|
48
|
+
load_tool_file(path)
|
|
49
|
+
loaded += 1
|
|
50
|
+
rescue StandardError => e
|
|
51
|
+
Rubino.ui.warning("Failed to load tool #{path}: #{e.message}")
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
loaded
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def load_tool_file(path)
|
|
61
|
+
# Load in a clean context
|
|
62
|
+
load(path)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# DSL builder for custom tools
|
|
67
|
+
class CustomToolBuilder
|
|
68
|
+
attr_reader :_name, :_description, :_input_schema, :_risk_level, :_execute_block
|
|
69
|
+
|
|
70
|
+
def initialize
|
|
71
|
+
@_risk_level = :low
|
|
72
|
+
@_input_schema = { type: "object", properties: {} }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def name(val)
|
|
76
|
+
@_name = val
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def description(val)
|
|
80
|
+
@_description = val
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def input_schema(val)
|
|
84
|
+
@_input_schema = val
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def risk_level(val)
|
|
88
|
+
@_risk_level = val
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def execute(&block)
|
|
92
|
+
@_execute_block = block
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Builds a Tool instance from the DSL
|
|
96
|
+
def build
|
|
97
|
+
builder = self
|
|
98
|
+
Class.new(Base) do
|
|
99
|
+
define_method(:name) { builder._name }
|
|
100
|
+
define_method(:description) { builder._description }
|
|
101
|
+
define_method(:input_schema) { builder._input_schema }
|
|
102
|
+
define_method(:risk_level) { builder._risk_level }
|
|
103
|
+
define_method(:call) { |args| builder._execute_block.call(args) }
|
|
104
|
+
end.new
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Module-level DSL method for defining custom tools
|
|
111
|
+
module Rubino
|
|
112
|
+
def self.define_tool(&)
|
|
113
|
+
builder = Tools::CustomToolBuilder.new
|
|
114
|
+
builder.instance_eval(&)
|
|
115
|
+
tool = builder.build
|
|
116
|
+
Tools::Registry.register(tool)
|
|
117
|
+
tool
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Tools
|
|
5
|
+
# Tool for performing exact string replacements in files.
|
|
6
|
+
# Replaces a specific old string with a new string - more precise than full file writes.
|
|
7
|
+
class EditTool < Base
|
|
8
|
+
def name
|
|
9
|
+
"edit"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def description
|
|
13
|
+
"Perform exact string replacement in a file. " \
|
|
14
|
+
"Specify the old text to find and the new text to replace it with. " \
|
|
15
|
+
"The old text must match exactly (including whitespace/indentation). " \
|
|
16
|
+
"Use replace_all to replace all occurrences."
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def input_schema
|
|
20
|
+
{
|
|
21
|
+
type: "object",
|
|
22
|
+
properties: {
|
|
23
|
+
file_path: {
|
|
24
|
+
type: "string",
|
|
25
|
+
description: "The path to the file to edit"
|
|
26
|
+
},
|
|
27
|
+
old_string: {
|
|
28
|
+
type: "string",
|
|
29
|
+
description: "The exact text to find and replace"
|
|
30
|
+
},
|
|
31
|
+
new_string: {
|
|
32
|
+
type: "string",
|
|
33
|
+
description: "The text to replace it with"
|
|
34
|
+
},
|
|
35
|
+
replace_all: {
|
|
36
|
+
type: "boolean",
|
|
37
|
+
description: "Replace all occurrences (default: false, replaces first only)"
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
required: %w[file_path old_string new_string]
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def risk_level
|
|
45
|
+
:medium
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def call(arguments)
|
|
49
|
+
file_path = arguments["file_path"] || arguments[:file_path]
|
|
50
|
+
old_string = arguments["old_string"] || arguments[:old_string]
|
|
51
|
+
new_string = arguments["new_string"] || arguments[:new_string]
|
|
52
|
+
replace_all = arguments["replace_all"] || arguments[:replace_all] || false
|
|
53
|
+
|
|
54
|
+
expanded = File.expand_path(file_path)
|
|
55
|
+
return workspace_violation_message(file_path) unless within_workspace?(expanded)
|
|
56
|
+
|
|
57
|
+
return "Error: File not found: #{file_path}" unless File.exist?(expanded)
|
|
58
|
+
|
|
59
|
+
if (gate = read_gate_error(expanded, file_path, verb: "edit"))
|
|
60
|
+
return gate
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
content = File.read(expanded)
|
|
64
|
+
|
|
65
|
+
unless content.include?(old_string)
|
|
66
|
+
return "Error: old_string not found in file content. " \
|
|
67
|
+
"Make sure the text matches exactly including whitespace."
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Count occurrences
|
|
71
|
+
count = content.scan(old_string).size
|
|
72
|
+
if count > 1 && !replace_all
|
|
73
|
+
return "Error: Found #{count} matches for old_string. " \
|
|
74
|
+
"Provide more surrounding context to make it unique, " \
|
|
75
|
+
"or set replace_all: true to replace all occurrences."
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Perform replacement — use block form so new_string is treated as a
|
|
79
|
+
# literal string, not a pattern (avoids \0, \1, \& interpolation bugs).
|
|
80
|
+
new_content = if replace_all
|
|
81
|
+
content.gsub(old_string) { new_string }
|
|
82
|
+
else
|
|
83
|
+
content.sub(old_string) { new_string }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
File.write(expanded, new_content)
|
|
87
|
+
|
|
88
|
+
replaced_count = replace_all ? count : 1
|
|
89
|
+
added = new_string.to_s.lines.size
|
|
90
|
+
removed = old_string.to_s.lines.size
|
|
91
|
+
{ output: "Edit applied: #{replaced_count} replacement(s) in #{file_path}",
|
|
92
|
+
metrics: "#{replaced_count} replacement#{"s" if replaced_count != 1} · " \
|
|
93
|
+
"+#{added * replaced_count} −#{removed * replaced_count}",
|
|
94
|
+
body: build_diff_preview(old_string, new_string, replaced_count),
|
|
95
|
+
body_kind: :diff }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
# Inline diff shown between the `tool · edit` and `done · edit` headers.
|
|
101
|
+
# Not a real unified diff — just `- old` then `+ new` so the user can
|
|
102
|
+
# see at a glance what the model is changing without scrolling back to
|
|
103
|
+
# the approval prompt. Trimmed to the first 12 lines; long edits still
|
|
104
|
+
# apply, the body is only a preview.
|
|
105
|
+
MAX_DIFF_LINES = 12
|
|
106
|
+
|
|
107
|
+
def build_diff_preview(old_str, new_str, replaced_count)
|
|
108
|
+
minus = old_str.to_s.lines.map { |l| "- #{l.chomp}" }
|
|
109
|
+
plus = new_str.to_s.lines.map { |l| "+ #{l.chomp}" }
|
|
110
|
+
lines = minus + plus
|
|
111
|
+
suffix = []
|
|
112
|
+
if lines.size > MAX_DIFF_LINES
|
|
113
|
+
dropped = lines.size - MAX_DIFF_LINES
|
|
114
|
+
lines = lines.first(MAX_DIFF_LINES)
|
|
115
|
+
suffix << " [… #{dropped} more line(s)]"
|
|
116
|
+
end
|
|
117
|
+
suffix << " (× #{replaced_count} occurrences)" if replaced_count > 1
|
|
118
|
+
(lines + suffix).join("\n")
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Tools
|
|
5
|
+
# Tool for git operations: status, diff, log, branch info.
|
|
6
|
+
class GitTool < Base
|
|
7
|
+
def name
|
|
8
|
+
"git"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def description
|
|
12
|
+
"Execute git commands to inspect repository state. " \
|
|
13
|
+
"Supports status, diff, log, branch, and show operations."
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def input_schema
|
|
17
|
+
{
|
|
18
|
+
type: "object",
|
|
19
|
+
properties: {
|
|
20
|
+
command: {
|
|
21
|
+
type: "string",
|
|
22
|
+
enum: %w[status diff log branch show],
|
|
23
|
+
description: "The git command to execute"
|
|
24
|
+
},
|
|
25
|
+
args: {
|
|
26
|
+
type: "string",
|
|
27
|
+
description: "Additional arguments for the command"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
required: %w[command]
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def risk_level
|
|
35
|
+
:low # Read-only git operations
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def call(arguments)
|
|
39
|
+
command = arguments["command"] || arguments[:command]
|
|
40
|
+
args = arguments["args"] || arguments[:args] || ""
|
|
41
|
+
|
|
42
|
+
case command
|
|
43
|
+
when "status"
|
|
44
|
+
execute_git("status", args)
|
|
45
|
+
when "diff"
|
|
46
|
+
execute_git("diff", args)
|
|
47
|
+
when "log"
|
|
48
|
+
execute_git("log --oneline -20", args)
|
|
49
|
+
when "branch"
|
|
50
|
+
execute_git("branch", args)
|
|
51
|
+
when "show"
|
|
52
|
+
execute_git("show", args)
|
|
53
|
+
else
|
|
54
|
+
"Unknown git command: #{command}"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def execute_git(cmd, args)
|
|
61
|
+
# Split cmd into tokens and append sanitised args to avoid shell injection.
|
|
62
|
+
# IO.popen with an argv array never passes the arguments through a shell.
|
|
63
|
+
argv = ["git"] + cmd.split + args.split
|
|
64
|
+
result = IO.popen(argv, err: %i[child out], &:read)
|
|
65
|
+
result.empty? ? "(no output)" : result
|
|
66
|
+
rescue StandardError => e
|
|
67
|
+
"Git error: #{e.message}"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|