swarm_cli 2.0.0.pre.1 → 2.0.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.
@@ -0,0 +1,640 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "reline"
4
+ require "tty-spinner"
5
+ require "tty-markdown"
6
+ require "tty-box"
7
+ require "pastel"
8
+
9
+ module SwarmCLI
10
+ # InteractiveREPL provides a professional, interactive terminal interface
11
+ # for conversing with SwarmSDK agents.
12
+ #
13
+ # Features:
14
+ # - Multiline input with intuitive submission (Enter on empty line or Ctrl+D)
15
+ # - Beautiful Markdown rendering for agent responses
16
+ # - Progress indicators during processing
17
+ # - Command system (/help, /exit, /clear, etc.)
18
+ # - Conversation history with context preservation
19
+ # - Professional styling with Pastel and TTY tools
20
+ #
21
+ class InteractiveREPL
22
+ COMMANDS = {
23
+ "/help" => "Show available commands",
24
+ "/clear" => "Clear the screen",
25
+ "/history" => "Show conversation history",
26
+ "/exit" => "Exit the REPL (or press Ctrl+D)",
27
+ }.freeze
28
+
29
+ def initialize(swarm:, options:, initial_message: nil)
30
+ @swarm = swarm
31
+ @options = options
32
+ @initial_message = initial_message
33
+ @conversation_history = []
34
+ @session_results = [] # Accumulate all results for session summary
35
+ @validation_warnings_shown = false
36
+
37
+ setup_ui_components
38
+
39
+ # Create formatter for swarm execution output (interactive mode)
40
+ @formatter = Formatters::HumanFormatter.new(
41
+ output: $stdout,
42
+ quiet: options.quiet?,
43
+ truncate: options.truncate?,
44
+ verbose: options.verbose?,
45
+ mode: :interactive,
46
+ )
47
+ end
48
+
49
+ def run
50
+ display_welcome
51
+
52
+ # Emit validation warnings before first prompt
53
+ emit_validation_warnings_before_prompt
54
+
55
+ # Send initial message if provided
56
+ if @initial_message && !@initial_message.empty?
57
+ handle_message(@initial_message)
58
+ end
59
+
60
+ main_loop
61
+ display_goodbye
62
+ display_session_summary
63
+ rescue Interrupt
64
+ puts "\n"
65
+ display_goodbye
66
+ display_session_summary
67
+ exit(130)
68
+ end
69
+
70
+ private
71
+
72
+ def setup_ui_components
73
+ @pastel = Pastel.new(enabled: $stdout.tty?)
74
+
75
+ # Configure Reline for smooth, flicker-free input (like IRB)
76
+ Reline.output = $stdout
77
+ Reline.input = $stdin
78
+
79
+ # Configure tab completion UI colors (Ruby 3.1+)
80
+ configure_completion_ui
81
+
82
+ # Enable automatic completions (show as you type)
83
+ Reline.autocompletion = true
84
+
85
+ # Configure word break characters
86
+ Reline.completer_word_break_characters = " \t\n,;|&"
87
+
88
+ # Disable default autocomplete (uses start_with? filtering)
89
+ Reline.add_dialog_proc(:autocomplete, nil, nil)
90
+
91
+ # Add custom fuzzy completion dialog (bypasses Reline's filtering)
92
+ setup_fuzzy_completion
93
+
94
+ # Rebind Tab to invoke our custom dialog (not the default :complete method)
95
+ config = Reline.core.config
96
+ config.add_default_key_binding_by_keymap(:emacs, [9], :fuzzy_complete)
97
+ config.add_default_key_binding_by_keymap(:vi_insert, [9], :fuzzy_complete)
98
+
99
+ # Setup colors using detached styles for performance
100
+ @colors = {
101
+ prompt: @pastel.bright_cyan.bold.detach,
102
+ user_input: @pastel.white.detach,
103
+ agent_text: @pastel.bright_white.detach,
104
+ agent_label: @pastel.bright_blue.bold.detach,
105
+ success: @pastel.bright_green.detach,
106
+ success_icon: @pastel.bright_green.bold.detach,
107
+ error: @pastel.bright_red.detach,
108
+ error_icon: @pastel.bright_red.bold.detach,
109
+ warning: @pastel.bright_yellow.detach,
110
+ system: @pastel.dim.detach,
111
+ system_bracket: @pastel.bright_black.detach,
112
+ divider: @pastel.bright_black.detach,
113
+ header: @pastel.bright_cyan.bold.detach,
114
+ code: @pastel.bright_magenta.detach,
115
+ }
116
+ end
117
+
118
+ def display_welcome
119
+ divider = @colors[:divider].call("─" * 60)
120
+
121
+ puts ""
122
+ puts divider
123
+ puts @colors[:header].call("🚀 Swarm CLI Interactive REPL")
124
+ puts divider
125
+ puts ""
126
+ puts @colors[:agent_text].call("Swarm: #{@swarm.name}")
127
+ puts @colors[:system].call("Lead Agent: #{@swarm.lead_agent}")
128
+ puts ""
129
+ puts @colors[:system].call("Type your message and press Enter to submit")
130
+ puts @colors[:system].call("Type #{@colors[:code].call("/help")} for commands or #{@colors[:code].call("/exit")} to quit")
131
+ puts ""
132
+ puts divider
133
+ puts ""
134
+ end
135
+
136
+ def main_loop
137
+ catch(:exit_repl) do
138
+ loop do
139
+ input = read_user_input
140
+
141
+ break if input.nil? # Ctrl+D pressed
142
+ next if input.strip.empty?
143
+
144
+ if input.start_with?("/")
145
+ handle_command(input.strip)
146
+ else
147
+ handle_message(input)
148
+ end
149
+
150
+ puts "" # Spacing between interactions
151
+ end
152
+ end
153
+ end
154
+
155
+ def read_user_input
156
+ # Display stats separately (they scroll up naturally)
157
+ display_prompt_stats
158
+
159
+ # Build the prompt indicator with colors
160
+ prompt_indicator = build_prompt_indicator
161
+
162
+ # Use Reline for flicker-free input (same as IRB)
163
+ # Second parameter true = add to history for arrow up/down
164
+ line = Reline.readline(prompt_indicator, true)
165
+
166
+ return if line.nil? # Ctrl+D returns nil
167
+
168
+ # Reline doesn't include newline, just strip whitespace
169
+ line.strip
170
+ end
171
+
172
+ def display_prompt_stats
173
+ # Only show stats if we have conversation history
174
+ stats = build_prompt_stats
175
+ puts stats if stats && !stats.empty?
176
+ end
177
+
178
+ def build_prompt_indicator
179
+ # Reline supports ANSI colors without flickering!
180
+ # Use your beautiful colored prompt
181
+ @pastel.bright_cyan("You") +
182
+ @pastel.bright_black(" ❯ ")
183
+ end
184
+
185
+ def build_prompt_stats
186
+ return "" if @conversation_history.empty?
187
+
188
+ parts = []
189
+
190
+ # Agent name
191
+ parts << @colors[:agent_label].call(@swarm.lead_agent.to_s)
192
+
193
+ # Message count (user messages only)
194
+ msg_count = @conversation_history.count { |entry| entry[:role] == "user" }
195
+ parts << "#{msg_count} #{msg_count == 1 ? "msg" : "msgs"}"
196
+
197
+ # Get last result stats if available
198
+ if @last_result
199
+ # Token count
200
+ tokens = @last_result.total_tokens
201
+ if tokens > 0
202
+ formatted_tokens = format_number(tokens)
203
+ parts << "#{formatted_tokens} tokens"
204
+ end
205
+
206
+ # Cost
207
+ cost = @last_result.total_cost
208
+ if cost > 0
209
+ formatted_cost = format_cost_value(cost)
210
+ parts << formatted_cost
211
+ end
212
+
213
+ # Context percentage (from last log entry with usage info)
214
+ if @last_context_percentage
215
+ color_method = context_percentage_color(@last_context_percentage)
216
+ colored_pct = @pastel.public_send(color_method, @last_context_percentage)
217
+ parts << "#{colored_pct} context"
218
+ end
219
+ end
220
+
221
+ "[#{parts.join(" • ")}]"
222
+ end
223
+
224
+ def format_number(num)
225
+ if num >= 1_000_000
226
+ "#{(num / 1_000_000.0).round(1)}M"
227
+ elsif num >= 1_000
228
+ "#{(num / 1_000.0).round(1)}K"
229
+ else
230
+ num.to_s
231
+ end
232
+ end
233
+
234
+ def format_cost_value(cost)
235
+ if cost < 0.01
236
+ "$#{format("%.4f", cost)}"
237
+ elsif cost < 1.0
238
+ "$#{format("%.3f", cost)}"
239
+ else
240
+ "$#{format("%.2f", cost)}"
241
+ end
242
+ end
243
+
244
+ def context_percentage_color(percentage_string)
245
+ percentage = percentage_string.to_s.gsub("%", "").to_f
246
+
247
+ if percentage < 50
248
+ :green
249
+ elsif percentage < 80
250
+ :yellow
251
+ else
252
+ :red
253
+ end
254
+ end
255
+
256
+ def handle_command(input)
257
+ command = input.split.first.downcase
258
+
259
+ case command
260
+ when "/help"
261
+ display_help
262
+ when "/clear"
263
+ system("clear") || system("cls")
264
+ display_welcome
265
+ when "/history"
266
+ display_history
267
+ when "/exit"
268
+ # Break from main loop to trigger session summary
269
+ throw(:exit_repl)
270
+ else
271
+ puts render_error("Unknown command: #{command}")
272
+ puts @colors[:system].call("Type /help for available commands")
273
+ end
274
+ end
275
+
276
+ def handle_message(input)
277
+ # Add to history
278
+ @conversation_history << { role: "user", content: input }
279
+
280
+ puts ""
281
+
282
+ # Execute swarm with logging through formatter
283
+ result = @swarm.execute(input) do |log_entry|
284
+ # Skip model warnings - already emitted before first prompt
285
+ next if log_entry[:type] == "model_lookup_warning"
286
+
287
+ @formatter.on_log(log_entry)
288
+
289
+ # Track context percentage from usage info
290
+ if log_entry[:usage] && log_entry[:usage][:tokens_used_percentage]
291
+ @last_context_percentage = log_entry[:usage][:tokens_used_percentage]
292
+ end
293
+ end
294
+
295
+ # Check for errors
296
+ if result.failure?
297
+ @formatter.on_error(error: result.error, duration: result.duration)
298
+ return
299
+ end
300
+
301
+ # Display success through formatter (minimal in interactive mode)
302
+ @formatter.on_success(result: result)
303
+
304
+ # Store result for prompt stats and session summary
305
+ @last_result = result
306
+ @session_results << result
307
+
308
+ # Add response to history
309
+ @conversation_history << { role: "agent", content: result.content }
310
+ rescue StandardError => e
311
+ @formatter.on_error(error: e)
312
+ end
313
+
314
+ def emit_validation_warnings_before_prompt
315
+ # Setup temporary logging to capture and display warnings
316
+ SwarmSDK::LogCollector.on_log do |log_entry|
317
+ @formatter.on_log(log_entry) if log_entry[:type] == "model_lookup_warning"
318
+ end
319
+
320
+ SwarmSDK::LogStream.emitter = SwarmSDK::LogCollector
321
+
322
+ # Emit validation warnings as log events
323
+ @swarm.emit_validation_warnings
324
+
325
+ # Clean up
326
+ SwarmSDK::LogCollector.reset!
327
+ SwarmSDK::LogStream.reset!
328
+
329
+ # Add spacing if warnings were shown
330
+ puts "" if @swarm.validate.any?
331
+ rescue StandardError
332
+ # Ignore errors during validation emission
333
+ begin
334
+ SwarmSDK::LogCollector.reset!
335
+ rescue
336
+ nil
337
+ end
338
+ begin
339
+ SwarmSDK::LogStream.reset!
340
+ rescue
341
+ nil
342
+ end
343
+ end
344
+
345
+ def display_help
346
+ help_box = TTY::Box.frame(
347
+ @colors[:header].call("Available Commands:"),
348
+ "",
349
+ *COMMANDS.map do |cmd, desc|
350
+ cmd_styled = @colors[:code].call(cmd.ljust(15))
351
+ desc_styled = @colors[:system].call(desc)
352
+ " #{cmd_styled} #{desc_styled}"
353
+ end,
354
+ "",
355
+ @colors[:system].call("Input Tips:"),
356
+ @colors[:system].call(" • Type your message and press Enter to submit"),
357
+ @colors[:system].call(" • Press Ctrl+D to exit"),
358
+ @colors[:system].call(" • Use arrow keys for history and editing"),
359
+ @colors[:system].call(" • Type / for commands or @ for file paths"),
360
+ @colors[:system].call(" • Use Shift-Tab to navigate autocomplete menu"),
361
+ border: :light,
362
+ padding: [1, 2],
363
+ align: :left,
364
+ title: { top_left: " HELP " },
365
+ style: {
366
+ border: { fg: :bright_yellow },
367
+ },
368
+ )
369
+
370
+ puts help_box
371
+ end
372
+
373
+ def display_history
374
+ if @conversation_history.empty?
375
+ puts @colors[:system].call("No conversation history yet")
376
+ return
377
+ end
378
+
379
+ puts @colors[:header].call("Conversation History:")
380
+ puts @colors[:divider].call("─" * 60)
381
+ puts ""
382
+
383
+ @conversation_history.each_with_index do |entry, index|
384
+ role_label = if entry[:role] == "user"
385
+ @colors[:prompt].call("User")
386
+ else
387
+ @colors[:agent_label].call("Agent")
388
+ end
389
+
390
+ puts "#{index + 1}. #{role_label}:"
391
+
392
+ # Truncate long messages in history view
393
+ content = entry[:content]
394
+ if content.length > 200
395
+ content = content[0...200] + "..."
396
+ end
397
+
398
+ puts @colors[:system].call(" #{content.gsub("\n", "\n ")}")
399
+ puts ""
400
+ end
401
+
402
+ puts @colors[:divider].call("─" * 60)
403
+ end
404
+
405
+ def display_goodbye
406
+ puts ""
407
+ goodbye_text = @colors[:success].call("👋 Goodbye! Thanks for using Swarm CLI")
408
+ puts goodbye_text
409
+ puts ""
410
+ end
411
+
412
+ def display_session_summary
413
+ return if @session_results.empty?
414
+
415
+ # Calculate session totals
416
+ total_tokens = @session_results.sum(&:total_tokens)
417
+ total_cost = @session_results.sum(&:total_cost)
418
+ total_llm_requests = @session_results.sum(&:llm_requests)
419
+ total_tool_calls = @session_results.sum(&:tool_calls_count)
420
+ all_agents = @session_results.flat_map(&:agents_involved).uniq
421
+
422
+ # Get session duration (time from first to last message)
423
+ session_duration = if @session_results.size > 1
424
+ @session_results.map(&:duration).sum
425
+ else
426
+ @session_results.first&.duration || 0
427
+ end
428
+
429
+ # Render session summary
430
+ divider = @colors[:divider].call("─" * 60)
431
+ puts divider
432
+ puts @colors[:header].call("📊 Session Summary")
433
+ puts divider
434
+ puts ""
435
+
436
+ # Message count
437
+ msg_count = @conversation_history.count { |entry| entry[:role] == "user" }
438
+ puts " #{@colors[:agent_label].call("Messages sent:")} #{msg_count}"
439
+
440
+ # Agents used
441
+ if all_agents.any?
442
+ agents_list = all_agents.map { |agent| @colors[:agent_label].call(agent.to_s) }.join(", ")
443
+ puts " #{@colors[:agent_label].call("Agents used:")} #{agents_list}"
444
+ end
445
+
446
+ # LLM requests
447
+ puts " #{@colors[:system].call("LLM Requests:")} #{total_llm_requests}"
448
+
449
+ # Tool calls
450
+ puts " #{@colors[:system].call("Tool Calls:")} #{total_tool_calls}"
451
+
452
+ # Tokens
453
+ formatted_tokens = SwarmCLI::UI::Formatters::Number.format(total_tokens)
454
+ puts " #{@colors[:system].call("Total Tokens:")} #{formatted_tokens}"
455
+
456
+ # Cost (colored)
457
+ formatted_cost = SwarmCLI::UI::Formatters::Cost.format(total_cost, pastel: @pastel)
458
+ puts " #{@colors[:system].call("Total Cost:")} #{formatted_cost}"
459
+
460
+ # Duration
461
+ formatted_duration = SwarmCLI::UI::Formatters::Time.duration(session_duration)
462
+ puts " #{@colors[:system].call("Session Duration:")} #{formatted_duration}"
463
+
464
+ puts ""
465
+ puts divider
466
+ puts ""
467
+ end
468
+
469
+ def render_error(message)
470
+ icon = @colors[:error_icon].call("✗")
471
+ text = @colors[:error].call(message)
472
+ "#{icon} #{text}"
473
+ end
474
+
475
+ def render_system_message(text)
476
+ bracket_open = @colors[:system_bracket].call("[")
477
+ bracket_close = @colors[:system_bracket].call("]")
478
+ content = @colors[:system].call(text)
479
+ "#{bracket_open}#{content}#{bracket_close}"
480
+ end
481
+
482
+ def configure_completion_ui
483
+ # Only configure if Reline::Face is available (Ruby 3.1+)
484
+ return unless defined?(Reline::Face)
485
+
486
+ Reline::Face.config(:completion_dialog) do |conf|
487
+ conf.define(:default, foreground: :white, background: :blue)
488
+ conf.define(:enhanced, foreground: :black, background: :cyan) # Selected item
489
+ conf.define(:scrollbar, foreground: :cyan, background: :blue)
490
+ end
491
+ rescue StandardError
492
+ # Ignore errors if Face configuration fails
493
+ end
494
+
495
+ def setup_fuzzy_completion
496
+ # Capture COMMANDS for use in lambda
497
+ commands = COMMANDS
498
+
499
+ # Capture file completion logic for use in lambda (since lambda runs in different context)
500
+ file_completions = lambda do |target|
501
+ has_at_prefix = target.start_with?("@")
502
+ query = has_at_prefix ? target[1..] : target
503
+
504
+ next Dir.glob("*").sort.first(20) if query.empty?
505
+
506
+ # Find files matching query anywhere in path
507
+ pattern = "**/*#{query}*"
508
+ found = Dir.glob(pattern, File::FNM_CASEFOLD).reject do |path|
509
+ path.split("/").any? { |part| part.start_with?(".") }
510
+ end.sort.first(20)
511
+
512
+ # Add @ prefix if needed
513
+ has_at_prefix ? found.map { |p| "@#{p}" } : found
514
+ end
515
+
516
+ # Custom dialog proc for fuzzy file/command completion
517
+ fuzzy_proc = lambda do
518
+ # State: [pre, target, post, matches, pointer, navigating]
519
+
520
+ # Check if this is a navigation key press
521
+ is_nav_key = key&.match?(dialog.name)
522
+
523
+ # If we were in navigation mode and user typed a regular key (not Tab), exit nav mode
524
+ if !context.empty? && context.size >= 6 && context[5] && !is_nav_key
525
+ context[5] = false # Exit navigation mode
526
+ end
527
+
528
+ # Early check: if user typed and current target has spaces, close dialog
529
+ unless is_nav_key || context.empty?
530
+ _, target_check, = retrieve_completion_block
531
+ if target_check.include?(" ")
532
+ context.clear
533
+ return
534
+ end
535
+ end
536
+
537
+ # Detect if we should recalculate matches
538
+ should_recalculate = if context.empty?
539
+ true # First time - initialize
540
+ elsif is_nav_key
541
+ false # Navigation key - don't recalculate, just cycle
542
+ elsif context.size >= 6 && context[5]
543
+ false # We're in navigation mode - keep matches stable
544
+ else
545
+ true # User typed something - recalculate
546
+ end
547
+
548
+ # Recalculate matches if user typed
549
+ if should_recalculate
550
+ preposing, target, postposing = retrieve_completion_block
551
+
552
+ # Don't show completions if the target itself has spaces
553
+ # (allows "@lib/swarm" in middle of sentence like "check @lib/swarm file")
554
+ return if target.include?(" ")
555
+
556
+ matches = if target.start_with?("/")
557
+ # Command completions
558
+ query = target[1..] || ""
559
+ commands.keys.map(&:to_s).select do |cmd|
560
+ query.empty? || cmd.downcase.include?(query.downcase)
561
+ end.sort
562
+ elsif target.start_with?("@") || target.include?("/")
563
+ # File path completions - use captured lambda
564
+ file_completions.call(target)
565
+ end
566
+
567
+ return if matches.nil? || matches.empty?
568
+
569
+ # Store fresh values - not in navigation mode yet
570
+ context.clear
571
+ context.push(preposing, target, postposing, matches, 0, false)
572
+ end
573
+
574
+ # Use stored values
575
+ stored_pre, _, stored_post, matches, pointer, _ = context
576
+
577
+ # Handle navigation keys
578
+ if is_nav_key
579
+ # Check if Enter was pressed - close dialog without submitting
580
+ # Must check key.char (not method_symbol, which is :fuzzy_complete when trapped)
581
+ if key.char == "\r" || key.char == "\n"
582
+ # Enter pressed - accept completion and close dialog
583
+ # Clear context so dialog doesn't reappear
584
+ context.clear
585
+ return
586
+ end
587
+
588
+ # Update pointer (cycle through matches)
589
+ # Tab is now bound to :fuzzy_complete, Shift-Tab to :completion_journey_up
590
+ pointer = if key.method_symbol == :completion_journey_up
591
+ # Shift-Tab - cycle backward
592
+ (pointer - 1) % matches.size
593
+ else
594
+ # Tab (:fuzzy_complete) - cycle forward
595
+ (pointer + 1) % matches.size
596
+ end
597
+
598
+ # Update line buffer with selected completion
599
+ selected = matches[pointer]
600
+
601
+ # Get current line editor state
602
+ le = @line_editor
603
+
604
+ new_line = stored_pre + selected + stored_post
605
+ new_cursor = stored_pre.length + selected.bytesize
606
+
607
+ # Update buffer using public APIs
608
+ le.set_current_line(new_line)
609
+ le.byte_pointer = new_cursor
610
+
611
+ # Update state - mark as navigating so we don't recalculate
612
+ context[4] = pointer
613
+ context[5] = true # Now in navigation mode
614
+ end
615
+
616
+ # Set visual highlight
617
+ dialog.pointer = pointer
618
+
619
+ # Trap Shift-Tab and Enter (Tab is already bound to our dialog)
620
+ dialog.trap_key = [[27, 91, 90], [13]]
621
+
622
+ # Position dropdown
623
+ x = [cursor_pos.x, 0].max
624
+ y = 0
625
+
626
+ # Return dialog
627
+ Reline::DialogRenderInfo.new(
628
+ pos: Reline::CursorPos.new(x, y),
629
+ contents: matches,
630
+ scrollbar: true,
631
+ height: [15, matches.size].min,
632
+ face: :completion_dialog,
633
+ )
634
+ end
635
+
636
+ # Register the custom fuzzy dialog
637
+ Reline.add_dialog_proc(:fuzzy_complete, fuzzy_proc, [])
638
+ end
639
+ end
640
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmCLI
4
+ class MigrateOptions
5
+ include TTY::Option
6
+
7
+ usage do
8
+ program "swarm"
9
+ command "migrate"
10
+ desc "Migrate a Claude Swarm v1 configuration to SwarmSDK v2 format"
11
+ example "swarm migrate old-config.yml"
12
+ example "swarm migrate old-config.yml --output new-config.yml"
13
+ end
14
+
15
+ argument :input_file do
16
+ desc "Path to Claude Swarm v1 configuration file (YAML)"
17
+ required
18
+ end
19
+
20
+ option :output do
21
+ short "-o"
22
+ long "--output FILE"
23
+ desc "Output file path (if not specified, prints to stdout)"
24
+ end
25
+
26
+ option :help do
27
+ short "-h"
28
+ long "--help"
29
+ desc "Print usage"
30
+ end
31
+
32
+ def validate!
33
+ errors = []
34
+
35
+ # Input file must exist
36
+ if input_file && !File.exist?(input_file)
37
+ errors << "Input file not found: #{input_file}"
38
+ end
39
+
40
+ unless errors.empty?
41
+ raise SwarmCLI::ExecutionError, errors.join("\n")
42
+ end
43
+ end
44
+
45
+ # Convenience accessors that delegate to params
46
+ def input_file
47
+ params[:input_file]
48
+ end
49
+
50
+ def output
51
+ params[:output]
52
+ end
53
+ end
54
+ end