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.
Files changed (376) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +115 -0
  4. data/.rubocop_todo.yml +955 -0
  5. data/.ruby-version +1 -0
  6. data/AGENTS.md +97 -0
  7. data/CHANGELOG.md +344 -0
  8. data/CONTRIBUTING.md +69 -0
  9. data/LICENSE +21 -0
  10. data/README.md +200 -0
  11. data/Rakefile +8 -0
  12. data/docs/agents.md +190 -0
  13. data/docs/api/v1.md +414 -0
  14. data/docs/architecture.md +177 -0
  15. data/docs/commands.md +375 -0
  16. data/docs/configuration.md +590 -0
  17. data/docs/getting-started.md +143 -0
  18. data/docs/jobs.md +332 -0
  19. data/docs/mcp.md +128 -0
  20. data/docs/memory.md +98 -0
  21. data/docs/models-and-keys.md +173 -0
  22. data/docs/oauth-providers.md +145 -0
  23. data/docs/plugins.md +195 -0
  24. data/docs/security.md +145 -0
  25. data/docs/skills.md +322 -0
  26. data/docs/tools.md +395 -0
  27. data/docs/troubleshooting.md +73 -0
  28. data/exe/rubino +9 -0
  29. data/install.sh +275 -0
  30. data/lib/rubino/active_skill.rb +50 -0
  31. data/lib/rubino/agent/agent_registry.rb +120 -0
  32. data/lib/rubino/agent/backoff_policy.rb +116 -0
  33. data/lib/rubino/agent/definition.rb +128 -0
  34. data/lib/rubino/agent/degenerate_recovery.rb +271 -0
  35. data/lib/rubino/agent/fallback_chain.rb +194 -0
  36. data/lib/rubino/agent/iteration_budget.rb +50 -0
  37. data/lib/rubino/agent/loop.rb +617 -0
  38. data/lib/rubino/agent/model_call_runner.rb +383 -0
  39. data/lib/rubino/agent/prompts/build.txt +69 -0
  40. data/lib/rubino/agent/prompts/compaction.txt +20 -0
  41. data/lib/rubino/agent/prompts/explore.txt +19 -0
  42. data/lib/rubino/agent/prompts/general.txt +20 -0
  43. data/lib/rubino/agent/prompts/plan.txt +31 -0
  44. data/lib/rubino/agent/response_validator.rb +70 -0
  45. data/lib/rubino/agent/router.rb +65 -0
  46. data/lib/rubino/agent/runner.rb +195 -0
  47. data/lib/rubino/agent/tool_executor.rb +402 -0
  48. data/lib/rubino/agent/truncation_continuation.rb +137 -0
  49. data/lib/rubino/api/middleware/auth.rb +43 -0
  50. data/lib/rubino/api/middleware/error_handler.rb +65 -0
  51. data/lib/rubino/api/middleware/json_parser.rb +100 -0
  52. data/lib/rubino/api/middleware/observability.rb +59 -0
  53. data/lib/rubino/api/middleware/rate_limit.rb +136 -0
  54. data/lib/rubino/api/operations/approvals/decide_operation.rb +49 -0
  55. data/lib/rubino/api/operations/clarifications/decide_operation.rb +44 -0
  56. data/lib/rubino/api/operations/cron_jobs/create_operation.rb +46 -0
  57. data/lib/rubino/api/operations/cron_jobs/delete_operation.rb +36 -0
  58. data/lib/rubino/api/operations/cron_jobs/list_operation.rb +55 -0
  59. data/lib/rubino/api/operations/cron_jobs/pause_operation.rb +34 -0
  60. data/lib/rubino/api/operations/cron_jobs/resume_operation.rb +34 -0
  61. data/lib/rubino/api/operations/cron_jobs/schedule_validation.rb +30 -0
  62. data/lib/rubino/api/operations/cron_jobs/show_operation.rb +32 -0
  63. data/lib/rubino/api/operations/cron_jobs/trigger_operation.rb +38 -0
  64. data/lib/rubino/api/operations/cron_jobs/update_operation.rb +42 -0
  65. data/lib/rubino/api/operations/files/read_operation.rb +40 -0
  66. data/lib/rubino/api/operations/files/upload_operation.rb +175 -0
  67. data/lib/rubino/api/operations/health_operation.rb +46 -0
  68. data/lib/rubino/api/operations/memory/delete_operation.rb +32 -0
  69. data/lib/rubino/api/operations/memory/index_operation.rb +80 -0
  70. data/lib/rubino/api/operations/memory/stats_operation.rb +28 -0
  71. data/lib/rubino/api/operations/metrics_operation.rb +18 -0
  72. data/lib/rubino/api/operations/mode/show_operation.rb +29 -0
  73. data/lib/rubino/api/operations/mode/update_operation.rb +42 -0
  74. data/lib/rubino/api/operations/models/list_operation.rb +45 -0
  75. data/lib/rubino/api/operations/oauth/connections/disconnect_operation.rb +77 -0
  76. data/lib/rubino/api/operations/oauth/connections/list_operation.rb +36 -0
  77. data/lib/rubino/api/operations/oauth/providers/callback_operation.rb +82 -0
  78. data/lib/rubino/api/operations/oauth/providers/connect_operation.rb +44 -0
  79. data/lib/rubino/api/operations/oauth/providers/list_operation.rb +35 -0
  80. data/lib/rubino/api/operations/oauth/serializer.rb +21 -0
  81. data/lib/rubino/api/operations/runs/create_operation.rb +77 -0
  82. data/lib/rubino/api/operations/runs/events_operation.rb +195 -0
  83. data/lib/rubino/api/operations/runs/stop_operation.rb +34 -0
  84. data/lib/rubino/api/operations/sessions/create_operation.rb +46 -0
  85. data/lib/rubino/api/operations/sessions/delete_operation.rb +33 -0
  86. data/lib/rubino/api/operations/sessions/index_operation.rb +82 -0
  87. data/lib/rubino/api/operations/sessions/retry_operation.rb +45 -0
  88. data/lib/rubino/api/operations/sessions/show_operation.rb +59 -0
  89. data/lib/rubino/api/operations/sessions/undo_operation.rb +38 -0
  90. data/lib/rubino/api/operations/skills/list_operation.rb +34 -0
  91. data/lib/rubino/api/operations/skills/toggle_operation.rb +40 -0
  92. data/lib/rubino/api/operations/tasks/index_operation.rb +30 -0
  93. data/lib/rubino/api/operations/tasks/serializer.rb +60 -0
  94. data/lib/rubino/api/operations/tasks/show_operation.rb +33 -0
  95. data/lib/rubino/api/operations/tasks/stop_operation.rb +47 -0
  96. data/lib/rubino/api/request.rb +54 -0
  97. data/lib/rubino/api/responses.rb +64 -0
  98. data/lib/rubino/api/router.rb +72 -0
  99. data/lib/rubino/api/schemas.rb +103 -0
  100. data/lib/rubino/api/server.rb +102 -0
  101. data/lib/rubino/api/tls.rb +108 -0
  102. data/lib/rubino/attachments/classification.rb +16 -0
  103. data/lib/rubino/attachments/classify.rb +171 -0
  104. data/lib/rubino/attachments/defang.rb +47 -0
  105. data/lib/rubino/attachments/policy.rb +36 -0
  106. data/lib/rubino/attachments/preamble.rb +120 -0
  107. data/lib/rubino/boot/encryption_key.rb +32 -0
  108. data/lib/rubino/cli/chat/bang_shell.rb +257 -0
  109. data/lib/rubino/cli/chat/completion_builder.rb +290 -0
  110. data/lib/rubino/cli/chat/idle_card_host.rb +69 -0
  111. data/lib/rubino/cli/chat/image_inbox.rb +168 -0
  112. data/lib/rubino/cli/chat/session_resolver.rb +176 -0
  113. data/lib/rubino/cli/chat_command.rb +1674 -0
  114. data/lib/rubino/cli/commands.rb +250 -0
  115. data/lib/rubino/cli/config_command.rb +96 -0
  116. data/lib/rubino/cli/doctor_command.rb +251 -0
  117. data/lib/rubino/cli/jobs_command.rb +60 -0
  118. data/lib/rubino/cli/memory_command.rb +135 -0
  119. data/lib/rubino/cli/onboarding_wizard.rb +207 -0
  120. data/lib/rubino/cli/server_command.rb +139 -0
  121. data/lib/rubino/cli/session_command.rb +125 -0
  122. data/lib/rubino/cli/setup_command.rb +107 -0
  123. data/lib/rubino/cli/skills_command.rb +85 -0
  124. data/lib/rubino/cli/tools_command.rb +81 -0
  125. data/lib/rubino/cli/trust_gate.rb +71 -0
  126. data/lib/rubino/commands/built_ins.rb +46 -0
  127. data/lib/rubino/commands/command.rb +116 -0
  128. data/lib/rubino/commands/executor.rb +550 -0
  129. data/lib/rubino/commands/handlers/agents.rb +510 -0
  130. data/lib/rubino/commands/handlers/config.rb +88 -0
  131. data/lib/rubino/commands/handlers/help.rb +148 -0
  132. data/lib/rubino/commands/handlers/jobs.rb +71 -0
  133. data/lib/rubino/commands/handlers/mcp.rb +229 -0
  134. data/lib/rubino/commands/handlers/memory.rb +200 -0
  135. data/lib/rubino/commands/handlers/sessions.rb +207 -0
  136. data/lib/rubino/commands/handlers/skills.rb +195 -0
  137. data/lib/rubino/commands/handlers/status.rb +211 -0
  138. data/lib/rubino/commands/loader.rb +90 -0
  139. data/lib/rubino/config/configuration.rb +455 -0
  140. data/lib/rubino/config/defaults.rb +569 -0
  141. data/lib/rubino/config/loader.rb +115 -0
  142. data/lib/rubino/config/reasoning_prefs.rb +67 -0
  143. data/lib/rubino/config/writer.rb +72 -0
  144. data/lib/rubino/context/compressor.rb +149 -0
  145. data/lib/rubino/context/environment_inspector.rb +176 -0
  146. data/lib/rubino/context/file_discovery.rb +45 -0
  147. data/lib/rubino/context/message_boundary.rb +39 -0
  148. data/lib/rubino/context/prompt_assembler.rb +382 -0
  149. data/lib/rubino/context/summary_builder.rb +159 -0
  150. data/lib/rubino/context/token_budget.rb +68 -0
  151. data/lib/rubino/context/tool_pair_sanitizer.rb +70 -0
  152. data/lib/rubino/database/connection.rb +77 -0
  153. data/lib/rubino/database/migrations/001_create_initial_schema.rb +156 -0
  154. data/lib/rubino/database/migrations/002_create_runs.rb +45 -0
  155. data/lib/rubino/database/migrations/003_create_skill_states.rb +15 -0
  156. data/lib/rubino/database/migrations/004_create_cron_jobs.rb +36 -0
  157. data/lib/rubino/database/migrations/005_create_oauth_connections.rb +27 -0
  158. data/lib/rubino/database/migrations/006_create_webhook_deliveries.rb +34 -0
  159. data/lib/rubino/database/migrations/007_create_messages_fts.rb +59 -0
  160. data/lib/rubino/database/migrations/008_create_memory_facts.rb +75 -0
  161. data/lib/rubino/database/migrations/009_create_memory_graph.rb +55 -0
  162. data/lib/rubino/database/migrations/010_add_owner_pid_to_sessions.rb +20 -0
  163. data/lib/rubino/database/migrator.rb +48 -0
  164. data/lib/rubino/documents/converters/csv.rb +79 -0
  165. data/lib/rubino/documents/converters/docx.rb +129 -0
  166. data/lib/rubino/documents/converters/html.rb +28 -0
  167. data/lib/rubino/documents/converters/json.rb +35 -0
  168. data/lib/rubino/documents/converters/pdf.rb +59 -0
  169. data/lib/rubino/documents/converters/plain.rb +68 -0
  170. data/lib/rubino/documents/converters/pptx.rb +64 -0
  171. data/lib/rubino/documents/converters/xlsx.rb +62 -0
  172. data/lib/rubino/documents/converters/xml.rb +45 -0
  173. data/lib/rubino/documents/html.rb +71 -0
  174. data/lib/rubino/documents/registry.rb +68 -0
  175. data/lib/rubino/documents/table.rb +63 -0
  176. data/lib/rubino/documents.rb +50 -0
  177. data/lib/rubino/errors.rb +119 -0
  178. data/lib/rubino/files/workspace.rb +93 -0
  179. data/lib/rubino/interaction/cancel_token.rb +43 -0
  180. data/lib/rubino/interaction/clipboard_image.rb +84 -0
  181. data/lib/rubino/interaction/event_bus.rb +48 -0
  182. data/lib/rubino/interaction/events.rb +101 -0
  183. data/lib/rubino/interaction/image_input.rb +127 -0
  184. data/lib/rubino/interaction/input_queue.rb +117 -0
  185. data/lib/rubino/interaction/lifecycle.rb +299 -0
  186. data/lib/rubino/interaction/probe.rb +65 -0
  187. data/lib/rubino/interaction/state.rb +56 -0
  188. data/lib/rubino/jobs/cron_job_repository.rb +75 -0
  189. data/lib/rubino/jobs/handlers/cleanup_sessions_job.rb +32 -0
  190. data/lib/rubino/jobs/handlers/compact_session_job.rb +21 -0
  191. data/lib/rubino/jobs/handlers/distill_skill_job.rb +186 -0
  192. data/lib/rubino/jobs/handlers/extract_memory_job.rb +37 -0
  193. data/lib/rubino/jobs/handlers/summarize_session_job.rb +21 -0
  194. data/lib/rubino/jobs/queue.rb +184 -0
  195. data/lib/rubino/jobs/registry.rb +45 -0
  196. data/lib/rubino/jobs/runner.rb +79 -0
  197. data/lib/rubino/jobs/scheduler.rb +138 -0
  198. data/lib/rubino/jobs/webhook_delivery.rb +225 -0
  199. data/lib/rubino/jobs/worker.rb +59 -0
  200. data/lib/rubino/llm/adapter_factory.rb +47 -0
  201. data/lib/rubino/llm/adapter_response.rb +65 -0
  202. data/lib/rubino/llm/auxiliary_client.rb +61 -0
  203. data/lib/rubino/llm/bedrock_bearer_client.rb +235 -0
  204. data/lib/rubino/llm/content_builder.rb +55 -0
  205. data/lib/rubino/llm/credential_check.rb +93 -0
  206. data/lib/rubino/llm/error_classifier.rb +364 -0
  207. data/lib/rubino/llm/fake_provider.rb +292 -0
  208. data/lib/rubino/llm/inline_think_filter.rb +58 -0
  209. data/lib/rubino/llm/model_catalog.rb +29 -0
  210. data/lib/rubino/llm/provider_resolver.rb +48 -0
  211. data/lib/rubino/llm/reasoning_manager.rb +100 -0
  212. data/lib/rubino/llm/request.rb +56 -0
  213. data/lib/rubino/llm/ruby_llm_adapter.rb +794 -0
  214. data/lib/rubino/llm/scenario_loader.rb +68 -0
  215. data/lib/rubino/llm/scenario_selector.rb +80 -0
  216. data/lib/rubino/llm/scenarios/agent-creates-cron-failure.yml +29 -0
  217. data/lib/rubino/llm/scenarios/agent-creates-cron.yml +36 -0
  218. data/lib/rubino/llm/scenarios/analysis.yml +501 -0
  219. data/lib/rubino/llm/scenarios/complex-analysis.yml +598 -0
  220. data/lib/rubino/llm/scenarios/failure.yml +65 -0
  221. data/lib/rubino/llm/scenarios/happy-path.yml +24 -0
  222. data/lib/rubino/llm/scenarios/provider-quota-completed.yml +14 -0
  223. data/lib/rubino/llm/scenarios/wide-table.yml +121 -0
  224. data/lib/rubino/llm/scenarios/with-approvals.yml +50 -0
  225. data/lib/rubino/llm/scenarios/with-artifacts.yml +98 -0
  226. data/lib/rubino/llm/scenarios/with-clarify.yml +32 -0
  227. data/lib/rubino/llm/scenarios/with-reasoning.yml +175 -0
  228. data/lib/rubino/llm/scenarios/with-uploads.yml +104 -0
  229. data/lib/rubino/llm/thinking_support.rb +84 -0
  230. data/lib/rubino/llm/tool_bridge.rb +89 -0
  231. data/lib/rubino/logger.rb +99 -0
  232. data/lib/rubino/mcp/manager.rb +180 -0
  233. data/lib/rubino/mcp/mcp_tool_wrapper.rb +69 -0
  234. data/lib/rubino/mcp.rb +57 -0
  235. data/lib/rubino/memory/backend.rb +104 -0
  236. data/lib/rubino/memory/backends/default.rb +101 -0
  237. data/lib/rubino/memory/backends/sqlite.rb +653 -0
  238. data/lib/rubino/memory/backends.rb +53 -0
  239. data/lib/rubino/memory/deduplicator.rb +74 -0
  240. data/lib/rubino/memory/extractor.rb +85 -0
  241. data/lib/rubino/memory/flusher.rb +31 -0
  242. data/lib/rubino/memory/retriever.rb +50 -0
  243. data/lib/rubino/memory/sqlite_extraction_prompt.rb +70 -0
  244. data/lib/rubino/memory/sqlite_graph.rb +154 -0
  245. data/lib/rubino/memory/store.rb +228 -0
  246. data/lib/rubino/memory/threat_scanner.rb +68 -0
  247. data/lib/rubino/metrics.rb +175 -0
  248. data/lib/rubino/modes.rb +93 -0
  249. data/lib/rubino/oauth/connection_repository.rb +95 -0
  250. data/lib/rubino/oauth/provider/github.rb +75 -0
  251. data/lib/rubino/oauth/provider/google.rb +59 -0
  252. data/lib/rubino/oauth/provider.rb +149 -0
  253. data/lib/rubino/oauth/registry.rb +86 -0
  254. data/lib/rubino/oauth/token_encryptor.rb +87 -0
  255. data/lib/rubino/plugins/registry.rb +75 -0
  256. data/lib/rubino/plugins.rb +86 -0
  257. data/lib/rubino/run/approval_gate.rb +243 -0
  258. data/lib/rubino/run/attachment_downloader.rb +166 -0
  259. data/lib/rubino/run/event_store.rb +74 -0
  260. data/lib/rubino/run/executor.rb +383 -0
  261. data/lib/rubino/run/gate_registry.rb +39 -0
  262. data/lib/rubino/run/recorder.rb +69 -0
  263. data/lib/rubino/run/repository.rb +118 -0
  264. data/lib/rubino/run/session_approval_cache.rb +118 -0
  265. data/lib/rubino/security/allowlist_persister.rb +55 -0
  266. data/lib/rubino/security/approval_policy.rb +227 -0
  267. data/lib/rubino/security/command_allowlist.rb +24 -0
  268. data/lib/rubino/security/dangerous_patterns.rb +118 -0
  269. data/lib/rubino/security/deny_persister.rb +73 -0
  270. data/lib/rubino/security/doom_loop_detector.rb +43 -0
  271. data/lib/rubino/security/hardline_guard.rb +105 -0
  272. data/lib/rubino/security/pattern_matcher.rb +62 -0
  273. data/lib/rubino/security/prefix_deriver.rb +124 -0
  274. data/lib/rubino/security/readonly_commands.rb +211 -0
  275. data/lib/rubino/session/exporter.rb +101 -0
  276. data/lib/rubino/session/message.rb +77 -0
  277. data/lib/rubino/session/repository.rb +295 -0
  278. data/lib/rubino/session/store.rb +198 -0
  279. data/lib/rubino/session/summary_store.rb +65 -0
  280. data/lib/rubino/skills/prompt_index.rb +85 -0
  281. data/lib/rubino/skills/registry.rb +208 -0
  282. data/lib/rubino/skills/skill.rb +176 -0
  283. data/lib/rubino/skills/skill_tool.rb +215 -0
  284. data/lib/rubino/skills/state_repository.rb +37 -0
  285. data/lib/rubino/skills/toggle.rb +26 -0
  286. data/lib/rubino/tools/answer_child_tool.rb +83 -0
  287. data/lib/rubino/tools/ask_parent_tool.rb +232 -0
  288. data/lib/rubino/tools/attach_file_tool.rb +120 -0
  289. data/lib/rubino/tools/background_tasks.rb +520 -0
  290. data/lib/rubino/tools/base.rb +222 -0
  291. data/lib/rubino/tools/custom_tool_loader.rb +119 -0
  292. data/lib/rubino/tools/edit_tool.rb +122 -0
  293. data/lib/rubino/tools/git_tool.rb +71 -0
  294. data/lib/rubino/tools/github_tool.rb +233 -0
  295. data/lib/rubino/tools/glob_tool.rb +69 -0
  296. data/lib/rubino/tools/grep_tool.rb +206 -0
  297. data/lib/rubino/tools/memory_tool.rb +184 -0
  298. data/lib/rubino/tools/multi_edit_tool.rb +110 -0
  299. data/lib/rubino/tools/patch_tool.rb +260 -0
  300. data/lib/rubino/tools/probe_tool.rb +175 -0
  301. data/lib/rubino/tools/question_tool.rb +128 -0
  302. data/lib/rubino/tools/read_attachment_tool.rb +180 -0
  303. data/lib/rubino/tools/read_tool.rb +212 -0
  304. data/lib/rubino/tools/read_tracker.rb +98 -0
  305. data/lib/rubino/tools/registry.rb +166 -0
  306. data/lib/rubino/tools/result.rb +113 -0
  307. data/lib/rubino/tools/ruby_tool.rb +0 -0
  308. data/lib/rubino/tools/session_search_tool.rb +103 -0
  309. data/lib/rubino/tools/shell_input_tool.rb +96 -0
  310. data/lib/rubino/tools/shell_kill_tool.rb +76 -0
  311. data/lib/rubino/tools/shell_output_tool.rb +72 -0
  312. data/lib/rubino/tools/shell_registry.rb +158 -0
  313. data/lib/rubino/tools/shell_tail_tool.rb +118 -0
  314. data/lib/rubino/tools/shell_tool.rb +330 -0
  315. data/lib/rubino/tools/steer_tool.rb +118 -0
  316. data/lib/rubino/tools/subagent_probe.rb +89 -0
  317. data/lib/rubino/tools/summarize_file_tool.rb +182 -0
  318. data/lib/rubino/tools/task_result_tool.rb +90 -0
  319. data/lib/rubino/tools/task_stop_tool.rb +80 -0
  320. data/lib/rubino/tools/task_tool.rb +622 -0
  321. data/lib/rubino/tools/test_tool.rb +454 -0
  322. data/lib/rubino/tools/todo_tool.rb +93 -0
  323. data/lib/rubino/tools/tool_call_repository.rb +33 -0
  324. data/lib/rubino/tools/vision_tool.rb +85 -0
  325. data/lib/rubino/tools/webfetch_tool.rb +153 -0
  326. data/lib/rubino/tools/websearch_tool.rb +179 -0
  327. data/lib/rubino/tools/write_tool.rb +61 -0
  328. data/lib/rubino/trust.rb +88 -0
  329. data/lib/rubino/ui/api.rb +296 -0
  330. data/lib/rubino/ui/base.rb +252 -0
  331. data/lib/rubino/ui/bottom_composer.rb +1599 -0
  332. data/lib/rubino/ui/cli.rb +1987 -0
  333. data/lib/rubino/ui/completion_menu.rb +321 -0
  334. data/lib/rubino/ui/completion_source.rb +284 -0
  335. data/lib/rubino/ui/escape_reader.rb +169 -0
  336. data/lib/rubino/ui/indented_io.rb +88 -0
  337. data/lib/rubino/ui/input_history.rb +108 -0
  338. data/lib/rubino/ui/live_region.rb +183 -0
  339. data/lib/rubino/ui/markdown_renderer.rb +506 -0
  340. data/lib/rubino/ui/notifier.rb +163 -0
  341. data/lib/rubino/ui/null.rb +195 -0
  342. data/lib/rubino/ui/paste_store.rb +176 -0
  343. data/lib/rubino/ui/printer_base.rb +79 -0
  344. data/lib/rubino/ui/probe_wait_indicator.rb +75 -0
  345. data/lib/rubino/ui/queued_indicators.rb +66 -0
  346. data/lib/rubino/ui/status_bar.rb +100 -0
  347. data/lib/rubino/ui/stdout_proxy.rb +161 -0
  348. data/lib/rubino/ui/streaming_markdown.rb +186 -0
  349. data/lib/rubino/ui/subagent_cards.rb +134 -0
  350. data/lib/rubino/ui/subagent_view.rb +255 -0
  351. data/lib/rubino/ui.rb +21 -0
  352. data/lib/rubino/update_check.rb +187 -0
  353. data/lib/rubino/util/duration.rb +23 -0
  354. data/lib/rubino/util/hyperlink.rb +105 -0
  355. data/lib/rubino/util/output.rb +145 -0
  356. data/lib/rubino/util/secrets_mask.rb +83 -0
  357. data/lib/rubino/version.rb +5 -0
  358. data/lib/rubino/workspace.rb +85 -0
  359. data/lib/rubino-agent.rb +5 -0
  360. data/lib/rubino.rb +318 -0
  361. data/mise.toml +2 -0
  362. data/rubino-agent.gemspec +103 -0
  363. data/skills/ruby-expert/SKILL.md +67 -0
  364. data/skills/ruby-expert/references/concurrency.md +357 -0
  365. data/skills/ruby-expert/references/datetime-and-encoding.md +363 -0
  366. data/skills/ruby-expert/references/errors-and-types.md +460 -0
  367. data/skills/ruby-expert/references/gem-authoring.md +459 -0
  368. data/skills/ruby-expert/references/language-idioms.md +465 -0
  369. data/skills/ruby-expert/references/metaprogramming.md +339 -0
  370. data/skills/ruby-expert/references/oo-design.md +553 -0
  371. data/skills/ruby-expert/references/performance.md +383 -0
  372. data/skills/ruby-expert/references/rails.md +424 -0
  373. data/skills/ruby-expert/references/security.md +404 -0
  374. data/skills/ruby-expert/references/testing.md +473 -0
  375. data/skills/ruby-expert/references/tooling.md +466 -0
  376. metadata +856 -0
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Skills
5
+ # Discovers and manages skills from configured paths.
6
+ # Skills are loaded lazily - metadata is parsed upfront but
7
+ # full content is only loaded when the skill is invoked.
8
+ class Registry
9
+ # Flat-file skills: <dir>/<name>.md (legacy, kept for back-compat).
10
+ FLAT_GLOB = "*.md"
11
+ # Directory skills: <dir>/<name>/SKILL.md (Claude skill layout).
12
+ DIR_GLOB = File.join("*", "SKILL.md")
13
+
14
+ # Skills shipped *inside the gem* (skills/<name>/SKILL.md at the gem
15
+ # root, packaged via the gemspec's git-ls-files list). These are
16
+ # ALWAYS discovered — they don't depend on the user's skills.paths
17
+ # config (which `setup` freezes into config.yml) and they survive the
18
+ # folder-trust filter because this is an absolute path under the
19
+ # installed gem, owned by the user, not anything a visited repo can
20
+ # influence. This is how built-in skills (e.g. ruby-expert) reach every
21
+ # install with no copy step and update automatically on gem upgrade.
22
+ BUILTIN_SKILLS_DIR = File.expand_path("../../../skills", __dir__)
23
+
24
+ # +include_project_local+ controls whether the cwd `.rubino/skills`
25
+ # catalogue is discovered. Folder-trust passes false for an UNtrusted
26
+ # primary root so a hostile repo's skill descriptions can't be auto-
27
+ # injected into the system prompt before the user vouches for the folder
28
+ # (the home `~/.rubino/skills` catalogue is always loaded — it's the
29
+ # user's own, not attacker-controllable by cd-ing into a repo).
30
+ # +include_builtin+ controls whether the gem-bundled BUILTIN_SKILLS_DIR is
31
+ # scanned. Always on in production (built-ins ship with every install).
32
+ # When left nil it falls back to the `skills.include_builtin` config key
33
+ # (default true), so a caller that only has the config — like the prompt
34
+ # assembler, which builds its own Registry — can still opt out; tests that
35
+ # assert an exact catalogue pass false to isolate from the shipped skills.
36
+ def initialize(config: nil, state_repository: nil, include_project_local: true, include_builtin: nil)
37
+ @config = config || Rubino.configuration
38
+ @state_repository = state_repository
39
+ @include_project_local = include_project_local
40
+ @include_builtin = include_builtin.nil? ? (@config.dig("skills", "include_builtin") != false) : include_builtin
41
+ @skills = {}
42
+ @discovered = false
43
+ end
44
+
45
+ # A registry aligned with the prompt assembler's folder-trust gate (#63):
46
+ # in an untrusted cwd the project-local catalogue is excluded, so the
47
+ # /skills picker and activation surface only skills the assembler will
48
+ # actually pin into the system prompt — never a chip claiming an active
49
+ # skill whose SKILL.md is withheld.
50
+ def self.trusted(**)
51
+ new(include_project_local: project_local_trusted?, **)
52
+ end
53
+
54
+ # Mirrors Context::PromptAssembler#project_local_trusted?: trust-gate the
55
+ # cwd, but never let the check itself break discovery on a real error.
56
+ def self.project_local_trusted?
57
+ Rubino::Trust.trusted?(Rubino::Workspace.primary_root)
58
+ rescue StandardError
59
+ true
60
+ end
61
+
62
+ # Discovers all available skills from configured paths. Both the flat
63
+ # layout (<name>.md) and the directory layout (<name>/SKILL.md) are
64
+ # supported. When a name collides, the directory skill wins (it is the
65
+ # richer unit: it can carry bundled references/scripts/assets).
66
+ def discover!
67
+ previously_discovered = @discovered
68
+ known_before = @skills.keys
69
+ @skills.clear
70
+ skill_paths.each do |dir|
71
+ expanded = resolve_path(dir)
72
+ next unless File.directory?(expanded)
73
+
74
+ add_skills(Dir.glob(File.join(expanded, FLAT_GLOB)))
75
+ add_skills(Dir.glob(File.join(expanded, DIR_GLOB)))
76
+ end
77
+ @discovered = true
78
+ # Skill CREATION has no in-process tool — the agent writes files — so the
79
+ # cleanest available signal is a RE-scan surfacing a skill we hadn't seen
80
+ # before. Only count on a re-discover (not the first scan, which is just
81
+ # initial enumeration) so existing skills aren't booked as "created".
82
+ count_created!(known_before) if previously_discovered
83
+ @skills
84
+ end
85
+
86
+ # Returns all discovered skills (discovers on first call)
87
+ def all
88
+ discover! unless @discovered
89
+ @skills.values
90
+ end
91
+
92
+ # Finds a skill by name
93
+ def find(name)
94
+ discover! unless @discovered
95
+ @skills[name.to_s]
96
+ end
97
+
98
+ # Returns skill summaries for prompt inclusion (names + descriptions only).
99
+ # Disabled skills (per StateRepository) are excluded so a skill toggled
100
+ # off never appears in the system-prompt index (Skills::PromptIndex).
101
+ def summaries
102
+ enabled.map(&:summary)
103
+ end
104
+
105
+ # Loads and returns the full content of a skill by name. Returns nil when
106
+ # the skill is unknown; the disabled case is surfaced by #enabled? so the
107
+ # caller (SkillTool) can give a distinct "disabled" message.
108
+ def load_skill(name)
109
+ skill = find(name)
110
+ return nil unless skill
111
+
112
+ skill.content
113
+ end
114
+
115
+ # Returns skill names
116
+ def names
117
+ all.map(&:name)
118
+ end
119
+
120
+ # Skills not toggled off in the StateRepository (default-enabled when no
121
+ # row exists). Single source of truth for the enabled-filter shared by the
122
+ # system-prompt index (via #summaries) and the `skill` tool (via this).
123
+ def enabled
124
+ all.select { |skill| enabled?(skill.name) }
125
+ end
126
+
127
+ # Whether a skill is enabled (default-enabled when no state row exists).
128
+ def enabled?(name)
129
+ state_repository.enabled?(name)
130
+ end
131
+
132
+ private
133
+
134
+ # Increments +skills_created_total+ once per skill name that appears in a
135
+ # re-scan but was absent from the prior scan. NOTE: this is the only clean
136
+ # in-process creation signal available (there is no skill-creation tool);
137
+ # a skill created on disk is therefore counted the next time the registry
138
+ # re-discovers, not at write time. A skill removed and re-added would be
139
+ # re-counted — acceptable for a usage signal, not a ledger.
140
+ def count_created!(known_before)
141
+ new_names = @skills.keys - known_before
142
+ return if new_names.empty?
143
+
144
+ Rubino::Metrics.counter(:skills_created_total).increment(by: new_names.size)
145
+ end
146
+
147
+ def state_repository
148
+ @state_repository ||= StateRepository.new
149
+ end
150
+
151
+ # Resolves a configured skills dir to an absolute path. The stock
152
+ # "~/.rubino/..." entries follow the resolved home (RUBINO_HOME → else
153
+ # ~/.rubino), same resolver config/.env/DB/commands use, so an isolated
154
+ # home actually has its skills discovered (#135) instead of the literal
155
+ # path expanding against the REAL home. Any other path expands verbatim.
156
+ def resolve_path(dir)
157
+ if dir.to_s == "~/.rubino" || dir.to_s.start_with?("~/.rubino/")
158
+ File.join(Config::Loader.default_home_path, dir.to_s.delete_prefix("~/.rubino"))
159
+ else
160
+ File.expand_path(dir)
161
+ end
162
+ end
163
+
164
+ # Builds a Skill per path and indexes it by name. Called with flat paths
165
+ # first, then directory paths, so directory skills override flat ones on
166
+ # a name collision (see #discover!).
167
+ def add_skills(paths)
168
+ paths.each do |path|
169
+ skill = Skill.new(path: path)
170
+ @skills[skill.name] = skill
171
+ end
172
+ end
173
+
174
+ def skill_paths
175
+ paths = @config.dig("skills", "paths") || [
176
+ ".rubino/skills",
177
+ "~/.rubino/skills"
178
+ ]
179
+ unless @include_project_local
180
+ # Untrusted primary root: drop the project-local (cwd-relative) skill
181
+ # dirs, keeping only absolute / home (~) paths the user controls.
182
+ paths = paths.reject { |p| project_local_path?(p) }
183
+ end
184
+
185
+ # Built-in (gem-bundled) skills are scanned FIRST so a user skill of the
186
+ # same name — discovered later in .rubino/skills or ~/.rubino/skills —
187
+ # overrides the built-in on the registry's name-indexed merge (last
188
+ # writer wins in #add_skills). That lets a user shadow/customize a
189
+ # shipped skill while still getting the built-ins for free by default.
190
+ @include_builtin ? [BUILTIN_SKILLS_DIR, *paths] : paths
191
+ end
192
+
193
+ # A skill path is "project-local" when it resolves under the primary
194
+ # workspace root (the cwd a hostile repo could ship skills in), as
195
+ # opposed to an absolute or ~/.rubino path the user owns.
196
+ def project_local_path?(path)
197
+ return false if path.to_s.start_with?("~", "/")
198
+
199
+ expanded = File.expand_path(path.to_s)
200
+ root = File.expand_path(Workspace.primary_root)
201
+ expanded == root || expanded.start_with?("#{root}#{File::SEPARATOR}")
202
+ rescue StandardError
203
+ # Conservative: if we can't tell, treat as project-local and drop it.
204
+ true
205
+ end
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Rubino
6
+ module Skills
7
+ # Represents a single skill. Two layouts are supported:
8
+ # * flat file — <dir>/<name>.md (the skill name is the basename)
9
+ # * directory — <dir>/<name>/SKILL.md (the skill name is the dir name,
10
+ # plus bundled files under references/ scripts/ assets/ etc.)
11
+ #
12
+ # In both cases `path` points at the markdown body that carries the
13
+ # name/description frontmatter. Directory skills also expose `linked_files`
14
+ # (relative paths of bundled files) and can read a specific bundled file
15
+ # sandboxed to the skill's own directory.
16
+ class Skill
17
+ attr_reader :name, :description, :path, :metadata, :linked_files
18
+
19
+ def initialize(path:)
20
+ @path = path
21
+ @metadata = {}
22
+ @content = nil
23
+ @linked_files = []
24
+ @directory = directory_skill? ? File.dirname(path) : nil
25
+ discover_linked_files! if directory?
26
+ parse_frontmatter!
27
+ end
28
+
29
+ # True when this skill is backed by a <name>/SKILL.md directory.
30
+ def directory?
31
+ !@directory.nil?
32
+ end
33
+
34
+ # The skill's own directory (only for directory skills).
35
+ def dir
36
+ @directory
37
+ end
38
+
39
+ # Returns the full skill content (loaded lazily)
40
+ def content
41
+ @content ||= load_content
42
+ end
43
+
44
+ # Returns true if the skill has been fully loaded
45
+ def loaded?
46
+ !@content.nil?
47
+ end
48
+
49
+ # Reads a bundled file by its relative path, sandboxed to the skill dir.
50
+ # Returns the file contents, or nil if the skill has no directory, the
51
+ # path escapes the skill dir, or the file does not exist.
52
+ #
53
+ # Resolve and read happen back-to-back with no listing step in between, so
54
+ # the caller can't observe a "present in the listing but unreadable" state
55
+ # from THIS method. A File::ENOENT between #file? and #read (the skill dir
56
+ # being torn down mid-call) is swallowed to nil rather than raised, so a
57
+ # concurrent teardown reads as a clean miss instead of a crash (W3).
58
+ def read_file(relative_path)
59
+ return nil unless directory?
60
+
61
+ resolved = resolve_within_dir(relative_path)
62
+ return nil unless resolved && File.file?(resolved)
63
+
64
+ File.read(resolved)
65
+ rescue Errno::ENOENT, Errno::EACCES
66
+ nil
67
+ end
68
+
69
+ # Live relative paths of bundled files, recomputed from disk. Unlike the
70
+ # +linked_files+ snapshot taken at init, this reflects the current dir
71
+ # state — so an error message built from it can't list a file that
72
+ # #read_file just failed to find (the W3 self-contradiction). Empty for
73
+ # flat-file skills.
74
+ def current_linked_files
75
+ return [] unless directory?
76
+
77
+ collect_linked_files
78
+ end
79
+
80
+ # Returns a summary for the agent to see available skills
81
+ def summary
82
+ "#{@name}: #{@description}"
83
+ end
84
+
85
+ private
86
+
87
+ def directory_skill?
88
+ File.basename(@path) == "SKILL.md"
89
+ end
90
+
91
+ # Caches the init-time snapshot in +@linked_files+ (the system-prompt
92
+ # hint shows this). The live recompute path uses #collect_linked_files
93
+ # directly so the two never drift in logic.
94
+ def discover_linked_files!
95
+ @linked_files = collect_linked_files
96
+ end
97
+
98
+ # Relative paths of bundled files under the skill dir, excluding SKILL.md
99
+ # itself and vcs/junk dirs. Sorted for deterministic output. Re-globs the
100
+ # directory on every call, so it reflects current disk state.
101
+ def collect_linked_files
102
+ files = Dir.glob(File.join(@directory, "**", "*"), File::FNM_DOTMATCH).filter_map do |entry|
103
+ next unless File.file?(entry)
104
+
105
+ rel = entry.delete_prefix("#{@directory}#{File::SEPARATOR}")
106
+ next if rel == "SKILL.md"
107
+ next if rel.split(File::SEPARATOR).any? { |seg| EXCLUDED_DIRS.include?(seg) }
108
+
109
+ rel
110
+ end
111
+ files.sort
112
+ end
113
+
114
+ # Resolves a relative path against the skill dir and rejects anything that
115
+ # escapes it (via .., absolute paths, or symlinks pointing outside).
116
+ def resolve_within_dir(relative_path)
117
+ return nil if relative_path.nil? || relative_path.to_s.empty?
118
+
119
+ root = File.realpath(@directory)
120
+ target = File.expand_path(relative_path.to_s, root)
121
+ candidate = File.exist?(target) ? File.realpath(target) : target
122
+
123
+ return nil unless candidate == root || candidate.start_with?("#{root}#{File::SEPARATOR}")
124
+
125
+ candidate
126
+ rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
127
+ nil
128
+ end
129
+
130
+ def parse_frontmatter!
131
+ raw = File.read(@path)
132
+
133
+ if raw.start_with?("---")
134
+ parts = raw.split("---", 3)
135
+ if parts.size >= 3
136
+ begin
137
+ @metadata = YAML.safe_load(parts[1], permitted_classes: [Symbol]) || {}
138
+ rescue Psych::SyntaxError => e
139
+ warn "rubino: skipping malformed frontmatter in #{@path} " \
140
+ "(line #{e.line}: #{e.problem})"
141
+ @metadata = {}
142
+ end
143
+ @metadata = {} unless @metadata.is_a?(Hash)
144
+ @name = (@metadata["name"] || default_name).to_s
145
+ @description = @metadata["description"] || ""
146
+ else
147
+ @name = default_name
148
+ @description = ""
149
+ end
150
+ else
151
+ @name = default_name
152
+ @description = raw.lines.first&.strip&.sub(/^#\s*/, "") || ""
153
+ end
154
+ end
155
+
156
+ # For a directory skill the name is the directory name; for a flat file
157
+ # it is the markdown basename.
158
+ def default_name
159
+ directory? ? File.basename(@directory) : File.basename(@path, ".md")
160
+ end
161
+
162
+ def load_content
163
+ raw = File.read(@path)
164
+
165
+ if raw.start_with?("---")
166
+ parts = raw.split("---", 3)
167
+ parts.size >= 3 ? parts[2].strip : raw
168
+ else
169
+ raw
170
+ end
171
+ end
172
+
173
+ EXCLUDED_DIRS = %w[.git .svn .hg node_modules __pycache__ .DS_Store].freeze
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Rubino
6
+ module Skills
7
+ # Tool that allows the agent to load a skill on demand, and (Variant A —
8
+ # reference-style affordance) to CREATE a new skill inline during the turn.
9
+ #
10
+ # The agent sees skill names/descriptions in the system prompt and can invoke
11
+ # this tool to load the full skill instructions into context, or — after a
12
+ # complex, repeatable task — to distil what it just did into a new skill with
13
+ # action: "create" (0 extra LLM calls; the create happens inline on the
14
+ # tool-call the model already emitted).
15
+ class SkillTool < Tools::Base
16
+ # kebab-case, <=64 chars, mirrors the skill-creator frontmatter contract.
17
+ NAME_RE = /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/
18
+
19
+ def initialize(registry: nil)
20
+ @registry = registry || Registry.new
21
+ end
22
+
23
+ def name
24
+ "skill"
25
+ end
26
+
27
+ def description
28
+ "Load a specialized skill's instructions into context, or create a new " \
29
+ "skill. action defaults to \"load\": use it when a task matches one of " \
30
+ "the available skills listed under \"## Skills\" in the system prompt " \
31
+ "(pass file_path to load a bundled file). After finishing a complex, " \
32
+ "multi-step task (typically 5+ tool calls) that is likely to recur and " \
33
+ "isn't already covered, call action: \"create\" with name, description, " \
34
+ "and body to save it as a reusable skill."
35
+ end
36
+
37
+ def input_schema
38
+ {
39
+ type: "object",
40
+ properties: {
41
+ action: {
42
+ type: "string",
43
+ enum: %w[load create],
44
+ description: "\"load\" (default) loads an existing skill; " \
45
+ "\"create\" writes a new skill from name/description/body."
46
+ },
47
+ name: {
48
+ type: "string",
49
+ description: "The skill name. For load: the skill to load. " \
50
+ "For create: a kebab-case name (<=64 chars)."
51
+ },
52
+ file_path: {
53
+ type: "string",
54
+ description: "Optional (load only). Relative path of a bundled file within the " \
55
+ "skill (e.g. 'references/api.md', 'scripts/run.py') to load " \
56
+ "its contents. Use the linked_files listed when the skill " \
57
+ "body is first loaded."
58
+ },
59
+ description: {
60
+ type: "string",
61
+ description: "Required for create. One line: what the skill is for and WHEN " \
62
+ "it applies (the only text future runs see before loading it)."
63
+ },
64
+ body: {
65
+ type: "string",
66
+ description: "Required for create. The markdown body: proven step-by-step " \
67
+ "instructions, commands, and pitfalls. Be specific and prescriptive."
68
+ }
69
+ },
70
+ required: %w[name]
71
+ }
72
+ end
73
+
74
+ def risk_level
75
+ :low
76
+ end
77
+
78
+ # action: "load" (default) — three-level progressive disclosure:
79
+ # skill(name) -> Level 2: SKILL.md body
80
+ # skill(name, file_path: "ref.md") -> Level 3: one bundled file
81
+ # action: "create" — write a new <name>/SKILL.md inline (Variant A).
82
+ def call(arguments)
83
+ action = (arguments["action"] || arguments[:action] || "load").to_s
84
+ return create(arguments) if action == "create"
85
+
86
+ skill_name = arguments["name"] || arguments[:name]
87
+ file_path = arguments["file_path"] || arguments[:file_path]
88
+
89
+ skill = @registry.find(skill_name)
90
+ return not_found(skill_name) unless skill
91
+ return disabled(skill_name) unless @registry.enabled?(skill_name)
92
+
93
+ return load_bundled_file(skill, skill_name, file_path) if file_path && !file_path.to_s.empty?
94
+
95
+ load_body(skill, skill_name)
96
+ end
97
+
98
+ private
99
+
100
+ # ---- create (Variant A: inline, 0 extra LLM calls) --------------------
101
+
102
+ def create(arguments)
103
+ skill_name = (arguments["name"] || arguments[:name]).to_s.strip
104
+ description = (arguments["description"] || arguments[:description]).to_s.strip
105
+ body = (arguments["body"] || arguments[:body]).to_s
106
+
107
+ err = validate_create(skill_name, description, body)
108
+ return err if err
109
+
110
+ return duplicate(skill_name) if @registry.find(skill_name)
111
+
112
+ path = write_skill(skill_name, description, body)
113
+ # Re-discover so the new skill is immediately usable. The disk-diff in
114
+ # Registry#discover! is the SINGLE source of truth for
115
+ # skills_created_total — it books the just-written skill on this re-scan,
116
+ # so we must NOT increment the counter inline here too (that would
117
+ # double-count one creation).
118
+ @registry.discover!
119
+ Rubino.active_event_bus&.emit(
120
+ Interaction::Events::SKILL_CREATED,
121
+ name: skill_name, file_path: path
122
+ )
123
+ "Created skill '#{skill_name}' at #{path}. It is now available to load " \
124
+ "with skill(name: \"#{skill_name}\")."
125
+ rescue StandardError => e
126
+ "Could not create skill '#{skill_name}': #{e.message}"
127
+ end
128
+
129
+ def validate_create(skill_name, description, body)
130
+ return "Cannot create skill: name is required." if skill_name.empty?
131
+ unless skill_name.match?(NAME_RE) && skill_name.length <= 64
132
+ return "Cannot create skill: name must be kebab-case (lowercase letters, " \
133
+ "digits, hyphens) and <=64 chars; got #{skill_name.inspect}."
134
+ end
135
+ return "Cannot create skill: description is required." if description.empty?
136
+ return "Cannot create skill: description must be <=1024 chars." if description.length > 1024
137
+ return "Cannot create skill: body is required." if body.strip.empty?
138
+
139
+ nil
140
+ end
141
+
142
+ def write_skill(skill_name, description, body)
143
+ dir = File.join(skills_write_dir, skill_name)
144
+ FileUtils.mkdir_p(dir)
145
+ path = File.join(dir, "SKILL.md")
146
+ content = "---\nname: #{skill_name}\ndescription: #{yaml_scalar(description)}\n---\n\n"
147
+ content << body
148
+ content << "\n" unless content.end_with?("\n")
149
+ File.write(path, content)
150
+ path
151
+ end
152
+
153
+ # Quote the description so a colon/newline can't break the YAML frontmatter.
154
+ def yaml_scalar(text)
155
+ one_line = text.tr("\n", " ").strip
156
+ %("#{one_line.gsub('"', '\\"')}")
157
+ end
158
+
159
+ # First configured skills path (project-local .rubino/skills by
160
+ # default) — the same source the Registry discovers from, so a created
161
+ # skill is found on the immediate re-discover.
162
+ def skills_write_dir
163
+ dir = (Rubino.configuration.dig("skills", "paths") || [".rubino/skills"]).first
164
+ File.expand_path(dir.to_s)
165
+ end
166
+
167
+ def duplicate(skill_name)
168
+ "A skill named '#{skill_name}' already exists; not overwriting. " \
169
+ "Pick a different name or load the existing one with skill(name: \"#{skill_name}\")."
170
+ end
171
+
172
+ # ---- load (unchanged) -------------------------------------------------
173
+
174
+ def load_body(skill, skill_name)
175
+ body = "Skill '#{skill_name}' loaded:\n\n#{skill.content}"
176
+ body << linked_files_hint(skill, skill_name) unless skill.linked_files.empty?
177
+ announce_loaded(skill_name)
178
+ body
179
+ end
180
+
181
+ def announce_loaded(skill_name)
182
+ Metrics.counter(:skills_loaded_total).increment
183
+ Rubino.active_event_bus&.emit(
184
+ Interaction::Events::SKILL_LOADED,
185
+ name: skill_name
186
+ )
187
+ end
188
+
189
+ def linked_files_hint(skill, skill_name)
190
+ listing = skill.linked_files.map { |f| " - #{f}" }.join("\n")
191
+ "\n\nBundled files (load with skill(name: \"#{skill_name}\", file_path: \"...\")):\n#{listing}"
192
+ end
193
+
194
+ def load_bundled_file(skill, skill_name, file_path)
195
+ contents = skill.read_file(file_path)
196
+ if contents
197
+ "Skill '#{skill_name}' file '#{file_path}':\n\n#{contents}"
198
+ else
199
+ available = skill.current_linked_files.join(", ")
200
+ "File '#{file_path}' not found in skill '#{skill_name}'. " \
201
+ "Available files: #{available.empty? ? "(none)" : available}"
202
+ end
203
+ end
204
+
205
+ def not_found(skill_name)
206
+ available = @registry.names.join(", ")
207
+ "Skill '#{skill_name}' not found. Available skills: #{available}"
208
+ end
209
+
210
+ def disabled(skill_name)
211
+ "Skill '#{skill_name}' is disabled."
212
+ end
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Skills
5
+ # Persists per-skill enable/disable flags in the `skill_states` table.
6
+ #
7
+ # Default-enabled semantics: a skill with no row is treated as enabled,
8
+ # so #enabled? returns true for unknown names. Only an explicit #set with
9
+ # `enabled: false` disables a skill, and the choice survives restarts.
10
+ #
11
+ # Writes go through Sequel's `insert_conflict(target: :name)` which maps
12
+ # to SQLite's `INSERT ... ON CONFLICT(name) DO UPDATE` (UPSERT).
13
+ class StateRepository
14
+ def initialize(db: nil)
15
+ @db = db || Rubino.database.db
16
+ end
17
+
18
+ def enabled?(name)
19
+ row = @db[:skill_states].where(name: name.to_s).first
20
+ return true if row.nil?
21
+
22
+ row[:enabled] == true
23
+ end
24
+
25
+ def set(name, enabled:)
26
+ now = Time.now.utc.iso8601
27
+ @db[:skill_states]
28
+ .insert_conflict(target: :name, update: { enabled: enabled, updated_at: now })
29
+ .insert(name: name.to_s, enabled: enabled, updated_at: now)
30
+ end
31
+
32
+ def all
33
+ @db[:skill_states].all.to_h { |row| [row[:name], row[:enabled] == true] }
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Skills
5
+ # ONE enable/disable write path for every surface (#188): the HTTP API
6
+ # toggle (PUT /v1/skills/:name), the in-chat `/skills enable|disable`
7
+ # and the `rubino skills enable|disable` CLI verbs all validate against
8
+ # the registry and persist through the same StateRepository write —
9
+ # previously the API operation was the ONLY caller of StateRepository#set,
10
+ # so a CLI-only user literally could not disable a skill.
11
+ module Toggle
12
+ # Persists the enabled flag for +name+. Returns the registered Skill
13
+ # (state written), or nil when the name is unknown (nothing written) —
14
+ # the caller decides how to surface the miss (404 for the API, a
15
+ # lowercase ✗ line for CLI/chat).
16
+ def self.set(name, enabled:, registry: nil, state_repository: nil)
17
+ registry ||= Registry.new
18
+ skill = registry.find(name)
19
+ return nil unless skill
20
+
21
+ (state_repository || StateRepository.new).set(name, enabled: enabled)
22
+ skill
23
+ end
24
+ end
25
+ end
26
+ end