stream_weaver 0.3.0 → 0.3.1

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.
@@ -115,6 +115,12 @@ module StreamWeaver
115
115
  university_demo(args)
116
116
  when 'university-done'
117
117
  university_done(args)
118
+ when 'university-artifact'
119
+ university_artifact(args)
120
+ when 'university-cleanup'
121
+ university_cleanup(args)
122
+ when 'university-stop'
123
+ university_stop(args)
118
124
  when 'focus-me'
119
125
  focus_me
120
126
  when 'get-started'
@@ -720,6 +726,25 @@ module StreamWeaver
720
726
  streamweaver university-done <N> Mark step N done (same as clicking Mark done) and bring
721
727
  the University window forward. What every step's own
722
728
  closing ritual runs -- no click required from you.
729
+ streamweaver university-artifact The course's record of what it created -- saved docs,
730
+ [list | add <ref>] exported .org files, gists, demo canvas sessions.
731
+ [--step N] [--type T] Docs and sessions record themselves; `add` is for a
732
+ gist URL, which only your agent ever sees.
733
+ streamweaver university-cleanup Offer to delete everything the course created, group by
734
+ [--scan] [--dry-run] group (gists one at a time, showing each URL). Only
735
+ ever touches what the manifest above records; --scan
736
+ first adopts artifacts from a run that predates it,
737
+ found by the course's own file, gist and session
738
+ names (and adoption sticks, even if you then decline
739
+ every delete -- `university-artifact remove` undoes
740
+ one).
741
+ Composes with reset: cleanup takes back what the
742
+ course made, reset starts it over -- both, in either
743
+ order, leave nothing behind.
744
+ streamweaver university-stop Put the course down: stop the listener, close its demo
745
+ canvas sessions and the two iTerm2 windows
746
+ get-started opened. Keeps your progress and the
747
+ artifact manifest.
723
748
  streamweaver focus-me Bring the calling terminal's own iTerm2 pane to the
724
749
  front. Silent no-op outside iTerm2/darwin.
725
750
  HELP
@@ -1537,6 +1562,7 @@ module StreamWeaver
1537
1562
  # Stop the canvas bridge
1538
1563
  def self.canvas_stop
1539
1564
  require_relative 'canvas/client'
1565
+ Canvas::StalenessGuard.disable!
1540
1566
 
1541
1567
  if Canvas::Client.stop_bridge
1542
1568
  puts "Canvas bridge stopped"
@@ -1814,6 +1840,7 @@ module StreamWeaver
1814
1840
  # to blow away), asks for confirmation before stopping unless --yes.
1815
1841
  def self.canvas_restart(args = [])
1816
1842
  require_relative 'canvas/client'
1843
+ Canvas::StalenessGuard.disable!
1817
1844
 
1818
1845
  auto_yes = args.include?('--yes') || args.include?('-y')
1819
1846
 
@@ -1865,6 +1892,36 @@ module StreamWeaver
1865
1892
  exit 1 unless ok
1866
1893
  end
1867
1894
 
1895
+ # The programmatic core of canvas-restart: snapshot -> stop -> start ->
1896
+ # wait -> restore, with none of the command's narration, prompting or exit
1897
+ # codes. Returns { ok:, dir:, unconfirmed:, old_port:, port: } -- `ok` is
1898
+ # true iff every session that needed restoring came back, `unconfirmed`
1899
+ # names the sessions the snapshot could not read at all, and the two ports
1900
+ # let the caller say so when the bridge moved. All three are things the
1901
+ # caller owes the operator: it cannot honestly claim to have preserved a
1902
+ # session it never read, or to have healed anything if every open tab is
1903
+ # now pointing at a dead port (the ps84 port-squat gotcha, which
1904
+ # canvas-restart itself warns about in stars).
1905
+ #
1906
+ # Used by the staleness auto-heal (disc-171), which runs in the middle of
1907
+ # some other command: it must not exit that command, and must not stop to
1908
+ # ask a question nobody is sitting there to answer.
1909
+ def self.restart_bridge_preserving_sessions
1910
+ require_relative 'canvas/client'
1911
+ Canvas::StalenessGuard.disable!
1912
+
1913
+ old_port = Canvas::Client.bridge_running? ? Canvas::Client.read_bridge_info&.dig(:port) : nil
1914
+
1915
+ dir = default_snapshot_dir
1916
+ snapshot = do_canvas_snapshot(dir)
1917
+ Canvas::Client.stop_bridge
1918
+ new_port = Canvas::Client.ensure_bridge_running[:port]
1919
+ wait_for_bridge_ready
1920
+
1921
+ { ok: do_canvas_restore(dir, force: false), dir: dir,
1922
+ unconfirmed: snapshot[:unconfirmed] || [], old_port: old_port, port: new_port }
1923
+ end
1924
+
1868
1925
  # Bounded-wait retry on the bridge's liveness, called right after
