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,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+ require "zlib"
5
+
6
+ module Rubino
7
+ module UI
8
+ # Nested UI adapter for a running subagent (the `task` tool).
9
+ #
10
+ # While the parent loop delegates to a subagent, the child runs its own
11
+ # isolated Agent::Runner. By default that child is wired with UI::Null, so
12
+ # its activity is invisible. This adapter makes the child's TOOL ACTIVITY
13
+ # visible INLINE — compact rows indented under the parent's
14
+ # "● delegated → X" delegation boundary, in a per-subagent color — so the
15
+ # user can watch what the subagent is doing live.
16
+ #
17
+ # DISPLAY ONLY. This adapter writes to $stdout (which, during a parent turn,
18
+ # is the composer proxy → committed above the bottom composer like every
19
+ # other timeline row). It never touches the parent loop's `messages` or the
20
+ # parent recorder: the result-only contract is unchanged. The parent model
21
+ # still receives ONLY the subagent's final result (the `task` tool result).
22
+ #
23
+ # COLLAPSED-CARD MODE (Variant A — kills the flood, #124): instead of
24
+ # writing one $stdout row per child tool call (which buried the parent
25
+ # prompt), tool_started/tool_finished now feed the BackgroundTasks REGISTRY
26
+ # entry for this run (last_activity + a tool counter + a bounded recent-ring)
27
+ # and ask the parent UI to repaint its collapsed live CARD. The card shows a
28
+ # single in-place line per subagent (`▸ sa_… · explore · running · N tools ·
29
+ # Ns · <last_activity>`) that updates without scrolling — see UI::CLI
30
+ # #set_subagent_cards / UI::SubagentCards. The /agents <id> drill-in tails the
31
+ # same registry ring for the live recent: list (#71).
32
+ #
33
+ # The view is wired with the entry id at construction (TaskTool builds it per
34
+ # background run). With no id (legacy/foreground synchronous path, tests) it
35
+ # falls back to the OLD inline rows so the synchronous delegation surface and
36
+ # its specs are unchanged.
37
+ #
38
+ # Inline (legacy) format, 2-space extra indent under the delegation row:
39
+ # ` ⟂ explore · read lib/foo.rb`
40
+ # ` ⟂ explore · ✓ grep · 3 matches`
41
+ #
42
+ # Noise control:
43
+ # - stream / stream_end / assistant_text / thinking_started are SUPPRESSED
44
+ # (the subagent's prose isn't shown — only its steps and the final
45
+ # result, which the parent already prints as "✓ X: result");
46
+ # - note / status / info render as dim nested lines (low-noise) ONLY in the
47
+ # legacy inline path; in card mode they fold into the registry too;
48
+ # - confirm: in card mode it does NOT auto-deny — it surfaces the approval
49
+ # on the card and parks the child on a per-entry gate (Option 2; wired by
50
+ # TaskTool). With no approval handler (legacy/foreground) it auto-DENIES
51
+ # so a subagent never blocks on a prompt no one can answer.
52
+ class SubagentView < Base
53
+ # Deterministic per-subagent palette. Chosen by hashing the agent name so
54
+ # the same subagent always renders in the same color (no Math.random),
55
+ # and concurrent/sequential delegations to different subagents stay
56
+ # visually distinct. All names are valid Pastel foreground colors.
57
+ PALETTE = %i[cyan magenta blue yellow green bright_cyan].freeze
58
+
59
+ # Nested-row indent: 2 spaces beyond the CLI's own 2-space body indent so
60
+ # the subagent's steps read as nested under the "● delegated → X" row.
61
+ INDENT = " "
62
+
63
+ # Glyph prefixing every subagent activity row.
64
+ GLYPH = "⟂"
65
+
66
+ # @param entry_id [String, nil] the BackgroundTasks entry this view feeds
67
+ # in card mode. nil ⇒ legacy inline-row mode (synchronous/foreground path).
68
+ # @param parent_ui [UI::CLI, nil] the parent CLI whose live region hosts the
69
+ # collapsed card; #set_subagent_cards repaints it. Captured at spawn on the
70
+ # parent thread (the child thread has no access to the parent's UI).
71
+ # @param approve [#call, nil] in card mode, the approval handler TaskTool
72
+ # wires: called with (question, scope:, command:) and returns the boolean
73
+ # decision. nil ⇒ #confirm auto-denies (legacy behavior).
74
+ def initialize(agent_name:, out: $stdout, pastel: Pastel.new,
75
+ entry_id: nil, parent_ui: nil, approve: nil)
76
+ @agent_name = agent_name.to_s
77
+ @out = out
78
+ @pastel = pastel
79
+ @color = PALETTE[color_index(@agent_name)]
80
+ @entry_id = entry_id
81
+ @parent_ui = parent_ui
82
+ @approve = approve
83
+ end
84
+
85
+ # The color this view paints its rows in (exposed for tests).
86
+ attr_reader :color
87
+
88
+ # True when this view feeds a registry entry (collapsed-card mode) rather
89
+ # than flooding $stdout with per-tool rows (legacy inline mode).
90
+ def card_mode?
91
+ !@entry_id.nil?
92
+ end
93
+
94
+ # --- Rendered: tool activity (the "what it's doing") -------------------
95
+
96
+ # Card mode: record the tool start on the registry entry (last_activity +
97
+ # tool counter) and repaint the parent's collapsed card — NO $stdout row, so
98
+ # a read-heavy child never floods the parent terminal (#124). Legacy mode:
99
+ # the old inline ` ⟂ explore · read lib/foo.rb` row.
100
+ def tool_started(name, arguments: nil, at: nil)
101
+ hint = args_hint(arguments)
102
+ if card_mode?
103
+ activity = hint ? "#{name} #{hint}" : name.to_s
104
+ Tools::BackgroundTasks.instance.record_tool_started(@entry_id, activity)
105
+ repaint_cards
106
+ else
107
+ body = hint ? "#{name} #{hint}" : name.to_s
108
+ row(body)
109
+ end
110
+ end
111
+
112
+ # Card mode: append the terse finish line to the entry's recent-ring (which
113
+ # the /agents drill-in tails) and repaint. Legacy mode: the old inline row.
114
+ def tool_finished(name, result: nil)
115
+ failed = result.respond_to?(:success?) && !result.success?
116
+ icon = failed ? "✗" : "✓"
117
+ suffix = result_metric(result)
118
+ body = suffix ? "#{icon} #{name} · #{suffix}" : "#{icon} #{name}"
119
+ if card_mode?
120
+ Tools::BackgroundTasks.instance.record_tool_finished(@entry_id, body)
121
+ repaint_cards
122
+ else
123
+ row(body)
124
+ end
125
+ end
126
+
127
+ # tool_body / tool_chunk: the child's tool previews/streamed chunks. Kept
128
+ # quiet to stay low-noise — the start/finish rows already say what ran.
129
+ def tool_body(_text, kind: :plain); end
130
+ def tool_chunk(_name, _chunk); end
131
+
132
+ # --- Suppressed: the child's prose / token stream ---------------------
133
+
134
+ def stream(_chunk); end
135
+ def stream_end; end
136
+ def assistant_text(_text); end
137
+ def body(_text); end
138
+ def thinking_started; end
139
+ def replay_user_input(_text, at: nil); end
140
+ def table(headers:, rows:); end
141
+
142
+ # --- Low-noise: dim nested annotations -------------------------------
143
+
144
+ # In card mode these fold away (the card is the only surface); in legacy
145
+ # inline mode they keep their dim nested rows.
146
+ def note(text) = card_mode? ? nil : dim_row(text)
147
+ def status(text) = card_mode? ? nil : dim_row(text)
148
+ def info(text) = card_mode? ? nil : dim_row(text)
149
+
150
+ def success(message) = card_mode? ? nil : row("✓ #{message}")
151
+ def warning(message) = card_mode? ? nil : row("⚠ #{message}")
152
+ def error(message) = card_mode? ? nil : row("✗ #{message}")
153
+
154
+ # --- Suppressed lifecycle chrome ------------------------------------
155
+
156
+ def separator; end
157
+ def blank_line; end
158
+ def compression_started(at: nil); end
159
+ def compression_finished(_metadata, at: nil); end
160
+ def job_enqueued(_type); end
161
+ def job_started(_type); end
162
+ def job_finished(_type); end
163
+ def mode_changed(_name, previous: nil); end
164
+ def box_open(*_pieces, at: nil, color: nil); end
165
+ def box_close(*_pieces, color: nil); end
166
+ def queued(_text); end
167
+ def input_injected(_text); end
168
+
169
+ # --- Interactive: surface the approval, don't auto-deny -------------
170
+
171
+ # Option 2 — approval-surfacing. In card mode WITH an approval handler
172
+ # (wired by TaskTool), a child tool that needs approval is NOT silently
173
+ # denied: we hand off to @approve, which flips the registry entry to
174
+ # :needs_approval (surfacing it on the card + a parent note) and BLOCKS the
175
+ # child thread on a per-entry Run::ApprovalGate until the user answers via
176
+ # /agents <id> (or the 15-min bound auto-denies). The handler returns the
177
+ # boolean decision, which we return so the child's tool proceeds or denies.
178
+ #
179
+ # Without a handler (legacy inline / foreground path) we keep the old
180
+ # AUTO-DENY (false): a subagent there must never hang on a prompt no one can
181
+ # answer.
182
+ def confirm(question, scope: nil, **context)
183
+ return @approve.call(question, scope: scope, **context) if @approve
184
+
185
+ false
186
+ end
187
+
188
+ # No interactive clarification mid-delegation either.
189
+ def ask(_prompt)
190
+ nil
191
+ end
192
+
193
+ private
194
+
195
+ # Asks the parent CLI to repaint the collapsed card block from the
196
+ # registry's current snapshot. Best-effort and quiet: a repaint is cosmetic
197
+ # and must never break the child's run. No-op when there's no parent CLI
198
+ # (the registry still has the fresh data for the /agents drill-in).
199
+ def repaint_cards
200
+ @parent_ui.set_subagent_cards if @parent_ui.respond_to?(:set_subagent_cards)
201
+ rescue StandardError
202
+ nil
203
+ end
204
+
205
+ # Stable palette index for a name: CRC32 keeps it deterministic across
206
+ # processes (Ruby's String#hash is salted per-run) and dependency-free.
207
+ def color_index(name)
208
+ Zlib.crc32(name) % PALETTE.size
209
+ end
210
+
211
+ # Emits one colored, indented, name-prefixed activity row.
212
+ def row(text)
213
+ return if text.nil? || text.to_s.strip.empty?
214
+
215
+ @out.puts @pastel.public_send(@color, "#{INDENT}#{GLYPH} #{@agent_name} · #{text}")
216
+ end
217
+
218
+ # Dim variant for low-priority annotations (note/status/info).
219
+ def dim_row(text)
220
+ return if text.nil? || text.to_s.strip.empty?
221
+
222
+ @out.puts @pastel.dim("#{INDENT}#{GLYPH} #{@agent_name} · #{first_line(text, 80)}")
223
+ end
224
+
225
+ # A compact metric for the finish row: prefer the tool's own metrics,
226
+ # else a truncated preview of the output.
227
+ def result_metric(result)
228
+ return nil unless result
229
+
230
+ metric = result.metrics if result.respond_to?(:metrics)
231
+ return first_line(metric, 60) if metric && !metric.to_s.strip.empty?
232
+
233
+ preview = result.truncated_preview if result.respond_to?(:truncated_preview)
234
+ preview && !preview.to_s.strip.empty? ? first_line(preview, 60) : nil
235
+ end
236
+
237
+ # Short identifier piece from the tool arguments (path/pattern/command).
238
+ def args_hint(arguments)
239
+ return nil unless arguments.is_a?(Hash)
240
+
241
+ %i[file_path path pattern command].each do |k|
242
+ v = arguments[k] || arguments[k.to_s]
243
+ return first_line(v, 60) if v && !v.to_s.strip.empty?
244
+ end
245
+ nil
246
+ end
247
+
248
+ # First NON-BLANK line, elided to +max+ — a multi-line ruby/shell command
249
+ # often starts with a blank line, which would render an empty hint (#141).
250
+ def first_line(text, max)
251
+ Rubino::Util::Output.first_line(text, max)
252
+ end
253
+ end
254
+ end
255
+ end
data/lib/rubino/ui.rb ADDED
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ # UI module namespace and factory.
5
+ # All output in the application flows through a UI adapter.
6
+ module UI
7
+ # Factory method to build the appropriate UI adapter
8
+ def self.build(adapter_name)
9
+ case adapter_name.to_s
10
+ when "cli"
11
+ CLI.new
12
+ when "api"
13
+ API.new
14
+ when "null"
15
+ Null.new
16
+ else
17
+ raise ConfigurationError, "Unknown UI adapter: #{adapter_name}"
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+ require "time"
7
+ require "fileutils"
8
+
9
+ module Rubino
10
+ # Boot-time "new version available" notice + the `rubino update` mechanics.
11
+ #
12
+ # Two decoupled concerns, mirroring how `gh`/update-notifier do it:
13
+ #
14
+ # * SHOW (sync, zero network): `notice_from_cache` reads
15
+ # <RUBINO_HOME>/update_check.json and returns a one-line notice only when
16
+ # the cached `latest` is a valid Gem::Version strictly greater than the
17
+ # running VERSION. Pure local read — cannot slow boot, works offline.
18
+ #
19
+ # * REFRESH (out-of-band): `refresh_async_if_stale` spawns a detached,
20
+ # fully-rescued Thread (≈1.5s timeout) that GETs RubyGems and rewrites the
21
+ # cache for the NEXT boot. It is never joined, so this boot never blocks.
22
+ # Gated to once/24h, TTY-only, not-in-CI, and skipped entirely when
23
+ # RUBINO_NO_UPDATE_CHECK is set.
24
+ #
25
+ # The whole feature no-ops until rubino-agent is actually published: RubyGems
26
+ # currently returns {"version":"unknown"}, and "unknown" / non-semver / nil /
27
+ # any network error are all treated as "no info" → no notice.
28
+ module UpdateCheck
29
+ LATEST_URL = "https://rubygems.org/api/v1/versions/rubino-agent/latest.json"
30
+ GEM_NAME = "rubino-agent"
31
+ CACHE_FILE = "update_check.json"
32
+ CHECK_INTERVAL = 24 * 60 * 60 # 24h, like gh/Homebrew
33
+ NET_TIMEOUT = 1.5
34
+
35
+ module_function
36
+
37
+ # ---- SHOW (pure local read) -------------------------------------------
38
+
39
+ # One-line dim notice when a newer version is cached, else nil. The
40
+ # RUBINO_NO_UPDATE_CHECK opt-out disables the feature ENTIRELY — "no
41
+ # network, no notice" per docs/commands.md — so a previously-cached
42
+ # notice must not leak through either (#66).
43
+ def notice_from_cache
44
+ return nil if opted_out?
45
+
46
+ latest = cached_latest
47
+ return nil unless newer?(latest)
48
+
49
+ "▸ rubino v#{latest} available — run `rubino update`"
50
+ end
51
+
52
+ # ---- REFRESH (out-of-band, never awaited) -----------------------------
53
+
54
+ # Refresh the cache in a detached thread iff enabled and stale. Returns the
55
+ # spawned Thread (tests can join it) or nil when gated out. The caller never
56
+ # joins it on the boot path, so this boot is never slowed.
57
+ def refresh_async_if_stale
58
+ return nil unless checks_enabled?
59
+ return nil unless stale?
60
+
61
+ Thread.new do
62
+ latest = fetch_latest
63
+ write_cache(latest) if latest
64
+ rescue StandardError
65
+ # Offline, DNS, TLS, JSON garbage, FS — silent. The cache is left as-is,
66
+ # so a transient failure simply shows no notice.
67
+ nil
68
+ end
69
+ end
70
+
71
+ # ---- network ----------------------------------------------------------
72
+
73
+ # The latest published version string, or nil on failure / "unknown" /
74
+ # non-semver. Synchronous + short-timeout; callers that must not block run
75
+ # it on a detached thread (refresh_async_if_stale).
76
+ def fetch_latest
77
+ uri = URI(LATEST_URL)
78
+ http = Net::HTTP.new(uri.host, uri.port)
79
+ http.use_ssl = (uri.scheme == "https")
80
+ http.open_timeout = NET_TIMEOUT
81
+ http.read_timeout = NET_TIMEOUT
82
+
83
+ req = Net::HTTP::Get.new(uri)
84
+ req["User-Agent"] = "Rubino/#{Rubino::VERSION}"
85
+
86
+ res = http.request(req)
87
+ return nil unless res.is_a?(Net::HTTPSuccess)
88
+
89
+ version = JSON.parse(res.body)["version"].to_s
90
+ semver?(version) ? version : nil
91
+ rescue StandardError
92
+ nil
93
+ end
94
+
95
+ # ---- cache ------------------------------------------------------------
96
+
97
+ def cache_path
98
+ File.join(Rubino::Config::Loader.default_home_path, CACHE_FILE)
99
+ end
100
+
101
+ def cached_latest
102
+ return nil unless File.exist?(cache_path)
103
+
104
+ JSON.parse(File.read(cache_path))["latest"]
105
+ rescue StandardError
106
+ nil
107
+ end
108
+
109
+ # Atomic write (temp + rename) so a crashed refresh never leaves a torn file.
110
+ def write_cache(latest)
111
+ dir = File.dirname(cache_path)
112
+ FileUtils.mkdir_p(dir)
113
+ tmp = "#{cache_path}.#{Process.pid}.tmp"
114
+ File.write(tmp, JSON.generate("checked_at" => Time.now.utc.iso8601, "latest" => latest))
115
+ File.rename(tmp, cache_path)
116
+ rescue StandardError
117
+ nil
118
+ end
119
+
120
+ def clear_cache!
121
+ File.delete(cache_path) if File.exist?(cache_path)
122
+ rescue StandardError
123
+ nil
124
+ end
125
+
126
+ # ---- gating -----------------------------------------------------------
127
+
128
+ # The user's full opt-out: RUBINO_NO_UPDATE_CHECK set (to anything
129
+ # non-blank) disables refresh AND the cached boot notice.
130
+ def opted_out?
131
+ !ENV["RUBINO_NO_UPDATE_CHECK"].to_s.strip.empty?
132
+ end
133
+
134
+ # All must hold (mirrors gh): no opt-out env, interactive TTY, not CI.
135
+ def checks_enabled?
136
+ !opted_out? &&
137
+ $stdout.tty? &&
138
+ ENV["CI"].to_s.strip.empty?
139
+ end
140
+
141
+ # True when the cache is missing or its checked_at is older than 24h.
142
+ def stale?
143
+ return true unless File.exist?(cache_path)
144
+
145
+ checked_at = JSON.parse(File.read(cache_path))["checked_at"]
146
+ Time.now.utc - Time.parse(checked_at) >= CHECK_INTERVAL
147
+ rescue StandardError
148
+ true
149
+ end
150
+
151
+ # ---- update mechanics -------------------------------------------------
152
+
153
+ # How rubino was installed: :gem when a matching RubyGems spec is loaded,
154
+ # else :source (dev checkout / built from source / vendored).
155
+ def install_method
156
+ installed_gem_version(GEM_NAME) ? :gem : :source
157
+ end
158
+
159
+ def installed_gem_version(name)
160
+ Gem::Specification.find_by_name(name).version.to_s
161
+ rescue Gem::MissingSpecError, StandardError
162
+ nil
163
+ end
164
+
165
+ # Argv form (no shell) + active interpreter via Gem.ruby → updates the right
166
+ # install on a multi-Ruby machine and is injection-safe.
167
+ def gem_update_command
168
+ [Gem.ruby, "-S", "gem", "update", GEM_NAME]
169
+ end
170
+
171
+ # ---- version helpers --------------------------------------------------
172
+
173
+ # X.Y / X.Y.Z[.pre] — strict enough to reject "unknown" and other garbage.
174
+ def semver?(str)
175
+ !!(str.to_s =~ /\A\d+\.\d+(\.\d+)?([-.][0-9A-Za-z.-]+)?\z/)
176
+ end
177
+
178
+ # latest is a valid version strictly greater than the running VERSION.
179
+ def newer?(latest)
180
+ return false unless semver?(latest)
181
+
182
+ Gem::Version.new(latest) > Gem::Version.new(Rubino::VERSION)
183
+ rescue ArgumentError
184
+ false
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Util
5
+ # Compact, human-readable elapsed-time formatting shared by the agent
6
+ # cards and the /sessions + /agents listings (was copy-pasted into
7
+ # UI::SubagentCards, CLI::ChatCommand, and Commands::Executor).
8
+ #
9
+ # Coarse on purpose: seconds under a minute, then whole minutes, then
10
+ # whole hours — enough to read "how long" at a glance without a clock.
11
+ module Duration
12
+ module_function
13
+
14
+ def human_duration(seconds)
15
+ secs = seconds.to_i
16
+ return "#{secs}s" if secs < 60
17
+ return "#{secs / 60}m" if secs < 3600
18
+
19
+ "#{secs / 3600}h"
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Util
5
+ # OSC 8 terminal hyperlinks — wraps text in escape sequences that a
6
+ # supporting terminal renders as clickable links.
7
+ #
8
+ # Sequence shape: `\e]8;;URI\e\\LABEL\e]8;;\e\\`
9
+ # The first `\e]8;;` opens the link, `\e\\` (String Terminator) ends
10
+ # the URI segment, LABEL is what the user sees, and the trailing
11
+ # `\e]8;;\e\\` closes the link.
12
+ #
13
+ # ## Support detection
14
+ # OSC 8 is supported by iTerm2, WezTerm, vscode integrated terminal,
15
+ # Hyper, Ghostty, and kitty. Apple_Terminal does NOT support it and
16
+ # would render the escape codes as visible garbage. Detection is
17
+ # CONSERVATIVE: unknown terminals default to off, so users on
18
+ # Terminal.app or a tmux session whose outer terminal we can't
19
+ # introspect never see junk in their scrollback.
20
+ #
21
+ # Override with `RUBINO_HYPERLINKS=1` to force on (useful in
22
+ # tmux when you know the outer terminal supports OSC 8) or `=0` to
23
+ # force off. `NO_COLOR=1` also forces off, matching the broader
24
+ # convention used by every other ANSI-emitting tool in this CLI.
25
+ #
26
+ # ## Scope
27
+ # OSC 8 lives ENTIRELY in the CLI adapter. The API adapter emits raw
28
+ # structured events (tool name, arguments hash) and the web UI builds
29
+ # its own `<a>` elements from that — terminal escape codes have no
30
+ # business inside a JSON payload.
31
+ module Hyperlink
32
+ OPEN_PREFIX = "\e]8;;"
33
+ CLOSE_SUFFIX = "\e]8;;\e\\"
34
+ ST = "\e\\" # String Terminator
35
+
36
+ # Terminals known to render OSC 8 correctly. Conservative list —
37
+ # additions welcome as we confirm support elsewhere.
38
+ KNOWN_TERM_PROGRAMS = %w[iTerm.app WezTerm vscode Hyper ghostty].freeze
39
+
40
+ # True when the current terminal renders OSC 8 hyperlinks. Result is
41
+ # cached per process because env vars don't change mid-run.
42
+ def self.supported?
43
+ return @supported if defined?(@supported)
44
+
45
+ @supported = compute_support
46
+ end
47
+
48
+ # Test-only hook to reset the memoized support flag (specs flip env
49
+ # vars between examples). Not part of the public contract.
50
+ def self.reset!
51
+ remove_instance_variable(:@supported) if defined?(@supported)
52
+ end
53
+
54
+ # Wraps LABEL in the OSC 8 sequence pointing to URI. Returns LABEL
55
+ # unchanged when hyperlinks aren't supported, so callers can use the
56
+ # result unconditionally — no escape codes leak into a Terminal.app
57
+ # scrollback or an SSE payload.
58
+ def self.wrap(label, uri:)
59
+ return label.to_s if label.nil?
60
+ return label.to_s unless supported?
61
+ return label.to_s if uri.nil? || uri.to_s.empty?
62
+
63
+ "#{OPEN_PREFIX}#{uri}#{ST}#{label}#{CLOSE_SUFFIX}"
64
+ end
65
+
66
+ # Builds a `file://` URI for the given path, expanding to absolute
67
+ # so the terminal's URI handler doesn't try to resolve it against
68
+ # its own cwd. Returns nil when the path is empty or doesn't exist
69
+ # — callers should fall back to the raw label in that case.
70
+ def self.file_uri(path)
71
+ return nil if path.nil? || path.to_s.empty?
72
+
73
+ abs = File.expand_path(path.to_s)
74
+ return nil unless File.exist?(abs)
75
+
76
+ "file://#{abs}"
77
+ end
78
+
79
+ # Convenience for the common case: "I have a file path, wrap it as
80
+ # a clickable link to that file." Pass a different `label:` when
81
+ # the displayed text differs from the path (e.g. truncated to fit
82
+ # a header rule).
83
+ def self.wrap_path(path, label: nil)
84
+ uri = file_uri(path)
85
+ text = (label || path).to_s
86
+ return text if uri.nil?
87
+
88
+ wrap(text, uri: uri)
89
+ end
90
+
91
+ class << self
92
+ private
93
+
94
+ def compute_support
95
+ return false if ENV["NO_COLOR"] && !ENV["NO_COLOR"].empty?
96
+ return true if ENV["RUBINO_HYPERLINKS"] == "1"
97
+ return false if ENV["RUBINO_HYPERLINKS"] == "0"
98
+ return true if ENV["TERM"] == "xterm-kitty"
99
+
100
+ KNOWN_TERM_PROGRAMS.include?(ENV.fetch("TERM_PROGRAM", nil))
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end