swarm_cli 2.0.0.pre.1 → 2.0.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.
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmCLI
4
+ # Migrator converts Claude Swarm v1 YAML configurations to SwarmSDK v2 format.
5
+ #
6
+ # Key transformations:
7
+ # - version: 1 → 2
8
+ # - swarm.main → swarm.lead
9
+ # - swarm.instances → swarm.agents
10
+ # - For each agent:
11
+ # - prompt → system_prompt
12
+ # - connections → delegates_to
13
+ # - mcps → mcp_servers
14
+ # - allowed_tools → tools
15
+ # - vibe → bypass_permissions
16
+ # - reasoning_effort → parameters.reasoning
17
+ #
18
+ class Migrator
19
+ attr_reader :input_path
20
+
21
+ def initialize(input_path)
22
+ @input_path = input_path
23
+ end
24
+
25
+ def migrate
26
+ # Read and parse v1 YAML
27
+ v1_config = YAML.load_file(input_path)
28
+
29
+ # Validate it's a v1 config
30
+ unless v1_config["version"] == 1
31
+ raise SwarmCLI::ExecutionError, "Input file is not a v1 configuration (version: #{v1_config["version"]})"
32
+ end
33
+
34
+ # Build v2 config
35
+ v2_config = {
36
+ "version" => 2,
37
+ "swarm" => migrate_swarm(v1_config["swarm"]),
38
+ }
39
+
40
+ # Convert to YAML string
41
+ YAML.dump(v2_config)
42
+ end
43
+
44
+ private
45
+
46
+ def migrate_swarm(swarm)
47
+ v2_swarm = {
48
+ "name" => swarm["name"],
49
+ "lead" => swarm["main"], # main → lead
50
+ }
51
+
52
+ # Migrate instances → agents
53
+ if swarm["instances"]
54
+ v2_swarm["agents"] = migrate_agents(swarm["instances"])
55
+ end
56
+
57
+ v2_swarm
58
+ end
59
+
60
+ def migrate_agents(instances)
61
+ agents = {}
62
+
63
+ instances.each do |name, config|
64
+ agents[name] = migrate_agent(config)
65
+ end
66
+
67
+ agents
68
+ end
69
+
70
+ def migrate_agent(config)
71
+ agent = {}
72
+
73
+ # Copy fields that stay the same
74
+ agent["description"] = config["description"] if config["description"]
75
+ agent["directory"] = config["directory"] if config["directory"]
76
+ agent["model"] = config["model"] if config["model"]
77
+
78
+ # Migrate connections → delegates_to
79
+ agent["delegates_to"] = if config["connections"]
80
+ config["connections"]
81
+ elsif config.key?("connections") && config["connections"].nil?
82
+ # Explicit nil becomes empty array
83
+ []
84
+ else
85
+ # No connections field - add empty array for clarity
86
+ []
87
+ end
88
+
89
+ # Migrate prompt → system_prompt
90
+ agent["system_prompt"] = config["prompt"] if config["prompt"]
91
+
92
+ # Migrate mcps → mcp_servers
93
+ if config["mcps"]
94
+ agent["mcp_servers"] = config["mcps"]
95
+ end
96
+
97
+ # Migrate allowed_tools → tools
98
+ if config["allowed_tools"]
99
+ agent["tools"] = config["allowed_tools"]
100
+ end
101
+
102
+ # Migrate vibe → bypass_permissions
103
+ if config["vibe"]
104
+ agent["bypass_permissions"] = config["vibe"]
105
+ end
106
+
107
+ # Migrate reasoning_effort → parameters.reasoning
108
+ if config["reasoning_effort"]
109
+ agent["parameters"] ||= {}
110
+ agent["parameters"]["reasoning"] = config["reasoning_effort"]
111
+ end
112
+
113
+ # Copy any other fields (like provider, base_url, etc. if they exist)
114
+ # These are rare in v1 but handle them gracefully
115
+ ["provider", "base_url", "api_version"].each do |field|
116
+ agent[field] = config[field] if config[field]
117
+ end
118
+
119
+ # Handle parameters field - merge if it exists
120
+ if config["parameters"]
121
+ agent["parameters"] ||= {}
122
+ agent["parameters"].merge!(config["parameters"])
123
+ end
124
+
125
+ # Copy tools and permissions if they exist (rare in v1)
126
+ agent["tools"] = config["tools"] if config["tools"] && !config["allowed_tools"]
127
+ agent["permissions"] = config["permissions"] if config["permissions"]
128
+
129
+ agent
130
+ end
131
+ end
132
+ end
@@ -8,9 +8,11 @@ module SwarmCLI
8
8
  program "swarm"
