stream_weaver 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 (381) hide show
  1. checksums.yaml +7 -0
  2. data/.beads/.gitignore +77 -0
  3. data/.beads/README.md +81 -0
  4. data/.beads/config.yaml +54 -0
  5. data/.beads/metadata.json +7 -0
  6. data/.rspec +3 -0
  7. data/AGENTS.md +91 -0
  8. data/CHANGELOG.md +294 -0
  9. data/CLAUDE.md +67 -0
  10. data/LICENSE.txt +21 -0
  11. data/README.md +565 -0
  12. data/Rakefile +13 -0
  13. data/assets/streamweaver-hero.jpg +0 -0
  14. data/bench/baselines/ledger.rb +58 -0
  15. data/bench/baselines/warroom.rb +51 -0
  16. data/bench/fixtures/ledger.rb +76 -0
  17. data/bench/fixtures/warroom.rb +60 -0
  18. data/bench/results/6daf8b8.md +102 -0
  19. data/bench/results/c8d749b.md +102 -0
  20. data/bench/results/eb06ab9.md +102 -0
  21. data/bench/run.rb +151 -0
  22. data/bench/support.rb +84 -0
  23. data/copy_this/dto_run_sample_run_7631.html +220 -0
  24. data/docs/SERVICE_MODE.md +302 -0
  25. data/docs/architecture/how_streamweaver_works.md +772 -0
  26. data/docs/blog/2026-01-16-streamweaver-introduction.md +515 -0
  27. data/docs/bug-2026-08-31-canvas-bridge-socket-collision.md +83 -0
  28. data/docs/canvas-ipc-session-summary.md +127 -0
  29. data/docs/canvas-panel-workflow.md +172 -0
  30. data/docs/canvas-read.md +147 -0
  31. data/docs/canvas-roadmap.md +275 -0
  32. data/docs/case-studies/2026-09-03-didx-canvas-read-shelf.md +147 -0
  33. data/docs/claude-code-companion-skill-spec.md +567 -0
  34. data/docs/components_reference.md +1223 -0
  35. data/docs/crud-patterns.md +265 -0
  36. data/docs/endpoints.md +122 -0
  37. data/docs/for_llms.md +1 -0
  38. data/docs/form-patterns.md +165 -0
  39. data/docs/frontend-only.md +169 -0
  40. data/docs/html-artifact-audit.md +238 -0
  41. data/docs/ideas/2025-01-01-as-a-service.md +203 -0
  42. data/docs/ideas/2026-01-16-charm-tui-exploration.md +542 -0
  43. data/docs/ideas/2026-08-27-static-doc-shelf-export.md +153 -0
  44. data/docs/ideas/dhh-review-future-refactors.md +142 -0
  45. data/docs/opal-jamstack.md +47 -0
  46. data/docs/opal-npm-direction.md +184 -0
  47. data/docs/opal-spike-findings.md +194 -0
  48. data/docs/plans/2025-12-16-form-blocks-design.md +124 -0
  49. data/docs/plans/2025-12-30-examples-browser-design.md +152 -0
  50. data/docs/plans/2026-01-02-charts-design.md +288 -0
  51. data/docs/plans/2026-01-10-canvas-ipc-design.md +247 -0
  52. data/docs/plans/2026-01-19-cabinet-control-components.md +176 -0
  53. data/docs/plans/2026-01-26-gem-release-and-panel.md +140 -0
  54. data/docs/plans/canvas-claude-project-design.md +238 -0
  55. data/docs/plans/canvas-doc-location-and-discovery.md +32 -0
  56. data/docs/plans/org-doc-preview-surfaces.md +293 -0
  57. data/docs/plans/shadcn-polish-plan.md +417 -0
  58. data/docs/porting-artifacts.md +252 -0
  59. data/docs/reference/agent-skills-comparison.md +369 -0
  60. data/docs/reference/travel-state-prd.artifact.html +780 -0
  61. data/docs/research/2026-08-17-hotwire-alike-landscape.md +268 -0
  62. data/docs/research/2026-08-17-hotwire-concept-map.md +404 -0
  63. data/docs/research/2026-08-22-lazy-fragments-trigger-decision.md +246 -0
  64. data/docs/research/2026-08-22-learnhotwire-syllabus-coverage.md +239 -0
  65. data/docs/research/frontend-only-matrix.md +278 -0
  66. data/docs/research/streamweaver-way-spike-findings.md +529 -0
  67. data/docs/resource-dsl.md +342 -0
  68. data/docs/routing.md +302 -0
  69. data/docs/ruby-ui-comparison.md +319 -0
  70. data/docs/shared-dsl-fragments.md +356 -0
  71. data/docs/streamweaver-for-ai-agents.md +401 -0
  72. data/docs/streamweaver-frontend-vision.md +123 -0
  73. data/docs/streamweaver_canvas/glimmer_initial_final_layer.rb +77 -0
  74. data/docs/streamweaver_canvas/save_example.rb +9 -0
  75. data/docs/templates.md +213 -0
  76. data/docs/testing.md +155 -0
  77. data/docs/theming-hooks.md +281 -0
  78. data/docs/tutorials/the-streamweaver-way.md +882 -0
  79. data/docs/university/capability-inventory.md +137 -0
  80. data/docs/university/dependency-survey.md +114 -0
  81. data/docs/university/design-spec.md +217 -0
  82. data/docs/university/mockups/code-block-doc-theme-dark.png +0 -0
  83. data/docs/university/mockups/code-block-doc-theme-light.png +0 -0
  84. data/docs/university/mockups/course-list-dark.png +0 -0
  85. data/docs/university/mockups/course-list-light.png +0 -0
  86. data/docs/university/mockups/course_canvas_mockup.rb +741 -0
  87. data/docs/university/mockups/step-screen-dark.png +0 -0
  88. data/docs/university/mockups/step-screen-light.png +0 -0
  89. data/docs/university/roadmap.md +72 -0
  90. data/docs/university/send-to-coworker.md +77 -0
  91. data/docs/university/worker-session-mining.md +109 -0
  92. data/docs/visual/browser-rendering-spike.md +173 -0
  93. data/docs/visual/builder-visual-plan-analysis.md +378 -0
  94. data/docs/visual/sw-plan-format-exploration.md +511 -0
  95. data/docs/visual/sw-plan-rendering-deep-dive.md +453 -0
  96. data/docs/visual-skills/PROGRESS.md +60 -0
  97. data/docs/visual-skills/SESSION-CONTEXT.md +207 -0
  98. data/docs/visual-skills/analysis/components.md +317 -0
  99. data/docs/visual-skills/analysis/overlap.md +360 -0
  100. data/docs/visual-skills/analysis/pi-design-deck.md +1102 -0
  101. data/docs/visual-skills/analysis/streamweaver-inventory.md +93 -0
  102. data/docs/visual-skills/analysis/unified-specs.feature +1003 -0
  103. data/docs/visual-skills/analysis/visual-explainer.md +964 -0
  104. data/docs/visual-skills/blog/blog-series-outline.md +39 -0
  105. data/docs/visual-skills/blog/token-efficiency.md +73 -0
  106. data/docs/visual-skills/design/architecture.md +1725 -0
  107. data/docs/visual-skills/design/codex-review.md +340 -0
  108. data/docs/visual-skills/design/dhh-review.md +209 -0
  109. data/docs/visual-skills/design/evolution.md +100 -0
  110. data/docs/visual-skills/design/gemini-review.md +55 -0
  111. data/docs/visual-skills/design/review-synthesis.md +149 -0
  112. data/docs/visual-skills/implementation/STATE.md +22 -0
  113. data/docs/visual-skills/implementation/plan.md +347 -0
  114. data/docs/visual-skills/implementation/spike-findings.md +237 -0
  115. data/docs/visual-skills/implementation/tasks.md +702 -0
  116. data/docs/visual-skills/lessons-learned/process.md +145 -0
  117. data/examples/README.md +96 -0
  118. data/examples/advanced/all_components.rb +177 -0
  119. data/examples/advanced/examples_browser.rb +394 -0
  120. data/examples/advanced/teachables_browser.rb +261 -0
  121. data/examples/advanced/theme_tweaker.rb +366 -0
  122. data/examples/advanced/tutorial.rb +1528 -0
  123. data/examples/agentic/agentic_form.rb +30 -0
  124. data/examples/agentic/agentic_form_autoclose.rb +30 -0
  125. data/examples/agentic/cultivation_tracker.rb +69 -0
  126. data/examples/basic/hello_world.rb +22 -0
  127. data/examples/basic/opal_tabs_table.rb +44 -0
  128. data/examples/basic/todo_list.rb +39 -0
  129. data/examples/button_loading_test.rb +36 -0
  130. data/examples/canvas/mermaid_canvas_demo.sh +151 -0
  131. data/examples/charts/bar_chart_demo.rb +57 -0
  132. data/examples/charts/line_chart_demo.rb +73 -0
  133. data/examples/charts/pie_area_demo.rb +87 -0
  134. data/examples/charts/stacked_bar_chart_demo.rb +69 -0
  135. data/examples/claude_code/README.md +85 -0
  136. data/examples/claude_code/codebreaker/.claude/commands/infiltrate.md +632 -0
  137. data/examples/claude_code/codebreaker/.claude/settings.json +11 -0
  138. data/examples/claude_code/codebreaker/.claude/settings.local.json +17 -0
  139. data/examples/claude_code/codebreaker/.claude/skills/infiltrate.md +0 -0
  140. data/examples/claude_code/codebreaker/README.md +213 -0
  141. data/examples/claude_code/tutorial/.claude/commands/learn.md +453 -0
  142. data/examples/claude_code/tutorial/README.md +102 -0
  143. data/examples/claude_code/verification_flow/.claude/commands/verify.md +171 -0
  144. data/examples/claude_code/verification_flow/README.md +126 -0
  145. data/examples/components/annotated_code_demo.rb +54 -0
  146. data/examples/components/callout_demo.rb +33 -0
  147. data/examples/components/checkbox_group_demo.rb +46 -0
  148. data/examples/components/design_review.css +468 -0
  149. data/examples/components/design_review_demo.rb +44 -0
  150. data/examples/components/design_review_dsl.rb +300 -0
  151. data/examples/components/diff_block_demo.rb +112 -0
  152. data/examples/components/events_demo.rb +184 -0
  153. data/examples/components/form_demo.rb +62 -0
  154. data/examples/components/lesson_demo.rb +53 -0
  155. data/examples/components/markdown_demo.rb +110 -0
  156. data/examples/components/mermaid_demo.rb +166 -0
  157. data/examples/components/pareto_set.rb +64 -0
  158. data/examples/components/prd_demo.rb +21 -0
  159. data/examples/components/prd_dsl.rb +385 -0
  160. data/examples/components/quiz_demo.rb +47 -0
  161. data/examples/components/run_viewer_demo.rb +100 -0
  162. data/examples/components/score_and_collapsible_demo.rb +82 -0
  163. data/examples/components/select_stale_value_smoke_test.rb +39 -0
  164. data/examples/components/table_demo.rb +247 -0
  165. data/examples/components/timer_with_state_demo.rb +58 -0
  166. data/examples/components/todo_due_dates.rb +63 -0
  167. data/examples/components/uat_gaps_demo.rb +112 -0
  168. data/examples/dashboard/feed_simulator.rb +60 -0
  169. data/examples/dashboard/live_dashboard.rb +80 -0
  170. data/examples/dashboard_components.rb +108 -0
  171. data/examples/deferred_fragments_demo.rb +62 -0
  172. data/examples/generate_more_spike/README.md +63 -0
  173. data/examples/generate_more_spike/app.rb +441 -0
  174. data/examples/git_health.sh +401 -0
  175. data/examples/layout/layout_components_demo.rb +174 -0
  176. data/examples/layout/modal_demo.rb +215 -0
  177. data/examples/layout/navigation_demo.rb +227 -0
  178. data/examples/layout/route_tabs_demo.rb +52 -0
  179. data/examples/layout/routing_demo.rb +52 -0
  180. data/examples/layout/scroll_box_demo.rb +154 -0
  181. data/examples/lazy_fragments_demo.rb +151 -0
  182. data/examples/my_todos/README.md +29 -0
  183. data/examples/my_todos/my_todos.rb +312 -0
  184. data/examples/my_todos/store.rb +86 -0
  185. data/examples/opal/reactive_demo.rb +62 -0
  186. data/examples/opal/scenarios/s1_counter.rb +15 -0
  187. data/examples/opal/scenarios/s2_search_filter.rb +17 -0
  188. data/examples/opal/scenarios/s3_sibling_tabs.rb +25 -0
  189. data/examples/opal/scenarios/s4_shopping_cart.rb +34 -0
  190. data/examples/opal/scenarios/s5_watch.rb +39 -0
  191. data/examples/opal/scenarios/s6_on_start.rb +20 -0
  192. data/examples/opal/scenarios/s7_wizard.rb +33 -0
  193. data/examples/opal/scenarios/s8_loan_calculator.rb +21 -0
  194. data/examples/opal/scenarios/s9_dashboard.rb +27 -0
  195. data/examples/operations_dashboard_demo.rb +167 -0
  196. data/examples/panel_demo.sh +447 -0
  197. data/examples/parity/assets/tyrion_slice.css +86 -0
  198. data/examples/parity/rivet_people_slice.rb +182 -0
  199. data/examples/parity/tyrion_components.css +777 -0
  200. data/examples/parity/tyrion_warroom_components.rb +414 -0
  201. data/examples/parity/tyrion_warroom_slice.rb +549 -0
  202. data/examples/puma_dev/README.md +124 -0
  203. data/examples/puma_dev/config.ru +34 -0
  204. data/examples/puma_dev/standalone_app.rb +34 -0
  205. data/examples/scaffolding/blog.rb +49 -0
  206. data/examples/scaffolding/utf_lite.rb +105 -0
  207. data/examples/styling/feedback_demo.rb +229 -0
  208. data/examples/styling/style_showcase.rb +172 -0
  209. data/examples/styling/theme_demo.rb +508 -0
  210. data/examples/timer_health_checker.rb +111 -0
  211. data/examples/timer_showcase.rb +131 -0
  212. data/examples/tutorials/render_markdown.rb +112 -0
  213. data/examples/tutorials/streamweaver_way_tutorial.rb +44 -0
  214. data/examples/tutorials/tutorial_content.rb +1082 -0
  215. data/examples/visual_skills/design_deck_demo.rb +314 -0
  216. data/examples/visual_skills/explainer_demo.rb +499 -0
  217. data/exe/streamweaver +9 -0
  218. data/gsd/ROADMAP-1.0.md +167 -0
  219. data/gsd/STATE.md +13 -0
  220. data/gsd/research/market-positioning-research.md +139 -0
  221. data/gsd/research/production-patterns-research.md +270 -0
  222. data/gsd/research/repo-audit-1.0.md +274 -0
  223. data/lib/stream_weaver/action_token.rb +58 -0
  224. data/lib/stream_weaver/adapter/alpinejs.rb +8249 -0
  225. data/lib/stream_weaver/adapter/base.rb +591 -0
  226. data/lib/stream_weaver/adapter/opal.rb +334 -0
  227. data/lib/stream_weaver/adapter/static.rb +1118 -0
  228. data/lib/stream_weaver/admin.rb +176 -0
  229. data/lib/stream_weaver/app.rb +1821 -0
  230. data/lib/stream_weaver/assets/js/sw-copy.js +50 -0
  231. data/lib/stream_weaver/assets/js/sw-heredoc-rewrite.js +68 -0
  232. data/lib/stream_weaver/assets/js/sw-keyboard.js +165 -0
  233. data/lib/stream_weaver/assets/js/sw-mermaid-zoom.js +621 -0
  234. data/lib/stream_weaver/assets/js/sw-route-tabs.js +53 -0
  235. data/lib/stream_weaver/assets/js/sw-sidebar-toc.js +116 -0
  236. data/lib/stream_weaver/assets/js/sw-slide-nav.js +103 -0
  237. data/lib/stream_weaver/canvas/bridge.rb +239 -0
  238. data/lib/stream_weaver/canvas/bridge_server.rb +646 -0
  239. data/lib/stream_weaver/canvas/client.rb +298 -0
  240. data/lib/stream_weaver/canvas/doc_roots.rb +233 -0
  241. data/lib/stream_weaver/canvas/doc_store.rb +252 -0
  242. data/lib/stream_weaver/canvas/gist_publisher.rb +264 -0
  243. data/lib/stream_weaver/canvas/gist_save_handler.rb +89 -0
  244. data/lib/stream_weaver/canvas/gist_store.rb +135 -0
  245. data/lib/stream_weaver/canvas/helpers.rb +109 -0
  246. data/lib/stream_weaver/canvas/history.rb +90 -0
  247. data/lib/stream_weaver/canvas/protocol.rb +86 -0
  248. data/lib/stream_weaver/canvas/reader.rb +853 -0
  249. data/lib/stream_weaver/canvas/save_doc_widget.rb +457 -0
  250. data/lib/stream_weaver/canvas/scroll_top_hint.rb +21 -0
  251. data/lib/stream_weaver/canvas/session.rb +132 -0
  252. data/lib/stream_weaver/cli.rb +3235 -0
  253. data/lib/stream_weaver/component_assets.rb +70 -0
  254. data/lib/stream_weaver/component_registry.rb +67 -0
  255. data/lib/stream_weaver/component_renderer.rb +48 -0
  256. data/lib/stream_weaver/components/annotated_code.rb +53 -0
  257. data/lib/stream_weaver/components/api_endpoint.rb +42 -0
  258. data/lib/stream_weaver/components/callout.rb +59 -0
  259. data/lib/stream_weaver/components/chart.rb +84 -0
  260. data/lib/stream_weaver/components/code_block.rb +77 -0
  261. data/lib/stream_weaver/components/comparison.rb +39 -0
  262. data/lib/stream_weaver/components/decision.rb +38 -0
  263. data/lib/stream_weaver/components/deck/close_overlay.rb +84 -0
  264. data/lib/stream_weaver/components/deck/confirmation_bar.rb +60 -0
  265. data/lib/stream_weaver/components/deck/deck_option.rb +61 -0
  266. data/lib/stream_weaver/components/deck/deck_slide.rb +77 -0
  267. data/lib/stream_weaver/components/deck/deck_state.rb +469 -0
  268. data/lib/stream_weaver/components/deck/deck_summary.rb +73 -0
  269. data/lib/stream_weaver/components/deck/design_deck.rb +60 -0
  270. data/lib/stream_weaver/components/deck/generate_more_controls.rb +84 -0
  271. data/lib/stream_weaver/components/deck/model_selector.rb +84 -0
  272. data/lib/stream_weaver/components/deck/skeleton_placeholder.rb +36 -0
  273. data/lib/stream_weaver/components/diff_block.rb +125 -0
  274. data/lib/stream_weaver/components/doc_header.rb +57 -0
  275. data/lib/stream_weaver/components/image_block.rb +70 -0
  276. data/lib/stream_weaver/components/implementation_map.rb +33 -0
  277. data/lib/stream_weaver/components/keyboard_shortcuts.rb +94 -0
  278. data/lib/stream_weaver/components/kpi_dashboard.rb +78 -0
  279. data/lib/stream_weaver/components/mermaid.rb +79 -0
  280. data/lib/stream_weaver/components/pipeline.rb +63 -0
  281. data/lib/stream_weaver/components/sidebar_toc.rb +46 -0
  282. data/lib/stream_weaver/components/slide_container.rb +141 -0
  283. data/lib/stream_weaver/components/timeline_event.rb +58 -0
  284. data/lib/stream_weaver/components/wireframe.rb +29 -0
  285. data/lib/stream_weaver/components/wireframe_block.rb +34 -0
  286. data/lib/stream_weaver/components.rb +2711 -0
  287. data/lib/stream_weaver/css.rb +230 -0
  288. data/lib/stream_weaver/dev_fallback_overlay.rb +61 -0
  289. data/lib/stream_weaver/display_dsl.rb +1004 -0
  290. data/lib/stream_weaver/export/html_exporter.rb +478 -0
  291. data/lib/stream_weaver/feed.rb +34 -0
  292. data/lib/stream_weaver/feed_builder.rb +31 -0
  293. data/lib/stream_weaver/fonts.rb +33 -0
  294. data/lib/stream_weaver/interaction_runner.rb +487 -0
  295. data/lib/stream_weaver/iterm.rb +460 -0
  296. data/lib/stream_weaver/layout_registry.rb +92 -0
  297. data/lib/stream_weaver/opal/bridge.rb +52 -0
  298. data/lib/stream_weaver/opal/builder.rb +193 -0
  299. data/lib/stream_weaver/opal/env.rb +29 -0
  300. data/lib/stream_weaver/opal/reactive_state.rb +75 -0
  301. data/lib/stream_weaver/opal/regexp_anchor_patch.rb +62 -0
  302. data/lib/stream_weaver/opal/renderer.rb +77 -0
  303. data/lib/stream_weaver/opal/runtime.rb +250 -0
  304. data/lib/stream_weaver/opal/shell.rb +145 -0
  305. data/lib/stream_weaver/opal/string_bridge.rb +50 -0
  306. data/lib/stream_weaver/opal/stubs/diff.min.js +1 -0
  307. data/lib/stream_weaver/opal/stubs/digest.rb +15 -0
  308. data/lib/stream_weaver/opal/stubs/marked.umd.js +79 -0
  309. data/lib/stream_weaver/opal/stubs/md5.rb +3 -0
  310. data/lib/stream_weaver/opal/stubs/morphdom.min.js +775 -0
  311. data/lib/stream_weaver/opal/stubs/prism-tomorrow.min.css +1 -0
  312. data/lib/stream_weaver/opal/stubs/prism.min.js +1967 -0
  313. data/lib/stream_weaver/opal_entry.rb +136 -0
  314. data/lib/stream_weaver/org/inline.rb +95 -0
  315. data/lib/stream_weaver/org/reader.rb +561 -0
  316. data/lib/stream_weaver/org/recording_context.rb +86 -0
  317. data/lib/stream_weaver/org/source_splitter.rb +63 -0
  318. data/lib/stream_weaver/org/writer.rb +300 -0
  319. data/lib/stream_weaver/page_shell.rb +530 -0
  320. data/lib/stream_weaver/portfile.rb +79 -0
  321. data/lib/stream_weaver/pushable.rb +42 -0
  322. data/lib/stream_weaver/resource/default_views.rb +111 -0
  323. data/lib/stream_weaver/resource/field_input.rb +25 -0
  324. data/lib/stream_weaver/resource/state_keys.rb +16 -0
  325. data/lib/stream_weaver/resource/store.rb +18 -0
  326. data/lib/stream_weaver/resource.rb +104 -0
  327. data/lib/stream_weaver/server.rb +1471 -0
  328. data/lib/stream_weaver/service.rb +1240 -0
  329. data/lib/stream_weaver/service_client.rb +104 -0
  330. data/lib/stream_weaver/session_store.rb +186 -0
  331. data/lib/stream_weaver/skills/streamweaver-canvas-safe/SKILL.md +66 -0
  332. data/lib/stream_weaver/skills/streamweaver-canvas-safe/examples/canvas-safe-showcase.rb +98 -0
  333. data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/actions-and-buttons.md +47 -0
  334. data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/charts-and-diagrams.md +38 -0
  335. data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/deck.md +32 -0
  336. data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/inputs-and-forms.md +65 -0
  337. data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/tabs-and-navigation.md +53 -0
  338. data/lib/stream_weaver/skills/streamweaver-doc-builder/SKILL.md +298 -0
  339. data/lib/stream_weaver/skills/streamweaver-visual-companion/SKILL.md +130 -0
  340. data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/design-review-example.css +468 -0
  341. data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/design-review-example.rb +39 -0
  342. data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/design-review-example_dsl.rb +300 -0
  343. data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/doc-parity-example.rb +24 -0
  344. data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/doc-parity-example_dsl.rb +385 -0
  345. data/lib/stream_weaver/skills/streamweaver-visual-companion/references/checkpoints-and-forms.md +28 -0
  346. data/lib/stream_weaver/skills/streamweaver-visual-companion/references/cleanup-and-panel.md +44 -0
  347. data/lib/stream_weaver/skills/streamweaver-visual-companion/references/example-gallery.md +8 -0
  348. data/lib/stream_weaver/skills/streamweaver-visual-companion/references/persistence.md +58 -0
  349. data/lib/stream_weaver/skills/streamweaver-way/SKILL.md +396 -0
  350. data/lib/stream_weaver/skills/visual-plan/SKILL.md +201 -0
  351. data/lib/stream_weaver/skills/visual-recap/SKILL.md +244 -0
  352. data/lib/stream_weaver/streamer.rb +63 -0
  353. data/lib/stream_weaver/templates/choices.rb +149 -0
  354. data/lib/stream_weaver/templates/code.rb +157 -0
  355. data/lib/stream_weaver/templates/confirm.rb +110 -0
  356. data/lib/stream_weaver/templates/diff.rb +195 -0
  357. data/lib/stream_weaver/templates/info.rb +142 -0
  358. data/lib/stream_weaver/templates/table.rb +169 -0
  359. data/lib/stream_weaver/templates/wizard.rb +271 -0
  360. data/lib/stream_weaver/theme/auto_mode.rb +116 -0
  361. data/lib/stream_weaver/theme/presets.rb +555 -0
  362. data/lib/stream_weaver/theme.rb +639 -0
  363. data/lib/stream_weaver/university/canvas.rb +924 -0
  364. data/lib/stream_weaver/university/course.rb +699 -0
  365. data/lib/stream_weaver/university/demos/counter.rb +33 -0
  366. data/lib/stream_weaver/university/demos/dashboard.rb +147 -0
  367. data/lib/stream_weaver/university/demos/decision_form.rb +143 -0
  368. data/lib/stream_weaver/university/demos.rb +50 -0
  369. data/lib/stream_weaver/university/listener.rb +467 -0
  370. data/lib/stream_weaver/university/progress.rb +201 -0
  371. data/lib/stream_weaver/university/runner.rb +138 -0
  372. data/lib/stream_weaver/university/scripts/growing_doc.rb +609 -0
  373. data/lib/stream_weaver/university/scripts/growing_doc_state.rb +91 -0
  374. data/lib/stream_weaver/utils.rb +31 -0
  375. data/lib/stream_weaver/version.rb +5 -0
  376. data/lib/stream_weaver/views/canvas/reader_layout.erb +800 -0
  377. data/lib/stream_weaver/views.rb +3940 -0
  378. data/lib/stream_weaver.rb +122 -0
  379. data/llms.txt +1269 -0
  380. data/sig/stream_weaver.rbs +4 -0
  381. metadata +598 -0
