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.
- checksums.yaml +4 -4
- data/lib/swarm_cli/cli.rb +21 -1
- data/lib/swarm_cli/commands/migrate.rb +55 -0
- data/lib/swarm_cli/commands/run.rb +81 -24
- data/lib/swarm_cli/config_loader.rb +97 -0
- data/lib/swarm_cli/formatters/human_formatter.rb +404 -629
- data/lib/swarm_cli/interactive_repl.rb +640 -0
- data/lib/swarm_cli/migrate_options.rb +54 -0
- data/lib/swarm_cli/migrator.rb +132 -0
- data/lib/swarm_cli/options.rb +53 -17
- data/lib/swarm_cli/ui/components/agent_badge.rb +33 -0
- data/lib/swarm_cli/ui/components/content_block.rb +120 -0
- data/lib/swarm_cli/ui/components/divider.rb +57 -0
- data/lib/swarm_cli/ui/components/panel.rb +62 -0
- data/lib/swarm_cli/ui/components/usage_stats.rb +70 -0
- data/lib/swarm_cli/ui/formatters/cost.rb +49 -0
- data/lib/swarm_cli/ui/formatters/number.rb +58 -0
- data/lib/swarm_cli/ui/formatters/text.rb +77 -0
- data/lib/swarm_cli/ui/formatters/time.rb +73 -0
- data/lib/swarm_cli/ui/icons.rb +59 -0
- data/lib/swarm_cli/ui/renderers/event_renderer.rb +188 -0
- data/lib/swarm_cli/ui/state/agent_color_cache.rb +45 -0
- data/lib/swarm_cli/ui/state/depth_tracker.rb +40 -0
- data/lib/swarm_cli/ui/state/spinner_manager.rb +170 -0
- data/lib/swarm_cli/ui/state/usage_tracker.rb +62 -0
- data/lib/swarm_cli/version.rb +1 -1
- data/lib/swarm_cli.rb +3 -1
- metadata +23 -17
@@ -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
|