iterm2_ruby 0.2.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.
data/bin/iterm2ctl ADDED
@@ -0,0 +1,620 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "optparse"
6
+
7
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
8
+ require "iterm2"
9
+
10
+ module ITerm2CTL
11
+ module_function
12
+
13
+ def run(args = ARGV)
14
+ command = args.shift
15
+ case command
16
+ when "list" then cmd_list(args)
17
+ when "tabs" then cmd_tabs(args)
18
+ when "send" then cmd_send(args)
19
+ when "send-text" then cmd_send_text(args)
20
+ when "read" then cmd_read(args)
21
+ when "read-screen" then cmd_read_screen(args)
22
+ when "raise" then cmd_raise(args)
23
+ when "activate-session" then cmd_activate_session(args)
24
+ when "create" then cmd_create(args)
25
+ when "split" then cmd_split(args)
26
+ when "close" then cmd_close(args)
27
+ when "move" then cmd_move(args)
28
+ when "var" then cmd_var(args)
29
+ when "info" then cmd_info(args)
30
+ when "focus" then cmd_focus(args)
31
+ when "prompt" then cmd_prompt(args)
32
+ when "watch" then cmd_watch(args)
33
+ when "profile" then cmd_profile(args)
34
+ when "inject" then cmd_inject(args)
35
+ when "profiles" then cmd_profiles(args)
36
+ when "version" then puts "iterm2ctl #{ITerm2::VERSION}"
37
+ when "help", nil then usage
38
+ else
39
+ $stderr.puts "Unknown command: #{command}"
40
+ usage
41
+ exit 1
42
+ end
43
+ rescue ITerm2::ConnectionError => e
44
+ $stderr.puts "Connection error: #{e.message}"
45
+ exit 2
46
+ rescue ITerm2::AuthError => e
47
+ $stderr.puts "Auth error: #{e.message}"
48
+ exit 3
49
+ rescue ITerm2::NotFoundError => e
50
+ $stderr.puts "Not found: #{e.message}"
51
+ exit 4
52
+ rescue ITerm2::RPCError => e
53
+ $stderr.puts "RPC error: #{e.message}"
54
+ exit 5
55
+ end
56
+
57
+ def usage
58
+ puts <<~HELP
59
+ Usage: iterm2ctl <command> [options]
60
+
61
+ Commands:
62
+ list List all windows/tabs/sessions
63
+ list --json JSON topology output
64
+ list --triage Compact triage (window/tab/session/cwd/job)
65
+ list --with-cwd Include working directories
66
+ list --with-pid Include PIDs
67
+ tabs List tabs grouped by window
68
+ send TEXT [--session ID] Send text to a session
69
+ send-text ID TEXT Send text to specific session
70
+ read [--session ID] Read visible screen contents
71
+ read-screen ID Read screen for a specific session
72
+ raise PATTERN Raise tab matching title pattern
73
+ activate-session ID Activate a session directly
74
+ raise --cwd PATH Raise tab by working directory
75
+ create [window|tab] Create a new window or tab
76
+ split [--session ID] Split the current pane
77
+ close [--session ID] Close a session
78
+ move --tab ID --to-window ID Move a tab to another window
79
+ var get NAME [--session ID] Get a variable
80
+ var set NAME VALUE Set a variable (user.* only)
81
+ var all [--session ID] Dump all variables
82
+ info [--session ID] Show session tty, pid, cwd, job
83
+ focus Show current focus state
84
+ prompt [--session ID] Show prompt state (editing/running/finished)
85
+ watch [TYPE] [--session ID] Watch for events (focus/sessions/prompt/screen/layout)
86
+ profile [KEYS] [--session ID] Get profile properties
87
+ profiles [--properties K] List all profiles
88
+ inject TEXT [--session ID] Inject data as if from process
89
+ version Show version
90
+ help Show this help
91
+
92
+ Options:
93
+ --json Output as JSON
94
+ --session ID Target session by ID
95
+ --tab ID Target tab by ID
96
+ --window ID Target window by ID
97
+ HELP
98
+ end
99
+
100
+ def cmd_list(args)
101
+ json_mode = args.delete("--json")
102
+ triage_mode = args.delete("--triage")
103
+ with_cwd = args.delete("--with-cwd") || triage_mode
104
+ with_pid = args.delete("--with-pid") || triage_mode
105
+ enriched = with_cwd || with_pid
106
+
107
+ ITerm2.connect do |client|
108
+ sessions = enriched ? client.topology_enriched : client.topology
109
+
110
+ if json_mode
111
+ puts JSON.pretty_generate(sessions)
112
+ elsif triage_mode
113
+ puts format("%-10s %-10s %-38s %-30s %-10s %-18s", "Window", "Tab", "Session", "CWD", "PID", "Job")
114
+ puts "-" * 130
115
+ sessions.each do |s|
116
+ puts format("%-10s %-10s %-38s %-30s %-10s %-18s",
117
+ s[:window_id],
118
+ s[:tab_id],
119
+ s[:session_id],
120
+ (s[:cwd] || "")[0, 30],
121
+ s[:pid],
122
+ (s[:job] || "")[0, 18])
123
+ end
124
+ else
125
+ if sessions.empty?
126
+ puts "No sessions found"
127
+ return
128
+ end
129
+
130
+ if enriched
131
+ fmt = "%-38s %-30s"
132
+ fmt += " %-30s" if with_cwd
133
+ fmt += " %-8s" if with_pid
134
+ header_args = ["Session", "Title"]
135
+ header_args << "CWD" if with_cwd
136
+ header_args << "PID" if with_pid
137
+ puts format(fmt, *header_args)
138
+ puts "-" * (70 + (with_cwd ? 32 : 0) + (with_pid ? 10 : 0))
139
+ sessions.each do |s|
140
+ row = [s[:session_id], s[:title]&.slice(0, 29)]
141
+ row << s[:cwd]&.slice(0, 29) if with_cwd
142
+ row << s[:pid].to_s if with_pid
143
+ puts format(fmt, *row)
144
+ end
145
+ else
146
+ puts format("%-12s %-12s %-38s %s", "Window", "Tab", "Session", "Title")
147
+ puts "-" * 90
148
+ sessions.each do |s|
149
+ puts format("%-12s %-12s %-38s %s",
150
+ s[:window_id], s[:tab_id], s[:session_id], s[:title])
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
156
+
157
+ def cmd_tabs(args)
158
+ json_mode = args.delete("--json")
159
+
160
+ ITerm2.connect do |client|
161
+ grouped = client.topology.group_by { |s| s[:window_id] }
162
+ if json_mode
163
+ output = grouped.transform_values do |rows|
164
+ rows.group_by { |s| s[:tab_id] }.transform_values do |tab_rows|
165
+ {
166
+ session_count: tab_rows.count,
167
+ sessions: tab_rows.map { |r| { session_id: r[:session_id], title: r[:title] } }
168
+ }
169
+ end
170
+ end
171
+ puts JSON.pretty_generate(output)
172
+ else
173
+ grouped.keys.sort.each do |window_id|
174
+ puts "Window #{window_id}"
175
+ grouped[window_id].group_by { |s| s[:tab_id] }.each do |tab_id, tab_rows|
176
+ first_title = tab_rows.first[:title]
177
+ puts " Tab #{tab_id} (#{tab_rows.count} session#{tab_rows.count == 1 ? '' : 's'}) - #{first_title}"
178
+ end
179
+ end
180
+ end
181
+ end
182
+ end
183
+
184
+ def cmd_send_text(args)
185
+ session_id = args.shift
186
+ text = args.join(" ")
187
+ if session_id.nil? || text.empty?
188
+ $stderr.puts "Usage: iterm2ctl send-text SESSION_ID TEXT"
189
+ exit 1
190
+ end
191
+
192
+ ITerm2.connect do |client|
193
+ client.send_text(session_id, text.end_with?("\n") ? text : "#{text}\n")
194
+ puts "Sent to #{session_id}"
195
+ end
196
+ end
197
+
198
+ def cmd_read_screen(args)
199
+ session_id = args.shift
200
+ if session_id.nil?
201
+ $stderr.puts "Usage: iterm2ctl read-screen SESSION_ID [--json] [--scrollback N]"
202
+ exit 1
203
+ end
204
+
205
+ json_mode = args.delete("--json")
206
+ trailing = nil
207
+ if (idx = args.index("--scrollback"))
208
+ args.delete_at(idx)
209
+ trailing = args.delete_at(idx)&.to_i || 100
210
+ end
211
+
212
+ ITerm2.connect do |client|
213
+ result = client.read_screen(session_id, trailing_lines: trailing)
214
+ json_mode ? (puts JSON.pretty_generate(result)) : result[:lines].each { |line| puts line }
215
+ end
216
+ end
217
+
218
+ def cmd_activate_session(args)
219
+ session_id = args.shift
220
+ if session_id.nil?
221
+ $stderr.puts "Usage: iterm2ctl activate-session SESSION_ID"
222
+ exit 1
223
+ end
224
+
225
+ ITerm2.connect { |client| client.activate_session(session_id) }
226
+ puts "Activated #{session_id}"
227
+ end
228
+
229
+ def cmd_send(args)
230
+ opts = parse_target_opts(args)
231
+ text = args.join(" ")
232
+
233
+ if text.empty?
234
+ $stderr.puts "Usage: iterm2ctl send TEXT [--session ID]"
235
+ exit 1
236
+ end
237
+
238
+ # Append newline if not present (like typing a command)
239
+ text += "\n" unless text.end_with?("\n")
240
+
241
+ ITerm2.connect do |client|
242
+ session_id = resolve_session(client, opts)
243
+ client.send_text(session_id, text)
244
+ puts "Sent to #{session_id}"
245
+ end
246
+ end
247
+
248
+ def cmd_read(args)
249
+ opts = parse_target_opts(args)
250
+ json_mode = args.delete("--json")
251
+ trailing = nil
252
+
253
+ if (idx = args.index("--scrollback"))
254
+ args.delete_at(idx)
255
+ trailing = args.delete_at(idx)&.to_i || 100
256
+ end
257
+
258
+ ITerm2.connect do |client|
259
+ session_id = resolve_session(client, opts)
260
+ result = client.read_screen(session_id, trailing_lines: trailing)
261
+
262
+ if json_mode
263
+ puts JSON.pretty_generate(result)
264
+ else
265
+ result[:lines].each { |line| puts line }
266
+ end
267
+ end
268
+ end
269
+
270
+ def cmd_raise(args)
271
+ opts = parse_target_opts(args)
272
+ cwd_mode = false
273
+ if (idx = args.index("--cwd"))
274
+ args.delete_at(idx)
275
+ cwd_mode = true
276
+ end
277
+ pattern = args.join(" ")
278
+
279
+ if opts[:session]
280
+ ITerm2.connect { |c| c.activate_session(opts[:session]) }
281
+ puts "Raised session #{opts[:session]}"
282
+ elsif pattern.empty?
283
+ $stderr.puts "Usage: iterm2ctl raise PATTERN [--cwd] [--session ID]"
284
+ exit 1
285
+ elsif cwd_mode
286
+ ITerm2.connect { |c| c.raise_by_cwd(pattern) }
287
+ puts "Raised tab with cwd matching #{pattern.inspect}"
288
+ else
289
+ ITerm2.connect { |c| c.raise_by_title(pattern) }
290
+ puts "Raised tab matching #{pattern.inspect}"
291
+ end
292
+ end
293
+
294
+ def cmd_create(args)
295
+ opts = parse_target_opts(args)
296
+ type = args.shift || "tab"
297
+
298
+ ITerm2.connect do |client|
299
+ case type
300
+ when "window"
301
+ result = client.create_tab(profile_name: opts[:profile])
302
+ puts "Created window #{result[:window_id]} with session #{result[:session_id]}"
303
+ when "tab"
304
+ result = client.create_tab(window_id: opts[:window], profile_name: opts[:profile])
305
+ puts "Created tab #{result[:tab_id]} with session #{result[:session_id]}"
306
+ else
307
+ $stderr.puts "Usage: iterm2ctl create [window|tab]"
308
+ exit 1
309
+ end
310
+ end
311
+ end
312
+
313
+ def cmd_split(args)
314
+ opts = parse_target_opts(args)
315
+ vertical = !args.delete("--horizontal")
316
+
317
+ ITerm2.connect do |client|
318
+ session_id = resolve_session(client, opts)
319
+ new_id = client.split_pane(session_id, vertical: vertical, profile_name: opts[:profile])
320
+ puts "Split #{session_id} -> #{new_id}"
321
+ end
322
+ end
323
+
324
+ def cmd_close(args)
325
+ opts = parse_target_opts(args)
326
+ force = args.delete("--force")
327
+
328
+ ITerm2.connect do |client|
329
+ if opts[:tab]
330
+ client.close_tab(opts[:tab], force: !!force)
331
+ puts "Closed tab #{opts[:tab]}"
332
+ else
333
+ session_id = resolve_session(client, opts)
334
+ client.close_session(session_id, force: !!force)
335
+ puts "Closed session #{session_id}"
336
+ end
337
+ end
338
+ end
339
+
340
+ def cmd_move(args)
341
+ opts = parse_target_opts(args)
342
+ to_window = nil
343
+ if (idx = args.index("--to-window"))
344
+ args.delete_at(idx)
345
+ to_window = args.delete_at(idx)
346
+ end
347
+
348
+ tab_id = opts[:tab]
349
+ if tab_id.nil? || to_window.nil?
350
+ $stderr.puts "Usage: iterm2ctl move --tab TAB_ID --to-window WINDOW_ID"
351
+ exit 1
352
+ end
353
+
354
+ ITerm2.connect do |client|
355
+ # Get current tab order for the target window, then append the moved tab
356
+ sessions = client.topology
357
+ existing_tabs = sessions.select { |s| s[:window_id] == to_window }.map { |s| s[:tab_id] }.uniq
358
+ new_order = existing_tabs + [tab_id]
359
+
360
+ client.reorder_tabs(to_window => new_order)
361
+ puts "Moved tab #{tab_id} to window #{to_window}"
362
+ end
363
+ end
364
+
365
+ def cmd_var(args)
366
+ opts = parse_target_opts(args)
367
+ subcmd = args.shift
368
+
369
+ case subcmd
370
+ when "get"
371
+ name = args.shift
372
+ if name.nil?
373
+ $stderr.puts "Usage: iterm2ctl var get NAME [--session ID]"
374
+ exit 1
375
+ end
376
+ ITerm2.connect do |client|
377
+ scope = resolve_var_scope(client, opts)
378
+ val = client.get_variable(name, **scope)
379
+ puts val.is_a?(String) ? val : JSON.pretty_generate(val)
380
+ end
381
+
382
+ when "set"
383
+ name = args.shift
384
+ value = args.shift
385
+ if name.nil? || value.nil?
386
+ $stderr.puts "Usage: iterm2ctl var set NAME VALUE [--session ID]"
387
+ exit 1
388
+ end
389
+ ITerm2.connect do |client|
390
+ scope = resolve_var_scope(client, opts)
391
+ parsed = begin; JSON.parse(value); rescue; value; end
392
+ client.set_variables({ name => parsed }, **scope)
393
+ puts "Set #{name}"
394
+ end
395
+
396
+ when "all"
397
+ json_mode = args.delete("--json")
398
+ ITerm2.connect do |client|
399
+ scope = resolve_var_scope(client, opts)
400
+ all = client.get_variables("*", **scope)
401
+ if json_mode
402
+ puts JSON.pretty_generate(all)
403
+ else
404
+ all.sort_by { |k, _| k }.each do |k, v|
405
+ puts format("%-45s %s", k, v.is_a?(String) ? v : JSON.dump(v))
406
+ end
407
+ end
408
+ end
409
+
410
+ else
411
+ $stderr.puts "Usage: iterm2ctl var [get|set|all] ..."
412
+ exit 1
413
+ end
414
+ end
415
+
416
+ def cmd_info(args)
417
+ opts = parse_target_opts(args)
418
+ json_mode = args.delete("--json")
419
+
420
+ ITerm2.connect do |client|
421
+ session_id = resolve_session(client, opts)
422
+ info = client.session_info(session_id)
423
+
424
+ if json_mode
425
+ puts JSON.pretty_generate(info)
426
+ else
427
+ info.each do |k, v|
428
+ puts format("%-8s %s", "#{k}:", v || "(nil)")
429
+ end
430
+ end
431
+ end
432
+ end
433
+
434
+ def cmd_focus(args)
435
+ json_mode = args.delete("--json")
436
+
437
+ ITerm2.connect do |client|
438
+ result = client.focus
439
+
440
+ if json_mode
441
+ puts JSON.pretty_generate(result)
442
+ else
443
+ result.each do |k, v|
444
+ puts format("%-18s %s", "#{k}:", v.nil? ? "(none)" : v.to_s)
445
+ end
446
+ end
447
+ end
448
+ end
449
+
450
+ def cmd_prompt(args)
451
+ opts = parse_target_opts(args)
452
+ json_mode = args.delete("--json")
453
+
454
+ ITerm2.connect do |client|
455
+ session_id = resolve_session(client, opts)
456
+ result = client.get_prompt(session_id)
457
+
458
+ if json_mode
459
+ puts JSON.pretty_generate(result)
460
+ else
461
+ result.each do |k, v|
462
+ puts format("%-20s %s", "#{k}:", v.nil? ? "(nil)" : v.to_s)
463
+ end
464
+ end
465
+ end
466
+ end
467
+
468
+ def cmd_watch(args)
469
+ opts = parse_target_opts(args)
470
+ type = args.shift
471
+
472
+ ITerm2.connect do |client|
473
+ subscriptions = []
474
+
475
+ case type
476
+ when "focus", nil
477
+ subscriptions << client.on_focus_change { |n| puts JSON.dump(n) }
478
+ end
479
+
480
+ case type
481
+ when "sessions", nil
482
+ subscriptions << client.on_new_session { |n| puts JSON.dump(n) }
483
+ subscriptions << client.on_session_terminated { |n| puts JSON.dump(n) }
484
+ end
485
+
486
+ case type
487
+ when "prompt"
488
+ session_id = resolve_session(client, opts)
489
+ subscriptions << client.on_prompt_change(session_id) { |n| puts JSON.dump(n) }
490
+ when nil
491
+ # Skip prompt in watch-all (requires session_id)
492
+ end
493
+
494
+ case type
495
+ when "screen"
496
+ session_id = resolve_session(client, opts)
497
+ subscriptions << client.on_screen_update(session_id) { |n| puts JSON.dump(n) }
498
+ end
499
+
500
+ case type
501
+ when "layout", nil
502
+ subscriptions << client.on_layout_change { |n| puts JSON.dump(n) }
503
+ end
504
+
505
+ if subscriptions.empty?
506
+ $stderr.puts "No subscriptions created. Valid types: focus, sessions, prompt, screen, layout"
507
+ exit 1
508
+ end
509
+
510
+ $stdout.sync = true
511
+ $stderr.puts "Watching for #{type || 'all'} events... (Ctrl+C to stop)"
512
+ sleep
513
+ rescue Interrupt
514
+ $stderr.puts "\nStopping..."
515
+ end
516
+ end
517
+
518
+ def cmd_profile(args)
519
+ opts = parse_target_opts(args)
520
+ json_mode = args.delete("--json")
521
+ keys = args
522
+
523
+ ITerm2.connect do |client|
524
+ session_id = resolve_session(client, opts)
525
+ result = client.get_profile_property(session_id, *keys)
526
+
527
+ if json_mode
528
+ puts JSON.pretty_generate(result)
529
+ else
530
+ result.sort_by { |k, _| k }.each do |k, v|
531
+ puts format("%-40s %s", k, v.is_a?(String) ? v : JSON.dump(v))
532
+ end
533
+ end
534
+ end
535
+ end
536
+
537
+ def cmd_profiles(args)
538
+ json_mode = args.delete("--json")
539
+ properties = nil
540
+ if (idx = args.index("--properties"))
541
+ args.delete_at(idx)
542
+ properties = args.delete_at(idx)&.split(",")
543
+ end
544
+
545
+ ITerm2.connect do |client|
546
+ result = client.list_profiles(properties: properties)
547
+
548
+ if json_mode
549
+ puts JSON.pretty_generate(result)
550
+ else
551
+ result.each_with_index do |profile, i|
552
+ name = profile["Name"] || "(unnamed)"
553
+ guid = profile["Guid"] || "(no guid)"
554
+ puts "#{i + 1}. #{name} (#{guid})"
555
+ end
556
+ end
557
+ end
558
+ end
559
+
560
+ def cmd_inject(args)
561
+ opts = parse_target_opts(args)
562
+ text = args.join(" ")
563
+
564
+ if text.empty?
565
+ $stderr.puts "Usage: iterm2ctl inject TEXT [--session ID]"
566
+ exit 1
567
+ end
568
+
569
+ ITerm2.connect do |client|
570
+ session_id = resolve_session(client, opts)
571
+ client.inject(session_id, text)
572
+ puts "Injected to #{session_id}"
573
+ end
574
+ end
575
+
576
+ # Resolve scope for variable commands
577
+ def resolve_var_scope(client, opts)
578
+ if opts[:session]
579
+ { session_id: opts[:session] }
580
+ elsif opts[:tab]
581
+ { tab_id: opts[:tab] }
582
+ elsif opts[:window]
583
+ { window_id: opts[:window] }
584
+ else
585
+ # Default to first session
586
+ sessions = client.topology
587
+ raise ITerm2::NotFoundError, "No sessions found" if sessions.empty?
588
+ { session_id: sessions.first[:session_id] }
589
+ end
590
+ end
591
+
592
+ # Parse --session, --tab, --window, --profile from args (mutating)
593
+ def parse_target_opts(args)
594
+ opts = {}
595
+ %w[--session --tab --window --profile].each do |flag|
596
+ if (idx = args.index(flag))
597
+ args.delete_at(idx)
598
+ opts[flag.delete_prefix("--").to_sym] = args.delete_at(idx)
599
+ end
600
+ end
601
+ opts
602
+ end
603
+
604
+ # Resolve a session ID from opts, falling back to first session
605
+ def resolve_session(client, opts)
606
+ return opts[:session] if opts[:session]
607
+
608
+ sessions = client.topology
609
+ if opts[:tab]
610
+ match = sessions.find { |s| s[:tab_id] == opts[:tab] }
611
+ return match[:session_id] if match
612
+ raise ITerm2::NotFoundError, "Tab #{opts[:tab]} not found"
613
+ end
614
+
615
+ raise ITerm2::NotFoundError, "No sessions found" if sessions.empty?
616
+ sessions.first[:session_id]
617
+ end
618
+ end
619
+
620
+ ITerm2CTL.run