@@ -0,0 +1,3235 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+ require 'net/http'
5
+ require 'json'
6
+ require 'uri'
7
+ require 'fileutils'
8
+ require 'yaml'
9
+ require 'time'
10
+ require 'timeout'
11
+ require_relative 'opal/builder'
12
+ require_relative 'university/runner'
13
+ require_relative 'university/listener'
14
+
15
+ module StreamWeaver
16
+ # Command-line interface for StreamWeaver service
17
+ class CLI
18
+ DEFAULT_PORT = Service::DEFAULT_PORT
19
+
20
+ def self.run(args)
21
+ return help if args.empty?
22
+
23
+ command = args.shift
24
+
25
+ case command
26
+ when 'serve'
27
+ serve(args)
28
+ when 'run'
29
+ run_app(args)
30
+ when 'list'
31
+ list_apps
32
+ when 'remove'
33
+ remove_app(args.first)
34
+ when 'clear'
35
+ clear_apps
36
+ when 'admin'
37
+ admin
38
+ when 'showcase'
39
+ showcase
40
+ when 'tutorial'
41
+ tutorial
42
+ when 'stop'
43
+ stop_service
44
+ when 'status'
45
+ status
46
+ when 'llm'
47
+ llm_docs
48
+ when 'eval'
49
+ eval_dsl(args)
50
+ when 'prompt'
51
+ prompt_ui(args)
52
+ when 'live'
53
+ live_session(args)
54
+ when 'push'
55
+ push_content(args)
56
+ when 'live-list'
57
+ list_live_sessions
58
+ when 'live-close'
59
+ close_live_session(args.first)
60
+ when 'wait'
61
+ wait_for_submission(args)
62
+ when 'template'
63
+ run_template(args)
64
+ # Canvas commands (two-way IPC with Claude Code)
65
+ when 'canvas'
66
+ canvas_session(args)
67
+ when 'canvas-push'
68
+ canvas_push(args)
69
+ when 'canvas-wait'
70
+ canvas_wait(args)
71
+ when 'canvas-close'
72
+ canvas_close(args)
73
+ when 'canvas-raise'
74
+ canvas_raise(args)
75
+ when 'canvas-toast'
76
+ canvas_toast(args)
77
+ when 'canvas-list'
78
+ canvas_list
79
+ when 'canvas-reset'
80
+ canvas_reset(args)
81
+ when 'canvas-stop'
82
+ canvas_stop
83
+ when 'canvas-snapshot'
84
+ canvas_snapshot(args)
85
+ when 'canvas-restore'
86
+ canvas_restore(args)
87
+ when 'canvas-restart'
88
+ canvas_restart(args)
89
+ when 'canvas-read'
90
+ canvas_read(args)
91
+ when 'export'
92
+ export_html(args)
93
+ when 'org-export'
94
+ org_export(args)
95
+ when 'org-render'
96
+ org_render(args)
97
+ # High-level canvas helpers
98
+ when 'pick'
99
+ canvas_pick(args)
100
+ when 'confirm'
101
+ canvas_confirm(args)
102
+ when 'panel'
103
+ panel(args)
104
+ when 'opal-build'
105
+ opal_build(args)
106
+ when 'install-skill'
107
+ install_skill(args)
108
+ when 'setup', 'install'
109
+ setup
110
+ when 'university-listener'
111
+ university_listener(args)
112
+ when 'university-reset'
113
+ university_reset(args)
114
+ when 'university-demo'
115
+ university_demo(args)
116
+ when 'university-done'
117
+ university_done(args)
118
+ when 'focus-me'
119
+ focus_me
120
+ when 'get-started'
121
+ get_started(args)
122
+ when '--help', '-h', 'help'
123
+ help
124
+ when '--version', '-v'
125
+ puts "StreamWeaver #{StreamWeaver::VERSION}"
126
+ else
127
+ # Bare file path: streamweaver examples/basic/hello_world.rb
128
+ # Or with options: streamweaver --name "My App" file.rb
129
+ if command&.start_with?('-') || command&.end_with?('.rb')
130
+ run_app([command] + args)
131
+ else
132
+ puts "Unknown command: #{command}"
133
+ help
134
+ exit 1
135
+ end
136
+ end
137
+ end
138
+
139
+ # Start service in foreground (for development)
140
+ def self.serve(args)
141
+ port = nil
142
+
143
+ OptionParser.new do |opts|
144
+ opts.on('-p', '--port PORT', Integer, "Port (default: #{DEFAULT_PORT})") { |p| port = p }
145
+ end.parse!(args)
146
+
147
+ # No explicit --port: auto-increment past busy ports, same as standalone mode.
148
+ # Explicit --port: honor it strictly and fail on EADDRINUSE.
149
+ port ||= Service.find_available_port
150
+
151
+ puts "Starting StreamWeaver service on port #{port}..."
152
+ Service.set :port, port
153
+ Service.set :bind, '127.0.0.1'
154
+ Service.run!
155
+ end
156
+
157
+ # Run an app file
158
+ def self.run_app(args)
159
+ name = nil
160
+ file_path = nil
161
+
162
+ parser = OptionParser.new do |opts|
163
+ opts.banner = "Usage: streamweaver run [options] <file.rb>"
164
+ opts.on('-n', '--name NAME', 'Custom name for this app session') { |n| name = n }
165
+ end
166
+
167
+ begin
168
+ remaining = parser.parse(args)
169
+ file_path = remaining.first
170
+ rescue OptionParser::InvalidOption => e
171
+ # Might be a bare file path
172
+ file_path = args.find { |a| a.end_with?('.rb') }
173
+ end
174
+
175
+ unless file_path
176
+ puts "Usage: streamweaver run [--name NAME] <file.rb>"
177
+ exit 1
178
+ end
179
+
180
+ unless File.exist?(file_path)
181
+ puts "File not found: #{file_path}"
182
+ exit 1
183
+ end
184
+
185
+ ensure_service_running
186
+
187
+ # POST to service to load the app
188
+ uri = URI("http://localhost:#{service_port}/load-app")
189
+ params = { file_path: File.expand_path(file_path) }
190
+ params[:name] = name if name
191
+
192
+ response = Net::HTTP.post_form(uri, params)
193
+ result = JSON.parse(response.body)
194
+
195
+ if result['success']
196
+ url = "http://localhost:#{service_port}#{result['url']}"
197
+ puts "Loaded: #{result['name']} (#{File.basename(file_path)})"
198
+ puts "URL: #{url}"
199
+ open_browser(url)
200
+ else
201
+ puts "Error: #{result['error']}"
202
+ exit 1
203
+ end
204
+ rescue Errno::ECONNREFUSED
205
+ puts "Error: Could not connect to StreamWeaver service"
206
+ exit 1
207
+ end
208
+
209
+ # List all loaded apps with details
210
+ def self.list_apps
211
+ unless Service.service_running?
212
+ puts "StreamWeaver service is not running"
213
+ exit 1
214
+ end
215
+
216
+ begin
217
+ uri = URI("http://localhost:#{service_port}/api/apps")
218
+ response = Net::HTTP.get_response(uri)
219
+
220
+ if response.is_a?(Net::HTTPSuccess)
221
+ data = JSON.parse(response.body)
222
+ apps = data['apps'] || []
223
+
224
+ if apps.empty?
225
+ puts "No apps loaded"
226
+ else
227
+ puts "Loaded apps (#{apps.length}):\n\n"
228
+ puts format(" %-10s %-20s %-30s %10s %10s", "ID", "NAME", "FILE", "LOADED", "IDLE")
229
+ puts " " + "-" * 84
230
+
231
+ apps.each do |app|
232
+ loaded_ago = Utils.format_duration(app['age_seconds'])
233
+ idle_ago = Utils.format_duration(app['idle_seconds'])
234
+ file_name = File.basename(app['path'])
235
+
236
+ puts format(" %-10s %-20s %-30s %10s %10s",
237
+ app['id'][0..9],
238
+ Utils.truncate(app['name'], 20),
239
+ Utils.truncate(file_name, 30),
240
+ loaded_ago,
241
+ idle_ago
242
+ )
243
+ end
244
+ end
245
+ else
246
+ puts "Error getting app list"
247
+ exit 1
248
+ end
249
+ rescue Errno::ECONNREFUSED
250
+ puts "Error: Could not connect to StreamWeaver service"
251
+ exit 1
252
+ end
253
+ end
254
+
255
+ # Remove a specific app
256
+ def self.remove_app(app_id)
257
+ unless app_id
258
+ puts "Usage: streamweaver remove <app_id>"
259
+ puts "Use 'streamweaver list' to see app IDs"
260
+ exit 1
261
+ end
262
+
263
+ unless Service.service_running?
264
+ puts "StreamWeaver service is not running"
265
+ exit 1
266
+ end
267
+
268
+ begin
269
+ uri = URI("http://localhost:#{service_port}/remove-app")
270
+ response = Net::HTTP.post_form(uri, { app_id: app_id })
271
+ result = JSON.parse(response.body)
272
+
273
+ if result['success']
274
+ puts result['message']
275
+ else
276
+ puts "Error: #{result['error']}"
277
+ exit 1
278
+ end
279
+ rescue Errno::ECONNREFUSED
280
+ puts "Error: Could not connect to StreamWeaver service"
281
+ exit 1
282
+ end
283
+ end
284
+
285
+ # Clear all apps
286
+ def self.clear_apps
287
+ unless Service.service_running?
288
+ puts "StreamWeaver service is not running"
289
+ exit 1
290
+ end
291
+
292
+ begin
293
+ uri = URI("http://localhost:#{service_port}/clear-apps")
294
+ response = Net::HTTP.post_form(uri, {})
295
+ result = JSON.parse(response.body)
296
+
297
+ if result['success']
298
+ puts result['message']
299
+ else
300
+ puts "Error: #{result['error']}"
301
+ exit 1
302
+ end
303
+ rescue Errno::ECONNREFUSED
304
+ puts "Error: Could not connect to StreamWeaver service"
305
+ exit 1
306
+ end
307
+ end
308
+
309
+ # Open admin dashboard
310
+ def self.admin
311
+ ensure_service_running
312
+ url = "http://localhost:#{service_port}/admin"
313
+ puts "Opening admin dashboard..."
314
+ puts "URL: #{url}"
315
+ open_browser(url)
316
+ end
317
+
318
+ # Show examples browser (showcase)
319
+ # Runs the browser standalone (not in service) so it can use at_exit for cleanup
320
+ def self.showcase
321
+ examples_browser = File.expand_path('../../../examples/advanced/examples_browser.rb', __FILE__)
322
+
323
+ unless File.exist?(examples_browser)
324
+ puts "Examples browser not found at: #{examples_browser}"
325
+ exit 1
326
+ end
327
+
328
+ # Ensure service is running for the examples
329
+ ensure_service_running
330
+
331
+ puts "Starting Examples Browser..."
332
+ puts "Examples will load into service at http://localhost:#{service_port}"
333
+ puts "Press Ctrl+C to quit and cleanup\n\n"
334
+
335
+ # Run browser standalone (exec replaces process, so Ctrl+C triggers at_exit)
336
+ exec(RbConfig.ruby, examples_browser)
337
+ end
338
+
339
+ # Show interactive tutorial
340
+ # Runs the tutorial standalone (not in service) so it can use at_exit for cleanup
341
+ def self.tutorial
342
+ tutorial_app = File.expand_path('../../../examples/advanced/tutorial.rb', __FILE__)
343
+
344
+ unless File.exist?(tutorial_app)
345
+ puts "Tutorial not found at: #{tutorial_app}"
346
+ exit 1
347
+ end
348
+
349
+ # Ensure service is running for the playgrounds
350
+ ensure_service_running
351
+
352
+ puts "Starting StreamWeaver Tutorial..."
353
+ puts "Playgrounds will load into service at http://localhost:#{service_port}"
354
+ puts "Press Ctrl+C to quit and cleanup\n\n"
355
+
356
+ # Run tutorial standalone (exec replaces process, so Ctrl+C triggers at_exit)
357
+ exec(RbConfig.ruby, tutorial_app)
358
+ end
359
+
360
+ # Stop the service
361
+ def self.stop_service
362
+ if Service.service_running?
363
+ Service.stop
364
+ puts "StreamWeaver service stopped"
365
+ else
366
+ puts "No StreamWeaver service is running"
367
+ end
368
+ end
369
+
370
+ # Show service status
371
+ def self.status
372
+ if Service.service_running?
373
+ info = Service.read_pid_file
374
+ puts "StreamWeaver service is running"
375
+ puts " PID: #{info[:pid]}"
376
+ puts " Port: #{info[:port]}"
377
+ puts " URL: http://localhost:#{info[:port]}"
378
+
379
+ # Try to get detailed app list
380
+ begin
381
+ uri = URI("http://localhost:#{info[:port]}/api/apps")
382
+ response = Net::HTTP.get_response(uri)
383
+ if response.is_a?(Net::HTTPSuccess)
384
+ data = JSON.parse(response.body)
385
+ apps = data['apps'] || []
386
+ puts " Loaded apps: #{apps.length}"
387
+ apps.each do |app|
388
+ idle = Utils.format_duration(app['idle_seconds'])
389
+ puts " - #{app['id'][0..7]} #{app['name']} (idle #{idle})"
390
+ end
391
+ end
392
+ rescue
393
+ # Ignore errors getting status
394
+ end
395
+ else
396
+ puts "StreamWeaver service is not running"
397
+ puts " Start with: streamweaver serve"
398
+ puts " Or run an app: streamweaver <file.rb>"
399
+ end
400
+ end
401
+
402
+ def self.llm_docs
403
+ llms_path = File.expand_path('../../../llms.txt', __FILE__)
404
+ if File.exist?(llms_path)
405
+ puts File.read(llms_path)
406
+ else
407
+ $stderr.puts "Error: llms.txt not found at #{llms_path}"
408
+ exit 1
409
+ end
410
+ end
411
+
412
+ # Evaluate StreamWeaver DSL from stdin and return JSON result
413
+ # Usage: streamweaver eval <<'RUBY'
414
+ # app "Question" do
415
+ # radio_group :choice, ["A", "B", "C"]
416
+ # end.run_once!
417
+ # RUBY
418
+ def self.eval_dsl(args)
419
+ title = "StreamWeaver Prompt"
420
+ auto_close = false
421
+
422
+ OptionParser.new do |opts|
423
+ opts.banner = "Usage: streamweaver eval [options] < script.rb"
424
+ opts.on('-t', '--title TITLE', 'Window title') { |t| title = t }
425
+ opts.on('-c', '--auto-close', 'Close browser after submit') { auto_close = true }
426
+ end.parse!(args)
427
+
428
+ # Read DSL from stdin
429
+ if $stdin.tty?
430
+ $stderr.puts "Usage: streamweaver eval <<'RUBY'"
431
+ $stderr.puts " app \"Title\" do"
432
+ $stderr.puts " text_field :name"
433
+ $stderr.puts " end.run_once!"
434
+ $stderr.puts "RUBY"
435
+ exit 1
436
+ end
437
+
438
+ dsl_code = $stdin.read.strip
439
+
440
+ # Check if code already has run_once! or run!
441
+ unless dsl_code.include?('run_once!') || dsl_code.include?('.run!')
442
+ # Wrap in app block with run_once! if not present
443
+ if dsl_code.include?('app ')
444
+ # Has app block but no run - add run_once!
445
+ dsl_code = dsl_code.sub(/end\s*\z/, "end.run_once!#{auto_close ? '(auto_close_window: true)' : ''}")
446
+ else
447
+ # No app block - wrap everything
448
+ auto_close_opt = auto_close ? 'auto_close_window: true' : ''
449
+ dsl_code = <<~RUBY
450
+ app "#{title}" do
451
+ #{dsl_code}
452
+ end.run_once!(#{auto_close_opt})
453
+ RUBY
454
+ end
455
+ end
456
+
457
+ # Create temp file
458
+ require 'tempfile'
459
+ temp_file = Tempfile.new(['streamweaver_eval', '.rb'])
460
+ temp_file.write("require 'stream_weaver'\n\n#{dsl_code}")
461
+ temp_file.close
462
+
463
+ begin
464
+ # Execute and capture output (run_once! outputs JSON to stdout)
465
+ result = `#{RbConfig.ruby} #{temp_file.path}`
466
+ puts result
467
+ focus_terminal if auto_close || dsl_code.include?('auto_close')
468
+ ensure
469
+ temp_file.unlink
470
+ end
471
+ end
472
+
473
+ # Quick prompt UI from command-line flags
474
+ # Usage: streamweaver prompt "Title" --radio "choice:A,B,C" --text "notes:Any notes?"
475
+ def self.prompt_ui(args)
476
+ title = args.shift || "Prompt"
477
+ components = []
478
+ auto_close = true # Default to auto-close for better UX
479
+ description = nil
480
+
481
+ i = 0
482
+ while i < args.length
483
+ arg = args[i]
484
+ case arg
485
+ when '--radio'
486
+ i += 1
487
+ key, options = parse_component_arg(args[i])
488
+ components << "radio_group :#{key}, #{options.inspect}"
489
+ when '--select'
490
+ i += 1
491
+ key, options = parse_component_arg(args[i])
492
+ components << "select :#{key}, #{options.inspect}"
493
+ when '--text'
494
+ i += 1
495
+ key, placeholder = parse_component_arg(args[i])
496
+ placeholder_opt = placeholder ? ", placeholder: #{placeholder.first.inspect}" : ""
497
+ components << "text_field :#{key}#{placeholder_opt}"
498
+ when '--textarea'
499
+ i += 1
500
+ key, placeholder = parse_component_arg(args[i])
501
+ placeholder_opt = placeholder ? ", placeholder: #{placeholder.first.inspect}" : ""
502
+ components << "text_area :#{key}#{placeholder_opt}"
503
+ when '--checkbox'
504
+ i += 1
505
+ key, label = parse_component_arg(args[i])
506
+ label_str = label&.first || key.to_s.capitalize
507
+ components << "checkbox :#{key}, #{label_str.inspect}"
508
+ when '--confirm'
509
+ i += 1
510
+ key, label = parse_component_arg(args[i])
511
+ label_str = label&.first || "Confirm"
512
+ components << "checkbox :#{key}, #{label_str.inspect}"
513
+ when '--md', '--description'
514
+ i += 1
515
+ description = args[i]
516
+ when '--keep-open'
517
+ auto_close = false
518
+ end
519
+ i += 1
520
+ end
521
+
522
+ if components.empty?
523
+ $stderr.puts "Usage: streamweaver prompt \"Title\" --radio \"key:opt1,opt2\" --text \"key:placeholder\""
524
+ $stderr.puts ""
525
+ $stderr.puts "Options:"
526
+ $stderr.puts " --radio KEY:OPT1,OPT2,... Radio button group"
527
+ $stderr.puts " --select KEY:OPT1,OPT2,... Dropdown select"
528
+ $stderr.puts " --text KEY:PLACEHOLDER Text input"
529
+ $stderr.puts " --textarea KEY:PLACEHOLDER Multi-line text"
530
+ $stderr.puts " --checkbox KEY:LABEL Checkbox"
531
+ $stderr.puts " --confirm KEY:LABEL Confirmation checkbox"
532
+ $stderr.puts " --md TEXT Markdown description"
533
+ $stderr.puts " --keep-open Keep browser open after submit"
534
+ exit 1
535
+ end
536
+
537
+ # Build DSL
538
+ auto_close_opt = auto_close ? 'auto_close_window: true' : ''
539
+ md_line = description ? "md #{description.inspect}\n " : ""
540
+ dsl = <<~RUBY
541
+ require 'stream_weaver'
542
+
543
+ app "#{title}" do
544
+ #{md_line}#{components.join("\n ")}
545
+ end.run_once!(#{auto_close_opt})
546
+ RUBY
547
+
548
+ # Create temp file and execute
549
+ require 'tempfile'
550
+ temp_file = Tempfile.new(['streamweaver_prompt', '.rb'])
551
+ temp_file.write(dsl)
552
+ temp_file.close
553
+
554
+ begin
555
+ result = `#{RbConfig.ruby} #{temp_file.path}`
556
+ puts result
557
+ focus_terminal if auto_close
558
+ ensure
559
+ temp_file.unlink
560
+ end
561
+ end
562
+
563
+ # Parse "key:value1,value2" into [key, [value1, value2]]
564
+ def self.parse_component_arg(arg)
565
+ return [arg, nil] unless arg&.include?(':')
566
+ key, rest = arg.split(':', 2)
567
+ values = rest.include?(',') ? rest.split(',').map(&:strip) : [rest]
568
+ [key, values]
569
+ end
570
+
571
+ # Show help
572
+ def self.help
573
+ puts <<~HELP
574
+ StreamWeaver - Ruby DSL for reactive UIs
575
+
576
+ Usage:
577
+ streamweaver <file.rb> Run an app file
578
+ streamweaver run [options] <file> Run with options
579
+ streamweaver eval Evaluate DSL from stdin, return JSON
580
+ streamweaver prompt "Title" [opts] Quick UI from flags, return JSON
581
+ streamweaver list List all loaded apps
582
+ streamweaver remove <app_id> Remove a specific app
583
+ streamweaver clear Remove all apps
584
+ streamweaver admin Open admin dashboard
585
+ streamweaver tutorial Interactive tutorial
586
+ streamweaver showcase Browse all examples
587
+ streamweaver serve Start service in foreground
588
+ streamweaver stop Stop the background service
589
+ streamweaver status Show service status
590
+ streamweaver llm Output LLM documentation
591
+ streamweaver opal-build <app.rb> [--output DIR] Build a static Opal app to dist/
592
+ streamweaver --help Show this help
593
+ streamweaver --version Show version
594
+
595
+ Run Options:
596
+ -n, --name NAME Custom name for this app session
597
+
598
+ Prompt Options (for Claude Code integration):
599
+ --radio KEY:OPT1,OPT2,... Radio button group
600
+ --select KEY:OPT1,OPT2,... Dropdown select
601
+ --text KEY:PLACEHOLDER Text input
602
+ --textarea KEY:PLACEHOLDER Multi-line text
603
+ --checkbox KEY:LABEL Checkbox
604
+ -c, --auto-close Close browser after submit
605
+
606
+ Examples:
607
+ # Run an app
608
+ streamweaver examples/basic/hello_world.rb
609
+
610
+ # Quick prompt (for Claude Code)
611
+ streamweaver prompt "Pick approach" --radio "choice:Refactor,Adapter,Patch"
612
+
613
+ # Eval DSL from stdin
614
+ streamweaver eval <<'RUBY'
615
+ app "Survey" do
616
+ text_field :name
617
+ select :priority, ["Low", "Medium", "High"]
618
+ end.run_once!
619
+ RUBY
620
+
621
+ Live Sessions (update-in-place via SSE):
622
+ streamweaver live <name> Open a persistent live session
623
+ streamweaver push <name> [options] Push content to a live session
624
+ streamweaver live-list List all live sessions
625
+ streamweaver live-close <name> Close a live session
626
+
627
+ Push Options:
628
+ --target SELECTOR CSS selector (default: #main)
629
+ --action ACTION replace, append, prepend (default: replace)
630
+ --file FILE Read content from file
631
+ --html HTML HTML content directly
632
+ (or pipe content via stdin)
633
+
634
+ Live Session Examples:
635
+ # Open a persistent session
636
+ streamweaver live adventure
637
+
638
+ # Push content to it
639
+ echo "<h1>Hello!</h1>" | streamweaver push adventure
640
+ streamweaver push adventure --html "<p>New paragraph</p>" --action append
641
+ streamweaver push adventure --file scene.html --target "#story"
642
+
643
+ Canvas (Two-way IPC with Claude Code):
644
+ streamweaver canvas <name> Create/connect to canvas session
645
+ streamweaver canvas-push <name> Push DSL content (from stdin)
646
+ streamweaver canvas-wait <name> Wait for user interaction
647
+ streamweaver canvas-toast <name> <msg> Show toast overlay (doesn't replace content)
648
+ streamweaver canvas-close <name> Close a canvas session
649
+ streamweaver canvas-raise <name> Surface an already-pushed canvas (its iTerm pane if
650
+ tracked, else the URL in the default browser)
651
+ streamweaver canvas-reset <name> Reset session state (keep connections)
652
+ streamweaver canvas-reset --all Reset all sessions
653
+ streamweaver canvas-list List canvas sessions
654
+ streamweaver canvas-stop Stop the canvas bridge
655
+ streamweaver canvas-snapshot [dir] Capture every live session's DSL + theme/layout to dir
656
+ (default: ~/.streamweaver/snapshots/<timestamp>/)
657
+ streamweaver canvas-restore <dir> Recreate sessions from a canvas-snapshot dir
658
+ [--force] Overwrite sessions that already have content
659
+ streamweaver canvas-restart snapshot + stop + start + restore, one command
660
+ [--yes] Skip the confirm prompt for unconfirmed sessions
661
+ streamweaver canvas-read <file|dir> [...] Browse canvas DSL docs in a local viewer
662
+ [--theme=NAME] [--layout=NAME] Fallback for docs with no use_theme/use_layout
663
+ streamweaver export <file.rb> Write a canvas DSL doc out as standalone HTML
664
+ [-o out.html] Output path (default: <doc-name>.html)
665
+ [--inline-images] Embed local images as base64 data URIs
666
+ [--offline] Inline mermaid's library (needs network at
667
+ export time) so diagrams render in a viewer
668
+ whose CSP blocks external scripts entirely
669
+ (e.g. SharePoint's HTML preview)
670
+ streamweaver org-export <file.rb> Convert a saved DSL doc to a human-readable
671
+ org-mode sibling file (<name>.org)
672
+ streamweaver org-render <file.org> Convert an org-mode doc back to DSL body text
673
+ (prints to stdout)
674
+
675
+ Canvas Examples:
676
+ # Create session and open browser
677
+ streamweaver canvas survey
678
+
679
+ # Push UI content
680
+ streamweaver canvas-push survey <<'RUBY'
681
+ header1 "Quick Survey"
682
+ radio_group :choice, ["A", "B", "C"]
683
+ button "Submit"
684
+ RUBY
685
+
686
+ # Wait for user input (returns JSON)
687
+ streamweaver canvas-wait survey
688
+ # => {"choice":"B"}
689
+
690
+ Panel (iTerm2 Split + Canvas):
691
+ streamweaver panel [name] Split iTerm2, open canvas in right pane
692
+ streamweaver panel [name] --fresh Close existing session first, then open
693
+ streamweaver install Configure Claude Code (permissions + skills)
694
+ (alias: setup)
695
+ streamweaver install-skill [--global] Install Claude Code skills only
696
+
697
+ Panel Example (iTerm2):
698
+ # Split terminal, open canvas on right
699
+ streamweaver panel notes
700
+
701
+ # Push content from Claude Code
702
+ streamweaver canvas-push notes <<'RUBY'
703
+ header1 "Meeting Notes"
704
+ md "## Discussion Points\\n- Item 1\\n- Item 2"
705
+ RUBY
706
+
707
+ Getting Started (one-command door):
708
+ streamweaver get-started Setup + dependency check + open StreamWeaver University
709
+ [--degraded] Skip the iTerm2 premier check, use the browser fallback
710
+ [--yes] Skip the interactive confirm when degrading (premier deps missing)
711
+ [--agent claude|codex] Worker CLI to launch in the new tab (default: claude)
712
+ streamweaver university-listener Background process that makes the University
713
+ [start|stop|status] canvas buttons work (get-started starts it)
714
+ streamweaver university-reset [--yes] Reset course progress (backed up to progress.yml.bak),
715
+ close its demo canvas sessions, re-push the zero-state
716
+ course list. Same as the canvas's own Reset button.
717
+ streamweaver university-demo [<name>] Print the absolute path of a course demo file inside the
718
+ installed gem (no name: list them). The course prompts
719
+ run these; nothing is composed live.
720
+ streamweaver university-done <N> Mark step N done (same as clicking Mark done) and bring
721
+ the University window forward. What every step's own
722
+ closing ritual runs -- no click required from you.
723
+ streamweaver focus-me Bring the calling terminal's own iTerm2 pane to the
724
+ front. Silent no-op outside iTerm2/darwin.
725
+ HELP
726
+ end
727
+
728
+ # =========================================
729
+ # Live Session Commands
730
+ # =========================================
731
+
732
+ # Open a live session in browser
733
+ def self.live_session(args)
734
+ session_name = args.first
735
+
736
+ if session_name.nil? || help_flag?(session_name)
737
+ $stderr.puts "Usage: streamweaver live <session-name>"
738
+ exit 1
739
+ end
740
+
741
+ ensure_service_running
742
+ port = service_port
743
+
744
+ # Create session via API (will be created on first connection anyway)
745
+ url = "http://localhost:#{port}/live/#{URI.encode_www_form_component(session_name)}"
746
+
747
+ puts "Opening live session: #{session_name}"
748
+ puts "URL: #{url}"
749
+ puts ""
750
+ puts "Push content with:"
751
+ puts " echo '<h1>Hello</h1>' | streamweaver push #{session_name}"
752
+ puts " streamweaver push #{session_name} --html '<p>Content</p>'"
753
+ puts ""
754
+
755
+ open_browser(url)
756
+ end
757
+
758
+ # Push content to a live session
759
+ def self.push_content(args)
760
+ session_name = nil
761
+ target = '#main'
762
+ action = 'replace'
763
+ content = nil
764
+ is_dsl = false
765
+
766
+ stdin_dsl = false
767
+
768
+ parser = OptionParser.new do |opts|
769
+ opts.banner = "Usage: streamweaver push <session-name> [options]"
770
+ opts.on('-t', '--target SELECTOR', 'CSS selector (default: #main)') { |t| target = t }
771
+ opts.on('-a', '--action ACTION', 'replace, append, prepend (default: replace)') { |a| action = a }
772
+ opts.on('-f', '--file FILE', 'Read content from file') { |f| content = File.read(f) }
773
+ opts.on('-h', '--html HTML', 'HTML content directly') { |h| content = h }
774
+ opts.on('-d', '--dsl DSL', 'StreamWeaver DSL to render') { |d| content = d; is_dsl = true }
775
+ opts.on('--dsl-file FILE', 'StreamWeaver DSL from file') { |f| content = File.read(f); is_dsl = true }
776
+ opts.on('--stdin-dsl', 'Read DSL from stdin') { stdin_dsl = true; is_dsl = true }
777
+ end
778
+
779
+ remaining = parser.parse(args)
780
+ session_name = remaining.first
781
+
782
+ unless session_name
783
+ $stderr.puts "Usage: streamweaver push <session-name> [options]"
784
+ $stderr.puts ""
785
+ $stderr.puts "Options:"
786
+ $stderr.puts " -t, --target SELECTOR CSS selector (default: #main)"
787
+ $stderr.puts " -a, --action ACTION replace, append, prepend (default: replace)"
788
+ $stderr.puts " -f, --file FILE Read content from file"
789
+ $stderr.puts " -h, --html HTML HTML content directly"
790
+ $stderr.puts " -d, --dsl DSL StreamWeaver DSL to render"
791
+ $stderr.puts " --dsl-file FILE StreamWeaver DSL from file"
792
+ $stderr.puts " --stdin-dsl Read DSL from stdin"
793
+ $stderr.puts ""
794
+ $stderr.puts "Examples:"
795
+ $stderr.puts " echo '<h1>Hello</h1>' | streamweaver push my-session"
796
+ $stderr.puts " streamweaver push my-session --dsl 'header1 \"Title\"; text \"Hello\"'"
797
+ $stderr.puts " cat scene.rb | streamweaver push my-session --stdin-dsl"
798
+ exit 1
799
+ end
800
+
801
+ # Read from stdin if no content provided or if --stdin-dsl
802
+ if content.nil? || stdin_dsl
803
+ if $stdin.tty? && content.nil?
804
+ $stderr.puts "Error: No content provided. Use --html, --dsl, --file, --stdin-dsl, or pipe via stdin."
805
+ exit 1
806
+ end
807
+ content = $stdin.read if content.nil? || stdin_dsl
808
+ end
809
+
810
+ # Render DSL to HTML if needed
811
+ if is_dsl
812
+ content = render_dsl_to_html(content, session_name: session_name)
813
+ end
814
+
815
+ # Ensure service is running
816
+ unless Service.service_running?
817
+ $stderr.puts "Error: StreamWeaver service not running. Start with: streamweaver live #{session_name}"
818
+ exit 1
819
+ end
820
+
821
+ port = service_port
822
+
823
+ # POST to the push endpoint
824
+ uri = URI("http://localhost:#{port}/live/#{URI.encode_www_form_component(session_name)}/push")
825
+ req = Net::HTTP::Post.new(uri)
826
+ req.set_form_data(
827
+ 'target' => target,
828
+ 'action' => action,
829
+ 'content' => content
830
+ )
831
+
832
+ begin
833
+ response = Net::HTTP.start(uri.hostname, uri.port, open_timeout: 5, read_timeout: 10) { |http| http.request(req) }
834
+
835
+ if response.is_a?(Net::HTTPSuccess)
836
+ result = JSON.parse(response.body)
837
+ puts "Pushed to #{result['session']} #{result['target']} (#{result['action']})"
838
+ else
839
+ $stderr.puts "Error: #{response.body}"
840
+ exit 1
841
+ end
842
+ rescue => e
843
+ $stderr.puts "Error: #{e.message}"
844
+ exit 1
845
+ end
846
+ end
847
+
848
+ # List all live sessions
849
+ def self.list_live_sessions
850
+ unless Service.service_running?
851
+ puts "StreamWeaver service is not running"
852
+ return
853
+ end
854
+
855
+ port = service_port
856
+ uri = URI("http://localhost:#{port}/api/live")
857
+
858
+ begin
859
+ response = Net::HTTP.get_response(uri)
860
+ if response.is_a?(Net::HTTPSuccess)
861
+ data = JSON.parse(response.body)
862
+ sessions = data['sessions'] || []
863
+
864
+ if sessions.empty?
865
+ puts "No live sessions"
866
+ else
867
+ puts "Live Sessions:"
868
+ sessions.each do |s|
869
+ age = Utils.format_duration((Time.now - Time.parse(s['created_at'])).to_i) rescue 'unknown'
870
+ last_push = s['last_push'] ? Utils.format_duration((Time.now - Time.parse(s['last_push'])).to_i) + ' ago' : 'never'
871
+ puts " #{s['name']} - created #{age} ago, last push: #{last_push}"
872
+ end
873
+ end
874
+ else
875
+ puts "Error: #{response.body}"
876
+ end
877
+ rescue => e
878
+ puts "Error: #{e.message}"
879
+ end
880
+ end
881
+
882
+ # Close a live session
883
+ def self.close_live_session(session_name)
884
+ if session_name.nil? || help_flag?(session_name)
885
+ $stderr.puts "Usage: streamweaver live-close <session-name>"
886
+ exit 1
887
+ end
888
+
889
+ unless Service.service_running?
890
+ puts "StreamWeaver service is not running"
891
+ return
892
+ end
893
+
894
+ port = service_port
895
+ uri = URI("http://localhost:#{port}/live/#{URI.encode_www_form_component(session_name)}")
896
+ req = Net::HTTP::Delete.new(uri)
897
+
898
+ begin
899
+ response = Net::HTTP.start(uri.hostname, uri.port) { |http| http.request(req) }
900
+ result = JSON.parse(response.body)
901
+
902
+ if result['success']
903
+ puts result['message']
904
+ else
905
+ puts "Error: #{result['error']}"
906
+ end
907
+ rescue => e
908
+ puts "Error: #{e.message}"
909
+ end
910
+ end
911
+
912
+ # Wait for a submission from a live session
913
+ def self.wait_for_submission(args)
914
+ session_name = args.first
915
+ timeout = 300 # 5 minute default timeout
916
+
917
+ parser = OptionParser.new do |opts|
918
+ opts.banner = "Usage: streamweaver wait <session-name> [options]"
919
+ opts.on('-t', '--timeout SECONDS', Integer, 'Timeout in seconds (default: 300)') { |t| timeout = t }
920
+ end
921
+
922
+ remaining = parser.parse(args)
923
+ session_name = remaining.first
924
+
925
+ unless session_name
926
+ $stderr.puts "Usage: streamweaver wait <session-name> [--timeout SECONDS]"
927
+ exit 1
928
+ end
929
+
930
+ unless Service.service_running?
931
+ $stderr.puts "Error: StreamWeaver service not running"
932
+ exit 1
933
+ end
934
+
935
+ port = service_port
936
+ start_time = Time.now
937
+
938
+ # Poll for submissions
939
+ loop do
940
+ if Time.now - start_time > timeout
941
+ $stderr.puts "Timeout waiting for submission"
942
+ exit 1
943
+ end
944
+
945
+ uri = URI("http://localhost:#{port}/live/#{URI.encode_www_form_component(session_name)}/submissions")
946
+
947
+ begin
948
+ response = Net::HTTP.get_response(uri)
949
+ if response.is_a?(Net::HTTPSuccess)
950
+ data = JSON.parse(response.body)
951
+ submissions = data['submissions'] || []
952
+
953
+ if submissions.any?
954
+ # Return the first submission as JSON
955
+ puts JSON.generate(submissions.first['data'])
956
+ return
957
+ end
958
+ end
959
+ rescue => e
960
+ $stderr.puts "Poll error: #{e.message}"
961
+ end
962
+
963
+ sleep 0.5 # Poll every 500ms
964
+ end
965
+ end
966
+
967
+ # Run a pre-built template with JSON configuration
968
+ # Usage: streamweaver template wizard SESSION '{"title":"Setup","steps":[...]}'
969
+ def self.run_template(args)
970
+ template_name = args.shift
971
+ session_name = args.shift
972
+ json_data = args.shift
973
+
974
+ unless template_name && session_name
975
+ $stderr.puts "Usage: streamweaver template <template-name> <session-name> '<json-config>'"
976
+ $stderr.puts ""
977
+ $stderr.puts "Available templates:"
978
+ $stderr.puts " wizard - Multi-step form wizard"
979
+ $stderr.puts ""
980
+ $stderr.puts "Example:"
981
+ $stderr.puts ' streamweaver template wizard test \'{"title":"Setup","steps":[{"title":"Info","fields":[{"type":"text","key":"name","label":"Name"}]}]}\''
982
+ exit 1
983
+ end
984
+
985
+ unless Service.service_running?
986
+ ensure_service_running
987
+ end
988
+
989
+ # Parse JSON config
990
+ config = if json_data
991
+ JSON.parse(json_data)
992
+ else
993
+ # Read from stdin if no JSON provided
994
+ JSON.parse($stdin.read)
995
+ end
996
+
997
+ # Load and run the template
998
+ case template_name
999
+ when 'wizard'
1000
+ require_relative 'templates/wizard'
1001
+ result = Templates::Wizard.run(session: session_name, config: config)
1002
+ puts JSON.generate(result)
1003
+ when 'choices'
1004
+ require_relative 'templates/choices'
1005
+ result = Templates::Choices.run(session: session_name, config: config)
1006
+ puts JSON.generate(result)
1007
+ when 'confirm'
1008
+ require_relative 'templates/confirm'
1009
+ result = Templates::Confirm.run(session: session_name, config: config)
1010
+ puts JSON.generate(result)
1011
+ when 'info'
1012
+ require_relative 'templates/info'
1013
+ result = Templates::Info.run(session: session_name, config: config)
1014
+ puts JSON.generate(result)
1015
+ when 'table'
1016
+ require_relative 'templates/table'
1017
+ result = Templates::Table.run(session: session_name, config: config)
1018
+ puts JSON.generate(result)
1019
+ when 'code'
1020
+ require_relative 'templates/code'
1021
+ result = Templates::Code.run(session: session_name, config: config)
1022
+ puts JSON.generate(result)
1023
+ when 'diff'
1024
+ require_relative 'templates/diff'
1025
+ result = Templates::Diff.run(session: session_name, config: config)
1026
+ puts JSON.generate(result)
1027
+ else
1028
+ $stderr.puts "Unknown template: #{template_name}"
1029
+ $stderr.puts "Available: wizard, choices, confirm, info, table, code, diff"
1030
+ exit 1
1031
+ end
1032
+ rescue JSON::ParserError => e
1033
+ $stderr.puts "Invalid JSON: #{e.message}"
1034
+ exit 1
1035
+ rescue => e
1036
+ $stderr.puts "Template error: #{e.message}"
1037
+ $stderr.puts e.backtrace.first(5).join("\n") if ENV['DEBUG']
1038
+ exit 1
1039
+ end
1040
+
1041
+ # =========================================
1042
+ # Opal Build Command
1043
+ # =========================================
1044
+
1045
+ # Build a StreamWeaver app to a static HTML/JS bundle via Opal
1046
+ # Usage: streamweaver opal-build <app.rb> [--output DIR] [--theme PRESET]
1047
+ def self.opal_build(args)
1048
+ file = args.shift
1049
+ unless file && File.exist?(file)
1050
+ $stderr.puts "Usage: streamweaver opal-build <app.rb> [--output DIR] [--theme PRESET]"
1051
+ exit 1
1052
+ end
1053
+ output_dir = if args.include?('--output')
1054
+ val = args[args.index('--output') + 1]
1055
+ unless val && !val.start_with?('--')
1056
+ $stderr.puts "Error: --output requires a directory argument"
1057
+ exit 1
1058
+ end
1059
+ val
1060
+ else
1061
+ 'dist'
1062
+ end
1063
+ theme = if args.include?('--theme')
1064
+ args[args.index('--theme') + 1]
1065
+ end
1066
+ StreamWeaver::Opal::OpalBuilder.build(file, output_dir: output_dir, theme: theme)
1067
+ puts "Built to #{output_dir}/"
1068
+ puts "Open #{output_dir}/index.html in a browser or deploy to GitHub Pages."
1069
+ rescue LoadError => e
1070
+ $stderr.puts "Error: #{e.message}"
1071
+ exit 1
1072
+ end
1073
+
1074
+ private
1075
+
1076
+ def self.ensure_service_running
1077
+ return if Service.service_running?
1078
+
1079
+ puts "Starting StreamWeaver service..."
1080
+ result = Service.launch_background
1081
+ puts "Service started on port #{result[:port]}"
1082
+
1083
+ # Wait for service to be ready (up to 10 seconds)
1084
+ 10.times do
1085
+ begin
1086
+ uri = URI("http://localhost:#{result[:port]}/api/status")
1087
+ response = Net::HTTP.get_response(uri)
1088
+ return if response.is_a?(Net::HTTPSuccess)
1089
+ rescue Errno::ECONNREFUSED
1090
+ # Not ready yet
1091
+ end
1092
+ sleep 1
1093
+ end
1094
+
1095
+ puts "Warning: Service may not be ready yet"
1096
+ end
1097
+
1098
+ def self.service_port
1099
+ info = Service.read_pid_file
1100
+ info ? info[:port] : DEFAULT_PORT
1101
+ end
1102
+
1103
+ # True when arg looks like a help request rather than a real resource
1104
+ # name. Commands that read a positional name via plain `args.first`
1105
+ # (no OptionParser in front of them) don't get OptionParser's automatic
1106
+ # --help/-h handling for free, so without this check `streamweaver
1107
+ # canvas --help` creates a canvas session literally named "--help"
1108
+ # instead of showing usage (stream_weaver bug report, 2026-08-24).
1109
+ def self.help_flag?(arg)
1110
+ arg == '--help' || arg == '-h'
1111
+ end
1112
+
1113
+ # The one place that actually shells out to open a URL. Guarded here,
1114
+ # at the root, rather than trusting every call site to remember
1115
+ # `unless ENV['SW_NO_OPEN']` -- several didn't (stream_weaver bug
1116
+ # report: specs popping real browser tabs on the developer's desktop).
1117
+ def self.open_browser(url)
1118
+ return if ENV['SW_NO_OPEN']
1119
+
1120
+ case RbConfig::CONFIG['host_os']
1121
+ when /darwin|mac os/
1122
+ system('open', url)
1123
+ when /linux|bsd/
1124
+ system('xdg-open', url)
1125
+ when /mswin|msys|mingw|cygwin|bccwin|wince|emc/
1126
+ system('start', url)
1127
+ end
1128
+ end
1129
+
1130
+ # Render StreamWeaver DSL to HTML
1131
+ # @param dsl_code [String] StreamWeaver DSL code (e.g., "header1 'Title'; text 'Hello'")
1132
+ # @param session_name [String] Live session name for URL routing
1133
+ # @return [String] Rendered HTML
1134
+ def self.render_dsl_to_html(dsl_code, session_name: nil)
1135
+ require 'json'
1136
+
1137
+ # Create a mini app to evaluate the DSL
1138
+ mini_app = StreamWeaver::App.new("Live Push")
1139
+
1140
+ # Evaluate the DSL in the context of the app
1141
+ mini_app.instance_eval(dsl_code)
1142
+
1143
+ # Create adapter with URL prefix for live session submit
1144
+ url_prefix = session_name ? "/live/#{session_name}" : "/live"
1145
+ adapter = StreamWeaver::Adapter::AlpineJS.new(url_prefix: url_prefix, deck_server: false)
1146
+
1147
+ # Render components to HTML using a Phlex view with adapter
1148
+ # Wrap in x-data container for Alpine.js binding (required for hx-include="[x-model]")
1149
+ state = {}
1150
+ view = Class.new(Phlex::HTML) do
1151
+ attr_reader :adapter
1152
+
1153
+ define_method(:initialize) do |components, st, adp|
1154
+ @components = components
1155
+ @state = st
1156
+ @adapter = adp
1157
+ end
1158
+
1159
+ define_method(:view_template) do
1160
+ # Wrap in div with x-data for Alpine.js form binding
1161
+ div(id: "main", "x-data": JSON.generate(@state)) do
1162
+ @components.each { |c| c.render(self, @state) }
1163
+ end
1164
+ end
1165
+ end
1166
+
1167
+ view.new(mini_app.components, state, adapter).call
1168
+ end
1169
+
1170
+ # =========================================
1171
+ # Canvas Commands (Two-way IPC)
1172
+ # =========================================
1173
+
1174
+ # Create or connect to a canvas session
1175
+ def self.canvas_session(args)
1176
+ # Required before the guard below, not after -- the method's own
1177
+ # `rescue Canvas::Client::NotRunningError` can't resolve that constant
1178
+ # on an early exit if Canvas hasn't been loaded yet (pre-existing bug,
1179
+ # surfaced by the --help guard now exiting from this same spot).
1180
+ require_relative 'canvas/client'
1181
+
1182
+ layout = :fluid
1183
+ args = args.dup
1184
+ if (i = args.index { |a| a.start_with?('--layout=') })
1185
+ layout = args.delete_at(i).split('=', 2).last.to_sym
1186
+ end
1187
+ session_name = args.first
1188
+
1189
+ if session_name.nil? || help_flag?(session_name)
1190
+ $stderr.puts "Usage: streamweaver canvas [--layout=fluid|full|wide|default] <session-name>"
1191
+ exit 1
1192
+ end
1193
+
1194
+ # Ensure bridge is running
1195
+ info = Canvas::Client.ensure_bridge_running
1196
+ port = info[:port] || Canvas::Bridge::DEFAULT_PORT
1197
+
1198
+ # Create session
1199
+ response = Canvas::Client.send_message(
1200
+ Canvas::Protocol::Messages.create(session_name, layout: layout)
1201
+ )
1202
+
1203
+ if response && response[:type] == 'ready'
1204
+ puts "Canvas session: #{session_name}"
1205
+ puts "URL: #{response[:url]}"
1206
+ puts ""
1207
+ puts "Push content with:"
1208
+ puts " streamweaver canvas-push #{session_name} <<'RUBY'"
1209
+ puts " header1 'Hello'"
1210
+ puts " radio_group :choice, ['A', 'B', 'C']"
1211
+ puts " button 'Submit'"
1212
+ puts " RUBY"
1213
+ puts ""
1214
+ puts "Wait for user input:"
1215
+ puts " streamweaver canvas-wait #{session_name}"
1216
+
1217
+ open_browser(response[:url])
1218
+ else
1219
+ $stderr.puts "Error creating canvas session"
1220
+ exit 1
1221
+ end
1222
+ rescue Canvas::Client::NotRunningError => e
1223
+ $stderr.puts "Error: #{e.message}"
1224
+ $stderr.puts "Try: streamweaver canvas #{session_name}"
1225
+ exit 1
1226
+ end
1227
+
1228
+ # Push DSL content to a canvas session
1229
+ def self.canvas_push(args)
1230
+ stylesheet_paths = []
1231
+ parser = OptionParser.new do |opts|
1232
+ opts.banner = "Usage: streamweaver canvas-push <session-name> [options] < dsl.rb"
1233
+ opts.on('-s', '--stylesheet PATH', 'Local CSS file to inline into the canvas head (repeatable)') { |p| stylesheet_paths << p }
1234
+ end
1235
+ remaining = parser.parse(args)
1236
+ session_name = remaining.first
1237
+
1238
+ unless session_name
1239
+ $stderr.puts "Usage: streamweaver canvas-push <session-name> [--stylesheet PATH] < dsl.rb"
1240
+ exit 1
1241
+ end
1242
+
1243
+ # Read DSL from stdin
1244
+ if $stdin.tty?
1245
+ $stderr.puts "Usage: streamweaver canvas-push #{session_name} <<'RUBY'"
1246
+ $stderr.puts " header1 'Title'"
1247
+ $stderr.puts " text_field :name"
1248
+ $stderr.puts "RUBY"
1249
+ exit 1
1250
+ end
1251
+
1252
+ dsl = $stdin.read.strip
1253
+ dsl = prepend_stylesheets(dsl, stylesheet_paths) unless stylesheet_paths.empty?
1254
+
1255
+ require_relative 'canvas/client'
1256
+ require_relative 'canvas/doc_store'
1257
+
1258
+ # Computed on THIS (the pushing) side's cwd, not the bridge's -- the
1259
+ # bridge process outlives any single repo (stream_weaver-j3b3).
1260
+ source_dir = Canvas::DocStore.git_root(Dir.pwd)
1261
+
1262
+ response = Canvas::Client.send_message(
1263
+ Canvas::Protocol::Messages.push(session_name, dsl, source_dir: source_dir)
1264
+ )
1265
+
1266
+ # Check for DSL errors reported back from the bridge
1267
+ if response && response[:type] == 'push_error'
1268
+ $stderr.puts "DSL Error: #{response[:message]}"
1269
+ $stderr.puts "Pushed with error to #{session_name}"
1270
+ exit 1
1271
+ elsif response && response[:type] == 'error'
1272
+ $stderr.puts "Error: #{response[:message]}"
1273
+ exit 1
1274
+ else
1275
+ puts "Pushed to #{session_name}"
1276
+ record_push_history(session_name, dsl)
1277
+ end
1278
+ rescue Canvas::Client::NotRunningError => e
1279
+ $stderr.puts "Error: #{e.message}"
1280
+ exit 1
1281
+ rescue Canvas::Client::ConnectionError => e
1282
+ $stderr.puts "Error: #{e.message}"
1283
+ exit 1
1284
+ end
1285
+
1286
+ # Reads each --stylesheet PATH from local disk (resolved against the
1287
+ # invoking shell's cwd, which the CLI process shares -- unlike the
1288
+ # bridge process on the other end of the socket) and prepends a
1289
+ # `use_stylesheet(...)` call per file so the pushed DSL text carries its
1290
+ # own CSS content for the bridge to inline (stream_weaver-9uk). Paths
1291
+ # are read here, not in the DSL body, precisely because canvas-push has
1292
+ # no reliable notion of "the DSL's own directory" once it's plain text.
1293
+ def self.prepend_stylesheets(dsl, paths)
1294
+ declarations = paths.map do |path|
1295
+ abs_path = File.expand_path(path)
1296
+ unless File.exist?(abs_path)
1297
+ $stderr.puts "Error: stylesheet not found: #{path}"
1298
+ exit 1
1299
+ end
1300
+ "use_stylesheet(#{File.read(abs_path).inspect})"
1301
+ end
1302
+
1303
+ (declarations + [dsl]).join("\n")
1304
+ end
1305
+
1306
+ # Auto-save a successful canvas-push to ephemeral history (Tier 1).
1307
+ # Runs cleanup lazily, once per CLI process. Never re-raises -- a history
1308
+ # failure must not make a successful push look broken.
1309
+ def self.record_push_history(session_name, dsl)
1310
+ require_relative 'canvas/history'
1311
+ history_cleanup_once!
1312
+ saved_path = Canvas::History.record(session_name, dsl)
1313
+ $stderr.puts " saved: #{saved_path}"
1314
+ rescue ArgumentError => e
1315
+ # Bad session name slipped through -- push already succeeded.
1316
+ $stderr.puts "Warning: history save skipped (#{e.message})"
1317
+ rescue StandardError => e
1318
+ $stderr.puts "Warning: history save failed (#{e.message})"
1319
+ end
1320
+
1321
+ # Run History.cleanup at most once per CLI process. Errors are swallowed
1322
+ # (warned to stderr) and the flag is still set so we don't retry on the
1323
+ # next push within the same process.
1324
+ def self.history_cleanup_once!
1325
+ return if @history_cleaned
1326
+
1327
+ @history_cleaned = true
1328
+ Canvas::History.cleanup
1329
+ rescue StandardError => e
1330
+ $stderr.puts "Warning: history cleanup failed: #{e.message}"
1331
+ end
1332
+
1333
+ # Show a toast notification on a canvas session (doesn't replace main content)
1334
+ def self.canvas_toast(args)
1335
+ message = nil
1336
+ variant = 'warning'
1337
+ duration = 0 # 0 = persistent until dismissed or next push
1338
+
1339
+ parser = OptionParser.new do |opts|
1340
+ opts.banner = "Usage: streamweaver canvas-toast <session-name> <message> [options]"
1341
+ opts.on('-v', '--variant VARIANT', 'Toast variant: info, success, warning, error (default: warning)') { |v| variant = v }
1342
+ opts.on('-d', '--duration MS', Integer, 'Auto-dismiss after milliseconds (default: 0 = persistent)') { |d| duration = d }
1343
+ end
1344
+
1345
+ remaining = parser.parse(args)
1346
+ session_name = remaining.shift
1347
+ message = remaining.join(' ')
1348
+
1349
+ unless session_name && !message.empty?
1350
+ $stderr.puts "Usage: streamweaver canvas-toast <session-name> <message> [--variant warning] [--duration 5000]"
1351
+ $stderr.puts "Example: streamweaver canvas-toast myapp 'Check terminal for permissions' --variant warning"
1352
+ exit 1
1353
+ end
1354
+
1355
+ require_relative 'canvas/client'
1356
+
1357
+ response = Canvas::Client.send_message({
1358
+ type: 'toast',
1359
+ name: session_name,
1360
+ message: message,
1361
+ variant: variant,
1362
+ duration: duration
1363
+ })
1364
+
1365
+ puts "Toast sent to #{session_name}"
1366
+ rescue Canvas::Client::NotRunningError => e
1367
+ $stderr.puts "Error: #{e.message}"
1368
+ exit 1
1369
+ rescue Canvas::Client::ConnectionError => e
1370
+ $stderr.puts "Error: #{e.message}"
1371
+ exit 1
1372
+ end
1373
+
1374
+ # Wait for user interaction on a canvas session
1375
+ def self.canvas_wait(args)
1376
+ session_name = args.first
1377
+ timeout = 300 # 5 minute default
1378
+ event_filter = 'action' # Default to waiting for button clicks only
1379
+
1380
+ parser = OptionParser.new do |opts|
1381
+ opts.banner = "Usage: streamweaver canvas-wait <session-name> [options]"
1382
+ opts.on('-t', '--timeout SECONDS', Integer, 'Timeout in seconds (default: 300)') { |t| timeout = t }
1383
+ opts.on('-e', '--event TYPE', 'Event type to wait for (default: action)') { |e| event_filter = e }
1384
+ opts.on('-a', '--any', 'Wait for any event (not just action)') { event_filter = nil }
1385
+ end
1386
+
1387
+ remaining = parser.parse(args)
1388
+ session_name = remaining.first
1389
+
1390
+ unless session_name
1391
+ $stderr.puts "Usage: streamweaver canvas-wait <session-name> [--timeout SECONDS]"
1392
+ exit 1
1393
+ end
1394
+
1395
+ require_relative 'canvas/client'
1396
+
1397
+ # Wait for an event, optionally filtering by event type
1398
+ start_time = Time.now
1399
+ loop do
1400
+ remaining_time = timeout - (Time.now - start_time).to_i
1401
+ if remaining_time <= 0
1402
+ $stderr.puts "Timeout waiting for user interaction"
1403
+ exit 1
1404
+ end
1405
+
1406
+ result = Canvas::Client.send_and_wait(
1407
+ { type: 'subscribe', name: session_name },
1408
+ event_type: 'event',
1409
+ timeout: [remaining_time, 5].min # Check every 5 seconds max
1410
+ )
1411
+
1412
+ if result
1413
+ # Check if event matches filter
1414
+ event_data = result[:data] || {}
1415
+ if event_filter.nil? || event_data[:type] == event_filter
1416
+ # Output the event data as JSON
1417
+ puts JSON.generate(event_data)
1418
+ return
1419
+ end
1420
+ # Event didn't match filter, keep waiting
1421
+ end
1422
+ end
1423
+ rescue Canvas::Client::NotRunningError => e
1424
+ $stderr.puts "Error: #{e.message}"
1425
+ exit 1
1426
+ end
1427
+
1428
+ # Close a canvas session
1429
+ def self.canvas_close(args)
1430
+ # See canvas_session for why this require comes before the guard.
1431
+ require_relative 'canvas/client'
1432
+
1433
+ session_name = args.first
1434
+
1435
+ if session_name.nil? || help_flag?(session_name)
1436
+ $stderr.puts "Usage: streamweaver canvas-close <session-name>"
1437
+ exit 1
1438
+ end
1439
+
1440
+ response = Canvas::Client.send_message(
1441
+ Canvas::Protocol::Messages.close(session_name)
1442
+ )
1443
+
1444
+ if response&.dig(:type) == 'closed'
1445
+ puts "Closed canvas session: #{session_name}"
1446
+ puts "Closed browser pane" if ITerm.close_pane(response[:pane_id])
1447
+ else
1448
+ $stderr.puts "Session not found: #{session_name}"
1449
+ exit 1
1450
+ end
1451
+ rescue Canvas::Client::NotRunningError => e
1452
+ $stderr.puts "Error: #{e.message}"
1453
+ exit 1
1454
+ end
1455
+
1456
+ # Surfaces an already-pushed canvas session without opening a second
1457
+ # pane. `panel` always splits a NEW pane (stream_weaver, round-7 UAT:
1458
+ # calling it again on a session that already has one would duplicate
1459
+ # it), and only a successful worker-prompt *submit* raises anything on
1460
+ # its own (ITerm.send_to_session's activate_session_quietly) -- a push
1461
+ # the worker makes mid-response, after the user's attention has moved
1462
+ # elsewhere (e.g. to a blocking standalone form's own browser tab),
1463
+ # raises nothing. This is the one-liner for that gap: reuse the pane
1464
+ # iTerm already tracks for the session (Session#pane_id, set by `panel`
1465
+ # via set_pane_id) when there is one, else fall back to raising the
1466
+ # session's URL in the default browser the same way `panel` does when
1467
+ # iTerm2 isn't available.
1468
+ def self.canvas_raise(args)
1469
+ require_relative 'iterm'
1470
+ require_relative 'canvas/client'
1471
+ require_relative 'canvas/bridge_server'
1472
+
1473
+ session_name = args.first
1474
+
1475
+ if session_name.nil? || help_flag?(session_name)
1476
+ $stderr.puts "Usage: streamweaver canvas-raise <session-name>"
1477
+ exit 1
1478
+ end
1479
+
1480
+ response = Canvas::Client.send_message({ type: 'list' })
1481
+ sessions = (response && response[:sessions]) || []
1482
+ session = sessions.find { |s| s[:name].to_s == session_name }
1483
+
1484
+ unless session
1485
+ $stderr.puts "Session not found: #{session_name}"
1486
+ exit 1
1487
+ end
1488
+
1489
+ pane_id = session[:pane_id]
1490
+ # session_alive? already guards on ITerm.available? internally.
1491
+ if pane_id && ITerm.session_alive?(pane_id)
1492
+ ITerm.activate_session(pane_id)
1493
+ puts "Raised '#{session_name}' in its iTerm pane"
1494
+ else
1495
+ # The `list` call above already proved the bridge is up -- its port
1496
+ # is only missing here in a vanishingly narrow race (bridge exits
1497
+ # between that call and this one), so this falls back to the
1498
+ # bridge's own default rather than a second not-running check.
1499
+ port = Canvas::Client.read_bridge_info&.dig(:port) || Canvas::BridgeServer::DEFAULT_PORT
1500
+ url = "http://localhost:#{port}/canvas/#{session_name}"
1501
+ open_browser(url)
1502
+ puts "Raised '#{session_name}' at #{url}"
1503
+ end
1504
+ rescue Canvas::Client::NotRunningError => e
1505
+ $stderr.puts "Error: #{e.message}"
1506
+ exit 1
1507
+ end
1508
+
1509
+ # List all canvas sessions
1510
+ def self.canvas_list
1511
+ require_relative 'canvas/client'
1512
+
1513
+ unless Canvas::Client.bridge_running?
1514
+ puts "Canvas bridge is not running"
1515
+ return
1516
+ end
1517
+
1518
+ response = Canvas::Client.send_message({ type: 'list' })
1519
+
1520
+ if response && response[:sessions]
1521
+ sessions = response[:sessions]
1522
+ if sessions.empty?
1523
+ puts "No canvas sessions"
1524
+ else
1525
+ puts "Canvas sessions:"
1526
+ sessions.each do |s|
1527
+ puts " #{s[:name]} - #{s[:websocket_count]} connections"
1528
+ end
1529
+ end
1530
+ else
1531
+ puts "No canvas sessions"
1532
+ end
1533
+ rescue Canvas::Client::NotRunningError
1534
+ puts "Canvas bridge is not running"
1535
+ end
1536
+
1537
+ # Stop the canvas bridge
1538
+ def self.canvas_stop
1539
+ require_relative 'canvas/client'
1540
+
1541
+ if Canvas::Client.stop_bridge
1542
+ puts "Canvas bridge stopped"
1543
+ else
1544
+ puts "Canvas bridge is not running"
1545
+ end
1546
+ end
1547
+
1548
+ # =========================================
1549
+ # Canvas Snapshot / Restore / Restart
1550
+ # (stream_weaver-ps84 -- a bridge restart blanks in-memory sessions;
1551
+ # this trio makes "capture, restart, put it all back" one door)
1552
+ # =========================================
1553
+
1554
+ DEFAULT_SNAPSHOT_ROOT = File.expand_path('~/.streamweaver/snapshots')
1555
+
1556
+ # STREAMWEAVER_SNAPSHOT_ROOT overrides the root -- same pattern as
1557
+ # Canvas::History's STREAMWEAVER_HISTORY_ROOT, so specs (and ad-hoc
1558
+ # tooling) can redirect snapshot writes away from the developer's real
1559
+ # home directory.
1560
+ def self.snapshot_root
1561
+ env = ENV['STREAMWEAVER_SNAPSHOT_ROOT']
1562
+ env && !env.empty? ? env : DEFAULT_SNAPSHOT_ROOT
1563
+ end
1564
+
1565
+ def self.default_snapshot_dir
1566
+ File.join(snapshot_root, Time.now.utc.strftime('%Y%m%dT%H%M%SZ'))
1567
+ end
1568
+
1569
+ # Capture every live canvas session's DSL + theme/layout to DIR
1570
+ # (default: ~/.streamweaver/snapshots/<UTC timestamp>/).
1571
+ def self.canvas_snapshot(args)
1572
+ require_relative 'canvas/client'
1573
+
1574
+ if args.any? { |a| help_flag?(a) }
1575
+ $stderr.puts "Usage: streamweaver canvas-snapshot [dir]"
1576
+ exit 1
1577
+ end
1578
+
1579
+ dir = File.expand_path(args.first || default_snapshot_dir)
1580
+ do_canvas_snapshot(dir)
1581
+ end
1582
+
1583
+ # Does the actual capture and printing; returns { dir:, sessions:,
1584
+ # unconfirmed: }. Never exits -- shared with canvas-restart, which must
1585
+ # keep going regardless.
1586
+ def self.do_canvas_snapshot(dir)
1587
+ require_relative 'canvas/client'
1588
+ require_relative 'canvas/history' # for the session-name filesystem-safety regex + fallback root
1589
+
1590
+ FileUtils.mkdir_p(dir)
1591
+ manifest_sessions = []
1592
+
1593
+ unless Canvas::Client.bridge_running?
1594
+ puts "Canvas bridge is not running -- nothing to snapshot"
1595
+ write_snapshot_manifest(dir, manifest_sessions)
1596
+ puts "Snapshot dir: #{dir}"
1597
+ return { dir: dir, sessions: manifest_sessions, unconfirmed: [] }
1598
+ end
1599
+
1600
+ response = Canvas::Client.send_message({ type: 'list' })
1601
+ sessions = (response && response[:sessions]) || []
1602
+
1603
+ sessions.each do |s|
1604
+ name = s[:name].to_s
1605
+ theme = (s[:theme] || 'default').to_s
1606
+ layout = (s[:layout] || 'fluid').to_s
1607
+ captured_at = Time.now.utc.iso8601
1608
+
1609
+ unless name.match?(Canvas::History::VALID_NAME)
1610
+ puts "skip #{name.inspect} (unsafe session name, not snapshotted)"
1611
+ manifest_sessions << { 'name' => name, 'theme' => theme, 'layout' => layout,
1612
+ 'captured_at' => captured_at, 'has_content' => false, 'unconfirmed' => false }
1613
+ next
1614
+ end
1615
+
1616
+ dsl, source, unconfirmed = fetch_session_dsl(name)
1617
+ has_content = !(dsl.nil? || dsl.strip.empty?)
1618
+ manifest_sessions << { 'name' => name, 'theme' => theme, 'layout' => layout,
1619
+ 'captured_at' => captured_at, 'has_content' => has_content,
1620
+ 'source' => (has_content ? source.to_s : nil), 'unconfirmed' => unconfirmed }
1621
+
1622
+ if has_content
1623
+ File.write(File.join(dir, "#{name}.rb"), dsl)
1624
+ puts "snap #{name} (theme=#{theme}, layout=#{layout}, source=#{source}, #{dsl.bytesize} bytes)"
1625
+ elsif unconfirmed
1626
+ puts "skip #{name} (UNCONFIRMED -- bridge predates get_dsl, no history/ fallback found; may still hold live content)"
1627
+ else
1628
+ puts "skip #{name} (no content)"
1629
+ end
1630
+ end
1631
+
1632
+ write_snapshot_manifest(dir, manifest_sessions)
1633
+
1634
+ not_preserved = manifest_sessions.reject { |s| s['has_content'] }.map { |s| s['name'] }
1635
+ unconfirmed_names = manifest_sessions.select { |s| s['unconfirmed'] }.map { |s| s['name'] }
1636
+ unless not_preserved.empty?
1637
+ puts ""
1638
+ puts "NOT preserved: #{not_preserved.join(', ')}"
1639
+ puts "UNCONFIRMED (may still hold live content on the bridge): #{unconfirmed_names.join(', ')}" unless unconfirmed_names.empty?
1640
+ end
1641
+
1642
+ puts "Snapshot dir: #{dir}"
1643
+ { dir: dir, sessions: manifest_sessions, unconfirmed: unconfirmed_names }
1644
+ rescue Canvas::Client::NotRunningError, Canvas::Client::ConnectionError => e
1645
+ $stderr.puts "Warning: canvas bridge stopped mid-snapshot (#{e.message})"
1646
+ write_snapshot_manifest(dir, manifest_sessions || [])
1647
+ puts "Snapshot dir: #{dir}"
1648
+ { dir: dir, sessions: manifest_sessions || [], unconfirmed: [] }
1649
+ end
1650
+
1651
+ def self.write_snapshot_manifest(dir, sessions)
1652
+ manifest = { 'captured_at' => Time.now.utc.iso8601, 'sessions' => sessions }
1653
+ File.write(File.join(dir, 'manifest.yml'), YAML.dump(manifest))
1654
+ end
1655
+
1656
+ # Fetches a session's live DSL via the bridge. Returns [dsl, source,
1657
+ # unconfirmed]:
1658
+ # - bridge understands get_dsl, session has content -> [dsl, :bridge, false]
1659
+ # - bridge understands get_dsl, session confirmed empty -> [nil, nil, false]
1660
+ # - bridge predates get_dsl (pre-ps84 code) -- falls back to the
1661
+ # newest ~/.streamweaver/history/<name>/*.rb snapshot:
1662
+ # history had something -> [dsl, :history, false]
1663
+ # history had nothing -> [nil, nil, true] # genuinely unknown
1664
+ # `unconfirmed` means we could neither ask the live bridge nor find a
1665
+ # history trail -- the session may still hold real content we simply
1666
+ # couldn't reach (the chicken-and-egg this fix cycle exists to flag,
1667
+ # stream_weaver-ps84).
1668
+ def self.fetch_session_dsl(name)
1669
+ response = Canvas::Client.send_message(Canvas::Protocol::Messages.get_dsl(name))
1670
+
1671
+ if response && response[:type] == 'dsl'
1672
+ dsl = response[:dsl]
1673
+ [dsl, (dsl.nil? ? nil : :bridge), false]
1674
+ elsif response && response[:type] == 'error' && response[:message].to_s.start_with?('Unknown message type:')
1675
+ dsl = history_fallback_dsl(name)
1676
+ [dsl, (dsl.nil? ? nil : :history), dsl.nil?]
1677
+ else
1678
+ [nil, nil, false] # protocol understood; session just doesn't exist
1679
+ end
1680
+ end
1681
+
1682
+ # Newest ~/.streamweaver/history/<name>/*.rb, or nil. Only consulted
1683
+ # when the live bridge can't answer get_dsl at all (old code) --
1684
+ # History.root already respects STREAMWEAVER_HISTORY_ROOT for specs.
1685
+ def self.history_fallback_dsl(name)
1686
+ require_relative 'canvas/history'
1687
+ dir = File.join(Canvas::History.root, name)
1688
+ return nil unless Dir.exist?(dir)
1689
+
1690
+ latest = Dir.glob(File.join(dir, '*.rb')).max_by { |f| File.mtime(f) }
1691
+ latest && File.read(latest)
1692
+ rescue StandardError
1693
+ nil
1694
+ end
1695
+
1696
+ # name => true/false (has content), for every currently-live session.
1697
+ # Empty hash if the bridge isn't running. Used only for canvas-restore's
1698
+ # "already exists with content" guard -- deliberately does not consult
1699
+ # the history/ fallback (an already-running bridge that can't answer
1700
+ # get_dsl is a pre-ps84 process, and restore always talks to a bridge it
1701
+ # just ensure_bridge_running'd, so this path is effectively new-code-only).
1702
+ def self.existing_sessions_with_content
1703
+ return {} unless Canvas::Client.bridge_running?
1704
+
1705
+ response = Canvas::Client.send_message({ type: 'list' })
1706
+ names = ((response && response[:sessions]) || []).map { |s| s[:name].to_s }
1707
+ names.each_with_object({}) do |name, h|
1708
+ dsl, = fetch_session_dsl(name)
1709
+ h[name] = !(dsl.nil? || dsl.strip.empty?)
1710
+ end
1711
+ end
1712
+
1713
+ # Recreate each session recorded in DIR/manifest.yml with its original
1714
+ # theme/layout, then push its body.
1715
+ def self.canvas_restore(args)
1716
+ require_relative 'canvas/client'
1717
+
1718
+ if args.any? { |a| help_flag?(a) }
1719
+ $stderr.puts "Usage: streamweaver canvas-restore <dir> [--force]"
1720
+ exit 1
1721
+ end
1722
+
1723
+ force = args.include?('--force')
1724
+ dir = args.reject { |a| a == '--force' }.first
1725
+
1726
+ unless dir
1727
+ $stderr.puts "Usage: streamweaver canvas-restore <dir> [--force]"
1728
+ exit 1
1729
+ end
1730
+
1731
+ ok = do_canvas_restore(File.expand_path(dir), force: force)
1732
+ exit 1 unless ok
1733
+ end
1734
+
1735
+ # Does the actual restore and printing; returns true iff every session
1736
+ # that needed restoring succeeded (skips don't count as failures).
1737
+ # Never exits -- shared with canvas-restart.
1738
+ def self.do_canvas_restore(dir, force: false)
1739
+ require_relative 'canvas/client'
1740
+
1741
+ manifest_path = File.join(dir, 'manifest.yml')
1742
+ unless File.exist?(manifest_path)
1743
+ $stderr.puts "Error: no manifest.yml found in #{dir}"
1744
+ return false
1745
+ end
1746
+
1747
+ manifest = YAML.safe_load(File.read(manifest_path)) || {}
1748
+ sessions = manifest['sessions'] || []
1749
+
1750
+ if sessions.empty?
1751
+ puts "No sessions recorded in #{dir}"
1752
+ return true
1753
+ end
1754
+
1755
+ Canvas::Client.ensure_bridge_running
1756
+ existing = existing_sessions_with_content
1757
+ ok = true
1758
+
1759
+ sessions.each do |entry|
1760
+ name = entry['name']
1761
+
1762
+ unless entry['has_content']
1763
+ puts "skip #{name} (empty snapshot)"
1764
+ next
1765
+ end
1766
+
1767
+ if existing[name] && !force
1768
+ puts "skip #{name} (already exists with content; use --force to overwrite)"
1769
+ next
1770
+ end
1771
+
1772
+ body_path = File.join(dir, "#{name}.rb")
1773
+ unless File.exist?(body_path)
1774
+ puts "FAIL #{name} (missing #{name}.rb)"
1775
+ ok = false
1776
+ next
1777
+ end
1778
+
1779
+ dsl = File.read(body_path)
1780
+ theme = (entry['theme'] || 'default').to_sym
1781
+ layout = (entry['layout'] || 'fluid').to_sym
1782
+
1783
+ create_resp = Canvas::Client.send_message(
1784
+ Canvas::Protocol::Messages.create(name, layout: layout, theme: theme)
1785
+ )
1786
+ unless create_resp && create_resp[:type] == 'ready'
1787
+ puts "FAIL #{name} (create failed)"
1788
+ ok = false
1789
+ next
1790
+ end
1791
+
1792
+ push_resp = Canvas::Client.send_message(Canvas::Protocol::Messages.push(name, dsl))
1793
+ if push_resp && push_resp[:type] == 'push_ok'
1794
+ puts "OK #{name}"
1795
+ else
1796
+ detail = push_resp && push_resp[:message] ? " (#{push_resp[:message]})" : ''
1797
+ puts "FAIL #{name}#{detail}"
1798
+ ok = false
1799
+ end
1800
+ end
1801
+
1802
+ ok
1803
+ rescue Canvas::Client::NotRunningError, Canvas::Client::ConnectionError => e
1804
+ $stderr.puts "Error: #{e.message}"
1805
+ false
1806
+ end
1807
+
1808
+ # snapshot -> stop -> ensure bridge running (on the currently installed
1809
+ # gem code) -> restore, one command. Warns loudly if the restarted
1810
+ # bridge lands on a different port -- any browser tab pointing at the
1811
+ # old port is stale (port-squat gotcha, stream_weaver-ps84). If the
1812
+ # snapshot step leaves any session UNCONFIRMED (old-code bridge, no
1813
+ # history/ fallback -- it may still be holding live content we're about
1814
+ # to blow away), asks for confirmation before stopping unless --yes.
1815
+ def self.canvas_restart(args = [])
1816
+ require_relative 'canvas/client'
1817
+
1818
+ auto_yes = args.include?('--yes') || args.include?('-y')
1819
+
1820
+ old_info = Canvas::Client.bridge_running? ? Canvas::Client.read_bridge_info : nil
1821
+ old_port = old_info && old_info[:port]
1822
+
1823
+ puts "== Snapshotting live sessions =="
1824
+ dir = default_snapshot_dir
1825
+ snapshot = do_canvas_snapshot(dir)
1826
+ unconfirmed = snapshot[:unconfirmed] || []
1827
+
1828
+ if !unconfirmed.empty? && !auto_yes
1829
+ puts ""
1830
+ print "#{unconfirmed.size} session(s) UNCONFIRMED and may hold live content that stopping the bridge will lose. Continue anyway? [y/N] "
1831
+ answer = $stdin.gets.to_s.strip.downcase
1832
+ unless %w[y yes].include?(answer)
1833
+ puts "Aborted -- canvas bridge left running. Snapshot saved at #{dir} for inspection."
1834
+ exit 1
1835
+ end
1836
+ end
1837
+
1838
+ puts ""
1839
+ puts "== Stopping canvas bridge =="
1840
+ puts(Canvas::Client.stop_bridge ? "Canvas bridge stopped" : "Canvas bridge was not running")
1841
+
1842
+ puts ""
1843
+ puts "== Starting canvas bridge =="
1844
+ new_info = Canvas::Client.ensure_bridge_running
1845
+ new_port = new_info[:port]
1846
+ puts "Canvas bridge running on port #{new_port} (pid #{new_info[:pid]})"
1847
+
1848
+ unless wait_for_bridge_ready
1849
+ $stderr.puts "Warning: canvas bridge liveness probe did not stabilize within 5s of starting -- attempting restore anyway"
1850
+ end
1851
+
1852
+ puts ""
1853
+ puts "== Restoring sessions =="
1854
+ ok = do_canvas_restore(dir, force: false)
1855
+
1856
+ puts ""
1857
+ puts "canvas-restart summary: snapshot=#{dir} port=#{new_port} restore=#{ok ? 'OK' : 'FAILED'}"
1858
+
1859
+ if old_port && new_port && old_port != new_port
1860
+ $stderr.puts ""
1861
+ $stderr.puts "*** WARNING: canvas bridge moved from port #{old_port} to #{new_port}. ***"
1862
+ $stderr.puts "*** Any browser tab still pointing at port #{old_port} is stale -- reload it at the new session URLs. ***"
1863
+ end
1864
+
1865
+ exit 1 unless ok
1866
+ end
1867
+
1868
+ # Bounded-wait retry on the bridge's liveness, called right after
1869
+ # ensure_bridge_running in canvas-restart before handing off to
1870
+ # do_canvas_restore (stream_weaver-f568, a canvas-restart fix cycle
1871
+ # follow-up to ps84): a single Canvas::Client.bridge_running? read
1872
+ # immediately after a fresh start was observed to occasionally read as
1873
+ # false on Forrest's machine even though the bridge really was up
1874
+ # (a manual canvas-restore moments later worked fine) -- Client itself
1875
+ # has no cached state to explain that, so this treats it as a transient
1876
+ # race and re-probes with an actual round trip (bridge_running? plus a
1877
+ # live send_message) rather than trusting a single reading.
1878
+ def self.wait_for_bridge_ready(timeout: 5, interval: 0.2)
1879
+ deadline = Time.now + timeout
1880
+
1881
+ loop do
1882
+ begin
1883
+ return true if Canvas::Client.bridge_running? && Canvas::Client.send_message({ type: 'list' })
1884
+ rescue Canvas::Client::NotRunningError, Canvas::Client::ConnectionError
1885
+ # not ready yet -- fall through to retry
1886
+ end
1887
+
1888
+ return false if Time.now >= deadline
1889
+
1890
+ sleep interval
1891
+ end
1892
+ end
1893
+
1894
+ def self.canvas_read(args)
1895
+ require_relative 'canvas/reader'
1896
+ require_relative 'canvas/doc_store'
1897
+ require_relative 'canvas/history'
1898
+
1899
+ # Fallback theme/layout for files that don't declare their own via
1900
+ # `use_theme`/`use_layout` (stream_weaver-csf). Precedence:
1901
+ # DSL use_theme > --theme flag > :default/:fluid.
1902
+ theme = nil
1903
+ layout = nil
1904
+ args = args.reject do |arg|
1905
+ case arg
1906
+ when /\A--theme=(.+)\z/ then theme = Regexp.last_match(1); true
1907
+ when /\A--layout=(.+)\z/ then layout = Regexp.last_match(1); true
1908
+ else false
1909
+ end
1910
+ end
1911
+ StreamWeaver::Canvas::Reader.configure_defaults!(theme: theme, layout: layout)
1912
+
1913
+ history_roots = []
1914
+ labels = {}
1915
+ if args.empty?
1916
+ args, history_roots, labels = canvas_read_default_args
1917
+ else
1918
+ register_explicit_roots(args)
1919
+ end
1920
+
1921
+ begin
1922
+ file_list = StreamWeaver::Canvas::Reader::FileList.build(args, history_roots: history_roots, labels: labels)
1923
+ rescue StreamWeaver::Canvas::Reader::NoFilesError => e
1924
+ $stderr.puts "Error: #{e.message}"
1925
+ exit 1
1926
+ end
1927
+
1928
+ StreamWeaver::Canvas::Reader.configure_files!(file_list)
1929
+
1930
+ port = StreamWeaver::Canvas::Reader.find_available_port
1931
+ StreamWeaver::Canvas::Reader.set :port, port
1932
+
1933
+ url = "http://127.0.0.1:#{port}/?file=0"
1934
+ puts "canvas-read #{file_list.size} file(s) → #{url}"
1935
+ puts "Ctrl-C to stop"
1936
+
1937
+ # SW_NO_OPEN was already honored by `streamweaver run`/the bridge
1938
+ # (server.rb) but never by canvas-read, so booting one for a test or a
1939
+ # script flooded the desktop with tabs. Checked here rather than inside
1940
+ # open_browser so server.rb's explicit `open_browser: true` override
1941
+ # keeps working.
1942
+ Thread.new { sleep 0.8; open_browser(url) } unless ENV['SW_NO_OPEN']
1943
+
1944
+ StreamWeaver::Canvas::Reader.run!
1945
+ end
1946
+
1947
+ # Converts a saved DSL doc to a human-readable org-mode sibling file,
1948
+ # written next to the source .rb file.
1949
+ def self.org_export(args)
1950
+ require_relative 'org/writer'
1951
+ rb_path = args.first
1952
+ abort "Usage: streamweaver org-export <file.rb>" unless rb_path && File.file?(rb_path)
1953
+
1954
+ begin
1955
+ org = StreamWeaver::Org::Writer.from_dsl(File.read(rb_path))
1956
+ rescue ScriptError, StandardError => e
1957
+ $stderr.puts "Error: org-export failed: #{e.message}"
1958
+ exit 1
1959
+ end
1960
+
1961
+ org_path = rb_path.sub(/\.rb\z/, ".org")
1962
+ $stderr.puts "Warning: overwriting existing #{org_path}" if File.exist?(org_path)
1963
+ File.write(org_path, org)
1964
+ puts "Wrote #{org_path}"
1965
+ end
1966
+
1967
+ # Converts an org-mode doc back to DSL body text, printed to stdout.
1968
+ def self.org_render(args)
1969
+ require_relative 'org/reader'
1970
+ org_path = args.first
1971
+ abort "Usage: streamweaver org-render <file.org>" unless org_path && File.file?(org_path)
1972
+
1973
+ begin
1974
+ dsl = StreamWeaver::Org::Reader.to_dsl(File.read(org_path))
1975
+ rescue ScriptError, StandardError => e
1976
+ $stderr.puts "Error: org-render failed: #{e.message}"
1977
+ exit 1
1978
+ end
1979
+
1980
+ puts dsl
1981
+ end
1982
+
1983
+ # Writes a canvas-doc DSL file out as a standalone HTML document -- the
1984
+ # same thing the reader's Export HTML button downloads.
1985
+ def self.export_html(args)
1986
+ require_relative 'export/html_exporter'
1987
+
1988
+ source = nil
1989
+ output = nil
1990
+ inline_images = false
1991
+ offline = false
1992
+ until args.empty?
1993
+ arg = args.shift
1994
+ case arg
1995
+ when '-o', '--output'
1996
+ output = args.shift
1997
+ if output.nil?
1998
+ $stderr.puts "Error: #{arg} requires a value"
1999
+ exit 1
2000
+ end
2001
+ when /\A--output=(.+)\z/ then output = Regexp.last_match(1)
2002
+ when '--inline-images' then inline_images = true
2003
+ when '--offline' then offline = true
2004
+ else source = arg
2005
+ end
2006
+ end
2007
+
2008
+ unless source && File.file?(source)
2009
+ $stderr.puts "Usage: streamweaver export <file.rb> [-o out.html] [--inline-images] [--offline]"
2010
+ $stderr.puts "Error: no such file: #{source}" if source
2011
+ exit 1
2012
+ end
2013
+
2014
+ output ||= StreamWeaver::Export::HtmlExporter.export_filename(source)
2015
+
2016
+ begin
2017
+ # layout: 'fluid' matches the reader's own fallback (Reader.fallback_layout,
2018
+ # canvas/reader.rb), not HtmlExporter.from_dsl's bare default of :default --
2019
+ # without this, `streamweaver export doc.rb` and the reader's "Export HTML"
2020
+ # button silently produce different-width documents from the same source
2021
+ # file whenever it doesn't declare its own use_layout.
2022
+ path = StreamWeaver::Export::HtmlExporter.from_dsl_file(source, theme: :default, layout: :fluid)
2023
+ .export(path: output, inline_images: inline_images, offline: offline)
2024
+ rescue StreamWeaver::Export::InvalidDslError, StreamWeaver::Export::OfflineAssetError => e
2025
+ $stderr.puts "Error: #{e.message}"
2026
+ exit 1
2027
+ rescue ScriptError, StandardError => e
2028
+ $stderr.puts "Error: export failed: #{e.message}"
2029
+ exit 1
2030
+ end
2031
+
2032
+ puts "Exported #{source} → #{path}"
2033
+ end
2034
+
2035
+ # Resolves the no-arg default for `streamweaver canvas-read`. Returns
2036
+ # [args, history_roots, labels] where args is the list of
2037
+ # directories/files for FileList.build, history_roots tags
2038
+ # ~/.streamweaver/history/ paths so the sidebar can render them in a
2039
+ # separate collapsed section, and labels names each docs root for the
2040
+ # sidebar's repo filter (stream_weaver-iugu).
2041
+ #
2042
+ # Docs roots are no longer just this repo's: DocRoots unions a scan of
2043
+ # ~/work with the append-only registry and the global store, so a doc
2044
+ # saved in one repo is readable from a canvas-read launched in another.
2045
+ # Dropping roots with no .rb/.org in them is DocRoots.roots' own job now
2046
+ # (stream_weaver-uvaj) rather than a second filter here -- with one
2047
+ # deliberate difference this file no longer overrides: the host repo's
2048
+ # root and the global store stay in the list even while empty, so a doc
2049
+ # saved into one mid-session is picked up by FileList's directory-mtime
2050
+ # watch instead of needing a restart.
2051
+ def self.canvas_read_default_args
2052
+ require_relative 'canvas/doc_roots'
2053
+
2054
+ docs_path = StreamWeaver::Canvas::DocStore.path
2055
+ history_root = StreamWeaver::Canvas::History.root
2056
+
2057
+ docs_roots = StreamWeaver::Canvas::DocRoots.roots
2058
+ labels = StreamWeaver::Canvas::DocRoots.labels(docs_roots)
2059
+ session_dirs = Dir.glob(File.join(history_root, '*/')).map { |d| d.sub(%r{/\z}, '') }.sort
2060
+
2061
+ args = docs_roots + session_dirs
2062
+
2063
+ # "Nothing to read anywhere," not "no directories": the host repo's docs
2064
+ # root and the global store are now listed even while empty (they're
2065
+ # where a save would land, and the reader watches them for one), so a
2066
+ # non-empty `args` no longer implies there is anything IN it. Someone
2067
+ # running this for the first time with no docs at all still needs the
2068
+ # how-to-use hint rather than FileList's "No .rb or .org files found".
2069
+ if args.none? { |dir| StreamWeaver::Canvas::DocRoots.docs?(dir) }
2070
+ $stderr.puts "Usage: streamweaver canvas-read <file|dir> [file|dir ...]"
2071
+ $stderr.puts " streamweaver canvas-read (no args; defaults to #{docs_path} + ~/.streamweaver/history/)"
2072
+ exit 1
2073
+ end
2074
+
2075
+ summary = []
2076
+ summary << "#{docs_roots.size} doc root(s)" if docs_roots.any?
2077
+ summary << "#{session_dirs.size} history session(s)" if session_dirs.any?
2078
+ puts "canvas-read using default — #{summary.join(', ')}"
2079
+ # Every root printed, not just a count: a rail that suddenly lists five
2080
+ # repos should be traceable to the paths that produced it without
2081
+ # guessing. Same reason the scan roots and their override are named --
2082
+ # a repo that ISN'T listed is the case that needs explaining.
2083
+ docs_roots.each { |root| puts " #{labels[root]}: #{root}" }
2084
+ if docs_roots.any?
2085
+ scan = StreamWeaver::Canvas::DocRoots.scan_roots
2086
+ puts " scanned: #{scan.empty? ? '(none)' : scan.join(', ')} (override: STREAMWEAVER_DOCS_SCAN_ROOTS)"
2087
+ end
2088
+
2089
+ [args, [history_root], labels]
2090
+ end
2091
+
2092
+ # Records an explicitly-passed docs directory in the registry when
2093
+ # nothing already discovers it (stream_weaver-iugu) -- opening a
2094
+ # pre-existing doc once is what backfills a repo living outside the
2095
+ # scan roots, so there is no separate registration command to know about.
2096
+ def self.register_explicit_roots(args)
2097
+ require_relative 'canvas/doc_roots'
2098
+
2099
+ args.each do |arg|
2100
+ dir = if File.directory?(arg)
2101
+ arg
2102
+ elsif File.file?(arg)
2103
+ File.dirname(arg)
2104
+ end
2105
+ StreamWeaver::Canvas::DocRoots.record_if_new(dir) if dir
2106
+ end
2107
+ end
2108
+
2109
+ def self.canvas_reset(args)
2110
+ reset_all = args.include?('--all') || args.include?('-a')
2111
+ session_name = args.reject { |a| a.start_with?('-') }.first
2112
+
2113
+ unless session_name || reset_all
2114
+ $stderr.puts "Usage: streamweaver canvas-reset <session-name>"
2115
+ $stderr.puts " streamweaver canvas-reset --all"
2116
+ exit 1
2117
+ end
2118
+
2119
+ require_relative 'canvas/client'
2120
+
2121
+ unless Canvas::Client.bridge_running?
2122
+ $stderr.puts "Canvas bridge is not running"
2123
+ exit 1
2124
+ end
2125
+
2126
+ response = Canvas::Client.send_message({
2127
+ type: 'reset', name: session_name, all: reset_all
2128
+ })
2129
+
2130
+ case response&.dig(:type)
2131
+ when 'reset_ok'
2132
+ message = reset_all ? "Reset all canvas sessions (#{response[:count]} sessions)" : "Reset canvas session: #{session_name}"
2133
+ puts message
2134
+ when 'error'
2135
+ $stderr.puts "Error: #{response[:message]}"
2136
+ exit 1
2137
+ else
2138
+ $stderr.puts "Failed to reset session"
2139
+ exit 1
2140
+ end
2141
+ rescue Canvas::Client::NotRunningError => e
2142
+ $stderr.puts "Error: #{e.message}"
2143
+ exit 1
2144
+ end
2145
+
2146
+ # =========================================
2147
+ # High-level Canvas Helpers
2148
+ # =========================================
2149
+
2150
+ # Quick pick from a list of choices
2151
+ # Usage: streamweaver pick "Title" "Choice1" "Choice2" "Choice3"
2152
+ def self.canvas_pick(args)
2153
+ if args.length < 2
2154
+ $stderr.puts "Usage: streamweaver pick \"Title\" \"Choice1\" \"Choice2\" ..."
2155
+ exit 1
2156
+ end
2157
+
2158
+ title = args.shift
2159
+ choices = args
2160
+
2161
+ require_relative 'canvas/client'
2162
+ require_relative 'canvas/helpers'
2163
+
2164
+ # Generate session name
2165
+ session_name = "pick_#{Time.now.to_i}"
2166
+
2167
+ # Ensure bridge is running
2168
+ Canvas::Client.ensure_bridge_running
2169
+
2170
+ # Create session
2171
+ response = Canvas::Client.send_message(
2172
+ Canvas::Protocol::Messages.create(session_name)
2173
+ )
2174
+
2175
+ if response && response[:type] == 'ready'
2176
+ # Open browser
2177
+ open_browser(response[:url])
2178
+
2179
+ # Push the pick UI
2180
+ require_relative 'canvas/doc_store'
2181
+ dsl = Canvas::Helpers.pick_dsl(title, choices)
2182
+ Canvas::Client.send_message(
2183
+ Canvas::Protocol::Messages.push(session_name, dsl, source_dir: Canvas::DocStore.git_root(Dir.pwd))
2184
+ )
2185
+
2186
+ # Wait for response
2187
+ result = Canvas::Client.send_and_wait(
2188
+ { type: 'subscribe', name: session_name },
2189
+ event_type: 'event',
2190
+ timeout: 300
2191
+ )
2192
+
2193
+ # Close session
2194
+ Canvas::Client.send_message(
2195
+ Canvas::Protocol::Messages.close(session_name)
2196
+ )
2197
+
2198
+ if result && result[:data]
2199
+ choice = Canvas::Helpers.parse_pick_result(result[:data])
2200
+ puts JSON.generate({ choice: choice })
2201
+ else
2202
+ $stderr.puts "Timeout or cancelled"
2203
+ exit 1
2204
+ end
2205
+ else
2206
+ $stderr.puts "Failed to create canvas session"
2207
+ exit 1
2208
+ end
2209
+ rescue Canvas::Client::NotRunningError => e
2210
+ $stderr.puts "Error: #{e.message}"
2211
+ exit 1
2212
+ end
2213
+
2214
+ # Quick confirmation dialog
2215
+ # Usage: streamweaver confirm "Are you sure?"
2216
+ def self.canvas_confirm(args)
2217
+ message = args.first
2218
+ yes_label = "Confirm"
2219
+ no_label = "Cancel"
2220
+
2221
+ parser = OptionParser.new do |opts|
2222
+ opts.banner = "Usage: streamweaver confirm \"Message\" [options]"
2223
+ opts.on('--yes LABEL', 'Yes button label') { |l| yes_label = l }
2224
+ opts.on('--no LABEL', 'No button label') { |l| no_label = l }
2225
+ end
2226
+
2227
+ remaining = parser.parse(args)
2228
+ message = remaining.first
2229
+
2230
+ unless message
2231
+ $stderr.puts "Usage: streamweaver confirm \"Are you sure?\" [--yes LABEL] [--no LABEL]"
2232
+ exit 1
2233
+ end
2234
+
2235
+ require_relative 'canvas/client'
2236
+ require_relative 'canvas/helpers'
2237
+
2238
+ # Generate session name
2239
+ session_name = "confirm_#{Time.now.to_i}"
2240
+
2241
+ # Ensure bridge is running
2242
+ Canvas::Client.ensure_bridge_running
2243
+
2244
+ # Create session
2245
+ response = Canvas::Client.send_message(
2246
+ Canvas::Protocol::Messages.create(session_name)
2247
+ )
2248
+
2249
+ if response && response[:type] == 'ready'
2250
+ # Open browser
2251
+ open_browser(response[:url])
2252
+
2253
+ # Push the confirm UI
2254
+ require_relative 'canvas/doc_store'
2255
+ dsl = Canvas::Helpers.confirm_dsl(message, yes_label: yes_label, no_label: no_label)
2256
+ Canvas::Client.send_message(
2257
+ Canvas::Protocol::Messages.push(session_name, dsl, source_dir: Canvas::DocStore.git_root(Dir.pwd))
2258
+ )
2259
+
2260
+ # Wait for response
2261
+ result = Canvas::Client.send_and_wait(
2262
+ { type: 'subscribe', name: session_name },
2263
+ event_type: 'event',
2264
+ timeout: 300
2265
+ )
2266
+
2267
+ # Close session
2268
+ Canvas::Client.send_message(
2269
+ Canvas::Protocol::Messages.close(session_name)
2270
+ )
2271
+
2272
+ if result && result[:data]
2273
+ confirmed = Canvas::Helpers.parse_confirm_result(result[:data])
2274
+ puts JSON.generate({ confirmed: confirmed })
2275
+ else
2276
+ $stderr.puts "Timeout or cancelled"
2277
+ exit 1
2278
+ end
2279
+ else
2280
+ $stderr.puts "Failed to create canvas session"
2281
+ exit 1
2282
+ end
2283
+ rescue Canvas::Client::NotRunningError => e
2284
+ $stderr.puts "Error: #{e.message}"
2285
+ exit 1
2286
+ end
2287
+
2288
+ # =========================================
2289
+ # Panel Command - iTerm2 split with canvas
2290
+ # =========================================
2291
+
2292
+ # Open a canvas panel in a split iTerm2 pane
2293
+ # Usage: streamweaver panel [session-name] [--fresh]
2294
+ #
2295
+ # Panel never opens an external browser - the point is to have the browser
2296
+ # inline in a split pane. If iTerm2 Web Browser profile isn't available,
2297
+ # we just print the URL for the user to open manually.
2298
+ def self.panel(args)
2299
+ # See canvas_session for why this require comes before the guard.
2300
+ require_relative 'iterm'
2301
+ require_relative 'canvas/client'
2302
+
2303
+ # Checked before the '-'-prefixed args get filtered out below --
2304
+ # otherwise --help/-h silently vanish and session_name falls back to
2305
+ # a random "panel-<hex>" name, creating an iTerm split + canvas
2306
+ # session nobody asked for instead of showing usage.
2307
+ if args.any? { |a| help_flag?(a) }
2308
+ $stderr.puts "Usage: streamweaver panel [session-name] [--fresh] [--layout=NAME] [--theme=NAME]"
2309
+ exit 1
2310
+ end
2311
+
2312
+ debug = ENV['DEBUG_PANEL']
2313
+ fresh = args.include?('--fresh') || args.include?('-f')
2314
+ layout_arg = args.find { |a| a.start_with?('--layout=') }
2315
+ layout = layout_arg ? layout_arg.split('=', 2).last.to_sym : :fluid
2316
+ theme_arg = args.find { |a| a.start_with?('--theme=') }
2317
+ theme = theme_arg ? theme_arg.split('=', 2).last.to_sym : :default
2318
+ args = args.reject { |a| a.start_with?('-') }
2319
+
2320
+ session_name = args.first || "panel-#{SecureRandom.hex(4)}"
2321
+ $stderr.puts "[DEBUG] session_name: #{session_name}, fresh: #{fresh}" if debug
2322
+
2323
+ # Ensure bridge is running
2324
+ $stderr.puts "[DEBUG] Calling ensure_bridge_running..." if debug
2325
+ bridge_info = Canvas::Client.ensure_bridge_running
2326
+ $stderr.puts "[DEBUG] Bridge info: #{bridge_info.inspect}" if debug
2327
+
2328
+ if fresh
2329
+ $stderr.puts "[DEBUG] Closing existing session for fresh start..." if debug
2330
+ Canvas::Client.send_message(Canvas::Protocol::Messages.close(session_name))
2331
+ end
2332
+
2333
+ # Create session
2334
+ $stderr.puts "[DEBUG] Creating session..." if debug
2335
+ response = Canvas::Client.send_message(
2336
+ Canvas::Protocol::Messages.create(session_name, layout: layout, theme: theme)
2337
+ )
2338
+ $stderr.puts "[DEBUG] Response: #{response.inspect}" if debug
2339
+
2340
+ if response && response[:type] == 'ready'
2341
+ url = response[:url]
2342
+ $stderr.puts "[DEBUG] URL from response: #{url.inspect}" if debug
2343
+
2344
+ # Verify URL is accessible
2345
+ if debug
2346
+ require 'net/http'
2347
+ begin
2348
+ test_response = Net::HTTP.get_response(URI(url))
2349
+ $stderr.puts "[DEBUG] URL test: HTTP #{test_response.code}"
2350
+ rescue => e
2351
+ $stderr.puts "[DEBUG] URL test FAILED: #{e.message}"
2352
+ end
2353
+ end
2354
+
2355
+ # Try iTerm2 split with browser profile (never open external browser)
2356
+ if ITerm.available?
2357
+ $stderr.puts "[DEBUG] Calling ITerm.split_vertical_with_url..." if debug
2358
+ iterm_result = ITerm.split_vertical_with_url(url, open_browser: false)
2359
+ $stderr.puts "[DEBUG] iTerm result: #{iterm_result.inspect}" if debug
2360
+
2361
+ # Store pane_id in the canvas session for later cleanup
2362
+ if iterm_result[:pane_id]
2363
+ Canvas::Client.send_message(
2364
+ Canvas::Protocol::Messages.set_pane_id(session_name, iterm_result[:pane_id])
2365
+ )
2366
+ end
2367
+
2368
+ case iterm_result[:type]
2369
+ when :browser
2370
+ puts "Canvas '#{session_name}' ready"
2371
+ puts "Browser opened in split pane"
2372
+ puts "URL: #{url}"
2373
+ else
2374
+ # Split pane unavailable or failed — open external browser (Forrest's Law)
2375
+ puts "Canvas '#{session_name}' ready at #{url}"
2376
+ puts "(Opening browser...)"
2377
+ open_browser(url) unless ENV['SW_NO_OPEN']
2378
+ end
2379
+ else
2380
+ # iTerm2 not available — open external browser (Forrest's Law)
2381
+ puts "Canvas '#{session_name}' ready at #{url}"
2382
+ if ITerm.gem_missing?
2383
+ puts "(Tip: `gem install iterm2_ruby` to open canvases in an iTerm split pane)"
2384
+ end
2385
+ puts "(iTerm2 not available — opening browser...)"
2386
+ open_browser(url) unless ENV['SW_NO_OPEN']
2387
+ end
2388
+
2389
+ puts ""
2390
+ puts "Push content with:"
2391
+ puts " streamweaver canvas-push #{session_name} <<'RUBY'"
2392
+ puts " header1 'Hello'"
2393
+ puts " md 'Your content here'"
2394
+ puts " RUBY"
2395
+ else
2396
+ $stderr.puts "Error: Failed to create canvas session"
2397
+ exit 1
2398
+ end
2399
+ rescue Canvas::Client::NotRunningError => e
2400
+ $stderr.puts "Error: #{e.message}"
2401
+ exit 1
2402
+ end
2403
+
2404
+ # =========================================
2405
+ # Skill Installation
2406
+ # =========================================
2407
+
2408
+ SKILL_CONTENT = <<~'MARKDOWN'
2409
+ # StreamWeaver Panel Skill
2410
+
2411
+ Use StreamWeaver panels to present information visually, not just collect input.
2412
+
2413
+ ## When to Use Panels
2414
+
2415
+ - **Presenting results**: Tables, formatted reports, summaries
2416
+ - **Status displays**: Progress dashboards, build status
2417
+ - **Rich choices**: When terminal options are too limiting
2418
+ - **Documentation**: Help text, guides with formatting
2419
+ - **Visual content**: Diagrams, charts
2420
+
2421
+ ## How to Use
2422
+
2423
+ 1. Open a panel: `streamweaver panel [session-name]`
2424
+ 2. Push content: `streamweaver canvas-push <session> <<< 'your DSL'`
2425
+ 3. Wait for interaction: `streamweaver canvas-wait <session>`
2426
+
2427
+ ## Example: Present a Report
2428
+
2429
+ ```bash
2430
+ streamweaver panel report
2431
+ streamweaver canvas-push report <<'RUBY'
2432
+ header1 "Analysis Complete"
2433
+ md "## Summary"
2434
+ md "- 47 files analyzed"
2435
+ md "- 3 issues found"
2436
+ table headers: ["File", "Issue", "Severity"], rows: [
2437
+ ["api.rb", "N+1 query", "warning"],
2438
+ ["auth.rb", "Missing validation", "error"],
2439
+ ["user.rb", "Deprecated method", "info"]
2440
+ ]
2441
+ button "Acknowledged"
2442
+ RUBY
2443
+ result=$(streamweaver canvas-wait report)
2444
+ ```
2445
+
2446
+ ## Example: Quick Selection
2447
+
2448
+ ```bash
2449
+ streamweaver panel picker
2450
+ streamweaver canvas-push picker <<'RUBY'
2451
+ header2 "Select Database"
2452
+ radio_group :choice, ["PostgreSQL", "SQLite", "MySQL"]
2453
+ button "Continue"
2454
+ RUBY
2455
+ result=$(streamweaver canvas-wait picker)
2456
+ # result contains JSON with the selection
2457
+ ```
2458
+
2459
+ ## Key Insight
2460
+
2461
+ Panels aren't just for forms - use them whenever visual presentation
2462
+ helps the user understand information better than terminal text.
2463
+
2464
+ ## DSL Quick Reference
2465
+
2466
+ ```ruby
2467
+ # Text
2468
+ text "Plain text"
2469
+ md "**Markdown** with *formatting*"
2470
+ header1 "Title" # through header6
2471
+
2472
+ # Forms
2473
+ text_field :name, placeholder: "Name"
2474
+ select :option, ["A", "B", "C"]
2475
+ checkbox :agree, "I agree"
2476
+ radio_group :choice, ["Option 1", "Option 2"]
2477
+
2478
+ # Layout
2479
+ card { text "In a card" }
2480
+ columns widths: ['50%', '50%'] do
2481
+ column { text "Left" }
2482
+ column { text "Right" }
2483
+ end
2484
+
2485
+ # Data
2486
+ table headers: ["Col1", "Col2"], rows: [["a", "b"]]
2487
+ bar_chart data: { a: 10, b: 20 }
2488
+
2489
+ # Actions
2490
+ button "Click me"
2491
+ ```
2492
+ MARKDOWN
2493
+
2494
+ # Install the StreamWeaver skills for Claude Code, plus the gem-sourced
2495
+ # skills to the .agents/skills/ cross-tool alias that Codex CLI, Gemini
2496
+ # CLI, and GitHub Copilot all discover natively (verified against each
2497
+ # tool's own docs). Claude Code does not read .agents/skills/ itself, so
2498
+ # its own ~/.claude/skills/ (or project .claude/skills/) path stays primary.
2499
+ # Usage: streamweaver install-skill [--global]
2500
+ def self.install_skill(args)
2501
+ global = args.include?('--global') || args.include?('-g')
2502
+
2503
+ claude_dir = global ? File.expand_path('~/.claude/skills') : File.join(Dir.pwd, '.claude', 'skills')
2504
+ agents_dir = global ? File.expand_path('~/.agents/skills') : File.join(Dir.pwd, '.agents', 'skills')
2505
+
2506
+ FileUtils.mkdir_p(claude_dir)
2507
+
2508
+ # Panel skill: inline content, flat .md file, no frontmatter — not
2509
+ # SKILL.md-spec-compliant, so Claude Code only (its legacy loose-file
2510
+ # skill format). Not installed to .agents/skills/.
2511
+ File.write(File.join(claude_dir, 'streamweaver-panel.md'), SKILL_CONTENT)
2512
+
2513
+ # Gem-sourced skills (proper SKILL.md with frontmatter) — symlinked so
2514
+ # gem updates propagate, into both Claude Code's own path and the
2515
+ # cross-tool alias.
2516
+ gem_skills = {
2517
+ 'streamweaver-visual-companion' => File.join(__dir__, 'skills', 'streamweaver-visual-companion', 'SKILL.md'),
2518
+ 'streamweaver-doc-builder' => File.join(__dir__, 'skills', 'streamweaver-doc-builder', 'SKILL.md'),
2519
+ 'streamweaver-way' => File.join(__dir__, 'skills', 'streamweaver-way', 'SKILL.md'),
2520
+ 'streamweaver-canvas-safe' => File.join(__dir__, 'skills', 'streamweaver-canvas-safe', 'SKILL.md'),
2521
+ 'visual-plan' => File.join(__dir__, 'skills', 'visual-plan', 'SKILL.md'),
2522
+ 'visual-recap' => File.join(__dir__, 'skills', 'visual-recap', 'SKILL.md')
2523
+ }
2524
+
2525
+ # Symlink the whole skill directory (not just SKILL.md) so sibling
2526
+ # examples/ and references/ content -- the progressive-disclosure
2527
+ # material a skill's own SKILL.md points to by relative path -- is
2528
+ # actually reachable through the installed path too (stream_weaver-5fyf:
2529
+ # a file-only symlink left that content unreachable outside the source
2530
+ # repo checkout).
2531
+ [claude_dir, agents_dir].each do |root|
2532
+ FileUtils.mkdir_p(root)
2533
+ gem_skills.each do |name, src|
2534
+ dir = File.join(root, name)
2535
+ src_dir = File.dirname(src)
2536
+ FileUtils.rm_rf(dir) if File.exist?(dir) || File.symlink?(dir)
2537
+ FileUtils.ln_s(src_dir, dir)
2538
+ end
2539
+ end
2540
+
2541
+ claude_location = global ? "global (~/.claude/skills/)" : "project (.claude/skills/)"
2542
+ agents_location = global ? "global (~/.agents/skills/)" : "project (.agents/skills/)"
2543
+ puts "StreamWeaver skills installed to #{claude_location}"
2544
+ puts " streamweaver-panel.md (panel workflow reference, Claude Code only)"
2545
+ puts " streamweaver-visual-companion/ (brainstorming companion, symlinked from gem)"
2546
+ puts " streamweaver-doc-builder/ (editorial doc builder, symlinked from gem)"
2547
+ puts " streamweaver-way/ (interactive app conventions, symlinked from gem)"
2548
+ puts " streamweaver-canvas-safe/ (backend-less compatibility reference, symlinked from gem)"
2549
+ puts " visual-plan/ (pre-implementation planning canvas, symlinked from gem)"
2550
+ puts " visual-recap/ (post-implementation recap canvas, symlinked from gem)"
2551
+ puts ""
2552
+ puts "Also installed to #{agents_location} — the cross-tool alias Codex CLI, Gemini CLI,"
2553
+ puts "and GitHub Copilot all discover natively (Claude Code uses its own path above instead)."
2554
+ puts ""
2555
+ puts "Claude Code will now know how to use StreamWeaver panels, the visual companion, the doc builder, the interactive-app conventions, what plays well in a backend-less canvas doc, and how to plan/recap work visually on canvas."
2556
+ end
2557
+
2558
+ # One-command setup for Claude Code integration
2559
+ # Adds bash permissions and installs the panel skill globally
2560
+ def self.setup
2561
+ settings_path = File.expand_path('~/.claude/settings.json')
2562
+
2563
+ # Step 1: Add bash permissions to settings.json
2564
+ settings = if File.exist?(settings_path)
2565
+ JSON.parse(File.read(settings_path))
2566
+ else
2567
+ {}
2568
+ end
2569
+
2570
+ # Ensure permissions structure exists
2571
+ settings['permissions'] ||= {}
2572
+ settings['permissions']['allow'] ||= []
2573
+
2574
+ # Add streamweaver permission if not present
2575
+ permission = 'Bash(streamweaver *)'
2576
+ unless settings['permissions']['allow'].include?(permission)
2577
+ settings['permissions']['allow'] << permission
2578
+ end
2579
+
2580
+ # Write settings
2581
+ FileUtils.mkdir_p(File.dirname(settings_path))
2582
+ File.write(settings_path, JSON.pretty_generate(settings))
2583
+ puts "Added bash permission: #{permission}"
2584
+ puts " Path: #{settings_path}"
2585
+
2586
+ # Step 2: Install skills globally
2587
+ install_skill(['--global'])
2588
+
2589
+ puts ""
2590
+ puts "StreamWeaver setup complete!"
2591
+ puts ""
2592
+ puts "Skills installed (~/.claude/skills/, plus ~/.agents/skills/ for Codex/Gemini CLI/Copilot):"
2593
+ puts " panel → streamweaver-panel.md (Claude Code only)"
2594
+ puts " visual-companion → symlink → gem (auto-updates with gem)"
2595
+ puts " doc-builder → symlink → gem (auto-updates with gem)"
2596
+ puts " streamweaver-way → symlink → gem (auto-updates with gem)"
2597
+ puts " canvas-safe → symlink → gem (auto-updates with gem)"
2598
+ puts " visual-plan → symlink → gem (auto-updates with gem)"
2599
+ puts " visual-recap → symlink → gem (auto-updates with gem)"
2600
+ puts ""
2601
+ puts "Run: streamweaver panel <name> to start a visual companion session."
2602
+ end
2603
+
2604
+ # =========================================
2605
+ # Get Started (one-command door)
2606
+ # =========================================
2607
+
2608
+ # One name, defined once. The CLI pushes to this session and the
2609
+ # listener filters events on it -- two literals here would mean a
2610
+ # single-character edit could kill every button on the canvas while
2611
+ # both sides' specs stayed green, which is precisely the class of seam
2612
+ # failure the 2026-08-29 UAT was.
2613
+ UNIVERSITY_SESSION = University::Listener::SESSION
2614
+ # Keep in sync with stream_weaver.gemspec's required_ruby_version.
2615
+ GET_STARTED_MIN_RUBY = '3.0.0'
2616
+ # `gh auth status` validates the token against the GitHub API -- on a
2617
+ # captive portal or dead network, that can hang far longer than an
2618
+ # "advisory, never a blocker" probe should ever cost the very first
2619
+ # command a new user runs.
2620
+ GH_AUTH_TIMEOUT = 3 # seconds
2621
+
2622
+ # One command: wraps `setup`, reports on dependencies across three tiers
2623
+ # (core / agent skills / premier iTerm2 surface), then opens the
2624
+ # StreamWeaver University canvas -- premier, as a controller window of
2625
+ # its own plus an agent-only worker tab, or degraded, as a browser tab
2626
+ # with side-by-side instructions. iTerm2 is opt-OUT, not optional: this
2627
+ # nags hard on a missing premier dependency and only degrades on
2628
+ # --degraded or an explicit interactive "continue anyway".
2629
+ def self.get_started(args)
2630
+ degraded_flag = false
2631
+ yes_flag = false
2632
+ agent = 'claude'
2633
+
2634
+ parser = OptionParser.new do |opts|
2635
+ opts.banner = "Usage: streamweaver get-started [--degraded] [--yes] [--agent claude|codex]"
2636
+ opts.on('--degraded', 'Skip the iTerm2 premier check, use the browser fallback') { degraded_flag = true }
2637
+ opts.on('-y', '--yes', 'Skip the interactive confirm when degrading (premier deps missing)') { yes_flag = true }
2638
+ opts.on('--agent AGENT', 'Worker CLI to launch: claude (default) or codex') { |a| agent = a }
2639
+ end
2640
+
2641
+ if args.any? { |a| help_flag?(a) }
2642
+ $stderr.puts parser
2643
+ exit 1
2644
+ end
2645
+
2646
+ parser.parse!(args)
2647
+
2648
+ unless %w[claude codex].include?(agent)
2649
+ $stderr.puts "Unknown --agent: #{agent.inspect} (expected 'claude' or 'codex')"
2650
+ exit 1
2651
+ end
2652
+
2653
+ require_relative 'iterm'
2654
+ require_relative 'canvas/client'
2655
+
2656
+ puts "=== StreamWeaver University: get-started ==="
2657
+ puts ""
2658
+ setup
2659
+ puts ""
2660
+
2661
+ # An explicit --degraded means the premier probes' answer can't change
2662
+ # the outcome -- skip them rather than burning the Python API handshake
2663
+ # timeout and the bridge boot just to discard the result.
2664
+ report = get_started_dependency_report(check_premier: !degraded_flag)
2665
+ print_get_started_report(report)
2666
+ puts ""
2667
+
2668
+ if degraded_flag
2669
+ puts "(premier iTerm2 checks skipped — degraded mode requested)"
2670
+ puts ""
2671
+ return get_started_degraded
2672
+ end
2673
+
2674
+ if get_started_premier_ok?(report)
2675
+ # Trilaws invariant: on a green-dependency machine, the bare command
2676
+ # runs zero-prompt -- no confirm here, ever. --yes has nothing to
2677
+ # skip on this branch; it only matters below, on the degraded path.
2678
+ get_started_premier(agent)
2679
+ else
2680
+ print_get_started_remediation(report)
2681
+ puts ""
2682
+
2683
+ unless $stdin.tty?
2684
+ $stderr.puts "Not running interactively and premier dependencies are missing."
2685
+ $stderr.puts "Pass --degraded to continue anyway."
2686
+ exit 1
2687
+ end
2688
+
2689
+ unless yes_flag || get_started_confirm?("Continue in degraded (browser tab) mode?", default: false)
2690
+ puts "Aborting. Install the missing pieces above, then re-run: streamweaver get-started"
2691
+ exit 1
2692
+ end
2693
+
2694
+ get_started_degraded
2695
+ end
2696
+ end
2697
+
2698
+ # Interactive y/n prompt with a default for a bare Enter. Non-tty
2699
+ # (piped/dark-factory) always returns the default rather than blocking.
2700
+ def self.get_started_confirm?(question, default:)
2701
+ return default unless $stdin.tty?
2702
+
2703
+ print "#{question} #{default ? '[Y/n]' : '[y/N]'} "
2704
+ answer = $stdin.gets
2705
+ return false if answer.nil? # EOF (Ctrl-D) means "get me out", not "yes"
2706
+
2707
+ answer = answer.strip.downcase
2708
+ answer.empty? ? default : answer.start_with?('y')
2709
+ end
2710
+
2711
+ # --- Dependency probes (each stubbable independently in specs) ---
2712
+
2713
+ def self.get_started_ruby_ok?
2714
+ Gem::Version.new(RUBY_VERSION) >= Gem::Version.new(GET_STARTED_MIN_RUBY)
2715
+ end
2716
+
2717
+ def self.get_started_bridge_ok?
2718
+ require_relative 'canvas/client'
2719
+ !!Canvas::Client.ensure_bridge_running
2720
+ rescue StandardError
2721
+ false
2722
+ end
2723
+
2724
+ def self.get_started_claude_skills_root?
2725
+ Dir.exist?(File.expand_path('~/.claude/skills'))
2726
+ end
2727
+
2728
+ def self.get_started_agents_skills_root?
2729
+ Dir.exist?(File.expand_path('~/.agents/skills'))
2730
+ end
2731
+
2732
+ def self.get_started_agent_cli_present?
2733
+ %w[claude codex].any? { |cmd| command_on_path?(cmd) }
2734
+ end
2735
+
2736
+ def self.command_on_path?(cmd)
2737
+ ENV['PATH'].to_s.split(File::PATH_SEPARATOR).any? do |dir|
2738
+ path = File.join(dir, cmd)
2739
+ File.file?(path) && File.executable?(path)
2740
+ end
2741
+ end
2742
+
2743
+ def self.get_started_premier_darwin?
2744
+ RbConfig::CONFIG['host_os'].match?(/darwin/)
2745
+ end
2746
+
2747
+ def self.get_started_premier_in_iterm?
2748
+ !ENV['ITERM_SESSION_ID'].to_s.empty?
2749
+ end
2750
+
2751
+ # Checks the gem itself, independent of darwin/in_iterm -- ITerm.available?
2752
+ # (used by every other consumer in this file) conflates all three, which
2753
+ # would tell a macOS iTerm2 user who already has the gem installed to
2754
+ # `gem install iterm2_ruby` for the wrong reason (not being in iTerm2).
2755
+ def self.get_started_premier_gem_loadable?
2756
+ require 'iterm2'
2757
+ true
2758
+ rescue LoadError
2759
+ false
2760
+ end
2761
+
2762
+ def self.get_started_premier_python_api?
2763
+ require_relative 'iterm'
2764
+ ITerm.python_api_reachable?
2765
+ end
2766
+
2767
+ # `gh` is only needed by course step 5 (pushing a doc to a gist), long
2768
+ # after get-started has already opened the door -- advisory, never a
2769
+ # blocker, same spirit as the "no agent CLI" warning above.
2770
+ def self.get_started_gh_cli_present?
2771
+ command_on_path?('gh')
2772
+ end
2773
+
2774
+ # A timed-out `gh` keeps running as an orphaned child until this
2775
+ # process exits -- harmless for a short-lived `get-started`, and no
2776
+ # worse than what happens to any backgrounded child of a CLI that
2777
+ # doesn't reap its own subprocesses on exit.
2778
+ def self.get_started_gh_authed?
2779
+ return false unless get_started_gh_cli_present?
2780
+
2781
+ Timeout.timeout(GH_AUTH_TIMEOUT) { !!system('gh', 'auth', 'status', out: File::NULL, err: File::NULL) }
2782
+ rescue Timeout::Error
2783
+ false
2784
+ end
2785
+
2786
+ # check_premier: false skips the 4 premier probes (and their real cost --
2787
+ # a Python API handshake timeout, a bridge boot) entirely, leaving them
2788
+ # nil. Used when --degraded is explicit: the answer can't change the
2789
+ # already-decided outcome, so there's nothing to spend the probes on.
2790
+ def self.get_started_dependency_report(check_premier: true)
2791
+ {
2792
+ core: {
2793
+ ruby_ok: get_started_ruby_ok?,
2794
+ ruby_version: RUBY_VERSION,
2795
+ bridge_ok: get_started_bridge_ok?
2796
+ },
2797
+ skills: {
2798
+ claude_root: get_started_claude_skills_root?,
2799
+ agents_root: get_started_agents_skills_root?,
2800
+ agent_cli: get_started_agent_cli_present?
2801
+ },
2802
+ # Neither entry here blocks get-started -- both are only needed by
2803
+ # a specific later course step, long after the door has opened.
2804
+ course: {
2805
+ gh_cli: get_started_gh_cli_present?,
2806
+ gh_authed: get_started_gh_authed?
2807
+ },
2808
+ premier: if check_premier
2809
+ {
2810
+ darwin: get_started_premier_darwin?,
2811
+ in_iterm: get_started_premier_in_iterm?,
2812
+ gem_loadable: get_started_premier_gem_loadable?,
2813
+ python_api: get_started_premier_python_api?
2814
+ }
2815
+ else
2816
+ { darwin: nil, in_iterm: nil, gem_loadable: nil, python_api: nil }
2817
+ end
2818
+ }
2819
+ end
2820
+
2821
+ def self.get_started_premier_ok?(report)
2822
+ report[:premier].values.all?
2823
+ end
2824
+
2825
+ # --- Reporting ---
2826
+
2827
+ # nil means "not checked" (premier tier when --degraded skipped it) --
2828
+ # distinct from a checked-and-failing false.
2829
+ def self.get_started_check_mark(ok)
2830
+ return "⏭ " if ok.nil?
2831
+ ok ? "✅" : "❌"
2832
+ end
2833
+
2834
+ def self.print_get_started_report(report)
2835
+ puts "Dependency check:"
2836
+ puts " core"
2837
+ puts " #{get_started_check_mark(report[:core][:ruby_ok])} Ruby #{report[:core][:ruby_version]} (>= #{GET_STARTED_MIN_RUBY})"
2838
+ puts " #{get_started_check_mark(report[:core][:bridge_ok])} canvas bridge can start"
2839
+ puts " agent skills"
2840
+ puts " #{get_started_check_mark(report[:skills][:claude_root])} Claude skills root (~/.claude/skills)"
2841
+ puts " #{get_started_check_mark(report[:skills][:agents_root])} cross-tool skills root (~/.agents/skills)"
2842
+ if report[:skills][:agent_cli]
2843
+ puts " ✅ agent CLI on PATH (claude or codex)"
2844
+ else
2845
+ puts " ⚠️ no agent CLI (`claude` or `codex`) found on PATH"
2846
+ end
2847
+ puts " premier surface (iTerm2 canvas window + worker tab)"
2848
+ puts " #{get_started_check_mark(report[:premier][:darwin])} macOS"
2849
+ puts " #{get_started_check_mark(report[:premier][:in_iterm])} running inside iTerm2"
2850
+ puts " #{get_started_check_mark(report[:premier][:gem_loadable])} iterm2_ruby gem installed"
2851
+ puts " #{get_started_check_mark(report[:premier][:python_api])} iTerm2 Python API reachable"
2852
+ # `.dig` -- :course is absent from a hand-built report (a caller that
2853
+ # only wants the premier-tier printing, e.g. an old/partial report),
2854
+ # and neither entry here ever blocks get-started. Distinguished from
2855
+ # a real, checked-and-failing false the same way get_started_check_mark
2856
+ # distinguishes a skipped premier tier: nil means "not checked", not
2857
+ # "checked and missing".
2858
+ gh_cli = report.dig(:course, :gh_cli)
2859
+ puts " course tools (used by later steps only -- never blocks get-started)"
2860
+ case gh_cli
2861
+ when true
2862
+ puts " #{get_started_check_mark(report[:course][:gh_authed])} gh CLI authenticated (`gh auth status`) -- step 5 pushes a doc to a gist with it"
2863
+ when false
2864
+ puts " ⚠️ gh CLI not found on PATH -- step 5 pushes a doc to a gist with it"
2865
+ puts " Install: https://cli.github.com (or `brew install gh`), then `gh auth login`."
2866
+ else
2867
+ puts " ⏭ gh CLI (not checked)"
2868
+ end
2869
+ puts " 💡 Install the StreamWeaver Doc Viewer Chrome extension now, so it's ready for step 5:"
2870
+ puts " https://chromewebstore.google.com/detail/streamweaver-doc-viewer/odjjednfpfiagefgpcfdlelldphmpcgj"
2871
+ puts " 💡 Browser control speeds up the worker session -- claude-in-chrome, playwright-cli, or " \
2872
+ "gstack's /browse skill, any one of them (course steps verify with curl first, so this is " \
2873
+ "optional, never a blocker). The Chrome extension above always installs in your own browser " \
2874
+ "regardless of which one you use."
2875
+ end
2876
+
2877
+ def self.print_get_started_remediation(report)
2878
+ premier = report[:premier]
2879
+ puts "⚠️ The full canvas-window + worker-tab experience needs iTerm2 — some checks above failed."
2880
+ puts ""
2881
+ unless premier[:darwin]
2882
+ puts " - Premier mode is macOS + iTerm2 only. On this platform, use --degraded."
2883
+ end
2884
+ if premier[:darwin] && !premier[:in_iterm]
2885
+ puts " - Run this from inside iTerm2 (not another terminal app)."
2886
+ end
2887
+ if premier[:darwin] && !premier[:gem_loadable]
2888
+ puts " - gem install iterm2_ruby"
2889
+ end
2890
+ if premier[:darwin] && premier[:in_iterm] && premier[:gem_loadable] && !premier[:python_api]
2891
+ puts " - iTerm2 → Settings → General → Magic → Enable Python API"
2892
+ end
2893
+ end
2894
+
2895
+ # Manual control over the background process that makes the University
2896
+ # canvas's buttons do anything. `get-started` starts it for you; this is
2897
+ # for restarting it after a crash, or seeing where its log is.
2898
+ def self.university_listener(args)
2899
+ case (args.first || 'status')
2900
+ when 'start'
2901
+ pid = University::Listener.start!
2902
+ puts "University listener started (pid #{pid})"
2903
+ puts "Log: #{University::Listener.log_path}"
2904
+ when 'stop'
2905
+ if University::Listener.stop!
2906
+ puts "University listener stopped."
2907
+ else
2908
+ puts "University listener was not running."
2909
+ end
2910
+ when 'status'
2911
+ state = University::Listener.status
2912
+ if state[:running]
2913
+ puts "University listener is running (pid #{state[:pid]})"
2914
+ else
2915
+ puts "University listener is not running."
2916
+ end
2917
+ puts "Log: #{state[:log]}"
2918
+ else
2919
+ $stderr.puts "Usage: streamweaver university-listener [start|stop|status]"
2920
+ exit 1
2921
+ end
2922
+ end
2923
+
2924
+ # "Reset course" from the terminal -- the same thing the canvas's own
2925
+ # Reset button does (University::Listener.handle_token's
2926
+ # "reset-course" branch), for anyone who wants it without clicking:
2927
+ # back up + clear the progress ledger, close the demo canvas sessions
2928
+ # the course itself opened, and re-push the course list so it comes
2929
+ # back at the zero-state. The bridge/session steps are skipped
2930
+ # entirely when no bridge is running -- resetting progress does not
2931
+ # require one, and there would be nothing to close or re-push into.
2932
+ def self.university_reset(args)
2933
+ yes_flag = args.any? { |a| %w[-y --yes].include?(a) }
2934
+ return puts("Aborted. No changes made.") unless yes_flag || university_reset_confirmed?
2935
+
2936
+ progress_path = University::Progress.path
2937
+ had_progress = File.exist?(progress_path)
2938
+ University::Progress.load.reset!
2939
+ puts(had_progress ? "Progress reset (backup: #{progress_path}.bak)" : "Progress reset (there was nothing to back up)")
2940
+
2941
+ require_relative 'canvas/client'
2942
+ if Canvas::Client.bridge_running?
2943
+ University::Listener.close_demo_sessions!
2944
+ puts "Closed demo canvas sessions: #{University::Listener::DEMO_SESSION_NAMES.join(', ')}"
2945
+ # Listener.repush, not push_get_started_placeholder_canvas -- same
2946
+ # push, but this reuses the exact call the canvas's own Reset
2947
+ # button already makes instead of a second implementation of "read
2948
+ # canvas.rb, push it to the university session."
2949
+ University::Listener.repush
2950
+ puts "Re-pushed the course list at its zero-state."
2951
+ else
2952
+ puts "(canvas bridge not running -- nothing to close or re-push)"
2953
+ end
2954
+ end
2955
+
2956
+ # Print the absolute path of a canned course demo inside the installed
2957
+ # gem. Every Getting Started prompt runs its demo through this rather
2958
+ # than naming a path, so the course works from a plain `gem install`
2959
+ # with no checkout anywhere -- and so a worker session can never go
2960
+ # looking for the source repo (round-5 UAT, 2026-09-03).
2961
+ def self.university_demo(args)
2962
+ require_relative 'university/demos'
2963
+ name = args.find { |a| !a.start_with?('-') }
2964
+
2965
+ unless name
2966
+ puts "Course demos (streamweaver university-demo <name> prints the path):"
2967
+ University::Demos::NAMES.each { |n| puts " #{n}" }
2968
+ return
2969
+ end
2970
+
2971
+ path = University::Demos.path(name)
2972
+ unless path
2973
+ $stderr.puts "Unknown demo: #{name}"
2974
+ $stderr.puts "Known demos: #{University::Demos::NAMES.join(', ')}"
2975
+ exit 1
2976
+ end
2977
+
2978
+ unless File.exist?(path)
2979
+ $stderr.puts "Demo #{name} is registered but missing from the gem at #{path}"
2980
+ exit 1
2981
+ end
2982
+
2983
+ puts path
2984
+ end
2985
+
2986
+ # `streamweaver university-done <N>`: the terminal door onto exactly
2987
+ # what the canvas's own Mark-done button does (University::Listener.
2988
+ # university_done!, same ledger write + repush as the button), PLUS
2989
+ # bringing the controller window forward -- so a worker's own closing
2990
+ # ritual (course.rb's closing_ritual) never has to hand a click back to
2991
+ # the user (round-8 UAT: "click Mark done yourself" cost real minutes of
2992
+ # ambiguity about whether a step was actually finished). The manual
2993
+ # button is untouched; this is a second door onto the same effect.
2994
+ # Reuses `canvas_raise` for the "bring forward" half rather than a
2995
+ # second copy of its pane-vs-browser fallback logic.
2996
+ def self.university_done(args)
2997
+ step_arg = args.find { |a| !a.start_with?('-') }
2998
+ step_number = step_arg.to_i if step_arg&.match?(/\A\d+\z/)
2999
+ unless step_number && University::Course.step(step_number)
3000
+ $stderr.puts "Usage: streamweaver university-done <step-number> " \
3001
+ "(1-#{University::Course::GETTING_STARTED_STEPS.size})"
3002
+ exit 1
3003
+ end
3004
+
3005
+ require_relative 'canvas/client'
3006
+ # mark_step_done! writes the ledger before the repush that follows it,
3007
+ # so a bridge failure below still leaves the step recorded done --
3008
+ # both rescues below report that honestly instead of implying nothing
3009
+ # happened. The success line is printed BEFORE canvas_raise, not
3010
+ # after -- canvas_raise prints its own confirmation ("Raised
3011
+ # 'university' in its iTerm pane"), so this never claims the raise
3012
+ # landed before finding out whether it did.
3013
+ University::Listener.university_done!(step_number)
3014
+ puts "Marked step #{step_number} done."
3015
+ begin
3016
+ canvas_raise(['university'])
3017
+ rescue SystemExit
3018
+ # canvas_raise exits 1 when the university session isn't in the
3019
+ # bridge's session list -- routine right after a canvas-restart.
3020
+ # The ledger write already landed, so that is not this command's
3021
+ # failure to report. Rescued here, narrowly, rather than at the
3022
+ # method level -- the args-validation `exit 1` above must still
3023
+ # exit for real.
3024
+ puts "Marked step #{step_number} done, but the University canvas isn't open to bring forward."
3025
+ end
3026
+ rescue Canvas::Client::NotRunningError, Canvas::Client::ConnectionError => e
3027
+ puts "Marked step #{step_number} done (progress saved), but could not reach the canvas bridge to bring it forward (#{e.message})."
3028
+ end
3029
+
3030
+ # `streamweaver focus-me`: activates the CALLING terminal's own iTerm2
3031
+ # pane -- round-9 UAT's fix for step 3's blocking standalone form
3032
+ # (`run_once!`), which opens ITS OWN browser tab and pulls the user's
3033
+ # attention there. The instant that process returns, the worker's own
3034
+ # tab has nothing that raises it back to front on its own (unlike a Run
3035
+ # submit, which ITerm.send_to_session already raises), so the worker's
3036
+ # reaction to the JSON prints into a pane nobody is looking at. This is
3037
+ # the one-liner for that gap: reuse ITerm.current_session_guid's own
3038
+ # parsing of ITERM_SESSION_ID ("w0t0p0:<UUID>" -- the UUID half is the
3039
+ # session id the iTerm2 API actually wants) and activate it directly.
3040
+ #
3041
+ # Deliberately ONE expression, nothing before it: `available?` inside
3042
+ # `activate_session` is the only check that runs first, and it's a
3043
+ # cheap env/gem check, not an RPC -- the raise itself is meant to be the
3044
+ # very first side effect this command has (Forrest, live UAT: "the
3045
+ # pause between raise is long"). Silent no-op with no session id (env
3046
+ # missing or garbled -- current_session_guid already returns nil for
3047
+ # both) and outside iTerm2/darwin (available? already gates on both):
3048
+ # every course prompt calls this blind, so a case that is entirely
3049
+ # normal off a Mac must never surface as an error.
3050
+ def self.focus_me
3051
+ require_relative 'iterm'
3052
+ ITerm.activate_session(ITerm.current_session_guid)
3053
+ end
3054
+
3055
+ def self.university_reset_confirmed?
3056
+ get_started_confirm?(
3057
+ "Reset University progress and close its demo canvas sessions " \
3058
+ "(#{University::Listener::DEMO_SESSION_NAMES.join(', ')})? " \
3059
+ "Your current progress is backed up to progress.yml.bak.",
3060
+ default: false
3061
+ )
3062
+ end
3063
+
3064
+ # --- Premier / degraded paths ---
3065
+
3066
+ # Product design (revised 2026-08-31): three surfaces, each with one job.
3067
+ # The calling terminal is left untouched. A new tab in the caller's own
3068
+ # window holds ONLY the agent -- so it stays free to acquire its own
3069
+ # demo canvas pane per step, which is what the course prompts have it
3070
+ # do from step 1 onward. And the University canvas, being the
3071
+ # controller rather than a sidecar, gets a window of its own.
3072
+ def self.get_started_premier(agent)
3073
+ puts "=== Opening premier experience ==="
3074
+ canvas_url = get_started_create_university_canvas
3075
+
3076
+ unless command_on_path?(agent)
3077
+ $stderr.puts "`#{agent}` is not on PATH — install it, or re-run with --agent claude|codex for the one you have."
3078
+ return get_started_push_and_listen
3079
+ end
3080
+
3081
+ # A new iTerm2 tab starts in $HOME, not here -- pass the invoking
3082
+ # directory through so the worker lands in the project, not ~.
3083
+ invoking_dir = Dir.pwd
3084
+ worker_session_id = ITerm.open_worker_tab(agent, dir: invoking_dir)
3085
+
3086
+ unless worker_session_id
3087
+ $stderr.puts "Could not open a worker tab automatically — open one manually, cd #{invoking_dir}, and run `#{agent}`."
3088
+ return get_started_push_and_listen
3089
+ end
3090
+
3091
+ controller_session_id = get_started_open_controller_window(canvas_url)
3092
+ path = write_get_started_worker_json(worker_session_id, agent, invoking_dir,
3093
+ controller_session_id: controller_session_id)
3094
+
3095
+ puts "Worker tab started running `#{agent}` in #{invoking_dir} (iTerm session #{worker_session_id})"
3096
+ if controller_session_id
3097
+ puts "University canvas opened in its own window (iTerm session #{controller_session_id})"
3098
+ else
3099
+ # Forrest's Law: don't hand the user a URL and a chore. The degraded
3100
+ # path already opens the browser itself; do the same here.
3101
+ $stderr.puts "Could not open the canvas window in iTerm2 — opening it in your browser instead."
3102
+ open_browser(canvas_url)
3103
+ end
3104
+ puts "Recorded: #{path}"
3105
+
3106
+ get_started_push_and_listen
3107
+ end
3108
+
3109
+ def self.get_started_degraded
3110
+ puts "=== Opening degraded (browser) experience ==="
3111
+ url = get_started_create_university_canvas
3112
+
3113
+ puts "Canvas URL: #{url}"
3114
+ open_browser(url) unless ENV['SW_NO_OPEN']
3115
+
3116
+ puts ""
3117
+ puts "Arrange your terminal and the browser tab side by side:"
3118
+ puts " 1. Keep this terminal window open."
3119
+ puts " 2. Open a second terminal window/pane next to the browser tab above."
3120
+ puts " 3. Run your agent CLI (claude or codex) in that second terminal."
3121
+ puts " 4. Copy prompts from the canvas into the agent as you go."
3122
+
3123
+ get_started_push_and_listen
3124
+ end
3125
+
3126
+ # Push the canvas, then start the process that makes its buttons work.
3127
+ # Order matters and is asserted: the listener re-pushes on every click,
3128
+ # so it must not be racing the initial push. Every exit from the premier
3129
+ # and degraded paths goes through here -- a canvas without a listener is
3130
+ # a screen of buttons that silently do nothing, which is exactly what
3131
+ # UAT found on 2026-08-29.
3132
+ def self.get_started_push_and_listen
3133
+ result = push_get_started_placeholder_canvas
3134
+ pid = University::Listener.start!
3135
+ puts "University listener running (pid #{pid}) — log: #{University::Listener.log_path}"
3136
+ result
3137
+ end
3138
+
3139
+ # Creates (get-or-create; idempotent) the university canvas session with
3140
+ # the :doc theme and returns its URL. Aborts loudly rather than letting
3141
+ # a caller push into or split onto a session that doesn't exist.
3142
+ def self.get_started_create_university_canvas
3143
+ Canvas::Client.ensure_bridge_running
3144
+ response = Canvas::Client.send_message(
3145
+ Canvas::Protocol::Messages.create(UNIVERSITY_SESSION, layout: :fluid, theme: :doc)
3146
+ )
3147
+ url = response && response[:type] == 'ready' ? response[:url] : nil
3148
+
3149
+ unless url
3150
+ $stderr.puts "Error: failed to create the canvas session (#{response.inspect})"
3151
+ exit 1
3152
+ end
3153
+
3154
+ url
3155
+ end
3156
+
3157
+ # Opens the University canvas as the controller, in a window of its
3158
+ # own -- not a pane in the worker tab, which belongs to the agent.
3159
+ # Records the browser session as the canvas session's pane_id (the same
3160
+ # bookkeeping `panel` does) so later cleanup can close it. Returns that
3161
+ # session id, or nil if the window couldn't be opened.
3162
+ #
3163
+ # `url` is whatever the bridge just handed back from its create
3164
+ # response, so it always carries the port the bridge is actually
3165
+ # listening on -- never a remembered one (a restart can move it).
3166
+ def self.get_started_open_controller_window(url)
3167
+ session_id = ITerm.open_browser_window(url)
3168
+ if session_id
3169
+ Canvas::Client.send_message(
3170
+ Canvas::Protocol::Messages.set_pane_id(UNIVERSITY_SESSION, session_id)
3171
+ )
3172
+ end
3173
+ session_id
3174
+ end
3175
+
3176
+ # The course-list app (lib/stream_weaver/university/canvas.rb) -- name
3177
+ # kept as-is (course-list-canvas / progress-ledger) since callers and
3178
+ # their specs already stub this method by name. Pushed last on both
3179
+ # paths (criterion 10). Pushes the app file's own source text verbatim,
3180
+ # same contract `canvas-push <name> < file.rb` uses for stdin -- see the
3181
+ # header comment in canvas.rb for why it's a raw string, not an object.
3182
+ def self.push_get_started_placeholder_canvas
3183
+ Canvas::Client.ensure_bridge_running
3184
+ dsl = File.read(File.expand_path('university/canvas.rb', __dir__))
3185
+ Canvas::Client.send_message(
3186
+ Canvas::Protocol::Messages.push(UNIVERSITY_SESSION, dsl, source_dir: nil)
3187
+ )
3188
+ end
3189
+
3190
+ # University::Runner is the reader of this file and owns its path --
3191
+ # including the STREAMWEAVER_UNIVERSITY_WORKER override, which the
3192
+ # writer must honor too or a spec exercising this path would overwrite
3193
+ # the developer's real recorded worker session.
3194
+ def self.write_get_started_worker_json(session_id, agent, cwd, controller_session_id: nil)
3195
+ path = University::Runner.worker_path
3196
+ FileUtils.mkdir_p(File.dirname(path))
3197
+ File.write(path, JSON.pretty_generate({
3198
+ session_id: session_id,
3199
+ agent: agent,
3200
+ cwd: cwd,
3201
+ controller_session_id: controller_session_id,
3202
+ created_at: Time.now.utc.strftime('%Y-%m-%dT%H:%M:%SZ')
3203
+ }))
3204
+ path
3205
+ end
3206
+
3207
+ # Bring terminal back to front after browser auto-closes
3208
+ def self.focus_terminal
3209
+ case RbConfig::CONFIG['host_os']
3210
+ when /darwin|mac os/
3211
+ # Try iTerm2 first, fall back to Terminal.app
3212
+ script = <<~APPLESCRIPT
3213
+ tell application "System Events"
3214
+ set frontApp to name of first application process whose frontmost is true
3215
+ end tell
3216
+ if frontApp contains "iTerm" then
3217
+ tell application "iTerm2" to activate
3218
+ else if frontApp contains "Terminal" then
3219
+ tell application "Terminal" to activate
3220
+ else
3221
+ -- Try to activate iTerm2 if installed, otherwise Terminal
3222
+ try
3223
+ tell application "iTerm2" to activate
3224
+ on error
3225
+ tell application "Terminal" to activate
3226
+ end try
3227
+ end if
3228
+ APPLESCRIPT
3229
+ system('osascript', '-e', script)
3230
+ end
3231
+ # Linux/Windows: terminal typically stays focused or user can alt-tab
3232
+ end
3233
+
3234
+ end
3235
+ end