1869
1926
  # ensure_bridge_running in canvas-restart before handing off to
1870
1927
  # do_canvas_restore (stream_weaver-f568, a canvas-restart fix cycle
@@ -2293,8 +2350,8 @@ module StreamWeaver
2293
2350
  # Usage: streamweaver panel [session-name] [--fresh]
2294
2351
  #
2295
2352
  # 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.
2353
+ # inline in a split pane. If the split itself couldn't be made, we just
2354
+ # print the URL for the user to open manually.
2298
2355
  def self.panel(args)
2299
2356
  # See canvas_session for why this require comes before the guard.
2300
2357
  require_relative 'iterm'
@@ -2764,6 +2821,28 @@ module StreamWeaver
2764
2821
  ITerm.python_api_reachable?
2765
2822
  end
2766
2823
 
2824
+ # Advisory, not blocking: a missing Browser Plugin still lets get-started
2825
+ # run, it just falls back to opening the canvas in a regular browser tab
2826
+ # instead of an iTerm2 window (get_started_premier does that fallback
2827
+ # itself). Kept out of get_started_premier_ok?'s gate on purpose -- see
2828
+ # there. Field report: a fresh macOS tester who'd installed the plugin
2829
+ # but still lacked a saved "Web Browser" profile is no longer relevant
2830
+ # to whether panes render (StreamWeaver never needs that saved profile
2831
+ # -- see BROWSER_TYPE_PROPERTY in iterm.rb) but the plugin itself is a
2832
+ # real, separate download this cannot substitute for.
2833
+ def self.get_started_premier_browser_plugin?
2834
+ require_relative 'iterm'
2835
+ ITerm.browser_plugin_available?
2836
+ end
2837
+
2838
+ # Advisory, same reasoning as browser_plugin above. Ships enabled, so
2839
+ # this only ever fails for someone who went looking in Settings →
2840
+ # Advanced and switched it off.
2841
+ def self.get_started_premier_browser_style_enabled?
2842
+ require_relative 'iterm'
2843
+ ITerm.browser_style_profiles_enabled?
2844
+ end
2845
+
2767
2846
  # `gh` is only needed by course step 5 (pushing a doc to a gist), long
2768
2847
  # after get-started has already opened the door -- advisory, never a
2769
2848
  # blocker, same spirit as the "no agent CLI" warning above.
@@ -2810,16 +2889,23 @@ module StreamWeaver
2810
2889
  darwin: get_started_premier_darwin?,
2811
2890
  in_iterm: get_started_premier_in_iterm?,
2812
2891
  gem_loadable: get_started_premier_gem_loadable?,
2813
- python_api: get_started_premier_python_api?
2892
+ python_api: get_started_premier_python_api?,
2893
+ browser_plugin: get_started_premier_browser_plugin?,
2894
+ browser_style_enabled: get_started_premier_browser_style_enabled?
2814
2895
  }
2815
2896
  else
2816
- { darwin: nil, in_iterm: nil, gem_loadable: nil, python_api: nil }
2897
+ { darwin: nil, in_iterm: nil, gem_loadable: nil, python_api: nil,
2898
+ browser_plugin: nil, browser_style_enabled: nil }
2817
2899
  end
2818
2900
  }
2819
2901
  end
2820
2902
 
2903
+ # browser_plugin / browser_style_enabled are deliberately excluded: both
2904
+ # are advisory (see get_started_premier_browser_plugin? and its sibling),
2905
+ # never a reason to fall back to the fully degraded (non-iTerm2)
2906
+ # experience.
2821
2907
  def self.get_started_premier_ok?(report)
2822
- report[:premier].values.all?
2908
+ %i[darwin in_iterm gem_loadable python_api].all? { |k| report[:premier][k] }
2823
2909
  end
2824
2910
 
2825
2911
  # --- Reporting ---
@@ -2849,6 +2935,21 @@ module StreamWeaver
2849
2935
  puts " #{get_started_check_mark(report[:premier][:in_iterm])} running inside iTerm2"
2850
2936
  puts " #{get_started_check_mark(report[:premier][:gem_loadable])} iterm2_ruby gem installed"
2851
2937
  puts " #{get_started_check_mark(report[:premier][:python_api])} iTerm2 Python API reachable"
