claude_swarm 0.1.16 → 0.1.18
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/CHANGELOG.md +46 -0
- data/CLAUDE.md +48 -0
- data/README.md +228 -9
- data/claude-swarm.yml +8 -36
- data/examples/monitoring-demo.yml +26 -0
- data/examples/multi-directory.yml +26 -0
- data/examples/with-before-commands.yml +30 -0
- data/lib/claude_swarm/claude_code_executor.rb +8 -1
- data/lib/claude_swarm/claude_mcp_server.rb +2 -1
- data/lib/claude_swarm/cli.rb +102 -3
- data/lib/claude_swarm/commands/ps.rb +148 -0
- data/lib/claude_swarm/commands/show.rb +158 -0
- data/lib/claude_swarm/configuration.rb +34 -4
- data/lib/claude_swarm/mcp_generator.rb +3 -0
- data/lib/claude_swarm/orchestrator.rb +222 -4
- data/lib/claude_swarm/version.rb +1 -1
- data/lib/claude_swarm/worktree_manager.rb +353 -0
- data/llms.txt +2 -2
- metadata +7 -1
data/lib/claude_swarm/cli.rb
CHANGED
@@ -26,6 +26,9 @@ module ClaudeSwarm
|
|
26
26
|
desc: "Enable debug output"
|
27
27
|
method_option :session_id, type: :string,
|
28
28
|
desc: "Resume a previous session by ID or path"
|
29
|
+
method_option :worktree, type: :string, aliases: "-w",
|
30
|
+
desc: "Create instances in Git worktrees with the given name (auto-generated if true)",
|
31
|
+
banner: "[NAME]"
|
29
32
|
def start(config_file = nil)
|
30
33
|
# Handle session restoration
|
31
34
|
if options[:session_id]
|
@@ -54,7 +57,8 @@ module ClaudeSwarm
|
|
54
57
|
vibe: options[:vibe],
|
55
58
|
prompt: options[:prompt],
|
56
59
|
stream_logs: options[:stream_logs],
|
57
|
-
debug: options[:debug]
|
60
|
+
debug: options[:debug],
|
61
|
+
worktree: options[:worktree])
|
58
62
|
orchestrator.start
|
59
63
|
rescue Error => e
|
60
64
|
error e.message
|
@@ -71,6 +75,8 @@ module ClaudeSwarm
|
|
71
75
|
desc: "Instance name"
|
72
76
|
method_option :directory, aliases: "-d", type: :string, required: true,
|
73
77
|
desc: "Working directory for the instance"
|
78
|
+
method_option :directories, type: :array,
|
79
|
+
desc: "All directories (including main directory) for the instance"
|
74
80
|
method_option :model, aliases: "-m", type: :string, required: true,
|
75
81
|
desc: "Claude model to use (e.g., opus, sonnet)"
|
76
82
|
method_option :prompt, aliases: "-p", type: :string,
|
@@ -101,6 +107,7 @@ module ClaudeSwarm
|
|
101
107
|
instance_config = {
|
102
108
|
name: options[:name],
|
103
109
|
directory: options[:directory],
|
110
|
+
directories: options[:directories] || [options[:directory]],
|
104
111
|
model: options[:model],
|
105
112
|
prompt: options[:prompt],
|
106
113
|
description: options[:description],
|
@@ -108,7 +115,7 @@ module ClaudeSwarm
|
|
108
115
|
disallowed_tools: options[:disallowed_tools] || [],
|
109
116
|
connections: options[:connections] || [],
|
110
117
|
mcp_config_path: options[:mcp_config_path],
|
111
|
-
vibe: options[:vibe],
|
118
|
+
vibe: options[:vibe] || false,
|
112
119
|
instance_id: options[:instance_id],
|
113
120
|
claude_session_id: options[:claude_session_id]
|
114
121
|
}
|
@@ -144,6 +151,10 @@ module ClaudeSwarm
|
|
144
151
|
swarm:
|
145
152
|
name: "Swarm Name"
|
146
153
|
main: lead_developer
|
154
|
+
# before: # Optional: commands to run before launching swarm (executed in sequence)
|
155
|
+
# - "echo 'Setting up environment...'"
|
156
|
+
# - "npm install"
|
157
|
+
# - "docker-compose up -d"
|
147
158
|
instances:
|
148
159
|
lead_developer:
|
149
160
|
description: "Lead developer who coordinates the team and makes architectural decisions"
|
@@ -194,6 +205,82 @@ module ClaudeSwarm
|
|
194
205
|
say "Claude Swarm #{VERSION}"
|
195
206
|
end
|
196
207
|
|
208
|
+
desc "ps", "List running Claude Swarm sessions"
|
209
|
+
def ps
|
210
|
+
require_relative "commands/ps"
|
211
|
+
Commands::Ps.new.execute
|
212
|
+
end
|
213
|
+
|
214
|
+
desc "show SESSION_ID", "Show detailed session information"
|
215
|
+
def show(session_id)
|
216
|
+
require_relative "commands/show"
|
217
|
+
Commands::Show.new.execute(session_id)
|
218
|
+
end
|
219
|
+
|
220
|
+
desc "clean", "Remove stale session symlinks"
|
221
|
+
method_option :days, aliases: "-d", type: :numeric, default: 7,
|
222
|
+
desc: "Remove sessions older than N days"
|
223
|
+
def clean
|
224
|
+
run_dir = File.expand_path("~/.claude-swarm/run")
|
225
|
+
unless Dir.exist?(run_dir)
|
226
|
+
say "No run directory found", :yellow
|
227
|
+
return
|
228
|
+
end
|
229
|
+
|
230
|
+
cleaned = 0
|
231
|
+
Dir.glob("#{run_dir}/*").each do |symlink|
|
232
|
+
next unless File.symlink?(symlink)
|
233
|
+
|
234
|
+
begin
|
235
|
+
# Remove if target doesn't exist (stale)
|
236
|
+
unless File.exist?(File.readlink(symlink))
|
237
|
+
File.unlink(symlink)
|
238
|
+
cleaned += 1
|
239
|
+
next
|
240
|
+
end
|
241
|
+
|
242
|
+
# Remove if older than specified days
|
243
|
+
if File.stat(symlink).mtime < Time.now - (options[:days] * 86_400)
|
244
|
+
File.unlink(symlink)
|
245
|
+
cleaned += 1
|
246
|
+
end
|
247
|
+
rescue StandardError
|
248
|
+
# Skip problematic symlinks
|
249
|
+
end
|
250
|
+
end
|
251
|
+
|
252
|
+
say "Cleaned #{cleaned} stale session#{"s" unless cleaned == 1}", :green
|
253
|
+
end
|
254
|
+
|
255
|
+
desc "watch SESSION_ID", "Watch session logs"
|
256
|
+
method_option :lines, aliases: "-n", type: :numeric, default: 100,
|
257
|
+
desc: "Number of lines to show initially"
|
258
|
+
def watch(session_id)
|
259
|
+
# Find session path
|
260
|
+
run_symlink = File.join(File.expand_path("~/.claude-swarm/run"), session_id)
|
261
|
+
session_path = if File.symlink?(run_symlink)
|
262
|
+
File.readlink(run_symlink)
|
263
|
+
else
|
264
|
+
# Search in sessions directory
|
265
|
+
Dir.glob(File.expand_path("~/.claude-swarm/sessions/*/*")).find do |path|
|
266
|
+
File.basename(path) == session_id
|
267
|
+
end
|
268
|
+
end
|
269
|
+
|
270
|
+
unless session_path && Dir.exist?(session_path)
|
271
|
+
error "Session not found: #{session_id}"
|
272
|
+
exit 1
|
273
|
+
end
|
274
|
+
|
275
|
+
log_file = File.join(session_path, "session.log")
|
276
|
+
unless File.exist?(log_file)
|
277
|
+
error "Log file not found for session: #{session_id}"
|
278
|
+
exit 1
|
279
|
+
end
|
280
|
+
|
281
|
+
exec("tail", "-f", "-n", options[:lines].to_s, log_file)
|
282
|
+
end
|
283
|
+
|
197
284
|
desc "list-sessions", "List all available Claude Swarm sessions"
|
198
285
|
method_option :limit, aliases: "-l", type: :numeric, default: 10,
|
199
286
|
desc: "Maximum number of sessions to display"
|
@@ -316,6 +403,17 @@ module ClaudeSwarm
|
|
316
403
|
|
317
404
|
config = Configuration.new(config_file, base_dir: Dir.pwd)
|
318
405
|
|
406
|
+
# Load session metadata if it exists to check for worktree info
|
407
|
+
session_metadata_file = File.join(session_path, "session_metadata.json")
|
408
|
+
worktree_name = nil
|
409
|
+
if File.exist?(session_metadata_file)
|
410
|
+
metadata = JSON.parse(File.read(session_metadata_file))
|
411
|
+
if metadata["worktree"] && metadata["worktree"]["enabled"]
|
412
|
+
worktree_name = metadata["worktree"]["name"]
|
413
|
+
say "Restoring with worktree: #{worktree_name}", :green unless options[:prompt]
|
414
|
+
end
|
415
|
+
end
|
416
|
+
|
319
417
|
# Create orchestrator with restoration mode
|
320
418
|
generator = McpGenerator.new(config, vibe: options[:vibe], restore_session_path: session_path)
|
321
419
|
orchestrator = Orchestrator.new(config, generator,
|
@@ -323,7 +421,8 @@ module ClaudeSwarm
|
|
323
421
|
prompt: options[:prompt],
|
324
422
|
stream_logs: options[:stream_logs],
|
325
423
|
debug: options[:debug],
|
326
|
-
restore_session_path: session_path
|
424
|
+
restore_session_path: session_path,
|
425
|
+
worktree: worktree_name)
|
327
426
|
orchestrator.start
|
328
427
|
rescue StandardError => e
|
329
428
|
error "Failed to restore session: #{e.message}"
|
@@ -0,0 +1,148 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "yaml"
|
4
|
+
require "json"
|
5
|
+
require "time"
|
6
|
+
|
7
|
+
module ClaudeSwarm
|
8
|
+
module Commands
|
9
|
+
class Ps
|
10
|
+
RUN_DIR = File.expand_path("~/.claude-swarm/run")
|
11
|
+
|
12
|
+
def execute
|
13
|
+
unless Dir.exist?(RUN_DIR)
|
14
|
+
puts "No active sessions"
|
15
|
+
return
|
16
|
+
end
|
17
|
+
|
18
|
+
sessions = []
|
19
|
+
|
20
|
+
# Read all symlinks in run directory
|
21
|
+
Dir.glob("#{RUN_DIR}/*").each do |symlink|
|
22
|
+
next unless File.symlink?(symlink)
|
23
|
+
|
24
|
+
begin
|
25
|
+
session_dir = File.readlink(symlink)
|
26
|
+
# Skip if target doesn't exist (stale symlink)
|
27
|
+
next unless Dir.exist?(session_dir)
|
28
|
+
|
29
|
+
session_info = parse_session_info(session_dir)
|
30
|
+
sessions << session_info if session_info
|
31
|
+
rescue StandardError
|
32
|
+
# Skip problematic symlinks
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
if sessions.empty?
|
37
|
+
puts "No active sessions"
|
38
|
+
return
|
39
|
+
end
|
40
|
+
|
41
|
+
# Column widths
|
42
|
+
col_session = 15
|
43
|
+
col_swarm = 25
|
44
|
+
col_cost = 12
|
45
|
+
col_uptime = 10
|
46
|
+
|
47
|
+
# Display header with proper spacing
|
48
|
+
header = "#{
|
49
|
+
"SESSION_ID".ljust(col_session)
|
50
|
+
} #{
|
51
|
+
"SWARM_NAME".ljust(col_swarm)
|
52
|
+
} #{
|
53
|
+
"TOTAL_COST".ljust(col_cost)
|
54
|
+
} #{
|
55
|
+
"UPTIME".ljust(col_uptime)
|
56
|
+
} DIRECTORY"
|
57
|
+
puts "\n⚠️ \e[3mTotal cost does not include the cost of the main instance\e[0m\n\n"
|
58
|
+
puts header
|
59
|
+
puts "-" * header.length
|
60
|
+
|
61
|
+
# Display sessions sorted by start time (newest first)
|
62
|
+
sessions.sort_by { |s| s[:start_time] }.reverse.each do |session|
|
63
|
+
cost_str = format("$%.4f", session[:cost])
|
64
|
+
puts "#{
|
65
|
+
session[:id].ljust(col_session)
|
66
|
+
} #{
|
67
|
+
truncate(session[:name], col_swarm).ljust(col_swarm)
|
68
|
+
} #{
|
69
|
+
cost_str.ljust(col_cost)
|
70
|
+
} #{
|
71
|
+
session[:uptime].ljust(col_uptime)
|
72
|
+
} #{session[:directory]}"
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
private
|
77
|
+
|
78
|
+
def parse_session_info(session_dir)
|
79
|
+
session_id = File.basename(session_dir)
|
80
|
+
|
81
|
+
# Load config for swarm name and main directory
|
82
|
+
config_file = File.join(session_dir, "config.yml")
|
83
|
+
return nil unless File.exist?(config_file)
|
84
|
+
|
85
|
+
config = YAML.load_file(config_file)
|
86
|
+
swarm_name = config.dig("swarm", "name") || "Unknown"
|
87
|
+
main_instance = config.dig("swarm", "main")
|
88
|
+
|
89
|
+
# Get all directories - handle both string and array formats
|
90
|
+
dir_config = config.dig("swarm", "instances", main_instance, "directory")
|
91
|
+
directories = if dir_config.is_a?(Array)
|
92
|
+
dir_config
|
93
|
+
else
|
94
|
+
[dir_config || "."]
|
95
|
+
end
|
96
|
+
directories_str = directories.join(", ")
|
97
|
+
|
98
|
+
# Calculate total cost from JSON log
|
99
|
+
total_cost = calculate_total_cost(session_dir)
|
100
|
+
|
101
|
+
# Get uptime from directory creation time
|
102
|
+
start_time = File.stat(session_dir).ctime
|
103
|
+
uptime = format_duration(Time.now - start_time)
|
104
|
+
|
105
|
+
{
|
106
|
+
id: session_id,
|
107
|
+
name: swarm_name,
|
108
|
+
cost: total_cost,
|
109
|
+
uptime: uptime,
|
110
|
+
directory: directories_str,
|
111
|
+
start_time: start_time
|
112
|
+
}
|
113
|
+
rescue StandardError
|
114
|
+
nil
|
115
|
+
end
|
116
|
+
|
117
|
+
def calculate_total_cost(session_dir)
|
118
|
+
log_file = File.join(session_dir, "session.log.json")
|
119
|
+
return 0.0 unless File.exist?(log_file)
|
120
|
+
|
121
|
+
total = 0.0
|
122
|
+
File.foreach(log_file) do |line|
|
123
|
+
data = JSON.parse(line)
|
124
|
+
total += data["event"]["total_cost_usd"] if data.dig("event", "type") == "result" && data.dig("event", "total_cost_usd")
|
125
|
+
rescue JSON::ParserError
|
126
|
+
next
|
127
|
+
end
|
128
|
+
total
|
129
|
+
end
|
130
|
+
|
131
|
+
def format_duration(seconds)
|
132
|
+
if seconds < 60
|
133
|
+
"#{seconds.to_i}s"
|
134
|
+
elsif seconds < 3600
|
135
|
+
"#{(seconds / 60).to_i}m"
|
136
|
+
elsif seconds < 86_400
|
137
|
+
"#{(seconds / 3600).to_i}h"
|
138
|
+
else
|
139
|
+
"#{(seconds / 86_400).to_i}d"
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
def truncate(str, length)
|
144
|
+
str.length > length ? "#{str[0...length - 2]}.." : str
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|
148
|
+
end
|
@@ -0,0 +1,158 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "yaml"
|
4
|
+
require "json"
|
5
|
+
|
6
|
+
module ClaudeSwarm
|
7
|
+
module Commands
|
8
|
+
class Show
|
9
|
+
def execute(session_id)
|
10
|
+
session_path = find_session_path(session_id)
|
11
|
+
unless session_path
|
12
|
+
puts "Session not found: #{session_id}"
|
13
|
+
exit 1
|
14
|
+
end
|
15
|
+
|
16
|
+
# Load config to get main instance name
|
17
|
+
config = YAML.load_file(File.join(session_path, "config.yml"))
|
18
|
+
main_instance_name = config.dig("swarm", "main")
|
19
|
+
|
20
|
+
# Parse all events to build instance data
|
21
|
+
instances = parse_instance_hierarchy(session_path, main_instance_name)
|
22
|
+
|
23
|
+
# Calculate total cost (excluding main if not available)
|
24
|
+
total_cost = instances.values.sum { |i| i[:cost] }
|
25
|
+
cost_display = if instances[main_instance_name] && instances[main_instance_name][:has_cost_data]
|
26
|
+
format("$%.4f", total_cost)
|
27
|
+
else
|
28
|
+
"#{format("$%.4f", total_cost)} (excluding main instance)"
|
29
|
+
end
|
30
|
+
|
31
|
+
# Display session info
|
32
|
+
puts "Session: #{session_id}"
|
33
|
+
puts "Swarm: #{config.dig("swarm", "name")}"
|
34
|
+
puts "Total Cost: #{cost_display}"
|
35
|
+
|
36
|
+
# Try to read start directory
|
37
|
+
start_dir_file = File.join(session_path, "start_directory")
|
38
|
+
puts "Start Directory: #{File.read(start_dir_file).strip}" if File.exist?(start_dir_file)
|
39
|
+
|
40
|
+
puts
|
41
|
+
puts "Instance Hierarchy:"
|
42
|
+
puts "-" * 50
|
43
|
+
|
44
|
+
# Find root instances
|
45
|
+
roots = instances.values.select { |i| i[:called_by].empty? }
|
46
|
+
roots.each do |instance|
|
47
|
+
display_instance_tree(instance, instances, 0, main_instance_name)
|
48
|
+
end
|
49
|
+
|
50
|
+
# Add note about interactive main instance
|
51
|
+
return if instances[main_instance_name]&.dig(:has_cost_data)
|
52
|
+
|
53
|
+
puts
|
54
|
+
puts "Note: Main instance (#{main_instance_name}) cost is not tracked in interactive mode."
|
55
|
+
puts " View costs directly in the Claude interface."
|
56
|
+
end
|
57
|
+
|
58
|
+
private
|
59
|
+
|
60
|
+
def find_session_path(session_id)
|
61
|
+
# First check the run directory
|
62
|
+
run_symlink = File.join(File.expand_path("~/.claude-swarm/run"), session_id)
|
63
|
+
if File.symlink?(run_symlink)
|
64
|
+
target = File.readlink(run_symlink)
|
65
|
+
return target if Dir.exist?(target)
|
66
|
+
end
|
67
|
+
|
68
|
+
# Fall back to searching all sessions
|
69
|
+
Dir.glob(File.expand_path("~/.claude-swarm/sessions/*/*")).find do |path|
|
70
|
+
File.basename(path) == session_id
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
def parse_instance_hierarchy(session_path, _main_instance_name)
|
75
|
+
log_file = File.join(session_path, "session.log.json")
|
76
|
+
instances = {}
|
77
|
+
|
78
|
+
return instances unless File.exist?(log_file)
|
79
|
+
|
80
|
+
File.foreach(log_file) do |line|
|
81
|
+
data = JSON.parse(line)
|
82
|
+
instance_name = data["instance"]
|
83
|
+
instance_id = data["instance_id"]
|
84
|
+
calling_instance = data["calling_instance"]
|
85
|
+
|
86
|
+
# Initialize instance data
|
87
|
+
instances[instance_name] ||= {
|
88
|
+
name: instance_name,
|
89
|
+
id: instance_id,
|
90
|
+
cost: 0.0,
|
91
|
+
calls: 0,
|
92
|
+
called_by: Set.new,
|
93
|
+
calls_to: Set.new,
|
94
|
+
has_cost_data: false
|
95
|
+
}
|
96
|
+
|
97
|
+
# Track relationships
|
98
|
+
if calling_instance && calling_instance != instance_name
|
99
|
+
instances[instance_name][:called_by] << calling_instance
|
100
|
+
|
101
|
+
instances[calling_instance] ||= {
|
102
|
+
name: calling_instance,
|
103
|
+
id: data["calling_instance_id"],
|
104
|
+
cost: 0.0,
|
105
|
+
calls: 0,
|
106
|
+
called_by: Set.new,
|
107
|
+
calls_to: Set.new,
|
108
|
+
has_cost_data: false
|
109
|
+
}
|
110
|
+
instances[calling_instance][:calls_to] << instance_name
|
111
|
+
end
|
112
|
+
|
113
|
+
# Track costs and calls
|
114
|
+
if data.dig("event", "type") == "result"
|
115
|
+
instances[instance_name][:calls] += 1
|
116
|
+
if (cost = data.dig("event", "total_cost_usd"))
|
117
|
+
instances[instance_name][:cost] += cost
|
118
|
+
instances[instance_name][:has_cost_data] = true
|
119
|
+
end
|
120
|
+
end
|
121
|
+
rescue JSON::ParserError
|
122
|
+
next
|
123
|
+
end
|
124
|
+
|
125
|
+
instances
|
126
|
+
end
|
127
|
+
|
128
|
+
def display_instance_tree(instance, all_instances, level, main_instance_name)
|
129
|
+
indent = " " * level
|
130
|
+
prefix = level.zero? ? "├─" : "└─"
|
131
|
+
|
132
|
+
# Display instance name with special marker for main
|
133
|
+
instance_display = instance[:name]
|
134
|
+
instance_display += " [main]" if instance[:name] == main_instance_name
|
135
|
+
|
136
|
+
puts "#{indent}#{prefix} #{instance_display} (#{instance[:id]})"
|
137
|
+
|
138
|
+
# Display cost - show n/a for main instance without cost data
|
139
|
+
cost_display = if instance[:name] == main_instance_name && !instance[:has_cost_data]
|
140
|
+
"n/a (interactive)"
|
141
|
+
else
|
142
|
+
format("$%.4f", instance[:cost])
|
143
|
+
end
|
144
|
+
|
145
|
+
puts "#{indent} Cost: #{cost_display} | Calls: #{instance[:calls]}"
|
146
|
+
|
147
|
+
# Display children
|
148
|
+
children = instance[:calls_to].map { |name| all_instances[name] }.compact
|
149
|
+
children.each do |child|
|
150
|
+
# Don't recurse if we've already shown this instance (avoid cycles)
|
151
|
+
next if level.positive? && child[:called_by].size > 1
|
152
|
+
|
153
|
+
display_instance_tree(child, all_instances, level + 1, main_instance_name)
|
154
|
+
end
|
155
|
+
end
|
156
|
+
end
|
157
|
+
end
|
158
|
+
end
|
@@ -26,6 +26,10 @@ module ClaudeSwarm
|
|
26
26
|
instances[instance_name][:connections] || []
|
27
27
|
end
|
28
28
|
|
29
|
+
def before_commands
|
30
|
+
@swarm["before"] || []
|
31
|
+
end
|
32
|
+
|
29
33
|
private
|
30
34
|
|
31
35
|
def load_and_validate
|
@@ -86,9 +90,13 @@ module ClaudeSwarm
|
|
86
90
|
# Support both 'tools' (deprecated) and 'allowed_tools' for backward compatibility
|
87
91
|
allowed_tools = config["allowed_tools"] || config["tools"] || []
|
88
92
|
|
93
|
+
# Parse directory field - support both string and array
|
94
|
+
directories = parse_directories(config["directory"])
|
95
|
+
|
89
96
|
{
|
90
97
|
name: name,
|
91
|
-
directory:
|
98
|
+
directory: directories.first, # Keep single directory for backward compatibility
|
99
|
+
directories: directories, # New field with all directories
|
92
100
|
model: config["model"] || "sonnet",
|
93
101
|
connections: Array(config["connections"]),
|
94
102
|
tools: Array(allowed_tools), # Keep as 'tools' internally for compatibility
|
@@ -97,7 +105,8 @@ module ClaudeSwarm
|
|
97
105
|
mcps: parse_mcps(config["mcps"] || []),
|
98
106
|
prompt: config["prompt"],
|
99
107
|
description: config["description"],
|
100
|
-
vibe: config["vibe"] || false
|
108
|
+
vibe: config["vibe"] || false,
|
109
|
+
worktree: parse_worktree_value(config["worktree"])
|
101
110
|
}
|
102
111
|
end
|
103
112
|
|
@@ -156,8 +165,10 @@ module ClaudeSwarm
|
|
156
165
|
|
157
166
|
def validate_directories
|
158
167
|
@instances.each do |name, instance|
|
159
|
-
|
160
|
-
|
168
|
+
# Validate all directories in the directories array
|
169
|
+
instance[:directories].each do |directory|
|
170
|
+
raise Error, "Directory '#{directory}' for instance '#{name}' does not exist" unless File.directory?(directory)
|
171
|
+
end
|
161
172
|
end
|
162
173
|
end
|
163
174
|
|
@@ -168,8 +179,27 @@ module ClaudeSwarm
|
|
168
179
|
raise Error, "Instance '#{instance_name}' field '#{field_name}' must be an array, got #{field_value.class.name}" unless field_value.is_a?(Array)
|
169
180
|
end
|
170
181
|
|
182
|
+
def parse_directories(directory_config)
|
183
|
+
# Default to current directory if not specified
|
184
|
+
directory_config ||= "."
|
185
|
+
|
186
|
+
# Convert to array and expand paths
|
187
|
+
directories = Array(directory_config).map { |dir| expand_path(dir) }
|
188
|
+
|
189
|
+
# Ensure at least one directory
|
190
|
+
directories.empty? ? [expand_path(".")] : directories
|
191
|
+
end
|
192
|
+
|
171
193
|
def expand_path(path)
|
172
194
|
Pathname.new(path).expand_path(@base_dir).to_s
|
173
195
|
end
|
196
|
+
|
197
|
+
def parse_worktree_value(value)
|
198
|
+
return nil if value.nil? # Omitted means follow CLI behavior
|
199
|
+
return value if value.is_a?(TrueClass) || value.is_a?(FalseClass)
|
200
|
+
return value.to_s if value.is_a?(String) && !value.empty?
|
201
|
+
|
202
|
+
raise Error, "Invalid worktree value: #{value.inspect}. Must be true, false, or a non-empty string"
|
203
|
+
end
|
174
204
|
end
|
175
205
|
end
|
@@ -107,6 +107,9 @@ module ClaudeSwarm
|
|
107
107
|
"--model", instance[:model]
|
108
108
|
]
|
109
109
|
|
110
|
+
# Add directories array if we have multiple directories
|
111
|
+
args.push("--directories", *instance[:directories]) if instance[:directories] && instance[:directories].size > 1
|
112
|
+
|
110
113
|
# Add optional arguments
|
111
114
|
args.push("--prompt", instance[:prompt]) if instance[:prompt]
|
112
115
|
|