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.
- checksums.yaml +7 -0
- data/.beads/.gitignore +77 -0
- data/.beads/README.md +81 -0
- data/.beads/config.yaml +54 -0
- data/.beads/metadata.json +7 -0
- data/.rspec +3 -0
- data/AGENTS.md +91 -0
- data/CHANGELOG.md +294 -0
- data/CLAUDE.md +67 -0
- data/LICENSE.txt +21 -0
- data/README.md +565 -0
- data/Rakefile +13 -0
- data/assets/streamweaver-hero.jpg +0 -0
- data/bench/baselines/ledger.rb +58 -0
- data/bench/baselines/warroom.rb +51 -0
- data/bench/fixtures/ledger.rb +76 -0
- data/bench/fixtures/warroom.rb +60 -0
- data/bench/results/6daf8b8.md +102 -0
- data/bench/results/c8d749b.md +102 -0
- data/bench/results/eb06ab9.md +102 -0
- data/bench/run.rb +151 -0
- data/bench/support.rb +84 -0
- data/copy_this/dto_run_sample_run_7631.html +220 -0
- data/docs/SERVICE_MODE.md +302 -0
- data/docs/architecture/how_streamweaver_works.md +772 -0
- data/docs/blog/2026-01-16-streamweaver-introduction.md +515 -0
- data/docs/bug-2026-08-31-canvas-bridge-socket-collision.md +83 -0
- data/docs/canvas-ipc-session-summary.md +127 -0
- data/docs/canvas-panel-workflow.md +172 -0
- data/docs/canvas-read.md +147 -0
- data/docs/canvas-roadmap.md +275 -0
- data/docs/case-studies/2026-09-03-didx-canvas-read-shelf.md +147 -0
- data/docs/claude-code-companion-skill-spec.md +567 -0
- data/docs/components_reference.md +1223 -0
- data/docs/crud-patterns.md +265 -0
- data/docs/endpoints.md +122 -0
- data/docs/for_llms.md +1 -0
- data/docs/form-patterns.md +165 -0
- data/docs/frontend-only.md +169 -0
- data/docs/html-artifact-audit.md +238 -0
- data/docs/ideas/2025-01-01-as-a-service.md +203 -0
- data/docs/ideas/2026-01-16-charm-tui-exploration.md +542 -0
- data/docs/ideas/2026-08-27-static-doc-shelf-export.md +153 -0
- data/docs/ideas/dhh-review-future-refactors.md +142 -0
- data/docs/opal-jamstack.md +47 -0
- data/docs/opal-npm-direction.md +184 -0
- data/docs/opal-spike-findings.md +194 -0
- data/docs/plans/2025-12-16-form-blocks-design.md +124 -0
- data/docs/plans/2025-12-30-examples-browser-design.md +152 -0
- data/docs/plans/2026-01-02-charts-design.md +288 -0
- data/docs/plans/2026-01-10-canvas-ipc-design.md +247 -0
- data/docs/plans/2026-01-19-cabinet-control-components.md +176 -0
- data/docs/plans/2026-01-26-gem-release-and-panel.md +140 -0
- data/docs/plans/canvas-claude-project-design.md +238 -0
- data/docs/plans/canvas-doc-location-and-discovery.md +32 -0
- data/docs/plans/org-doc-preview-surfaces.md +293 -0
- data/docs/plans/shadcn-polish-plan.md +417 -0
- data/docs/porting-artifacts.md +252 -0
- data/docs/reference/agent-skills-comparison.md +369 -0
- data/docs/reference/travel-state-prd.artifact.html +780 -0
- data/docs/research/2026-08-17-hotwire-alike-landscape.md +268 -0
- data/docs/research/2026-08-17-hotwire-concept-map.md +404 -0
- data/docs/research/2026-08-22-lazy-fragments-trigger-decision.md +246 -0
- data/docs/research/2026-08-22-learnhotwire-syllabus-coverage.md +239 -0
- data/docs/research/frontend-only-matrix.md +278 -0
- data/docs/research/streamweaver-way-spike-findings.md +529 -0
- data/docs/resource-dsl.md +342 -0
- data/docs/routing.md +302 -0
- data/docs/ruby-ui-comparison.md +319 -0
- data/docs/shared-dsl-fragments.md +356 -0
- data/docs/streamweaver-for-ai-agents.md +401 -0
- data/docs/streamweaver-frontend-vision.md +123 -0
- data/docs/streamweaver_canvas/glimmer_initial_final_layer.rb +77 -0
- data/docs/streamweaver_canvas/save_example.rb +9 -0
- data/docs/templates.md +213 -0
- data/docs/testing.md +155 -0
- data/docs/theming-hooks.md +281 -0
- data/docs/tutorials/the-streamweaver-way.md +882 -0
- data/docs/university/capability-inventory.md +137 -0
- data/docs/university/dependency-survey.md +114 -0
- data/docs/university/design-spec.md +217 -0
- data/docs/university/mockups/code-block-doc-theme-dark.png +0 -0
- data/docs/university/mockups/code-block-doc-theme-light.png +0 -0
- data/docs/university/mockups/course-list-dark.png +0 -0
- data/docs/university/mockups/course-list-light.png +0 -0
- data/docs/university/mockups/course_canvas_mockup.rb +741 -0
- data/docs/university/mockups/step-screen-dark.png +0 -0
- data/docs/university/mockups/step-screen-light.png +0 -0
- data/docs/university/roadmap.md +72 -0
- data/docs/university/send-to-coworker.md +77 -0
- data/docs/university/worker-session-mining.md +109 -0
- data/docs/visual/browser-rendering-spike.md +173 -0
- data/docs/visual/builder-visual-plan-analysis.md +378 -0
- data/docs/visual/sw-plan-format-exploration.md +511 -0
- data/docs/visual/sw-plan-rendering-deep-dive.md +453 -0
- data/docs/visual-skills/PROGRESS.md +60 -0
- data/docs/visual-skills/SESSION-CONTEXT.md +207 -0
- data/docs/visual-skills/analysis/components.md +317 -0
- data/docs/visual-skills/analysis/overlap.md +360 -0
- data/docs/visual-skills/analysis/pi-design-deck.md +1102 -0
- data/docs/visual-skills/analysis/streamweaver-inventory.md +93 -0
- data/docs/visual-skills/analysis/unified-specs.feature +1003 -0
- data/docs/visual-skills/analysis/visual-explainer.md +964 -0
- data/docs/visual-skills/blog/blog-series-outline.md +39 -0
- data/docs/visual-skills/blog/token-efficiency.md +73 -0
- data/docs/visual-skills/design/architecture.md +1725 -0
- data/docs/visual-skills/design/codex-review.md +340 -0
- data/docs/visual-skills/design/dhh-review.md +209 -0
- data/docs/visual-skills/design/evolution.md +100 -0
- data/docs/visual-skills/design/gemini-review.md +55 -0
- data/docs/visual-skills/design/review-synthesis.md +149 -0
- data/docs/visual-skills/implementation/STATE.md +22 -0
- data/docs/visual-skills/implementation/plan.md +347 -0
- data/docs/visual-skills/implementation/spike-findings.md +237 -0
- data/docs/visual-skills/implementation/tasks.md +702 -0
- data/docs/visual-skills/lessons-learned/process.md +145 -0
- data/examples/README.md +96 -0
- data/examples/advanced/all_components.rb +177 -0
- data/examples/advanced/examples_browser.rb +394 -0
- data/examples/advanced/teachables_browser.rb +261 -0
- data/examples/advanced/theme_tweaker.rb +366 -0
- data/examples/advanced/tutorial.rb +1528 -0
- data/examples/agentic/agentic_form.rb +30 -0
- data/examples/agentic/agentic_form_autoclose.rb +30 -0
- data/examples/agentic/cultivation_tracker.rb +69 -0
- data/examples/basic/hello_world.rb +22 -0
- data/examples/basic/opal_tabs_table.rb +44 -0
- data/examples/basic/todo_list.rb +39 -0
- data/examples/button_loading_test.rb +36 -0
- data/examples/canvas/mermaid_canvas_demo.sh +151 -0
- data/examples/charts/bar_chart_demo.rb +57 -0
- data/examples/charts/line_chart_demo.rb +73 -0
- data/examples/charts/pie_area_demo.rb +87 -0
- data/examples/charts/stacked_bar_chart_demo.rb +69 -0
- data/examples/claude_code/README.md +85 -0
- data/examples/claude_code/codebreaker/.claude/commands/infiltrate.md +632 -0
- data/examples/claude_code/codebreaker/.claude/settings.json +11 -0
- data/examples/claude_code/codebreaker/.claude/settings.local.json +17 -0
- data/examples/claude_code/codebreaker/.claude/skills/infiltrate.md +0 -0
- data/examples/claude_code/codebreaker/README.md +213 -0
- data/examples/claude_code/tutorial/.claude/commands/learn.md +453 -0
- data/examples/claude_code/tutorial/README.md +102 -0
- data/examples/claude_code/verification_flow/.claude/commands/verify.md +171 -0
- data/examples/claude_code/verification_flow/README.md +126 -0
- data/examples/components/annotated_code_demo.rb +54 -0
- data/examples/components/callout_demo.rb +33 -0
- data/examples/components/checkbox_group_demo.rb +46 -0
- data/examples/components/design_review.css +468 -0
- data/examples/components/design_review_demo.rb +44 -0
- data/examples/components/design_review_dsl.rb +300 -0
- data/examples/components/diff_block_demo.rb +112 -0
- data/examples/components/events_demo.rb +184 -0
- data/examples/components/form_demo.rb +62 -0
- data/examples/components/lesson_demo.rb +53 -0
- data/examples/components/markdown_demo.rb +110 -0
- data/examples/components/mermaid_demo.rb +166 -0
- data/examples/components/pareto_set.rb +64 -0
- data/examples/components/prd_demo.rb +21 -0
- data/examples/components/prd_dsl.rb +385 -0
- data/examples/components/quiz_demo.rb +47 -0
- data/examples/components/run_viewer_demo.rb +100 -0
- data/examples/components/score_and_collapsible_demo.rb +82 -0
- data/examples/components/select_stale_value_smoke_test.rb +39 -0
- data/examples/components/table_demo.rb +247 -0
- data/examples/components/timer_with_state_demo.rb +58 -0
- data/examples/components/todo_due_dates.rb +63 -0
- data/examples/components/uat_gaps_demo.rb +112 -0
- data/examples/dashboard/feed_simulator.rb +60 -0
- data/examples/dashboard/live_dashboard.rb +80 -0
- data/examples/dashboard_components.rb +108 -0
- data/examples/deferred_fragments_demo.rb +62 -0
- data/examples/generate_more_spike/README.md +63 -0
- data/examples/generate_more_spike/app.rb +441 -0
- data/examples/git_health.sh +401 -0
- data/examples/layout/layout_components_demo.rb +174 -0
- data/examples/layout/modal_demo.rb +215 -0
- data/examples/layout/navigation_demo.rb +227 -0
- data/examples/layout/route_tabs_demo.rb +52 -0
- data/examples/layout/routing_demo.rb +52 -0
- data/examples/layout/scroll_box_demo.rb +154 -0
- data/examples/lazy_fragments_demo.rb +151 -0
- data/examples/my_todos/README.md +29 -0
- data/examples/my_todos/my_todos.rb +312 -0
- data/examples/my_todos/store.rb +86 -0
- data/examples/opal/reactive_demo.rb +62 -0
- data/examples/opal/scenarios/s1_counter.rb +15 -0
- data/examples/opal/scenarios/s2_search_filter.rb +17 -0
- data/examples/opal/scenarios/s3_sibling_tabs.rb +25 -0
- data/examples/opal/scenarios/s4_shopping_cart.rb +34 -0
- data/examples/opal/scenarios/s5_watch.rb +39 -0
- data/examples/opal/scenarios/s6_on_start.rb +20 -0
- data/examples/opal/scenarios/s7_wizard.rb +33 -0
- data/examples/opal/scenarios/s8_loan_calculator.rb +21 -0
- data/examples/opal/scenarios/s9_dashboard.rb +27 -0
- data/examples/operations_dashboard_demo.rb +167 -0
- data/examples/panel_demo.sh +447 -0
- data/examples/parity/assets/tyrion_slice.css +86 -0
- data/examples/parity/rivet_people_slice.rb +182 -0
- data/examples/parity/tyrion_components.css +777 -0
- data/examples/parity/tyrion_warroom_components.rb +414 -0
- data/examples/parity/tyrion_warroom_slice.rb +549 -0
- data/examples/puma_dev/README.md +124 -0
- data/examples/puma_dev/config.ru +34 -0
- data/examples/puma_dev/standalone_app.rb +34 -0
- data/examples/scaffolding/blog.rb +49 -0
- data/examples/scaffolding/utf_lite.rb +105 -0
- data/examples/styling/feedback_demo.rb +229 -0
- data/examples/styling/style_showcase.rb +172 -0
- data/examples/styling/theme_demo.rb +508 -0
- data/examples/timer_health_checker.rb +111 -0
- data/examples/timer_showcase.rb +131 -0
- data/examples/tutorials/render_markdown.rb +112 -0
- data/examples/tutorials/streamweaver_way_tutorial.rb +44 -0
- data/examples/tutorials/tutorial_content.rb +1082 -0
- data/examples/visual_skills/design_deck_demo.rb +314 -0
- data/examples/visual_skills/explainer_demo.rb +499 -0
- data/exe/streamweaver +9 -0
- data/gsd/ROADMAP-1.0.md +167 -0
- data/gsd/STATE.md +13 -0
- data/gsd/research/market-positioning-research.md +139 -0
- data/gsd/research/production-patterns-research.md +270 -0
- data/gsd/research/repo-audit-1.0.md +274 -0
- data/lib/stream_weaver/action_token.rb +58 -0
- data/lib/stream_weaver/adapter/alpinejs.rb +8249 -0
- data/lib/stream_weaver/adapter/base.rb +591 -0
- data/lib/stream_weaver/adapter/opal.rb +334 -0
- data/lib/stream_weaver/adapter/static.rb +1118 -0
- data/lib/stream_weaver/admin.rb +176 -0
- data/lib/stream_weaver/app.rb +1821 -0
- data/lib/stream_weaver/assets/js/sw-copy.js +50 -0
- data/lib/stream_weaver/assets/js/sw-heredoc-rewrite.js +68 -0
- data/lib/stream_weaver/assets/js/sw-keyboard.js +165 -0
- data/lib/stream_weaver/assets/js/sw-mermaid-zoom.js +621 -0
- data/lib/stream_weaver/assets/js/sw-route-tabs.js +53 -0
- data/lib/stream_weaver/assets/js/sw-sidebar-toc.js +116 -0
- data/lib/stream_weaver/assets/js/sw-slide-nav.js +103 -0
- data/lib/stream_weaver/canvas/bridge.rb +239 -0
- data/lib/stream_weaver/canvas/bridge_server.rb +646 -0
- data/lib/stream_weaver/canvas/client.rb +298 -0
- data/lib/stream_weaver/canvas/doc_roots.rb +233 -0
- data/lib/stream_weaver/canvas/doc_store.rb +252 -0
- data/lib/stream_weaver/canvas/gist_publisher.rb +264 -0
- data/lib/stream_weaver/canvas/gist_save_handler.rb +89 -0
- data/lib/stream_weaver/canvas/gist_store.rb +135 -0
- data/lib/stream_weaver/canvas/helpers.rb +109 -0
- data/lib/stream_weaver/canvas/history.rb +90 -0
- data/lib/stream_weaver/canvas/protocol.rb +86 -0
- data/lib/stream_weaver/canvas/reader.rb +853 -0
- data/lib/stream_weaver/canvas/save_doc_widget.rb +457 -0
- data/lib/stream_weaver/canvas/scroll_top_hint.rb +21 -0
- data/lib/stream_weaver/canvas/session.rb +132 -0
- data/lib/stream_weaver/cli.rb +3235 -0
- data/lib/stream_weaver/component_assets.rb +70 -0
- data/lib/stream_weaver/component_registry.rb +67 -0
- data/lib/stream_weaver/component_renderer.rb +48 -0
- data/lib/stream_weaver/components/annotated_code.rb +53 -0
- data/lib/stream_weaver/components/api_endpoint.rb +42 -0
- data/lib/stream_weaver/components/callout.rb +59 -0
- data/lib/stream_weaver/components/chart.rb +84 -0
- data/lib/stream_weaver/components/code_block.rb +77 -0
- data/lib/stream_weaver/components/comparison.rb +39 -0
- data/lib/stream_weaver/components/decision.rb +38 -0
- data/lib/stream_weaver/components/deck/close_overlay.rb +84 -0
- data/lib/stream_weaver/components/deck/confirmation_bar.rb +60 -0
- data/lib/stream_weaver/components/deck/deck_option.rb +61 -0
- data/lib/stream_weaver/components/deck/deck_slide.rb +77 -0
- data/lib/stream_weaver/components/deck/deck_state.rb +469 -0
- data/lib/stream_weaver/components/deck/deck_summary.rb +73 -0
- data/lib/stream_weaver/components/deck/design_deck.rb +60 -0
- data/lib/stream_weaver/components/deck/generate_more_controls.rb +84 -0
- data/lib/stream_weaver/components/deck/model_selector.rb +84 -0
- data/lib/stream_weaver/components/deck/skeleton_placeholder.rb +36 -0
- data/lib/stream_weaver/components/diff_block.rb +125 -0
- data/lib/stream_weaver/components/doc_header.rb +57 -0
- data/lib/stream_weaver/components/image_block.rb +70 -0
- data/lib/stream_weaver/components/implementation_map.rb +33 -0
- data/lib/stream_weaver/components/keyboard_shortcuts.rb +94 -0
- data/lib/stream_weaver/components/kpi_dashboard.rb +78 -0
- data/lib/stream_weaver/components/mermaid.rb +79 -0
- data/lib/stream_weaver/components/pipeline.rb +63 -0
- data/lib/stream_weaver/components/sidebar_toc.rb +46 -0
- data/lib/stream_weaver/components/slide_container.rb +141 -0
- data/lib/stream_weaver/components/timeline_event.rb +58 -0
- data/lib/stream_weaver/components/wireframe.rb +29 -0
- data/lib/stream_weaver/components/wireframe_block.rb +34 -0
- data/lib/stream_weaver/components.rb +2711 -0
- data/lib/stream_weaver/css.rb +230 -0
- data/lib/stream_weaver/dev_fallback_overlay.rb +61 -0
- data/lib/stream_weaver/display_dsl.rb +1004 -0
- data/lib/stream_weaver/export/html_exporter.rb +478 -0
- data/lib/stream_weaver/feed.rb +34 -0
- data/lib/stream_weaver/feed_builder.rb +31 -0
- data/lib/stream_weaver/fonts.rb +33 -0
- data/lib/stream_weaver/interaction_runner.rb +487 -0
- data/lib/stream_weaver/iterm.rb +460 -0
- data/lib/stream_weaver/layout_registry.rb +92 -0
- data/lib/stream_weaver/opal/bridge.rb +52 -0
- data/lib/stream_weaver/opal/builder.rb +193 -0
- data/lib/stream_weaver/opal/env.rb +29 -0
- data/lib/stream_weaver/opal/reactive_state.rb +75 -0
- data/lib/stream_weaver/opal/regexp_anchor_patch.rb +62 -0
- data/lib/stream_weaver/opal/renderer.rb +77 -0
- data/lib/stream_weaver/opal/runtime.rb +250 -0
- data/lib/stream_weaver/opal/shell.rb +145 -0
- data/lib/stream_weaver/opal/string_bridge.rb +50 -0
- data/lib/stream_weaver/opal/stubs/diff.min.js +1 -0
- data/lib/stream_weaver/opal/stubs/digest.rb +15 -0
- data/lib/stream_weaver/opal/stubs/marked.umd.js +79 -0
- data/lib/stream_weaver/opal/stubs/md5.rb +3 -0
- data/lib/stream_weaver/opal/stubs/morphdom.min.js +775 -0
- data/lib/stream_weaver/opal/stubs/prism-tomorrow.min.css +1 -0
- data/lib/stream_weaver/opal/stubs/prism.min.js +1967 -0
- data/lib/stream_weaver/opal_entry.rb +136 -0
- data/lib/stream_weaver/org/inline.rb +95 -0
- data/lib/stream_weaver/org/reader.rb +561 -0
- data/lib/stream_weaver/org/recording_context.rb +86 -0
- data/lib/stream_weaver/org/source_splitter.rb +63 -0
- data/lib/stream_weaver/org/writer.rb +300 -0
- data/lib/stream_weaver/page_shell.rb +530 -0
- data/lib/stream_weaver/portfile.rb +79 -0
- data/lib/stream_weaver/pushable.rb +42 -0
- data/lib/stream_weaver/resource/default_views.rb +111 -0
- data/lib/stream_weaver/resource/field_input.rb +25 -0
- data/lib/stream_weaver/resource/state_keys.rb +16 -0
- data/lib/stream_weaver/resource/store.rb +18 -0
- data/lib/stream_weaver/resource.rb +104 -0
- data/lib/stream_weaver/server.rb +1471 -0
- data/lib/stream_weaver/service.rb +1240 -0
- data/lib/stream_weaver/service_client.rb +104 -0
- data/lib/stream_weaver/session_store.rb +186 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/SKILL.md +66 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/examples/canvas-safe-showcase.rb +98 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/actions-and-buttons.md +47 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/charts-and-diagrams.md +38 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/deck.md +32 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/inputs-and-forms.md +65 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/tabs-and-navigation.md +53 -0
- data/lib/stream_weaver/skills/streamweaver-doc-builder/SKILL.md +298 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/SKILL.md +130 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/design-review-example.css +468 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/design-review-example.rb +39 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/design-review-example_dsl.rb +300 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/doc-parity-example.rb +24 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/doc-parity-example_dsl.rb +385 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/references/checkpoints-and-forms.md +28 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/references/cleanup-and-panel.md +44 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/references/example-gallery.md +8 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/references/persistence.md +58 -0
- data/lib/stream_weaver/skills/streamweaver-way/SKILL.md +396 -0
- data/lib/stream_weaver/skills/visual-plan/SKILL.md +201 -0
- data/lib/stream_weaver/skills/visual-recap/SKILL.md +244 -0
- data/lib/stream_weaver/streamer.rb +63 -0
- data/lib/stream_weaver/templates/choices.rb +149 -0
- data/lib/stream_weaver/templates/code.rb +157 -0
- data/lib/stream_weaver/templates/confirm.rb +110 -0
- data/lib/stream_weaver/templates/diff.rb +195 -0
- data/lib/stream_weaver/templates/info.rb +142 -0
- data/lib/stream_weaver/templates/table.rb +169 -0
- data/lib/stream_weaver/templates/wizard.rb +271 -0
- data/lib/stream_weaver/theme/auto_mode.rb +116 -0
- data/lib/stream_weaver/theme/presets.rb +555 -0
- data/lib/stream_weaver/theme.rb +639 -0
- data/lib/stream_weaver/university/canvas.rb +924 -0
- data/lib/stream_weaver/university/course.rb +699 -0
- data/lib/stream_weaver/university/demos/counter.rb +33 -0
- data/lib/stream_weaver/university/demos/dashboard.rb +147 -0
- data/lib/stream_weaver/university/demos/decision_form.rb +143 -0
- data/lib/stream_weaver/university/demos.rb +50 -0
- data/lib/stream_weaver/university/listener.rb +467 -0
- data/lib/stream_weaver/university/progress.rb +201 -0
- data/lib/stream_weaver/university/runner.rb +138 -0
- data/lib/stream_weaver/university/scripts/growing_doc.rb +609 -0
- data/lib/stream_weaver/university/scripts/growing_doc_state.rb +91 -0
- data/lib/stream_weaver/utils.rb +31 -0
- data/lib/stream_weaver/version.rb +5 -0
- data/lib/stream_weaver/views/canvas/reader_layout.erb +800 -0
- data/lib/stream_weaver/views.rb +3940 -0
- data/lib/stream_weaver.rb +122 -0
- data/llms.txt +1269 -0
- data/sig/stream_weaver.rbs +4 -0
- 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
|