9
9
  command "run"
10
10
  desc "Execute a swarm with AI agents"
11
- example "swarm run team.yml -p 'Build a REST API'"
12
- example "echo 'Build a REST API' | swarm run team.yml"
13
- example "swarm run team.yml -p 'Refactor code' --output-format json"
11
+ example "swarm run team.yml # Interactive REPL"
12
+ example "swarm run team.yml 'Build a REST API' # REPL with initial message"
13
+ example "echo 'Build API' | swarm run team.yml # REPL with piped message"
14
+ example "swarm run team.yml -p 'Build a REST API' # Non-interactive mode"
15
+ example "echo 'Build API' | swarm run team.yml -p # Non-interactive from stdin"
14
16
  end
15
17
 
16
18
  argument :config_file do
@@ -18,10 +20,15 @@ module SwarmCLI
18
20
  required
19
21
  end
20
22
 
21
- option :prompt do
23
+ argument :prompt_text do
24
+ desc "Initial message for REPL or prompt for execution (optional)"
25
+ optional
26
+ end
27
+
28
+ flag :prompt do
22
29
  short "-p"
23
- long "--prompt PROMPT"
24
- desc "Task prompt for the swarm (if not provided, reads from stdin)"
30
+ long "--prompt"
31
+ desc "Run in non-interactive mode (reads from argument or stdin)"
25
32
  end
26
33
 
27
34
  option :output_format do
@@ -62,24 +69,53 @@ module SwarmCLI
62
69
  def validate!
63
70
  errors = []
64
71
 
65
- # At least one of prompt or stdin must be provided
66
- if !prompt && $stdin.tty?
67
- errors << "Prompt is required. Use -p or provide input via stdin."
68
- end
69
-
70
72
  # Config file must exist
71
73
  if config_file && !File.exist?(config_file)
72
74
  errors << "Configuration file not found: #{config_file}"
73
75
  end
74
76
 
77
+ # Interactive mode cannot be used with JSON output
78
+ if interactive_mode? && output_format == "json"
79
+ errors << "Interactive mode is not compatible with --output-format json"
80
+ end
81
+
82
+ # Non-interactive mode requires a prompt
83
+ if non_interactive_mode? && !has_prompt_source?
84
+ errors << "Non-interactive mode (-p) requires a prompt (provide as argument or via stdin)"
85
+ end
86
+
75
87
  unless errors.empty?
76
88
  raise SwarmCLI::ExecutionError, errors.join("\n")
77
89
  end
78
90
  end
79
91
 
92
+ def interactive_mode?
93
+ # Interactive (REPL) mode when -p flag is NOT present
94
+ !params[:prompt]
95
+ end
96
+
97
+ def non_interactive_mode?
98
+ # Non-interactive mode when -p flag IS present
99
+ params[:prompt] == true
100
+ end
101
+
102
+ def initial_message
103
+ # For REPL mode - get initial message from argument or stdin (if piped)
104
+ return unless interactive_mode?
105
+
106
+ if params[:prompt_text] && !params[:prompt_text].empty?
107
+ params[:prompt_text]
108
+ elsif !$stdin.tty?
109
+ $stdin.read.strip
110
+ end
111
+ end
112
+
80
113
  def prompt_text