2938
+ if report[:premier][:browser_plugin] == false
2939
+ puts " ❌ iTerm2 Browser Plugin"
2940
+ puts " Not installed — download from https://iterm2.com/browser-plugin.html and unzip " \
2941
+ "into /Applications (no need to run it). Never blocks: get-started falls back to opening " \
2942
+ "the canvas in your regular browser tab."
2943
+ else
2944
+ puts " #{get_started_check_mark(report[:premier][:browser_plugin])} iTerm2 Browser Plugin"
2945
+ end
2946
+ if report[:premier][:browser_style_enabled] == false
2947
+ puts " ❌ iTerm2 \"Enable browser-style profiles\" (Settings → Advanced, Experimental Features)"
2948
+ puts " Off — turn it on and restart iTerm2. Never blocks: same browser-tab fallback as above."
2949
+ else
2950
+ puts " #{get_started_check_mark(report[:premier][:browser_style_enabled])} " \
2951
+ "iTerm2 \"Enable browser-style profiles\" (Advanced setting)"
2952
+ end
2852
2953
  # `.dig` -- :course is absent from a hand-built report (a caller that
2853
2954
  # only wants the premier-tier printing, e.g. an old/partial report),
2854
2955
  # and neither entry here ever blocks get-started. Distinguished from
@@ -2953,6 +3054,332 @@ module StreamWeaver
2953
3054
  end
2954
3055
  end
2955
3056
 
