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,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module Rubino
6
+ module CLI
7
+ # Main Thor command class. All subcommands are registered here.
8
+ class Commands < Thor
9
+ # Without an explicit namespace, Thor's `tree` command derives one by
10
+ # underscoring the class name — "Rubino::CLI::Commands" becomes the
11
+ # mangled "rubino:c_l_i:commands" (the CLI acronym splits into
12
+ # c_l_i) (F12/F14). Pin a clean label instead.
13
+ namespace "rubino"
14
+
15
+ def self.exit_on_failure?
16
+ true
17
+ end
18
+
19
+ # Allow passing prompt directly as default task:
20
+ # rubino "my prompt"
21
+ def self.default_command
22
+ :chat
23
+ end
24
+
25
+ # Help flags recognized on any top-level command (#134).
26
+ HELP_FLAGS = ["--help", "-h"].freeze
27
+
28
+ # Intercept `--version`/`-v` at dispatch (#32). Thor otherwise routes a
29
+ # bare `rubino --version` to the default `chat` task, which treats the
30
+ # flag as a prompt and fails with an API-key error. Handle it here —
31
+ # print the version and exit — before any chat/credential handling.
32
+ #
33
+ # Likewise intercept `rubino <command> --help` (#134): Thor 1.x only maps
34
+ # a LEADING help flag to the help task, so `chat --help`/`prompt --help`
35
+ # used to fall through as an unknown option, become the positional
36
+ # prompt, and start a REAL agent run (provider call + memory writes).
37
+ # Reroute to Thor's own `help <command>` before option parsing. Thor
38
+ # subcommands (config/memory/sessions/jobs) already handle their own
39
+ # `--help` and keep their richer subcommand listing.
40
+ def self.start(given_args = ARGV, config = {})
41
+ if ["--version", "-v"].include?(given_args.first)
42
+ puts "rubino v#{Rubino::VERSION}"
43
+ return
44
+ end
45
+
46
+ cmd = given_args.first.to_s.tr("-", "_")
47
+ if given_args.drop(1).intersect?(HELP_FLAGS) && commands.key?(cmd) && !subcommands.include?(cmd)
48
+ return super(["help", cmd], config)
49
+ end
50
+
51
+ super
52
+ end
53
+
54
+ # Wrap subcommand help so `chat --help` / `prompt --help` stay within 80
55
+ # columns (#217). Thor's stock #print_options lays the flags and their
56
+ # descriptions out in a 2-column table padded to the WIDEST flag — and the
57
+ # boolean variants (`[--no-x], [--skip-x]`) push that column past 60, so
58
+ # every description row overflowed 80 (the longest hit 137) with no
59
+ # wrapping. Render each option as its flag line followed by the
60
+ # description wrapped + indented on its own line(s) instead: bounded by
61
+ # construction, and it reads cleaner than the ragged padded table.
62
+ HELP_WRAP_COLUMNS = 80
63
+ HELP_DESC_INDENT = 6
64
+
65
+ def self.print_options(shell, options, group_name = nil)
66
+ return if options.empty?
67
+
68
+ shell.say(group_name ? "#{group_name} options:" : "Options:")
69
+ options.reject(&:hide).each do |option|
70
+ shell.say(" #{option.usage(0)}")
71
+ next unless option.description
72
+
73
+ wrap_help_description(option.description).each { |line| shell.say(line) }
74
+ end
75
+ shell.say ""
76
+ end
77
+
78
+ # Greedy word-wrap of a flag description to HELP_WRAP_COLUMNS, each line
79
+ # indented HELP_DESC_INDENT. Wrapped here (not via Thor's print_wrapped)
80
+ # so the bound is the fixed 80 the spec checks, not the live terminal
81
+ # width. A single word longer than the budget is emitted on its own line
82
+ # rather than dropped.
83
+ def self.wrap_help_description(description)
84
+ indent = " " * HELP_DESC_INDENT
85
+ budget = HELP_WRAP_COLUMNS - HELP_DESC_INDENT
86
+ lines = []
87
+ line = +""
88
+ description.to_s.split(/\s+/).each do |word|
89
+ if line.empty?
90
+ line << word
91
+ elsif line.length + 1 + word.length <= budget
92
+ line << " " << word
93
+ else
94
+ lines << (indent + line)
95
+ line = +word
96
+ end
97
+ end
98
+ lines << (indent + line) unless line.empty?
99
+ lines
100
+ end
101
+
102
+ desc "setup", "Initialize rubino configuration and database"
103
+ def setup
104
+ SetupCommand.new.execute
105
+ end
106
+
107
+ # ----------------------------------------------------------------
108
+ # chat — interactive and non-interactive
109
+ # ----------------------------------------------------------------
110
+ desc "chat [PROMPT]", "Chat with the agent (one-shot with -q)"
111
+
112
+ # One-shot / non-interactive
113
+ option :query, aliases: "-q", type: :string, desc: "One-shot prompt (non-interactive)"
114
+
115
+ # Attach image(s) to the turn's native vision slot. Repeatable:
116
+ # --image a.png --image b.jpg.
117
+ # A single-value, repeatable string (not a greedy array) so a trailing
118
+ # positional prompt — `--image pic.png "what is this?"` — stays the prompt
119
+ # instead of being swallowed as a second image. Works in both one-shot
120
+ # (-q) and interactive mode; @image tokens in the prompt itself are also
121
+ # honoured. Aligns with `llm`'s -a/--attachment.
122
+ option :image, aliases: "-i", type: :string, repeatable: true,
123
+ desc: "Attach image file to the prompt (repeatable)"
124
+
125
+ # Session management
126
+ option :session, aliases: "-s", type: :string, desc: "Resume session by ID"
127
+ option :resume, aliases: "-r", type: :string, desc: "Resume session by ID or title"
128
+ option :continue, aliases: "-c", type: :boolean, desc: "Resume most recent session"
129
+ option :new, type: :boolean,
130
+ desc: "Start a fresh session (bare `chat` resumes the last one by default)"
131
+
132
+ # Model / provider
133
+ option :model, aliases: "-m", type: :string, desc: "Override model (e.g. claude-sonnet-4-5)"
134
+ option :provider, type: :string, desc: "Override provider (e.g. bedrock, anthropic)"
135
+
136
+ # Behavior
137
+ option :yolo, type: :boolean, desc: "Skip all approval prompts"
138
+ option :max_turns, type: :numeric, desc: "Max tool iterations per turn"
139
+ option :ignore_rules, type: :boolean, desc: "Skip AGENTS.md and context files"
140
+
141
+ # Add extra allowed workspace roots at launch (repeatable), like Claude
142
+ # Code's --add-dir. Write/edit tools then accept files under any added
143
+ # root; an added dir's project context/skills are gated by folder-trust.
144
+ option :add_dir, type: :string, repeatable: true,
145
+ desc: "Add an extra allowed workspace directory (repeatable)"
146
+
147
+ def chat(prompt = nil)
148
+ # Support: rubino chat "prompt" as shorthand for -q
149
+ opts = options.to_h.merge(prompt ? { query: prompt } : {})
150
+ ChatCommand.new(opts).execute
151
+ end
152
+
153
+ # ----------------------------------------------------------------
154
+ # Shorthand: rubino prompt "my question"
155
+ # ----------------------------------------------------------------
156
+ desc "prompt PROMPT", "Run a one-shot prompt (alias for chat -q)"
157
+ option :model, aliases: "-m", type: :string, desc: "Override model"
158
+ option :provider, type: :string, desc: "Override provider"
159
+ option :image, aliases: "-i", type: :string, repeatable: true, desc: "Attach image file (repeatable)"
160
+ option :session, aliases: "-s", type: :string, desc: "Session ID to resume"
161
+ option :continue, aliases: "-c", type: :boolean, desc: "Resume most recent session"
162
+ option :resume, aliases: "-r", type: :string, desc: "Resume by ID or title"
163
+ option :yolo, type: :boolean, desc: "Skip approval prompts"
164
+ option :max_turns, type: :numeric, desc: "Max tool iterations"
165
+ option :ignore_rules, type: :boolean, desc: "Skip AGENTS.md/context files"
166
+ option :add_dir, type: :string, repeatable: true,
167
+ desc: "Add an extra allowed workspace directory (repeatable)"
168
+ def prompt(*args)
169
+ query = args.join(" ")
170
+ opts = options.to_h.merge(query: query)
171
+ ChatCommand.new(opts).execute
172
+ end
173
+
174
+ desc "config SUBCOMMAND", "Manage configuration"
175
+ subcommand "config", ConfigCommand
176
+
177
+ desc "memory SUBCOMMAND", "Manage persistent memories"
178
+ subcommand "memory", MemoryCommand
179
+
180
+ desc "sessions SUBCOMMAND", "Manage chat sessions"
181
+ subcommand "sessions", SessionCommand
182
+
183
+ desc "jobs SUBCOMMAND", "Manage background jobs"
184
+ subcommand "jobs", JobsCommand
185
+
186
+ desc "skills SUBCOMMAND", "Manage skills (list, show, enable, disable)"
187
+ subcommand "skills", SkillsCommand
188
+
189
+ desc "tools", "List available tools"
190
+ def tools
191
+ ToolsCommand.new.execute
192
+ end
193
+
194
+ desc "server", "Start the JSON API server"
195
+ option :port, type: :numeric, default: 4820, desc: "Port to listen on"
196
+ option :host, type: :string, desc: "Host/interface to bind (default 127.0.0.1; pass 0.0.0.0 to expose)"
197
+ option :api_key, type: :string, desc: "Bearer token required on every request"
198
+ def server
199
+ ServerCommand.new(options).execute
200
+ end
201
+
202
+ # The usage label matches the registered command name (tls_cert) so
203
+ # `--help` and `tree` render the SAME name (#20); Thor still dispatches
204
+ # the hyphenated spelling (`rubino tls-cert`) via its name normalization.
205
+ desc "tls_cert", "Print the self-signed TLS certificate PEM"
206
+ def tls_cert
207
+ $stdout.write(API::TLS.ensure_cert!)
208
+ end
209
+
210
+ desc "doctor", "Check system health"
211
+ def doctor
212
+ DoctorCommand.new.execute
213
+ end
214
+
215
+ desc "version", "Show version"
216
+ def version
217
+ Rubino.ui.info("rubino v#{Rubino::VERSION}")
218
+ end
219
+
220
+ desc "update", "Update rubino to the latest published version"
221
+ def update
222
+ ui = Rubino.ui
223
+ current = Rubino::VERSION
224
+
225
+ case Rubino::UpdateCheck.install_method
226
+ when :gem
227
+ ok = system(*Rubino::UpdateCheck.gem_update_command)
228
+ unless ok
229
+ ui.warning("gem update failed. If this is a permission error, re-run the installer or try `gem update --user-install #{Rubino::UpdateCheck::GEM_NAME}`.")
230
+ return
231
+ end
232
+ new_v = Rubino::UpdateCheck.installed_gem_version(Rubino::UpdateCheck::GEM_NAME)
233
+ if new_v && Gem::Version.new(new_v) > Gem::Version.new(current)
234
+ ui.info("rubino is now on v#{new_v} (was v#{current}).")
235
+ ui.status("Restart any running rubino sessions to pick up the new version.")
236
+ else
237
+ ui.info("rubino is already up to date (v#{current}).")
238
+ end
239
+ else
240
+ ui.warning("rubino wasn't installed from RubyGems (built from source / dev checkout).")
241
+ ui.status("Re-run the installer to update:")
242
+ ui.status(" curl -fsSL https://raw.githubusercontent.com/Jhonnyr97/rubino-agent/main/install.sh | bash")
243
+ end
244
+ ensure
245
+ # Drop the cached notice so the boot footer doesn't linger after update.
246
+ Rubino::UpdateCheck.clear_cache!
247
+ end
248
+ end
249
+ end
250
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module Rubino
6
+ module CLI
7
+ # Subcommands for managing configuration
8
+ class ConfigCommand < Thor
9
+ # Clean `tree`/help label instead of the underscored
10
+ # "rubino:c_l_i:config_command" Thor derives from the class name (F12).
11
+ namespace "rubino config"
12
+
13
+ def self.exit_on_failure?
14
+ true
15
+ end
16
+
17
+ desc "get KEY", "Get a configuration value (dot-notation; secrets masked)"
18
+ def get(key)
19
+ self.class.render_get(key, ui: Rubino.ui)
20
+ end
21
+
22
+ # ONE get rendering for both surfaces (#187): this CLI verb and the
23
+ # in-chat `/config get` (Commands::Executor). Resolves against the
24
+ # effective config (file merged over defaults), the same source `show`
25
+ # and the running agent use, so default-valued keys are returned instead
26
+ # of falsely reported "not found" (issue #36). A scalar intermediate
27
+ # node (e.g. descending into a String) has no #dig; treat such a path as
28
+ # "not found" rather than crashing. Secret-named keys render masked.
29
+ def self.render_get(key, ui:)
30
+ value =
31
+ begin
32
+ Rubino.configuration.dig(*key.split("."))
33
+ rescue TypeError
34
+ nil
35
+ end
36
+ if value.nil?
37
+ ui.warning("Key '#{key}' not found")
38
+ else
39
+ ui.info("#{key} = #{redact(value, key: key.split(".").last)}")
40
+ end
41
+ end
42
+
43
+ desc "set KEY VALUE", "Set a configuration value (dot-notation)"
44
+ def set(key, value)
45
+ writer = Config::Writer.new(config_path: config_path)
46
+ writer.set(key, value)
47
+ Rubino.ui.success("#{key} = #{value}")
48
+ rescue ConfigurationError => e
49
+ Rubino.ui.error(e.message)
50
+ exit(1)
51
+ end
52
+
53
+ desc "show", "Show full configuration (secrets masked)"
54
+ def show
55
+ self.class.render_show(ui: Rubino.ui)
56
+ end
57
+
58
+ # ONE full-config rendering for both surfaces (#187): this CLI verb and
59
+ # the in-chat `/config show` — with secret-named keys masked, which the
60
+ # clear-text dump never did (api_key landed verbatim in the scrollback).
61
+ def self.render_show(ui:)
62
+ ui.info(redact(Rubino.configuration.raw).to_yaml)
63
+ end
64
+
65
+ # Deep DISPLAY masking for config values (#187): a secret-named key's
66
+ # value renders as *** (Util::SecretsMask — the same heuristic approval
67
+ # prompts use), hashes/arrays are walked, and plain strings are scanned
68
+ # for inline `Bearer …`-style credentials. Display-only — the file and
69
+ # the live configuration keep the real values. Empty/nil values pass
70
+ # through unmasked so a *** never fakes a value that isn't set.
71
+ def self.redact(value, key: nil)
72
+ case value
73
+ when Hash then value.to_h { |k, v| [k, redact(v, key: k)] }
74
+ when Array then value.map { |v| redact(v, key: key) }
75
+ when String
76
+ value.empty? ? value : Util::SecretsMask.mask_value(value, key: key)
77
+ else value
78
+ end
79
+ end
80
+
81
+ desc "path", "Show config file path"
82
+ def path
83
+ Rubino.ui.info(config_path)
84
+ end
85
+
86
+ private
87
+
88
+ # Resolve through the Loader so config get/set/path operate on exactly
89
+ # the file the server loads (RUBINO_HOME-aware), not a recomputed
90
+ # File.join off a YAML default.
91
+ def config_path
92
+ Config::Loader.new.config_path
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,251 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module CLI
5
+ # Health check command that verifies all system components are working.
6
+ #
7
+ # Doctor is a READ-ONLY diagnosis (#68): it must never create the home
8
+ # directory or the database file while checking them — a never-setup
9
+ # install is reported as "run 'rubino setup'", not silently materialized
10
+ # at the umask's permissions and then declared healthy.
11
+ #
12
+ # Exit status (#67): non-zero when one or more required checks did not
13
+ # pass, so CI/scripts can gate on `rubino doctor`.
14
+ class DoctorCommand
15
+ def execute
16
+ ui = Rubino.ui
17
+ ui.info("Running system diagnostics...")
18
+ ui.blank_line
19
+
20
+ # Required checks score the headline verdict — these are what a CLI user
21
+ # needs for a working install. The encryption key is SERVER-ONLY (JSON
22
+ # API / OAuth) and a CLI-only user never touches it, so it lives in a
23
+ # separate optional section and is NOT counted against the score (#143):
24
+ # a healthy default install reports all-green.
25
+ required = [
26
+ check_config,
27
+ check_database,
28
+ check_migrations,
29
+ check_directories,
30
+ check_provider_keys,
31
+ check_model_configured
32
+ ]
33
+
34
+ ui.blank_line
35
+ ui.info("Optional (API/OAuth server):")
36
+ optional = [check_encryption_key]
37
+
38
+ # Document converters are an optional in-process capability (#6): report
39
+ # which CORE formats can be read in-process (their optional gem is
40
+ # loadable), but never let an absent gem fail doctor — pure-ruby formats
41
+ # always work and missing extraction gems only narrow the supported set.
42
+ check_document_converters
43
+
44
+ # MCP servers are optional integrations (#90): report each configured
45
+ # server's reachability best-effort, but never let a down MCP server
46
+ # fail doctor — it is informational, not a required check, so non-MCP
47
+ # users (and MCP users with a flaky server) still exit 0.
48
+ check_mcp_servers if MCP.enabled?
49
+
50
+ ui.blank_line
51
+ passed = required.count { |c| c[:status] == :ok }
52
+ total = required.size
53
+ optional_unconfigured = optional.count { |c| c[:status] != :ok }
54
+
55
+ if passed == total
56
+ ui.success("All #{total} checks passed!")
57
+ if optional_unconfigured.positive?
58
+ ui.info("(#{optional_unconfigured} optional server check#{"s" if optional_unconfigured != 1} not configured — only needed to run the API/OAuth server)")
59
+ end
60
+ else
61
+ ui.warning("#{passed}/#{total} required checks passed")
62
+ # Scripts/CI gate on doctor: a failed required check must be a
63
+ # non-zero exit, not a green 0 under a red report (#67).
64
+ exit(1)
65
+ end
66
+ end
67
+
68
+ private
69
+
70
+ def check_config
71
+ ui = Rubino.ui
72
+ loader = Config::Loader.new
73
+
74
+ if loader.config_exists?
75
+ ui.success("Config file exists: #{loader.config_path}")
76
+ { name: "config", status: :ok }
77
+ else
78
+ ui.error("config file missing. Run 'rubino setup'")
79
+ { name: "config", status: :fail }
80
+ end
81
+ end
82
+
83
+ def check_database
84
+ ui = Rubino.ui
85
+ unless database_on_disk?
86
+ ui.error("database not initialized: #{Rubino.database.db_path}. Run 'rubino setup'")
87
+ return { name: "database", status: :fail }
88
+ end
89
+
90
+ if Rubino.database.healthy?
91
+ ui.success("Database accessible: #{Rubino.database.db_path}")
92
+ { name: "database", status: :ok }
93
+ else
94
+ ui.error("database not accessible")
95
+ { name: "database", status: :fail }
96
+ end
97
+ rescue StandardError => e
98
+ ui.error("database error: #{e.message}")
99
+ { name: "database", status: :fail }
100
+ end
101
+
102
+ def check_migrations
103
+ ui = Rubino.ui
104
+ unless database_on_disk?
105
+ ui.error("migrations not run — no database. Run 'rubino setup'")
106
+ return { name: "migrations", status: :fail }
107
+ end
108
+
109
+ migrator = Database::Migrator.new(Rubino.database)
110
+
111
+ if migrator.pending?
112
+ ui.warning("Pending migrations exist")
113
+ { name: "migrations", status: :warn }
114
+ else
115
+ ui.success("Migrations up to date")
116
+ { name: "migrations", status: :ok }
117
+ end
118
+ rescue StandardError => e
119
+ ui.error("migration check failed: #{e.message}")
120
+ { name: "migrations", status: :fail }
121
+ end
122
+
123
+ # Read-only guard for the two DB checks (#68): SQLite lazily CREATES the
124
+ # file (and its parent directory) on the first connection, so probing a
125
+ # never-setup home with `SELECT 1` would mutate it — and doctor would then
126
+ # report the empty, unmigrated database it just created as "accessible".
127
+ # A missing file is an uninitialized install: report it without touching
128
+ # the disk.
129
+ def database_on_disk?
130
+ db = Rubino.database
131
+ db.memory? || File.exist?(db.db_path)
132
+ end
133
+
134
+ def check_directories
135
+ ui = Rubino.ui
136
+ home = Rubino.home_path
137
+
138
+ if File.directory?(home)
139
+ ui.success("Home directory exists: #{home}")
140
+ { name: "directories", status: :ok }
141
+ else
142
+ ui.error("home directory missing: #{home}. Run 'rubino setup'")
143
+ { name: "directories", status: :fail }
144
+ end
145
+ end
146
+
147
+ # Verifies the credentials for the ACTUALLY configured provider resolve —
148
+ # not a hardcoded ENV allowlist. A tenant on an openai_compatible backend
149
+ # (ollama, vllm, a hosted gateway, …) configures its key under
150
+ # providers.<name>.api_key in config.yml; the old hardcoded check ignored
151
+ # that and warned "No API keys found" on a correctly-configured tenant.
152
+ def check_provider_keys
153
+ ui = Rubino.ui
154
+ provider = LLM::CredentialCheck.resolved_provider
155
+
156
+ if LLM::CredentialCheck.usable?
157
+ ui.success("API key configured (#{provider})")
158
+ { name: "provider_keys", status: :ok }
159
+ else
160
+ ui.warning("No credentials found for provider '#{provider}'")
161
+ { name: "provider_keys", status: :warn }
162
+ end
163
+ end
164
+
165
+ def check_model_configured
166
+ ui = Rubino.ui
167
+ model = Rubino.configuration.model_default
168
+
169
+ if model && !model.empty?
170
+ ui.success("Model configured: #{model}")
171
+ { name: "model", status: :ok }
172
+ else
173
+ ui.error("no model configured")
174
+ { name: "model", status: :fail }
175
+ end
176
+ end
177
+
178
+ # Verifies the OAuth-token encryption key is present and well-formed
179
+ # WITHOUT crashing doctor itself: server boot uses Boot::EncryptionKey
180
+ # for the hard fail-fast path, but doctor must keep running so the
181
+ # operator sees every other check that did pass.
182
+ #
183
+ # The key is only needed by the JSON API / OAuth (encrypted-token) path;
184
+ # a CLI-only user never touches it. So a MISSING key is a :warn scoped to
185
+ # that path, not a scary red :fail that makes a healthy CLI install look
186
+ # broken (F4). A key that IS set but malformed is still a real :fail —
187
+ # that's a misconfiguration the operator must fix before the server boots.
188
+ def check_encryption_key
189
+ ui = Rubino.ui
190
+ OAuth::TokenEncryptor.from_env
191
+ ui.success("Encryption key configured")
192
+ { name: "encryption_key", status: :ok }
193
+ rescue OAuth::TokenEncryptor::KeyMissingError
194
+ ui.warning("RUBINO_ENCRYPTION_KEY not set (only needed for the API/OAuth server)")
195
+ { name: "encryption_key", status: :warn }
196
+ rescue ArgumentError => e
197
+ ui.error("RUBINO_ENCRYPTION_KEY invalid: #{e.message}")
198
+ { name: "encryption_key", status: :fail }
199
+ end
200
+
201
+ # Best-effort MCP reachability report (#90). Starts each configured
202
+ # server, health-checks it, and stops everything again — doctor stays
203
+ # read-only and leaves no child processes behind. Deliberately NOT part
204
+ # of the required score: a server that fails to start already warned via
205
+ # Manager#start_server, a started-but-dead one warns here, and neither
206
+ # flips the exit status. Any unexpected error degrades to a warning so
207
+ # the MCP section can never break doctor itself.
208
+ def check_mcp_servers
209
+ ui = Rubino.ui
210
+ ui.blank_line
211
+ ui.info("Optional (MCP servers, experimental):")
212
+
213
+ servers = Rubino.configuration.dig("mcp", "servers") || {}
214
+ manager = MCP::Manager.new
215
+ servers.each { |name, server_config| manager.start_server(name, server_config) }
216
+
217
+ manager.health_check.each do |status|
218
+ if status[:alive]
219
+ ui.success("MCP server '#{status[:name]}' reachable")
220
+ else
221
+ ui.warning("MCP server '#{status[:name]}' not reachable")
222
+ end
223
+ end
224
+ manager.stop_all!
225
+ rescue StandardError => e
226
+ ui.warning("MCP check failed: #{e.message}")
227
+ end
228
+
229
+ # Non-scoring report of the in-process document-conversion capability
230
+ # (#6), mirroring the MCP "Optional (…)" pattern. Pure-ruby formats are
231
+ # always green; a gem-backed format whose optional gem isn't installed is
232
+ # a warning (never a fail), so a healthy default install never shows red
233
+ # for a capability it can extend by installing an optional gem.
234
+ def check_document_converters
235
+ ui = Rubino.ui
236
+ ui.blank_line
237
+ ui.info("Optional (document converters, in-process via read_attachment):")
238
+
239
+ Rubino::Documents::Registry.capabilities.each do |format, available|
240
+ if available
241
+ ui.success("#{format} supported")
242
+ else
243
+ ui.warning("#{format} not available (install its optional gem to enable)")
244
+ end
245
+ end
246
+ rescue StandardError => e
247
+ ui.warning("Document-converter check failed: #{e.message}")
248
+ end
249
+ end
250
+ end
251
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module Rubino
6
+ module CLI
7
+ # Subcommands for managing the job queue
8
+ class JobsCommand < Thor
9
+ # Clean `tree`/help label instead of the underscored class-name default (F12).
10
+ namespace "rubino jobs"
11
+
12
+ def self.exit_on_failure?
13
+ true
14
+ end
15
+
16
+ desc "list", "List jobs in queue"
17
+ option :status, type: :string, desc: "Filter by status (queued, running, completed, failed)"
18
+ option :limit, type: :numeric, default: 20, desc: "Max results"
19
+ def list
20
+ Rubino.ensure_database_ready!
21
+ queue = Jobs::Queue.new
22
+ jobs = queue.list(status: options[:status], limit: options[:limit])
23
+
24
+ if jobs.empty?
25
+ Rubino.ui.info("No jobs found.")
26
+ return
27
+ end
28
+
29
+ self.class.render_list(jobs, ui: Rubino.ui)
30
+ end
31
+
32
+ # ONE jobs-table rendering for both surfaces (#187): this CLI verb and
33
+ # the in-chat /jobs list (Commands::Executor).
34
+ def self.render_list(jobs, ui:)
35
+ rows = jobs.map do |j|
36
+ [j[:id][0..7], j[:type], j[:status], j[:attempts].to_s, j[:run_at]]
37
+ end
38
+
39
+ ui.table(headers: %w[ID Type Status Attempts RunAt], rows: rows)
40
+ end
41
+
42
+ desc "process", "Run pending jobs now (manual mode)"
43
+ option :limit, type: :numeric, default: 10, desc: "Max jobs to process"
44
+ def process
45
+ runner = Jobs::Runner.new
46
+ processed = runner.run_pending(limit: options[:limit])
47
+ Rubino.ui.success("Processed #{processed} job(s)")
48
+ end
49
+
50
+ desc "worker", "Start a background worker loop"
51
+ def worker
52
+ Rubino.ui.info("Starting job worker (poll every #{Rubino.configuration.jobs_poll_interval}s)...")
53
+ Rubino.ui.info("Press Ctrl+C to stop.")
54
+
55
+ worker = Jobs::Worker.new
56
+ worker.start
57
+ end
58
+ end
59
+ end
60
+ end