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,550 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Commands
5
+ # Executes a slash command, rendering its template and feeding it to the agent.
6
+ #
7
+ # `runner:` (optional) is the live Agent::Runner for the interactive REPL.
8
+ # When present, `/status` and `/sessions` can read the current session / model
9
+ # straight off it. It is nil for non-interactive callers (and unit tests that
10
+ # don't exercise those commands), in which case those commands degrade
11
+ # gracefully instead of raising.
12
+ class Executor
13
+ # How many model ids the bare `/model` listing renders before deferring
14
+ # the rest to the completion dropdown.
15
+ MODEL_LIST_LIMIT = 12
16
+
17
+ def initialize(loader: nil, ui: nil, runner: nil)
18
+ @loader = loader || Loader.new
19
+ @ui = ui || Rubino.ui
20
+ @runner = runner
21
+ end
22
+
23
+ # Attempts to execute input as a slash command.
24
+ # Returns the rendered prompt if it's a command, nil otherwise.
25
+ def try_execute(input)
26
+ return nil unless @loader.slash_command?(input)
27
+
28
+ name, arguments = @loader.parse(input)
29
+ return nil unless name
30
+
31
+ # Check built-in commands first
32
+ built_in_result = handle_built_in(name, arguments)
33
+ return built_in_result if built_in_result
34
+
35
+ # Look up custom command
36
+ command = @loader.find(name)
37
+ unless command
38
+ @ui.error("unknown command: /#{name}")
39
+ @ui.info("Available: #{help_handler.available_commands.join(", ")}")
40
+ return :handled # Signal that it was handled (even if failed)
41
+ end
42
+
43
+ run_custom_command(command, name, arguments)
44
+ end
45
+
46
+ # Renders the welcome variant on first interactive boot. Best-effort: a
47
+ # welcome banner must never block the REPL from starting, so any assembler
48
+ # hiccup degrades to no banner rather than a crash. The boot header
49
+ # (workspace/branch/model) is printed by the chat command; this adds only
50
+ # the orientation, with no duplicate identity/session-id renderings.
51
+ def self.welcome(runner: nil, ui: nil)
52
+ new(ui: ui, runner: runner).send(:show_welcome)
53
+ rescue StandardError
54
+ nil
55
+ end
56
+
57
+ private
58
+
59
+ # Renders a custom command. `--preview` (anywhere in the arguments) shows
60
+ # the resolved prompt and asks for confirmation before sending it to the
61
+ # agent, so the user can see exactly what a command expands to first.
62
+ def run_custom_command(command, name, arguments)
63
+ args, preview = strip_preview_flag(arguments)
64
+ rendered = command.render(args)
65
+
66
+ if preview
67
+ show_command_preview(name, rendered)
68
+ return :handled unless confirm_run?(name)
69
+ end
70
+
71
+ @ui.status("Running command: /#{name}")
72
+ { prompt: rendered, agent: command.agent, model: command.model }
73
+ end
74
+
75
+ # Splits a `--preview` flag out of the argument string, returning
76
+ # [remaining_args, preview?]. Matches `--preview` as a standalone token so
77
+ # it isn't mistaken for part of a longer argument.
78
+ def strip_preview_flag(arguments)
79
+ tokens = arguments.to_s.split(/\s+/)
80
+ preview = tokens.delete("--preview") ? true : false
81
+ [tokens.join(" "), preview]
82
+ end
83
+
84
+ def show_command_preview(name, rendered)
85
+ @ui.info("Preview of /#{name} — the prompt that would be sent:")
86
+ @ui.separator
87
+ @ui.info(rendered)
88
+ @ui.separator
89
+ end
90
+
91
+ # Asks for confirmation before running a previewed command. The UI #ask
92
+ # returns nil for non-interactive adapters (Null/API), in which case we
93
+ # treat it as "no" so a preview never auto-fires without a human.
94
+ def confirm_run?(name)
95
+ answer = @ui.ask("Run /#{name}? [y/N] ")
96
+ answer.to_s.strip.downcase.start_with?("y")
97
+ end
98
+
99
+ def handle_built_in(name, arguments)
100
+ case name
101
+ when "help"
102
+ help_handler.show_help
103
+ :handled
104
+ when "exit", "quit"
105
+ :exit
106
+ when "commands"
107
+ help_handler.show_commands
108
+ :handled
109
+ when "skills"
110
+ skills_handler.handle_skills(arguments)
111
+ :handled
112
+ when "mcp"
113
+ mcp_handler.handle_mcp(arguments)
114
+ :handled
115
+ when "add-dir"
116
+ handle_add_dir(arguments)
117
+ :handled
118
+ when "dirs"
119
+ show_dirs
120
+ :handled
121
+ when "mode"
122
+ handle_mode(arguments)
123
+ :handled
124
+ when "model"
125
+ handle_model(arguments)
126
+ :handled
127
+ when "compact"
128
+ handle_compact
129
+ when "export"
130
+ handle_export(arguments)
131
+ :handled
132
+ when "reasoning"
133
+ handle_reasoning(arguments)
134
+ :handled
135
+ when "think"
136
+ handle_think(arguments)
137
+ :handled
138
+ when "status"
139
+ status_handler.show_status
140
+ :handled
141
+ when "memory"
142
+ memory_handler.handle_memory(arguments)
143
+ :handled
144
+ when "jobs"
145
+ jobs_handler.handle_jobs(arguments)
146
+ :handled
147
+ when "config"
148
+ config_handler.handle_config(arguments)
149
+ :handled
150
+ when "agents", "tasks"
151
+ agents_handler.handle_agents(arguments)
152
+ # handle_agents delegates to the puts-based UI (info/table), whose
153
+ # methods return nil; without an explicit :handled the falsy result
154
+ # makes try_execute fall through to the unknown-command path (#34).
155
+ :handled
156
+ when "reply"
157
+ agents_handler.handle_reply(arguments)
158
+ :handled
159
+ when "sessions"
160
+ sessions_handler.handle_sessions(arguments)
161
+ when "probe"
162
+ # `/probe <text>` is the discoverable alias for the `? ` prefix. We
163
+ # don't run the side-inference here (the Executor has no LLM seam) —
164
+ # we hand the REPL a {probe:} signal it runs against the live runner's
165
+ # session, then renders+discards. Bare `/probe` just teaches the tip.
166
+ sessions_handler.handle_probe(arguments)
167
+ when "queued"
168
+ # `/queued <msg>` is normally intercepted by the BottomComposer
169
+ # before it ever reaches the Executor (it queues the message for the
170
+ # next turn, like Alt+Enter). Reaching here means there was nothing
171
+ # to queue (bare `/queued`) or no composer owns the input (API/piped
172
+ # mode) — teach the usage instead of "Unknown command".
173
+ @ui.info("Queue a message to run after the current turn: /queued <message>")
174
+ @ui.info("(Alt+Enter queues the current input line the same way; " \
175
+ "plain Enter interrupts the turn and runs the line next.)")
176
+ :handled
177
+ when "branch"
178
+ # `/branch [name]` forks the CURRENT session at this point into a new
179
+ # saved one and switches into it. The REPL owns the runner/session, so
180
+ # we return a {branch_from:, title:} signal on the SAME channel /new
181
+ # and /sessions use, and it does the build/seed/swap.
182
+ sessions_handler.handle_branch(arguments)
183
+ when "new", "clear"
184
+ # Hand the REPL a signal to rebuild the runner on a brand-new session.
185
+ # The current session is left intact (and will be marked ended on the
186
+ # eventual teardown), so /new is the in-chat counterpart to `--new`.
187
+ # /clear is the muscle-memory alias every other agent CLI ships.
188
+ @ui.success("Starting a fresh session.")
189
+ { new_session: true }
190
+ end
191
+ end
192
+
193
+ # The domain handlers the dispatcher delegates to (#193 collaborator
194
+ # pattern). Each is a plain object given the deps it needs (ui/runner);
195
+ # the Executor stays the thin dispatcher/facade over the slash-command
196
+ # case. Memoized so each carries its own per-session state (e.g. the
197
+ # memory backend memo, the watch pastel).
198
+ def agents_handler
199
+ @agents_handler ||= Handlers::Agents.new(ui: @ui)
200
+ end
201
+
202
+ def sessions_handler
203
+ @sessions_handler ||= Handlers::Sessions.new(ui: @ui, runner: @runner)
204
+ end
205
+
206
+ def status_handler
207
+ @status_handler ||= Handlers::Status.new(ui: @ui, runner: @runner)
208
+ end
209
+
210
+ def memory_handler
211
+ @memory_handler ||= Handlers::Memory.new(ui: @ui)
212
+ end
213
+
214
+ def skills_handler
215
+ @skills_handler ||= Handlers::Skills.new(ui: @ui)
216
+ end
217
+
218
+ def mcp_handler
219
+ @mcp_handler ||= Handlers::MCP.new(ui: @ui)
220
+ end
221
+
222
+ def jobs_handler
223
+ @jobs_handler ||= Handlers::Jobs.new(ui: @ui)
224
+ end
225
+
226
+ def config_handler
227
+ @config_handler ||= Handlers::Config.new(ui: @ui)
228
+ end
229
+
230
+ def help_handler
231
+ @help_handler ||= Handlers::Help.new(ui: @ui, loader: @loader)
232
+ end
233
+
234
+ # `/mode` → show current + list
235
+ # `/mode list` → same
236
+ # `/mode <name>` → switch (default | plan | yolo)
237
+ #
238
+ # We delegate the actual transition to Rubino::Modes.set so the API
239
+ # adapter and any other caller go through the same gate (and trigger
240
+ # the same `mode_changed` UI event).
241
+ def handle_mode(arguments)
242
+ name = arguments.to_s.strip.downcase.split(/\s+/).first
243
+
244
+ if name.nil? || name.empty? || name == "list"
245
+ show_modes
246
+ return
247
+ end
248
+
249
+ previous = Rubino::Modes.current
250
+ Rubino::Modes.set(name)
251
+ @ui.mode_changed(Rubino::Modes.current, previous: previous)
252
+ warn_yolo_live_children(previous)
253
+ rescue ArgumentError => e
254
+ @ui.error(e.message)
255
+ @ui.info("Available: #{Rubino::Modes::ALL.join(", ")}")
256
+ end
257
+
258
+ # One warning line when an explicit `/mode yolo` lands while background
259
+ # children are live (#152): the gates of already-running subagents drop
260
+ # the moment the mode flips, which is easy to forget mid-session. The
261
+ # explicit command stays unconfirmed — this is information, not friction.
262
+ def warn_yolo_live_children(previous)
263
+ return unless Rubino::Modes.current == Rubino::Modes::YOLO && previous != Rubino::Modes::YOLO
264
+
265
+ live = Tools::BackgroundTasks.instance.running.size
266
+ return unless live.positive?
267
+
268
+ @ui.warning("⚡ yolo: #{live} running background subagent(s) will now run gated actions unprompted")
269
+ end
270
+
271
+ def show_modes
272
+ current = Rubino::Modes.current
273
+ @ui.info("Current mode: #{current} — #{Rubino::Modes.description(current)}")
274
+ @ui.info("Available:")
275
+ Rubino::Modes::ALL.each do |m|
276
+ marker = m == current ? "▸" : " "
277
+ @ui.info(" #{marker} /mode #{m} — #{Rubino::Modes.description(m)}")
278
+ end
279
+ end
280
+
281
+ # `/reasoning` → show current render mode
282
+ # `/reasoning <mode>` → switch (hidden | collapsed | full)
283
+ #
284
+ # Writes the new mode to display.reasoning on the live configuration so the
285
+ # LLM adapter gate (which reads config) and the CLI render path share one
286
+ # source of truth — no separate per-UI override to drift. An unknown value
287
+ # is rejected with the valid list.
288
+ def handle_reasoning(arguments)
289
+ name = arguments.to_s.strip.downcase.split(/\s+/).first
290
+ previous = Config::ReasoningPrefs.mode(Rubino.configuration)
291
+
292
+ if name.nil? || name.empty?
293
+ @ui.reasoning_status(previous) if @ui.respond_to?(:reasoning_status)
294
+ return
295
+ end
296
+
297
+ sym = name.to_sym
298
+ unless Config::ReasoningPrefs::RENDER_MODES.include?(sym)
299
+ @ui.error("unknown reasoning mode: #{name}")
300
+ @ui.info("Available: #{Config::ReasoningPrefs::RENDER_MODES.join(", ")}")
301
+ return
302
+ end
303
+
304
+ Rubino.configuration.set("display", "reasoning", sym.to_s)
305
+ persist_config("display.reasoning", sym.to_s)
306
+ @ui.reasoning_changed(sym, previous: previous) if @ui.respond_to?(:reasoning_changed)
307
+ end
308
+
309
+ # `/think` → show current effort
310
+ # `/think <level>` → switch (off | low | medium | high)
311
+ #
312
+ # Writes thinking.effort on the live configuration; the adapter derives the
313
+ # thinking-token budget from it on the next turn. An unknown value is
314
+ # rejected with the valid list.
315
+ def handle_think(arguments)
316
+ name = arguments.to_s.strip.downcase.split(/\s+/).first
317
+ previous = Config::ReasoningPrefs.effort(Rubino.configuration) ||
318
+ Config::ReasoningPrefs::DEFAULT_EFFORT
319
+
320
+ if name.nil? || name.empty?
321
+ @ui.think_status(previous) if @ui.respond_to?(:think_status)
322
+ return
323
+ end
324
+
325
+ sym = name.to_sym
326
+ unless Config::ReasoningPrefs::EFFORTS.include?(sym)
327
+ @ui.error("unknown effort: #{name}")
328
+ @ui.info("Available: #{Config::ReasoningPrefs::EFFORTS.join(", ")}")
329
+ return
330
+ end
331
+
332
+ Rubino.configuration.set("thinking", "effort", sym.to_s)
333
+ persist_config("thinking.effort", sym.to_s)
334
+ @ui.think_changed(sym, previous: previous) if @ui.respond_to?(:think_changed)
335
+ end
336
+
337
+ # `/model` → show current model/provider + the known models
338
+ # `/model <name>` → switch the LIVE session model
339
+ #
340
+ # The switch writes model.default through Config::Writer (the same
341
+ # persist path /think uses) AND retargets the live runner, so the very
342
+ # next turn hits the new model — no restart. The known-models list comes
343
+ # from the ruby_llm registry for the ACTIVE provider; custom backends
344
+ # (minimax/gateway) aren't enumerable there, so they degrade to the
345
+ # current model + a usage hint instead of an invented hardcoded list.
346
+ def handle_model(arguments)
347
+ name = arguments.to_s.strip.split(/\s+/).first
348
+
349
+ if name.nil? || name.empty?
350
+ show_model
351
+ return
352
+ end
353
+
354
+ previous = status_model
355
+ if name == previous
356
+ @ui.info("Already on #{name}.")
357
+ return
358
+ end
359
+
360
+ Rubino.configuration.set("model", "default", name)
361
+ persist_config("model.default", name)
362
+ @runner.switch_model!(name) if @runner.respond_to?(:switch_model!)
363
+ # Forget per-provider thinking rejections recorded this session: the
364
+ # new model may sit on a provider that does support a budget (and the
365
+ # MiniMax-family default is re-derived per turn from the new id).
366
+ LLM::ThinkingSupport.reset!
367
+ @ui.success("model: #{previous} → #{name} (persisted; applies from the next turn)")
368
+ warn_cross_provider_model(name)
369
+ end
370
+
371
+ def show_model
372
+ current = status_model
373
+ provider = active_provider(current)
374
+ @ui.info("Current model: #{current} (provider: #{provider})")
375
+
376
+ ids = LLM::ModelCatalog.ids_for(provider)
377
+ if ids.empty?
378
+ @ui.info("No model catalog for provider '#{provider}' — /model <name> switches anyway.")
379
+ return
380
+ end
381
+
382
+ @ui.info("Known models for #{provider}:")
383
+ ids.first(MODEL_LIST_LIMIT).each do |id|
384
+ marker = id == current ? "▸" : " "
385
+ @ui.info(" #{marker} /model #{id}")
386
+ end
387
+ rest = ids.size - MODEL_LIST_LIMIT
388
+ @ui.info(" … and #{rest} more (type `/model ` for the full dropdown)") if rest.positive?
389
+ end
390
+
391
+ # The model the next turn will run on — the live session's model, the
392
+ # runner's model_id, or the configured default (in that order). Shared by
393
+ # /model (here) and the /status panel (Handlers::Status reads it too).
394
+ def status_model
395
+ @runner&.session&.dig(:model) ||
396
+ (@runner.respond_to?(:model_id) ? @runner.model_id : nil) ||
397
+ Rubino.configuration.model_default
398
+ end
399
+
400
+ # The provider the next turn will actually route through — the single
401
+ # ProviderResolver seam AdapterFactory uses, fed with the configured
402
+ # explicit provider (or "auto" pattern-matching the model id).
403
+ def active_provider(model_id)
404
+ LLM::ProviderResolver.resolve(model_id, explicit_provider: Rubino.configuration.model_provider)
405
+ rescue StandardError
406
+ "(unknown)"
407
+ end
408
+
409
+ # An explicit model.provider pins routing regardless of model id, so
410
+ # `/model claude-x` under provider "minimax" keeps hitting MiniMax's
411
+ # endpoint. One informational line when the new id pattern-matches a
412
+ # different provider than the pinned one — gateway excepted, since a
413
+ # gateway proxies arbitrary model ids by design.
414
+ def warn_cross_provider_model(model_id)
415
+ explicit = Rubino.configuration.model_provider
416
+ return if explicit.nil? || explicit == "auto" || explicit == "gateway"
417
+
418
+ implied = LLM::ProviderResolver.resolve(model_id)
419
+ return if implied == explicit
420
+
421
+ @ui.info("Requests still route via provider '#{explicit}' — set model.provider to switch backends.")
422
+ rescue StandardError
423
+ nil
424
+ end
425
+
426
+ # `/compact` — manual compaction NOW, the same Context::Compressor +
427
+ # compression_started/finished pipeline the automatic threshold path
428
+ # runs, plus a tokens before→after report. Compaction lands in a CHILD
429
+ # session (head + summary + tail), so on success we hand the REPL a
430
+ # {compact_into:} signal and it swaps the runner into the child — the
431
+ # next turn runs on the compacted context.
432
+ def handle_compact
433
+ session = @runner&.session
434
+ unless session && Session::Repository.new.persisted?(session[:id])
435
+ @ui.error("nothing to compact — this session has no saved messages yet")
436
+ return :handled
437
+ end
438
+
439
+ store = Session::Store.new
440
+ before = estimate_session_tokens(store, session[:id], model_id: session[:model])
441
+
442
+ @ui.compression_started
443
+ result = Context::Compressor.new(session_id: session[:id]).compact!
444
+
445
+ if result[:skipped]
446
+ @ui.info("Nothing to compact yet — the session is still below the protected head/tail size.")
447
+ return :handled
448
+ end
449
+
450
+ @ui.compression_finished(result)
451
+ after = estimate_session_tokens(store, result[:target_session_id], model_id: session[:model])
452
+ @ui.info("Context: ~#{before} → ~#{after} tokens (#{result[:original_messages]} → " \
453
+ "#{result[:compacted_messages]} messages).")
454
+ { compact_into: result[:target_session_id] }
455
+ rescue StandardError => e
456
+ @ui.error("compaction failed: #{e.message}")
457
+ :handled
458
+ end
459
+
460
+ # The same chars/4 estimate the compaction thresholds and the status bar
461
+ # run on, over a session's stored messages.
462
+ def estimate_session_tokens(store, session_id, model_id:)
463
+ budget = Context::TokenBudget.new(model_id: model_id, config: Rubino.configuration)
464
+ budget.estimate_tokens(store.for_session(session_id).map { |m| { content: m.content } })
465
+ end
466
+
467
+ # `/export [path]` — write the session transcript as clean markdown via
468
+ # Session::Exporter (user/assistant turns, tool calls as one-liners,
469
+ # reasoning omitted). Default path ./rubino-session-<id8>.md.
470
+ def handle_export(arguments)
471
+ session = @runner&.session
472
+ unless session
473
+ @ui.error("no live session to export")
474
+ return
475
+ end
476
+
477
+ path = arguments.to_s.strip
478
+ target = Session::Exporter.new(session).write(path.empty? ? nil : path)
479
+ @ui.success("exported → #{target}")
480
+ rescue StandardError => e
481
+ @ui.error("export failed: #{e.message}")
482
+ end
483
+
484
+ # Write-through of a /reasoning // /think switch to config.yml so it
485
+ # survives the session, as docs/commands.md promises (#131). The in-memory
486
+ # set above stays authoritative for THIS session either way; a disk
487
+ # failure degrades to the old session-only behavior with a warning, never
488
+ # a broken command.
489
+ def persist_config(key_path, value)
490
+ path = Config::Loader.new.config_path
491
+ Config::Writer.new(config_path: path).set(key_path, value)
492
+ rescue StandardError => e
493
+ @ui.warning("could not persist #{key_path} to config: #{e.message}")
494
+ end
495
+
496
+ # --- welcome -----------------------------------------------------------
497
+ #
498
+ # First-run guidance — the counterpart to /status (Handlers::Status).
499
+ # `.welcome` (public, above) orients a newcomer: one identity line + what
500
+ # to DO next. NOT the state dump (#82); the at-a-glance panel lives in
501
+ # /status. This is the private instance method it calls.
502
+
503
+ # Color diet (P8): ONE cyan identity line; the hint commands are the
504
+ # only other accent (they're actionable pointers); descriptions plain.
505
+ def show_welcome
506
+ @ui.separator
507
+ @ui.info("rubino — ask in plain language; it reads, edits, and runs things for you.")
508
+ @ui.blank_line
509
+ @ui.status(" Ask anything, or try:")
510
+ @ui.hint_row("/status", "what's going on right now")
511
+ @ui.hint_row("/sessions", "resume past work")
512
+ @ui.hint_row("/memory", "what I recall about you")
513
+ @ui.hint_row("/help", "all commands and keys")
514
+ @ui.separator
515
+ end
516
+ # --- /add-dir & /dirs --------------------------------------------------
517
+ #
518
+ # Mid-session workspace management, mirroring Claude Code's --add-dir.
519
+ # `/add-dir <path>` adds an extra allowed root (write/edit can then reach
520
+ # files under it) and runs the folder-trust gate so its AGENTS.md/skills
521
+ # are only honored once vouched for. `/dirs` lists the current roots.
522
+
523
+ def handle_add_dir(arguments)
524
+ path = arguments.to_s.strip
525
+ if path.empty?
526
+ @ui.info("Usage: /add-dir <path> — adds an extra allowed workspace directory.")
527
+ return
528
+ end
529
+
530
+ real = Rubino::Workspace.add(path)
531
+ @ui.success("Added workspace root: #{real}")
532
+ # Gate the freshly-added dir interactively (same one-time prompt as boot).
533
+ CLI::TrustGate.new(ui: @ui, interactive: true).ensure_trust(real)
534
+ rescue ArgumentError => e
535
+ @ui.error("/add-dir #{path}: #{e.message}")
536
+ end
537
+
538
+ def show_dirs
539
+ roots = Rubino::Workspace.canonical_roots
540
+ @ui.info("Workspace roots (#{roots.size}):")
541
+ roots.each_with_index do |dir, i|
542
+ marker = i.zero? ? "▸" : " "
543
+ trust = Rubino::Trust.trusted?(dir) ? "" : " (untrusted — context/skills withheld)"
544
+ @ui.info(" #{marker} #{dir}#{trust}")
545
+ end
546
+ @ui.info("Add more with /add-dir <path>")
547
+ end
548
+ end
549
+ end
550
+ end