3057
+ # `streamweaver university-artifact add <ref>` / `list`: the manual door
3058
+ # onto the course's artifact manifest, for the one artifact nothing can
3059
+ # record on its own. A saved doc records itself (growing_doc) and a demo
3060
+ # canvas session records itself (Listener.warm_up!), but a gist exists
3061
+ # only inside the worker's own `gh gist create` output -- step 5's
3062
+ # prompt has the worker run this the moment it has the URL.
3063
+ #
3064
+ # The manifest is what `university-cleanup` is allowed to delete, so
3065
+ # `add` is deliberately narrow: a ref whose type can't be determined is
3066
+ # refused rather than recorded under a guess.
3067
+ def self.university_artifact(args)
3068
+ require_relative 'university/artifacts'
3069
+ case (args.first || 'list')
3070
+ when 'list' then university_artifact_list
3071
+ when 'add' then university_artifact_add(args.drop(1))
3072
+ when 'remove' then university_artifact_remove(args.drop(1))
3073
+ else
3074
+ $stderr.puts "Usage: streamweaver university-artifact " \
3075
+ "[list | add <ref> [--step N] [--type doc|org|gist|session] | remove <ref> [--type T]]"
3076
+ exit 1
3077
+ end
3078
+ end
3079
+
3080
+ def self.university_artifact_list
3081
+ grouped = University::Artifacts.grouped
3082
+ if grouped.empty?
3083
+ puts "No University artifacts recorded yet."
3084
+ return
3085
+ end
3086
+
3087
+ grouped.each do |type, entries|
3088
+ puts "#{University::Artifacts.label(type)} (#{entries.size}):"
3089
+ entries.each do |entry|
3090
+ step = entry['step'] ? " [step #{entry['step']}]" : ''
3091
+ puts " #{entry['ref']}#{step}"
3092
+ end
3093
+ end
3094
+ end
3095
+
3096
+ def self.university_artifact_add(args)
3097
+ step = flag_value(args, '--step')
3098
+ type = flag_value(args, '--type')
3099
+ ref = positional(args, valued_flags: %w[--step --type])
3100
+
3101
+ unless ref
3102
+ $stderr.puts "Usage: streamweaver university-artifact add <ref> [--step N] [--type doc|org|gist|session]"
3103
+ exit 1
3104
+ end
3105
+
3106
+ entry = University::Artifacts.record!(ref, type: type, step: step)
3107
+ unless entry
3108
+ $stderr.puts "Not recorded: could not tell what kind of artifact #{ref} is."
3109
+ $stderr.puts "Pass one explicitly: --type #{University::Artifacts::TYPES.join('|')}"
3110
+ exit 1
3111
+ end
3112
+
3113
+ puts "Recorded #{entry['type']}: #{entry['ref']}#{entry['step'] ? " (step #{entry['step']})" : ''}"
3114
+ end
3115
+
3116
+ # Drops one entry from the manifest WITHOUT deleting the thing it names
3117
+ # -- the counterweight to `add` and to `--scan`'s adoption. Recording is
3118
+ # what makes an artifact destroyable, so "I did not mean to record that"
3119
+ # needs an answer that isn't hand-editing artifacts.yml; without one, a
3120
+ # mistaken adoption is offered by every cleanup run forever.
3121
+ def self.university_artifact_remove(args)
3122
+ type = flag_value(args, '--type')
3123
+ ref = positional(args, valued_flags: %w[--type])
3124
+
3125
+ unless ref
3126
+ $stderr.puts "Usage: streamweaver university-artifact remove <ref> [--type doc|org|gist|session]"
3127
+ exit 1
3128
+ end
3129
+
3130
+ # A doc/org ref is stored expanded, so match what the user typed AND
3131
+ # what it resolves to from here -- otherwise a relative path they can
3132
+ # see in `list` is one they cannot remove. File.identical? is the last
3133
+ # of the three because it is the only one that sees through a symlink
3134
+ # (on macOS `Dir.pwd` reports /private/var where the recorded path
3135
+ # says /var, and string comparison alone misses that).
3136
+ candidates = [ref, File.expand_path(ref)].uniq
3137
+ matches = University::Artifacts.all.select do |entry|
3138
+ next false unless type.nil? || entry['type'] == type
3139
+
3140
+ candidates.include?(entry['ref']) ||
3141
+ (File.exist?(entry['ref']) && File.exist?(ref) && File.identical?(entry['ref'], ref))
3142
+ end
3143
+
3144
+ if matches.empty?
3145
+ $stderr.puts "Not in the manifest: #{ref}"
3146
+ exit 1
3147
+ end
3148
+
3149
+ matches.each do |entry|
3150
+ University::Artifacts.forget!(entry['type'], entry['ref'])
3151
+ puts "Removed #{entry['type']} from the manifest: #{entry['ref']}"
3152
+ end
3153
+ puts "(the #{matches.size == 1 ? 'artifact itself is' : 'artifacts themselves are'} untouched on disk)"
3154
+ end
3155
+
3156
+ # `--flag value` or `--flag=value`, or nil. Local to the university
3157
+ # artifact commands, which are the only ones here taking optional
3158
+ # valued flags.
3159
+ def self.flag_value(args, flag)
3160
+ inline = args.grep(/\A#{Regexp.escape(flag)}=/) { |a| a.split('=', 2).last }.first
3161
+ return inline if inline
3162
+
3163
+ idx = args.index(flag)
3164
+ idx ? args[idx + 1] : nil
3165
+ end
3166
+
3167
+ # The first real argument, with flags AND the values of the separated
3168
+ # `--flag value` forms skipped. Not just "the first thing not starting
3169
+ # with a dash": `university-artifact add --type doc /tmp/a.rb` would
3170
+ # otherwise record the literal ref "doc", and a relative ref like that
3171
+ # is precisely what cleanup would later resolve against some other
3172
+ # process's working directory. Flags-before-positional is exactly the
3173
+ # shape an agent produces, and step 5's prompt hands this command to one.
3174
+ def self.positional(args, valued_flags: [])
3175
+ skip_next = false
3176
+ args.find do |arg|
3177
+ if skip_next
3178
+ skip_next = false
3179
+ next false
3180
+ end
3181
+ skip_next = valued_flags.include?(arg)
3182
+ !arg.start_with?('-')
3183
+ end
3184
+ end
3185
+
3186
+ # `streamweaver university-cleanup`: the course offers to take back
3187
+ # everything it left on the machine -- the docs it saved, the .org files
3188
+ # exported from them, the gists published, the demo canvas sessions it
3189
+ # opened, and its own state files.
3190
+ #
3191
+ # Confirmed per GROUP, except gists, which are confirmed one at a time
3192
+ # showing the URL: a gist is the only artifact here that left the
3193
+ # machine and cannot be undeleted. Every delete goes through
3194
+ # University::Cleanup, the same module the canvas's own delete buttons
3195
+ # use, so "what may be deleted" is answered in exactly one place.
3196
+ def self.university_cleanup(args)
3197
+ require_relative 'university/cleanup'
3198
+ dry_run = args.include?('--dry-run')
3199
+ # A dry-run scan previews without adopting, so what it found is NOT in
3200
+ # the inventory below. Carried here so the two can't contradict each
3201
+ # other -- listing a found artifact and then saying there is nothing to
3202
+ # clean up is the one transcript this command must never print.
3203
+ scanned = args.include?('--scan') ? print_cleanup_scan(dry_run) : []
3204
+ inventory = University::Cleanup.inventory
3205
+
3206
+ if University::Cleanup.empty?(inventory)
3207
+ return puts("Nothing to clean up -- StreamWeaver University has not recorded any artifacts.") if
3208
+ scanned.empty?
3209
+ else
3210
+ print_cleanup_inventory(inventory)
3211
+ end
3212
+
3213
+ if dry_run
3214
+ puts ""
3215
+ puts "--dry-run: nothing was deleted."
3216
+ return
3217
+ end
3218
+
3219
+ puts ""
3220
+ # get_started_confirm? answers "no" for every question when there is
3221
+ # no tty to ask at, which is the right default for a destructive
3222
+ # command and a baffling transcript without this line -- every group
3223
+ # comes back "Kept" for no visible reason.
3224
+ puts "(no terminal to confirm at -- everything will be kept)" unless $stdin.tty?
3225
+ cleanup_group('doc', inventory['doc'], "Delete #{inventory['doc'].size} saved doc file(s)?")
3226
+ cleanup_group('org', inventory['org'], "Delete #{inventory['org'].size} exported .org file(s)?")
3227
+ cleanup_gists(inventory['gist'])
3228
+ cleanup_group('session', inventory['session'],
3229
+ "Close #{inventory['session'].size} course canvas session(s)?")
3230
+ cleanup_state_files(inventory[University::Cleanup::STATE])
3231
+ puts ""
3232
+ puts "Cleanup done."
3233
+ end
3234
+
3235
+ # `--scan`: find artifacts from a run that predates the manifest, by the
3236
+ # course's own deterministic names, and adopt them into it so the
3237
+ # ordinary grouped confirm can offer them. Adoption is a write, so
3238
+ # `--dry-run` reports the same findings and records nothing -- a flag
3239
+ # that promises to change nothing must not quietly change the manifest.
3240
+ def self.print_cleanup_scan(dry_run)
3241
+ refs = if dry_run
3242
+ University::Cleanup.scan_unrecorded.values.flatten
3243
+ else
3244
+ University::Cleanup.adopt_scan!.map { |entry| entry['ref'] }
3245
+ end
3246
+
3247
+ if refs.empty?
3248
+ puts "--scan: nothing on this machine matches the course's own file, session or gist names."
3249
+ else
3250
+ puts "--scan #{dry_run ? 'found' : 'adopted'} #{refs.size} artifact(s) the manifest did not record:"
3251
+ refs.each { |ref| puts " #{ref}" }
3252
+ end
3253
+ puts ""
3254
+ refs
3255
+ end
3256
+
3257
+ def self.print_cleanup_inventory(inventory)
3258
+ puts "StreamWeaver University created these:"
3259
+ print_cleanup_files(University::Artifacts.label('doc'), inventory['doc'])
3260
+ print_cleanup_files(University::Artifacts.label('org'), inventory['org'])
3261
+ print_cleanup_refs(University::Artifacts.label('gist'), inventory['gist'])
3262
+ print_cleanup_refs(University::Artifacts.label('session'), inventory['session'])
3263
+ print_cleanup_files("University state files", inventory[University::Cleanup::STATE])
3264
+ end
3265
+
3266
+ def self.print_cleanup_files(label, entries)
3267
+ return if entries.empty?
3268
+
3269
+ puts ""
3270
+ puts "#{label} (#{entries.size}):"
3271
+ entries.each do |entry|
3272
+ detail = entry[:exists] ? "#{entry[:size]} bytes" : "missing (already deleted)"
3273
+ puts " #{entry[:ref]} -- #{detail}"
3274
+ end
3275
+ end
3276
+
3277
+ def self.print_cleanup_refs(label, entries)
3278
+ return if entries.empty?
3279
+
3280
+ puts ""
3281
+ puts "#{label} (#{entries.size}):"
3282
+ entries.each { |entry| puts " #{entry[:ref]}" }
3283
+ end
3284
+
3285
+ # One y/N for a whole group. Declining leaves every entry in the
3286
+ # manifest, so a later run can still offer them. Deletes through
3287
+ # `delete_refs!` rather than looping `delete_entry!` here: that is where
3288
+ # a refusal becomes a reported outcome instead of an exception, and a
3289
+ # backtrace halfway through a destructive command -- with some files
3290
+ # already gone and the remaining groups never offered -- is the one
3291
+ # failure mode this command must not have.
3292
+ def self.cleanup_group(type, entries, question)
3293
+ return if entries.empty?
3294
+ return puts("Kept: #{University::Artifacts.label(type).downcase}.") unless
3295
+ get_started_confirm?(question, default: false)
3296
+
3297
+ University::Cleanup.delete_refs!(type, entries.map { |entry| entry[:ref] })
3298
+ .each { |outcome| puts " #{outcome.message}" }
3299
+ end
3300
+
3301
+ # Per ITEM, showing the URL. A gist is public, remote, and gone for
3302
+ # good -- the one thing here worth asking about individually.
3303
+ def self.cleanup_gists(entries)
3304
+ return if entries.empty?
3305
+
3306
+ unless University::Cleanup.gh_available?
3307
+ puts ""
3308
+ puts "gh is not installed, so gists cannot be deleted from here. Delete these yourself:"
3309
+ entries.each { |entry| puts " #{entry[:ref]}" }
3310
+ return
3311
+ end
3312
+
3313
+ entries.each do |entry|
3314
+ if get_started_confirm?("Delete gist #{entry[:ref]}?", default: false)
3315
+ University::Cleanup.delete_refs!('gist', [entry[:ref]])
3316
+ .each { |outcome| puts " #{outcome.message}" }
3317
+ else
3318
+ puts " Kept #{entry[:ref]}"
3319
+ end
3320
+ end
3321
+ end
3322
+
3323
+ def self.cleanup_state_files(entries)
3324
+ return if entries.empty?
3325
+
3326
+ question = "Delete #{entries.size} University state file(s) " \
3327
+ "(progress, artifact manifest, worker/listener records)?"
3328
+ return puts("Kept: University state files.") unless get_started_confirm?(question, default: false)
3329
+
3330
+ University::Cleanup.delete_state_files!.each { |outcome| puts " #{outcome.message}" }
3331
+ end
3332
+
3333
+ # `streamweaver university-stop`: put the course down without throwing
3334
+ # any of it away. Stops the listener, closes the demo canvas sessions and
3335
+ # the two iTerm2 surfaces `get-started` opened (the agent's worker tab
3336
+ # and the controller window), and keeps every file -- progress and the
3337
+ # artifact manifest both. Deleting is `university-cleanup`'s job;
3338
+ # starting over is `university-reset`'s. This is neither.
3339
+ #
3340
+ # Never touches a session or window the course did not open: the canvas
3341
+ # sessions come from the same allowlist `university-reset` closes by, and
3342
+ # the two iTerm2 sessions are the exact ids `get-started` recorded in
3343
+ # worker.json.
3344
+ def self.university_stop(_args = [])
3345
+ require_relative 'canvas/client'
3346
+ require_relative 'university/artifacts'
3347
+
3348
+ puts(University::Listener.stop! ? "Stopped the University listener." : "University listener was not running.")
3349
+
3350
+ if Canvas::Client.bridge_running?
3351
+ # clear_state: false -- stopping is not starting over, so step 4's
3352
+ # growing doc must still remember what it had when the user returns.
3353
+ University::Listener.close_demo_sessions!(clear_state: false)
3354
+ puts "Closed the course demo canvas sessions: #{University::Listener::DEMO_SESSION_NAMES.join(', ')}"
3355
+ else
3356
+ puts "(canvas bridge not running -- no demo sessions to close)"
3357
+ end
3358
+
3359
+ close_university_iterm_sessions
3360
+ puts "Kept your progress (#{University::Progress.path})"
3361
+ puts "Kept the artifact manifest (#{University::Artifacts.path}) -- `streamweaver university-cleanup` removes what the course created."
3362
+ end
3363
+
3364
+ # Closes the worker tab and the controller window `get-started` recorded,
3365
+ # and nothing else. A session that is already gone (or was never
3366
+ # recorded, on the degraded path) is skipped silently -- both are the
3367
+ # ordinary case, not a failure worth reporting.
3368
+ def self.close_university_iterm_sessions
3369
+ require_relative 'iterm'
3370
+ recorded = University::Runner.worker or return
3371
+
3372
+ {
3373
+ 'session_id' => 'worker tab',
3374
+ 'controller_session_id' => 'University canvas window'
3375
+ }.each do |key, label|
3376
+ id = recorded[key]
3377
+ next unless id && ITerm.session_alive?(id)
3378
+
3379
+ puts "Closed the #{label}." if ITerm.close_pane(id)
3380
+ end
3381
+ end
3382
+
2956
3383
  # Print the absolute path of a canned course demo inside the installed
2957
3384
  # gem. Every Getting Started prompt runs its demo through this rather
2958
3385
  # than naming a path, so the course works from a plain `gem install`
@@ -3099,6 +3526,22 @@ module StreamWeaver
3099
3526
  # Forrest's Law: don't hand the user a URL and a chore. The degraded
3100
3527
  # path already opens the browser itself; do the same here.
3101
3528
  $stderr.puts "Could not open the canvas window in iTerm2 — opening it in your browser instead."
3529
+ # Print WHY, honestly -- ITerm.open_browser_window used to swallow
3530
+ # its exception entirely (rescue StandardError; nil), which left a
3531
+ # tester with no way to tell what went wrong. last_error surfaces
3532
+ # the underlying exception; browser_plugin_available? gives the one
3533
+ # targeted hint worth naming explicitly, since a missing Browser
3534
+ # Plugin is the failure this path is most likely to hit and has a
3535
+ # known fix -- guarded on `available?` so a broader connectivity
3536
+ # failure (already explained by last_error above) doesn't get
3537
+ # misreported as a missing plugin.
3538
+ if (err = ITerm.last_error)
3539
+ $stderr.puts " (#{err.class}: #{err.message})"
3540
+ end
3541
+ if ITerm.available? && !ITerm.browser_plugin_available?
3542
+ $stderr.puts " iTerm2's Browser Plugin isn't installed — download it from " \
3543
+ "https://iterm2.com/browser-plugin.html and unzip into /Applications."
3544
+ end
3102
3545
  open_browser(canvas_url)
3103
3546
  end
3104
3547
  puts "Recorded: #{path}"
@@ -8,8 +8,40 @@ module StreamWeaver
8
8
  # (`gem install iterm2_ruby` — https://rubygems.org/gems/iterm2_ruby).
9
9
  # Falls back to system browser when iTerm2 API is unavailable.
10
10
  class ITerm
11
- # iTerm2's built-in profile that renders a web view instead of a shell.
12
- BROWSER_PROFILE = "Web Browser"
11
+ # A pane renders a web view instead of a shell when its profile's
12
+ # "Custom Command" property is the literal string "Browser" -- this is
13
+ # exactly how iTerm2's own built-in "Web Browser" profile is defined
14
+ # (confirmed by reading it out of `defaults read com.googlecode.iterm2
15
+ # "New Bookmarks"`). Passing it as a profile_customizations override on
16
+ # split_pane/create_tab produces a genuine browser pane from ANY base
17
+ # profile -- verified live (title came back as the loaded page's own
18
+ # <title>, not a shell prompt) -- so nothing here depends on a
19
+ # "Web Browser" profile actually being installed. A fresh macOS/iTerm2
20
+ # install with only "Default" in its profile list works identically.
21
+ #
22
+ # This still needs iTerm2's own browser feature to be usable, which
23
+ # (per iterm2.com/documentation-preferences-profiles-general.html) is
24
+ # gated on TWO things this override cannot bypass: the separately
25
+ # downloaded Browser Plugin (browser_plugin_available? below), and the
26
+ # "Enable browser-style profiles" Advanced setting (enabled by default;
27
+ # browser_style_profiles_enabled? below). The live verification above
28
+ # ran on a machine with both already satisfied -- a fresh install
29
+ # lacking the plugin needs it installed regardless of this override.
30
+ BROWSER_TYPE_PROPERTY = { "Custom Command" => "Browser" }.freeze
31
+
32
+ # iTerm2's bundle identifier for its own separately-downloaded Browser
33
+ # Plugin (iterm2.com/browser-plugin.html: unzip into /Applications,
34
+ # "you don't need to run it; iTerm2 will locate it automatically").
35
+ # Matches the plugin's own Info.plist CFBundleIdentifier, confirmed on
36
+ # a machine that has it installed.
37
+ BROWSER_PLUGIN_BUNDLE_ID = "com.googlecode.iterm2.iTermBrowserPlugin"
38
+
39
+ # The Advanced setting's persisted preference key (found in the app
40
+ # binary's strings: "advancedSettingsModelDictionary_browserProfiles" /
41
+ # "browserProfilesUserDefaultsKey", labeled in Settings → Advanced as
42
+ # "Experimental Features: Enable browser-style profiles?"). Unset on a
43
+ # machine that has never touched it -- iTerm2 defaults it to enabled.
44
+ BROWSER_STYLE_PROFILES_PREF_KEY = "browserProfiles"
13
45
 
14
46
  # Default window frames (points) for the two windows this class ever
15
47
  # creates from scratch -- the University controller (narrow, tall) and a
@@ -56,6 +88,41 @@ module StreamWeaver
56
88
  !ENV["ITERM_SESSION_ID"].to_s.empty?
57
89
  end
58
90
 
91
+ # True when iTerm2's Browser Plugin is installed -- checked the same
92
+ # way iTerm2 itself locates it (by bundle identifier via Launch
93
+ # Services/Spotlight, not a fixed path), so this is accurate
94
+ # regardless of which folder the user unzipped it into. Needs no
95
+ # Python API connection at all -- darwin-only, since mdfind is a
96
+ # macOS tool. False on any doubt (not darwin, mdfind missing/erroring,
97
+ # timeout), same spirit as every other probe in this file.
98
+ def browser_plugin_available?
99
+ return false unless RbConfig::CONFIG["host_os"].match?(/darwin/)
100
+
101
+ with_timeout(5, default: false) do
102
+ out = `mdfind "kMDItemCFBundleIdentifier == '#{BROWSER_PLUGIN_BUNDLE_ID}'" 2>/dev/null`
103
+ !out.strip.empty?
104
+ end
105
+ rescue StandardError
106
+ false
107
+ end
108
+
109
+ # True unless the user has explicitly turned off iTerm2's "Enable
110
+ # browser-style profiles" Advanced setting -- an "Experimental
111
+ # Features" toggle that ships enabled, so an unset preference (the
112
+ # common case) reads as enabled rather than disabled. Reads the
113
+ # plist directly via `defaults`, not the Python API -- this is a
114
+ # plain macOS preference, no RPC involved.
115
+ def browser_style_profiles_enabled?
116
+ return false unless RbConfig::CONFIG["host_os"].match?(/darwin/)
117
+
118
+ with_timeout(5, default: true) do
119
+ out = `defaults read com.googlecode.iterm2 #{BROWSER_STYLE_PROFILES_PREF_KEY} 2>/dev/null`.strip
120
+ out != "0"
121
+ end
122
+ rescue StandardError
123
+ true
124
+ end
125
+
59
126
  # Split a browser pane with the URL into `target_session` (any session
60
127
  # id, e.g. a just-created worker tab's session -- not necessarily the
61
128
  # calling pane). Defaults to the calling session (current_session_guid)
@@ -99,6 +166,7 @@ module StreamWeaver
99
166
  def open_browser_window(url)
100
167
  return nil unless available?
101
168
 
169
+ @last_error = nil
102
170
  with_timeout(10, default: nil) do
103
171
  connect do |c|
104
172
  created = c.create_tab
@@ -119,10 +187,22 @@ module StreamWeaver
119
187
  pane
120
188
  end
121
189
  end
122
- rescue StandardError
190
+ rescue StandardError => e
191
+ @last_error = e
123
192
  nil
124
193
  end
125
194
 
195
+ # The exception that made the most recent open_browser_window call
196
+ # fall back to nil, or nil if that call succeeded (or hasn't run
197
+ # yet). Reset at the start of every open_browser_window call, so a
198
+ # caller committed to the honest-fallback message (get-started's
199
+ # premier path, cli.rb) can report WHY the canvas window couldn't
200
+ # open instead of the silent nil this class returned before --
201
+ # rather than parse it out of a swallowed rescue.
202
+ def last_error
203
+ @last_error
204
+ end
205
+
126
206
  def close_pane(pane_id)
127
207
  return false unless available? && pane_id
128
208
  connect { |c| c.close_session(pane_id, force: true) }
@@ -298,15 +378,17 @@ module StreamWeaver
298
378
  # The browser pane for `url`, or nil. The real client raises on a
299
379
  # failed split (and returns nil only for an OK response with no
300
380
  # session), so both spellings of failure are caught here rather than
301
- # unwinding past the shell cleanup above.
381
+ # unwinding past the shell cleanup above. No profile_name: the split
382
+ # inherits whatever profile `shell` already has, and BROWSER_TYPE_PROPERTY
383
+ # overrides it into a browser pane regardless (see that constant).
302
384
  def browser_pane_in(client, shell, url)
303
385
  client.split_pane(
304
386
  shell,
305
387
  vertical: true,
306
- profile_name: BROWSER_PROFILE,
307
- profile_customizations: { "Initial URL" => url }
388
+ profile_customizations: BROWSER_TYPE_PROPERTY.merge("Initial URL" => url)
308
389
  )
309
- rescue StandardError
390
+ rescue StandardError => e
391
+ @last_error = e
310
392
  nil
311
393
  end
312
394
 
@@ -441,8 +523,7 @@ module StreamWeaver
441
523
  c.split_pane(
442
524
  guid,
443
525
  vertical: !horizontal,
444
- profile_name: BROWSER_PROFILE,
445
- profile_customizations: { "Initial URL" => url }
526
+ profile_customizations: BROWSER_TYPE_PROPERTY.merge("Initial URL" => url)
446
527
  )
447
528
  end
448
529
  end