81
- @prompt_text ||= if prompt
82
- prompt
114
+ # For non-interactive mode - get prompt from argument or stdin
115
+ raise SwarmCLI::ExecutionError, "Cannot get prompt_text in interactive mode" if interactive_mode?
116
+
117
+ @prompt_text ||= if params[:prompt_text] && !params[:prompt_text].empty?
118
+ params[:prompt_text]
83
119
  elsif !$stdin.tty?
84
120
  $stdin.read.strip
85
121
  else
@@ -87,15 +123,15 @@ module SwarmCLI
87
123
  end
88
124
  end
89
125
 
126
+ def has_prompt_source?
127
+ (params[:prompt_text] && !params[:prompt_text].empty?) || !$stdin.tty?
128
+ end
129
+
90
130
  # Convenience accessors that delegate to params
91
131
  def config_file
92
132
  params[:config_file]
93
133
  end
94
134
 
95
- def prompt
96
- params[:prompt]
97
- end
98
-
99
135
  def output_format
100
136
  params[:output_format]
101
137
  end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmCLI
4
+ module UI
5
+ module Components
6
+ # Renders agent names with consistent colors using cache
7
+ class AgentBadge
8
+ def initialize(pastel:, color_cache:)
9
+ @pastel = pastel
10
+ @color_cache = color_cache
11
+ end
12
+
13
+ # Render agent name with cached color
14
+ # architect → "architect" (in cyan)
15
+ def render(agent_name, icon: nil, bold: false)
16
+ color = @color_cache.get(agent_name)
17
+ text = icon ? "#{icon} #{agent_name}" : agent_name.to_s
18
+
19
+ styled = @pastel.public_send(color, text)
20
+ styled = @pastel.bold(styled) if bold
21
+
22
+ styled
23
+ end
24
+
25
+ # Render agent list (comma-separated, each colored)
26
+ # [architect, worker] → "architect, worker" (each colored differently)
27
+ def render_list(agent_names, separator: ", ")
28
+ agent_names.map { |name| render(name) }.join(separator)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmCLI
4
+ module UI
5
+ module Components
6
+ # Renders multi-line content blocks with indentation
7
+ class ContentBlock
8
+ def initialize(pastel:)
9
+ @pastel = pastel
10
+ end
11
+
12
+ # Render key-value pairs as indented block
13
+ # Arguments:
14
+ # file_path: "config.yml"
15
+ # mode: "read"
16
+ def render_hash(data, indent: 0, label: nil, truncate: false, max_value_length: 300)
17
+ return "" if data.nil? || data.empty?
18
+
19
+ lines = []
20
+ prefix = " " * indent
21
+
22
+ # Optional label
23
+ lines << "#{prefix}#{@pastel.dim("#{label}:")}" if label
24
+
25
+ # Render each key-value pair
26
+ data.each do |key, value|
27
+ formatted_value = format_value(value, truncate: truncate, max_length: max_value_length)
28
+ lines << "#{prefix} #{@pastel.cyan("#{key}:")} #{formatted_value}"
29
+ end
30
+
31
+ lines.join("\n")
32
+ end
33
+
34
+ # Render multi-line text with indentation
35
+ def render_text(text, indent: 0, color: :white, truncate: false, max_lines: nil, max_chars: nil)
36
+ return "" if text.nil? || text.empty?
37
+
38
+ prefix = " " * indent
39
+ content = text
40
+
41
+ # Strip system reminders
42
+ content = Formatters::Text.strip_system_reminders(content)
43
+ return "" if content.empty?
44
+
45
+ # Apply truncation if requested
46
+ if truncate
47
+ content, truncation_msg = Formatters::Text.truncate(
48
+ content,
49
+ lines: max_lines,
50
+ chars: max_chars,
51
+ )
52
+ end
53
+
54
+ # Render lines
55
+ lines = content.split("\n").map do |line|
56
+ "#{prefix} #{@pastel.public_send(color, line)}"
57
+ end
58
+
59
+ # Add truncation message if present
60
+ lines << "#{prefix} #{@pastel.dim(truncation_msg)}" if truncation_msg
61
+
62
+ lines.join("\n")
63
+ end
64
+
65
+ # Render list items
66
+ # • Item 1
67
+ # • Item 2
68
+ def render_list(items, indent: 0, bullet: UI::Icons::BULLET, color: :white)
69
+ return "" if items.nil? || items.empty?
70
+
71
+ prefix = " " * indent
72
+
73
+ items.map do |item|
74
+ "#{prefix} #{@pastel.public_send(color, "#{bullet} #{item}")}"
75
+ end.join("\n")
76
+ end
77
+
78
+ private
79
+
80
+ def format_value(value, truncate:, max_length:)
81
+ case value
82
+ when String
83
+ format_string_value(value, truncate: truncate, max_length: max_length)
84
+ when Array
85
+ @pastel.dim("[#{value.join(", ")}]")
86
+ when Hash
87
+ formatted = value.map { |k, v| "#{k}: #{v}" }.join(", ")
88
+ @pastel.dim("{#{formatted}}")
89
+ when Numeric
90
+ @pastel.white(value.to_s)
91
+ when TrueClass, FalseClass
92
+ @pastel.white(value.to_s)
93
+ when NilClass
94
+ @pastel.dim("nil")
95
+ else
96
+ @pastel.white(value.to_s)
97
+ end
98
+ end
99
+
100
+ def format_string_value(value, truncate:, max_length:)
101
+ return @pastel.white(value) unless truncate
102
+ return @pastel.white(value) if value.length <= max_length
103
+
104
+ lines = value.split("\n")
105
+
106
+ if lines.length > 3
107
+ # Multi-line content - show first 3 lines
108
+ preview = lines.first(3).join("\n")
109
+ line_info = "(#{lines.length} lines, #{value.length} chars)"
110
+ "#{@pastel.white(preview)}\n #{@pastel.dim("... #{line_info}")}"
111
+ else
112
+ # Single/few lines - character truncation
113
+ preview = value[0...max_length]
114
+ "#{@pastel.white(preview)}\n #{@pastel.dim("... (#{value.length} chars)")}"
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmCLI
4
+ module UI
5
+ module Components
6
+ # Divider rendering for visual separation
7
+ # Only horizontal lines - no side borders per design constraint
8
+ class Divider
9
+ def initialize(pastel:, terminal_width: 80)
10
+ @pastel = pastel
11
+ @terminal_width = terminal_width
12
+ end
13
+
14
+ # Full-width divider line
15
+ # ────────────────────────────────────────────────────────────
16
+ def full(char: "─", color: :dim)
17
+ @pastel.public_send(color, char * @terminal_width)
18
+ end
19
+
20
+ # Event separator (dotted, indented)
21
+ # ····························································
22
+ def event(indent: 0, char: "·")
23
+ prefix = " " * indent
24
+ line = char * 60
25
+ "#{prefix}#{@pastel.dim(line)}"
26
+ end
27
+
28
+ # Section divider with centered label
29
+ # ───────── Section Name ─────────
30
+ def section(label, char: "─", color: :dim)
31
+ label_width = label.length + 2 # Add spaces around label
32
+ total_line_width = @terminal_width
33
+ side_width = (total_line_width - label_width) / 2
34
+
35
+ left = char * side_width
36
+ right = char * (total_line_width - label_width - side_width)
37
+
38
+ @pastel.public_send(color, "#{left} #{label} #{right}")
39
+ end
40
+
41
+ # Top border only (no sides)
42
+ # ────────────────────────────────────────────────────────────
43
+ # Content here
44
+ def top(char: "─", color: :dim)
45
+ @pastel.public_send(color, char * @terminal_width)
46
+ end
47
+
48
+ # Bottom border only (no sides)
49
+ # Content here
50
+ # ────────────────────────────────────────────────────────────
51
+ def bottom(char: "─", color: :dim)
52
+ @pastel.public_send(color, char * @terminal_width)
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmCLI
4
+ module UI
5
+ module Components
6
+ # Renders highlighted panels for warnings, info, errors
7
+ # Uses top/bottom borders only (no sides per design constraint)
8
+ class Panel
9
+ TYPE_CONFIGS = {
10
+ warning: { color: :yellow, icon: UI::Icons::WARNING },
11
+ error: { color: :red, icon: UI::Icons::ERROR },
12
+ info: { color: :cyan, icon: UI::Icons::INFO },
13
+ success: { color: :green, icon: UI::Icons::SUCCESS },
14
+ }.freeze
15
+
16
+ def initialize(pastel:, terminal_width: 80)
17
+ @pastel = pastel
18
+ @terminal_width = terminal_width
19
+ end
20
+
21
+ # Render panel with top/bottom borders
22
+ #
23
+ # ⚠️ CONTEXT WARNING
24
+ # Context usage: 81.4% (threshold: 80%)
25
+ # Tokens remaining: 74,523
26
+ #
27
+ def render(type:, title:, lines:, indent: 0)
28
+ config = TYPE_CONFIGS[type] || TYPE_CONFIGS[:info]
29
+ prefix = " " * indent
30
+
31
+ output = []
32
+
33
+ # Title line with icon
34
+ icon = config[:icon]
35
+ colored_title = @pastel.public_send(config[:color], "#{icon} #{title}")
36
+ output << "#{prefix}#{colored_title}"
37
+
38
+ # Content lines
39
+ lines.each do |line|
40
+ output << "#{prefix} #{line}"
41
+ end
42
+
43
+ output << "" # Blank line after panel
44
+
45
+ output.join("\n")
46
+ end
47
+
48
+ # Render compact panel (single line)
49
+ # ⚠️ Context approaching limit (81.4%)
50
+ def render_compact(type:, message:, indent: 0)
51
+ config = TYPE_CONFIGS[type] || TYPE_CONFIGS[:info]
52
+ prefix = " " * indent
53
+
54
+ icon = config[:icon]
55
+ colored_msg = @pastel.public_send(config[:color], "#{icon} #{message}")
56
+
57
+ "#{prefix}#{colored_msg}"
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmCLI
4
+ module UI
5
+ module Components
6
+ # Renders usage statistics (tokens, cost, context percentage)
7
+ class UsageStats
8
+ def initialize(pastel:)
9
+ @pastel = pastel
10
+ end
11
+
12
+ # Render usage line with all available metrics
13
+ # 5,922 tokens │ $0.0016 │ 1.5% used, 394,078 remaining
14
+ def render(tokens:, cost:, context_pct: nil, remaining: nil, cumulative: nil)
15
+ parts = []
16
+
17
+ # Token count (always shown)
18
+ parts << "#{Formatters::Number.format(tokens)} tokens"
19
+
20
+ # Cost (always shown if > 0)
21
+ parts << Formatters::Cost.format(cost, pastel: @pastel) if cost > 0
22
+
23
+ # Context tracking (if available)
24
+ if context_pct
25
+ colored_pct = color_context_percentage(context_pct)
26
+
27
+ parts << if remaining
28
+ "#{colored_pct} used, #{Formatters::Number.compact(remaining)} remaining"
29
+ else
30
+ "#{colored_pct} used"
31
+ end
32
+ elsif cumulative
33
+ # Model doesn't have context limit, show cumulative
34
+ parts << "#{Formatters::Number.compact(cumulative)} cumulative"
35
+ end
36
+
37
+ @pastel.dim(parts.join(" #{@pastel.dim("│")} "))
38
+ end
39
+
40
+ # Render compact stats for prompt display
41
+ # 15.2K tokens • $0.045 • 3.8% context
42
+ def render_compact(tokens:, cost:, context_pct: nil)
43
+ parts = []
44
+
45
+ parts << "#{Formatters::Number.compact(tokens)} tokens" if tokens > 0
46
+ parts << Formatters::Cost.format_plain(cost) if cost > 0
47
+ parts << "#{context_pct} context" if context_pct
48
+
49
+ parts.join(" • ")
50
+ end
51
+
52
+ private
53
+
54
+ def color_context_percentage(percentage_string)
55
+ percentage = percentage_string.to_s.gsub("%", "").to_f
56
+
57
+ color = if percentage < 50
58
+ :green
59
+ elsif percentage < 80
60
+ :yellow
61
+ else
62
+ :red
63
+ end
64
+
65
+ @pastel.public_send(color, percentage_string)
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmCLI
4
+ module UI
5
+ module Formatters
6
+ # Cost formatting with color coding
7
+ class Cost
8
+ class << self
9
+ # Format cost with appropriate precision and color
10
+ # Small costs: green, $0.001234
11
+ # Medium costs: yellow, $0.1234
12
+ # Large costs: red, $12.34
13
+ def format(cost, pastel:)
14
+ return pastel.dim("$0.0000") if cost.nil? || cost.zero?
15
+
16
+ formatted = if cost < 0.01
17
+ Kernel.format("%.6f", cost)
18
+ elsif cost < 1.0
19
+ Kernel.format("%.4f", cost)
20
+ else
21
+ Kernel.format("%.2f", cost)
22
+ end
23
+
24
+ if cost < 0.01
25
+ pastel.green("$#{formatted}")
26
+ elsif cost < 1.0
27
+ pastel.yellow("$#{formatted}")
28
+ else
29
+ pastel.red("$#{formatted}")
30
+ end
31
+ end
32
+
33
+ # Format cost without color (for plain text)
34
+ def format_plain(cost)
35
+ return "$0.0000" if cost.nil? || cost.zero?
36
+
37
+ if cost < 0.01
38
+ Kernel.format("$%.6f", cost)
39
+ elsif cost < 1.0
40
+ Kernel.format("$%.4f", cost)
41
+ else
42
+ Kernel.format("$%.2f", cost)
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmCLI
4
+ module UI
5
+ module Formatters
6
+ # Number formatting utilities for terminal display
7
+ class Number
8
+ class << self
9
+ # Format number with thousand separators
10
+ # 5922 → "5,922"
11
+ # 1500000 → "1,500,000"
12
+ def format(num)
13
+ return "0" if num.nil? || num.zero?
14
+
15
+ num.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
16
+ end
17
+
18
+ # Format number with compact units (K, M, B)
19
+ # 5922 → "5.9K"
20
+ # 1500000 → "1.5M"
21
+ # 1500000000 → "1.5B"
22
+ def compact(num)
23
+ return "0" if num.nil? || num.zero?
24
+
25
+ case num
26
+ when 0...1_000
27
+ num.to_s
28
+ when 1_000...1_000_000
29
+ "#{(num / 1_000.0).round(1)}K"
30
+ when 1_000_000...1_000_000_000
31
+ "#{(num / 1_000_000.0).round(1)}M"
32
+ else
33
+ "#{(num / 1_000_000_000.0).round(1)}B"
34
+ end
35
+ end
36
+
37
+ # Format bytes with units (KB, MB, GB)
38
+ # 1024 → "1.0 KB"
39
+ # 1500000 → "1.4 MB"
40
+ def bytes(num)
41
+ return "0 B" if num.nil? || num.zero?
42
+
43
+ case num
44
+ when 0...1024
45
+ "#{num} B"
46
+ when 1024...1024**2
47
+ "#{(num / 1024.0).round(1)} KB"
48
+ when 1024**2...1024**3
49
+ "#{(num / 1024.0**2).round(1)} MB"
50
+ else
51
+ "#{(num / 1024.0**3).round(1)} GB"
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end