claude_swarm 0.1.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.
- checksums.yaml +7 -0
- data/.rubocop.yml +62 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +5 -0
- data/CLAUDE.md +103 -0
- data/LICENSE +21 -0
- data/README.md +391 -0
- data/Rakefile +12 -0
- data/claude-swarm.yml +36 -0
- data/exe/claude-swarm +6 -0
- data/lib/claude_swarm/claude_code_executor.rb +93 -0
- data/lib/claude_swarm/claude_mcp_server.rb +190 -0
- data/lib/claude_swarm/cli.rb +94 -0
- data/lib/claude_swarm/configuration.rb +126 -0
- data/lib/claude_swarm/mcp_generator.rb +112 -0
- data/lib/claude_swarm/orchestrator.rb +71 -0
- data/lib/claude_swarm/version.rb +5 -0
- data/lib/claude_swarm.rb +10 -0
- metadata +94 -0
@@ -0,0 +1,93 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "json"
|
4
|
+
require "open3"
|
5
|
+
|
6
|
+
module ClaudeSwarm
|
7
|
+
class ClaudeCodeExecutor
|
8
|
+
attr_reader :session_id, :last_response, :working_directory
|
9
|
+
|
10
|
+
def initialize(working_directory: Dir.pwd, model: nil, mcp_config: nil, vibe: false)
|
11
|
+
@working_directory = working_directory
|
12
|
+
@model = model
|
13
|
+
@mcp_config = mcp_config
|
14
|
+
@vibe = vibe
|
15
|
+
@session_id = nil
|
16
|
+
@last_response = nil
|
17
|
+
end
|
18
|
+
|
19
|
+
def execute(prompt, options = {})
|
20
|
+
cmd_array = build_command_array(prompt, options)
|
21
|
+
|
22
|
+
stdout, stderr, status = Open3.capture3(*cmd_array, chdir: @working_directory)
|
23
|
+
|
24
|
+
raise ExecutionError, "Claude Code execution failed: #{stderr}" unless status.success?
|
25
|
+
|
26
|
+
begin
|
27
|
+
response = JSON.parse(stdout)
|
28
|
+
@last_response = response
|
29
|
+
|
30
|
+
# Extract and store session ID from the response
|
31
|
+
@session_id = response["session_id"]
|
32
|
+
|
33
|
+
response
|
34
|
+
rescue JSON::ParserError => e
|
35
|
+
raise ParseError, "Failed to parse JSON response: #{e.message}\nOutput: #{stdout}"
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def execute_text(prompt, options = {})
|
40
|
+
response = execute(prompt, options)
|
41
|
+
response["result"] || ""
|
42
|
+
end
|
43
|
+
|
44
|
+
def reset_session
|
45
|
+
@session_id = nil
|
46
|
+
@last_response = nil
|
47
|
+
end
|
48
|
+
|
49
|
+
def has_session?
|
50
|
+
!@session_id.nil?
|
51
|
+
end
|
52
|
+
|
53
|
+
private
|
54
|
+
|
55
|
+
def build_command_array(prompt, options)
|
56
|
+
cmd_array = ["claude"]
|
57
|
+
|
58
|
+
# Add model if specified
|
59
|
+
cmd_array += ["--model", @model]
|
60
|
+
|
61
|
+
# Add MCP config if specified
|
62
|
+
cmd_array += ["--mcp-config", @mcp_config] if @mcp_config
|
63
|
+
|
64
|
+
# Resume session if we have a session ID
|
65
|
+
cmd_array += ["--resume", @session_id] if @session_id && !options[:new_session]
|
66
|
+
|
67
|
+
# Always use JSON output format for structured responses
|
68
|
+
cmd_array += ["--output-format", "json"]
|
69
|
+
|
70
|
+
# Add non-interactive mode
|
71
|
+
cmd_array << "--print"
|
72
|
+
|
73
|
+
# Add any custom system prompt
|
74
|
+
cmd_array += ["--system-prompt", options[:system_prompt]] if options[:system_prompt]
|
75
|
+
|
76
|
+
# Add any allowed tools or vibe flag
|
77
|
+
if @vibe
|
78
|
+
cmd_array << "--dangerously-skip-permissions"
|
79
|
+
elsif options[:allowed_tools]
|
80
|
+
tools = Array(options[:allowed_tools]).join(",")
|
81
|
+
cmd_array += ["--allowedTools", tools]
|
82
|
+
end
|
83
|
+
|
84
|
+
# Add the prompt as the last argument (no escaping needed with array syntax)
|
85
|
+
cmd_array << prompt
|
86
|
+
|
87
|
+
cmd_array
|
88
|
+
end
|
89
|
+
|
90
|
+
class ExecutionError < StandardError; end
|
91
|
+
class ParseError < StandardError; end
|
92
|
+
end
|
93
|
+
end
|
@@ -0,0 +1,190 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "fast_mcp"
|
4
|
+
require "json"
|
5
|
+
require "fileutils"
|
6
|
+
require "logger"
|
7
|
+
require_relative "claude_code_executor"
|
8
|
+
|
9
|
+
module ClaudeSwarm
|
10
|
+
class ClaudeMcpServer
|
11
|
+
SWARM_DIR = ".claude-swarm"
|
12
|
+
LOGS_DIR = "logs"
|
13
|
+
|
14
|
+
# Class variables to share state with tool classes
|
15
|
+
class << self
|
16
|
+
attr_accessor :executor, :instance_config, :logger, :session_timestamp
|
17
|
+
end
|
18
|
+
|
19
|
+
def initialize(instance_config)
|
20
|
+
@instance_config = instance_config
|
21
|
+
@executor = ClaudeCodeExecutor.new(
|
22
|
+
working_directory: instance_config[:directory],
|
23
|
+
model: instance_config[:model],
|
24
|
+
mcp_config: instance_config[:mcp_config_path],
|
25
|
+
vibe: instance_config[:vibe]
|
26
|
+
)
|
27
|
+
|
28
|
+
# Setup logging
|
29
|
+
setup_logging
|
30
|
+
|
31
|
+
# Set class variables so tools can access them
|
32
|
+
self.class.executor = @executor
|
33
|
+
self.class.instance_config = @instance_config
|
34
|
+
self.class.logger = @logger
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
def setup_logging
|
40
|
+
# Use environment variable for session timestamp if available (set by orchestrator)
|
41
|
+
# Otherwise create a new timestamp
|
42
|
+
self.class.session_timestamp ||= ENV["CLAUDE_SWARM_SESSION_TIMESTAMP"] || Time.now.strftime("%Y%m%d_%H%M%S")
|
43
|
+
|
44
|
+
# Ensure the logs directory exists
|
45
|
+
logs_dir = File.join(Dir.pwd, SWARM_DIR, LOGS_DIR)
|
46
|
+
FileUtils.mkdir_p(logs_dir)
|
47
|
+
|
48
|
+
# Create logger with timestamped filename
|
49
|
+
log_filename = "session_#{self.class.session_timestamp}.log"
|
50
|
+
log_path = File.join(logs_dir, log_filename)
|
51
|
+
@logger = Logger.new(log_path)
|
52
|
+
@logger.level = Logger::INFO
|
53
|
+
|
54
|
+
# Custom formatter for better readability
|
55
|
+
@logger.formatter = proc do |severity, datetime, _progname, msg|
|
56
|
+
"[#{datetime.strftime("%Y-%m-%d %H:%M:%S.%L")}] [#{severity}] #{msg}\n"
|
57
|
+
end
|
58
|
+
|
59
|
+
@logger.info("Started MCP server for instance: #{@instance_config[:name]}")
|
60
|
+
end
|
61
|
+
|
62
|
+
public
|
63
|
+
|
64
|
+
def start
|
65
|
+
server = FastMcp::Server.new(
|
66
|
+
name: @instance_config[:name],
|
67
|
+
version: "1.0.0"
|
68
|
+
)
|
69
|
+
|
70
|
+
# Register tool classes (not instances)
|
71
|
+
server.register_tool(TaskTool)
|
72
|
+
server.register_tool(SessionInfoTool)
|
73
|
+
server.register_tool(ResetSessionTool)
|
74
|
+
|
75
|
+
# Start the stdio server
|
76
|
+
server.start
|
77
|
+
end
|
78
|
+
|
79
|
+
class TaskTool < FastMcp::Tool
|
80
|
+
tool_name "task"
|
81
|
+
description "Execute a task using Claude Code"
|
82
|
+
|
83
|
+
arguments do
|
84
|
+
required(:prompt).filled(:string).description("The task or question for Claude")
|
85
|
+
optional(:new_session).filled(:bool).description("Start a new session (default: false)")
|
86
|
+
optional(:system_prompt).filled(:string).description("Override the system prompt for this request")
|
87
|
+
end
|
88
|
+
|
89
|
+
def call(prompt:, new_session: false, system_prompt: nil)
|
90
|
+
executor = ClaudeMcpServer.executor
|
91
|
+
instance_config = ClaudeMcpServer.instance_config
|
92
|
+
logger = ClaudeMcpServer.logger
|
93
|
+
|
94
|
+
options = {
|
95
|
+
new_session: new_session,
|
96
|
+
system_prompt: system_prompt || instance_config[:prompt]
|
97
|
+
}
|
98
|
+
|
99
|
+
# Add allowed tools from instance config
|
100
|
+
options[:allowed_tools] = instance_config[:tools] if instance_config[:tools]&.any?
|
101
|
+
|
102
|
+
begin
|
103
|
+
# Log the request
|
104
|
+
log_entry = {
|
105
|
+
timestamp: Time.now.utc.iso8601,
|
106
|
+
instance_name: instance_config[:name],
|
107
|
+
model: instance_config[:model],
|
108
|
+
working_directory: instance_config[:directory],
|
109
|
+
session_id: executor.session_id,
|
110
|
+
request: {
|
111
|
+
prompt: prompt,
|
112
|
+
new_session: new_session,
|
113
|
+
system_prompt: options[:system_prompt],
|
114
|
+
allowed_tools: options[:allowed_tools]
|
115
|
+
}
|
116
|
+
}
|
117
|
+
|
118
|
+
logger.info("REQUEST: #{JSON.pretty_generate(log_entry)}")
|
119
|
+
|
120
|
+
response = executor.execute(prompt, options)
|
121
|
+
|
122
|
+
# Log the response
|
123
|
+
response_entry = log_entry.merge(
|
124
|
+
session_id: executor.session_id, # Update with new session ID if changed
|
125
|
+
response: {
|
126
|
+
result: response["result"],
|
127
|
+
cost_usd: response["cost_usd"],
|
128
|
+
duration_ms: response["duration_ms"],
|
129
|
+
is_error: response["is_error"],
|
130
|
+
total_cost: response["total_cost"]
|
131
|
+
}
|
132
|
+
)
|
133
|
+
|
134
|
+
logger.info("RESPONSE: #{JSON.pretty_generate(response_entry)}")
|
135
|
+
|
136
|
+
# Return just the result text as expected by MCP
|
137
|
+
response["result"]
|
138
|
+
rescue ClaudeCodeExecutor::ExecutionError => e
|
139
|
+
logger.error("Execution error for #{instance_config[:name]}: #{e.message}")
|
140
|
+
raise StandardError, "Execution failed: #{e.message}"
|
141
|
+
rescue ClaudeCodeExecutor::ParseError => e
|
142
|
+
logger.error("Parse error for #{instance_config[:name]}: #{e.message}")
|
143
|
+
raise StandardError, "Parse error: #{e.message}"
|
144
|
+
rescue StandardError => e
|
145
|
+
logger.error("Unexpected error for #{instance_config[:name]}: #{e.class} - #{e.message}")
|
146
|
+
logger.error("Backtrace: #{e.backtrace.join("\n")}")
|
147
|
+
raise StandardError, "Unexpected error: #{e.message}"
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
class SessionInfoTool < FastMcp::Tool
|
153
|
+
tool_name "session_info"
|
154
|
+
description "Get information about the current Claude session"
|
155
|
+
|
156
|
+
arguments do
|
157
|
+
# No arguments needed
|
158
|
+
end
|
159
|
+
|
160
|
+
def call
|
161
|
+
executor = ClaudeMcpServer.executor
|
162
|
+
|
163
|
+
{
|
164
|
+
has_session: executor.has_session?,
|
165
|
+
session_id: executor.session_id,
|
166
|
+
working_directory: executor.working_directory
|
167
|
+
}
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
class ResetSessionTool < FastMcp::Tool
|
172
|
+
tool_name "reset_session"
|
173
|
+
description "Reset the Claude session, starting fresh on the next task"
|
174
|
+
|
175
|
+
arguments do
|
176
|
+
# No arguments needed
|
177
|
+
end
|
178
|
+
|
179
|
+
def call
|
180
|
+
executor = ClaudeMcpServer.executor
|
181
|
+
executor.reset_session
|
182
|
+
|
183
|
+
{
|
184
|
+
success: true,
|
185
|
+
message: "Session has been reset"
|
186
|
+
}
|
187
|
+
end
|
188
|
+
end
|
189
|
+
end
|
190
|
+
end
|
@@ -0,0 +1,94 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "thor"
|
4
|
+
require_relative "configuration"
|
5
|
+
require_relative "mcp_generator"
|
6
|
+
require_relative "orchestrator"
|
7
|
+
require_relative "claude_mcp_server"
|
8
|
+
|
9
|
+
module ClaudeSwarm
|
10
|
+
class CLI < Thor
|
11
|
+
def self.exit_on_failure?
|
12
|
+
true
|
13
|
+
end
|
14
|
+
|
15
|
+
desc "start [CONFIG_FILE]", "Start a Claude Swarm from configuration file"
|
16
|
+
method_option :config, aliases: "-c", type: :string, default: "claude-swarm.yml",
|
17
|
+
desc: "Path to configuration file"
|
18
|
+
method_option :vibe, type: :boolean, default: false,
|
19
|
+
desc: "Run with --dangerously-skip-permissions for all instances"
|
20
|
+
def start(config_file = nil)
|
21
|
+
config_path = config_file || options[:config]
|
22
|
+
unless File.exist?(config_path)
|
23
|
+
error "Configuration file not found: #{config_path}"
|
24
|
+
exit 1
|
25
|
+
end
|
26
|
+
|
27
|
+
say "Starting Claude Swarm from #{config_path}..."
|
28
|
+
begin
|
29
|
+
config = Configuration.new(config_path)
|
30
|
+
generator = McpGenerator.new(config, vibe: options[:vibe])
|
31
|
+
orchestrator = Orchestrator.new(config, generator, vibe: options[:vibe])
|
32
|
+
orchestrator.start
|
33
|
+
rescue Error => e
|
34
|
+
error e.message
|
35
|
+
exit 1
|
36
|
+
rescue StandardError => e
|
37
|
+
error "Unexpected error: #{e.message}"
|
38
|
+
error e.backtrace.join("\n") if options[:verbose]
|
39
|
+
exit 1
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
desc "mcp-serve", "Start an MCP server for a Claude instance"
|
44
|
+
method_option :name, aliases: "-n", type: :string, required: true,
|
45
|
+
desc: "Instance name"
|
46
|
+
method_option :directory, aliases: "-d", type: :string, required: true,
|
47
|
+
desc: "Working directory for the instance"
|
48
|
+
method_option :model, aliases: "-m", type: :string, required: true,
|
49
|
+
desc: "Claude model to use (e.g., opus, sonnet)"
|
50
|
+
method_option :prompt, aliases: "-p", type: :string,
|
51
|
+
desc: "System prompt for the instance"
|
52
|
+
method_option :tools, aliases: "-t", type: :array,
|
53
|
+
desc: "Allowed tools for the instance"
|
54
|
+
method_option :mcp_config_path, type: :string,
|
55
|
+
desc: "Path to MCP configuration file"
|
56
|
+
method_option :debug, type: :boolean, default: false,
|
57
|
+
desc: "Enable debug output"
|
58
|
+
method_option :vibe, type: :boolean, default: false,
|
59
|
+
desc: "Run with --dangerously-skip-permissions"
|
60
|
+
def mcp_serve
|
61
|
+
instance_config = {
|
62
|
+
name: options[:name],
|
63
|
+
directory: options[:directory],
|
64
|
+
model: options[:model],
|
65
|
+
prompt: options[:prompt],
|
66
|
+
tools: options[:tools] || [],
|
67
|
+
mcp_config_path: options[:mcp_config_path],
|
68
|
+
vibe: options[:vibe]
|
69
|
+
}
|
70
|
+
|
71
|
+
begin
|
72
|
+
server = ClaudeMcpServer.new(instance_config)
|
73
|
+
server.start
|
74
|
+
rescue StandardError => e
|
75
|
+
error "Error starting MCP server: #{e.message}"
|
76
|
+
error e.backtrace.join("\n") if options[:debug]
|
77
|
+
exit 1
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
desc "version", "Show Claude Swarm version"
|
82
|
+
def version
|
83
|
+
say "Claude Swarm #{VERSION}"
|
84
|
+
end
|
85
|
+
|
86
|
+
default_task :start
|
87
|
+
|
88
|
+
private
|
89
|
+
|
90
|
+
def error(message)
|
91
|
+
say message, :red
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
@@ -0,0 +1,126 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "yaml"
|
4
|
+
require "pathname"
|
5
|
+
|
6
|
+
module ClaudeSwarm
|
7
|
+
class Configuration
|
8
|
+
attr_reader :config, :swarm_name, :main_instance, :instances
|
9
|
+
|
10
|
+
def initialize(config_path)
|
11
|
+
@config_path = Pathname.new(config_path).expand_path
|
12
|
+
@config_dir = @config_path.dirname
|
13
|
+
load_and_validate
|
14
|
+
end
|
15
|
+
|
16
|
+
def main_instance_config
|
17
|
+
instances[main_instance]
|
18
|
+
end
|
19
|
+
|
20
|
+
def instance_names
|
21
|
+
instances.keys
|
22
|
+
end
|
23
|
+
|
24
|
+
def connections_for(instance_name)
|
25
|
+
instances[instance_name][:connections] || []
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
def load_and_validate
|
31
|
+
@config = YAML.load_file(@config_path)
|
32
|
+
validate_version
|
33
|
+
validate_swarm
|
34
|
+
parse_swarm
|
35
|
+
validate_directories
|
36
|
+
rescue Errno::ENOENT
|
37
|
+
raise Error, "Configuration file not found: #{@config_path}"
|
38
|
+
rescue Psych::SyntaxError => e
|
39
|
+
raise Error, "Invalid YAML syntax: #{e.message}"
|
40
|
+
end
|
41
|
+
|
42
|
+
def validate_version
|
43
|
+
version = @config["version"]
|
44
|
+
raise Error, "Missing 'version' field in configuration" unless version
|
45
|
+
raise Error, "Unsupported version: #{version}. Only version 1 is supported" unless version == 1
|
46
|
+
end
|
47
|
+
|
48
|
+
def validate_swarm
|
49
|
+
raise Error, "Missing 'swarm' field in configuration" unless @config["swarm"]
|
50
|
+
|
51
|
+
swarm = @config["swarm"]
|
52
|
+
raise Error, "Missing 'name' field in swarm configuration" unless swarm["name"]
|
53
|
+
raise Error, "Missing 'instances' field in swarm configuration" unless swarm["instances"]
|
54
|
+
raise Error, "Missing 'main' field in swarm configuration" unless swarm["main"]
|
55
|
+
|
56
|
+
raise Error, "No instances defined" if swarm["instances"].empty?
|
57
|
+
|
58
|
+
main = swarm["main"]
|
59
|
+
raise Error, "Main instance '#{main}' not found in instances" unless swarm["instances"].key?(main)
|
60
|
+
end
|
61
|
+
|
62
|
+
def parse_swarm
|
63
|
+
swarm = @config["swarm"]
|
64
|
+
@swarm_name = swarm["name"]
|
65
|
+
@main_instance = swarm["main"]
|
66
|
+
@instances = {}
|
67
|
+
swarm["instances"].each do |name, config|
|
68
|
+
@instances[name] = parse_instance(name, config)
|
69
|
+
end
|
70
|
+
validate_connections
|
71
|
+
end
|
72
|
+
|
73
|
+
def parse_instance(name, config)
|
74
|
+
config ||= {}
|
75
|
+
{
|
76
|
+
name: name,
|
77
|
+
role: config["role"] || name.to_s.tr("_", " ").capitalize,
|
78
|
+
directory: expand_path(config["directory"] || "."),
|
79
|
+
model: config["model"] || "sonnet",
|
80
|
+
connections: Array(config["connections"]),
|
81
|
+
tools: Array(config["tools"]),
|
82
|
+
mcps: parse_mcps(config["mcps"] || []),
|
83
|
+
prompt: config["prompt"]
|
84
|
+
}
|
85
|
+
end
|
86
|
+
|
87
|
+
def parse_mcps(mcps)
|
88
|
+
mcps.map do |mcp|
|
89
|
+
validate_mcp(mcp)
|
90
|
+
mcp
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
def validate_mcp(mcp)
|
95
|
+
raise Error, "MCP configuration missing 'name'" unless mcp["name"]
|
96
|
+
|
97
|
+
case mcp["type"]
|
98
|
+
when "stdio"
|
99
|
+
raise Error, "MCP '#{mcp["name"]}' missing 'command'" unless mcp["command"]
|
100
|
+
when "sse"
|
101
|
+
raise Error, "MCP '#{mcp["name"]}' missing 'url'" unless mcp["url"]
|
102
|
+
else
|
103
|
+
raise Error, "Unknown MCP type '#{mcp["type"]}' for '#{mcp["name"]}'"
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
def validate_connections
|
108
|
+
@instances.each do |name, instance|
|
109
|
+
instance[:connections].each do |connection|
|
110
|
+
raise Error, "Instance '#{name}' has connection to unknown instance '#{connection}'" unless @instances.key?(connection)
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
def validate_directories
|
116
|
+
@instances.each do |name, instance|
|
117
|
+
directory = instance[:directory]
|
118
|
+
raise Error, "Directory '#{directory}' for instance '#{name}' does not exist" unless File.directory?(directory)
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
def expand_path(path)
|
123
|
+
Pathname.new(path).expand_path(@config_dir).to_s
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
@@ -0,0 +1,112 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "json"
|
4
|
+
require "fileutils"
|
5
|
+
require "shellwords"
|
6
|
+
|
7
|
+
module ClaudeSwarm
|
8
|
+
class McpGenerator
|
9
|
+
SWARM_DIR = ".claude-swarm"
|
10
|
+
|
11
|
+
def initialize(configuration, vibe: false)
|
12
|
+
@config = configuration
|
13
|
+
@vibe = vibe
|
14
|
+
end
|
15
|
+
|
16
|
+
def generate_all
|
17
|
+
ensure_swarm_directory
|
18
|
+
|
19
|
+
@config.instances.each do |name, instance|
|
20
|
+
generate_mcp_config(name, instance)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def mcp_config_path(instance_name)
|
25
|
+
File.join(Dir.pwd, SWARM_DIR, "#{instance_name}.mcp.json")
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
def swarm_dir
|
31
|
+
File.join(Dir.pwd, SWARM_DIR)
|
32
|
+
end
|
33
|
+
|
34
|
+
def ensure_swarm_directory
|
35
|
+
FileUtils.mkdir_p(swarm_dir)
|
36
|
+
|
37
|
+
# Create logs directory as well
|
38
|
+
logs_dir = File.join(swarm_dir, "logs")
|
39
|
+
FileUtils.mkdir_p(logs_dir)
|
40
|
+
|
41
|
+
gitignore_path = File.join(swarm_dir, ".gitignore")
|
42
|
+
File.write(gitignore_path, "*\n") unless File.exist?(gitignore_path)
|
43
|
+
end
|
44
|
+
|
45
|
+
def generate_mcp_config(name, instance)
|
46
|
+
mcp_servers = {}
|
47
|
+
|
48
|
+
# Add configured MCP servers
|
49
|
+
instance[:mcps].each do |mcp|
|
50
|
+
mcp_servers[mcp["name"]] = build_mcp_server_config(mcp)
|
51
|
+
end
|
52
|
+
|
53
|
+
# Add connection MCPs for other instances
|
54
|
+
instance[:connections].each do |connection_name|
|
55
|
+
connected_instance = @config.instances[connection_name]
|
56
|
+
mcp_servers[connection_name] = build_instance_mcp_config(connection_name, connected_instance)
|
57
|
+
end
|
58
|
+
|
59
|
+
config = {
|
60
|
+
"mcpServers" => mcp_servers
|
61
|
+
}
|
62
|
+
|
63
|
+
File.write(mcp_config_path(name), JSON.pretty_generate(config))
|
64
|
+
end
|
65
|
+
|
66
|
+
def build_mcp_server_config(mcp)
|
67
|
+
case mcp["type"]
|
68
|
+
when "stdio"
|
69
|
+
{
|
70
|
+
"type" => "stdio",
|
71
|
+
"command" => mcp["command"],
|
72
|
+
"args" => mcp["args"] || []
|
73
|
+
}.tap do |config|
|
74
|
+
config["env"] = mcp["env"] if mcp["env"]
|
75
|
+
end
|
76
|
+
when "sse"
|
77
|
+
{
|
78
|
+
"type" => "sse",
|
79
|
+
"url" => mcp["url"]
|
80
|
+
}
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
def build_instance_mcp_config(name, instance)
|
85
|
+
# Get the path to the claude-swarm executable
|
86
|
+
exe_path = "claude-swarm"
|
87
|
+
|
88
|
+
# Build command-line arguments for Thor
|
89
|
+
args = [
|
90
|
+
"mcp-serve",
|
91
|
+
"--name", name,
|
92
|
+
"--directory", instance[:directory],
|
93
|
+
"--model", instance[:model]
|
94
|
+
]
|
95
|
+
|
96
|
+
# Add optional arguments
|
97
|
+
args.push("--prompt", instance[:prompt]) if instance[:prompt]
|
98
|
+
|
99
|
+
args.push("--tools", instance[:tools].join(",")) if instance[:tools] && !instance[:tools].empty?
|
100
|
+
|
101
|
+
args.push("--mcp-config-path", mcp_config_path(name))
|
102
|
+
|
103
|
+
args.push("--vibe") if @vibe
|
104
|
+
|
105
|
+
{
|
106
|
+
"type" => "stdio",
|
107
|
+
"command" => exe_path,
|
108
|
+
"args" => args
|
109
|
+
}
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "shellwords"
|
4
|
+
|
5
|
+
module ClaudeSwarm
|
6
|
+
class Orchestrator
|
7
|
+
def initialize(configuration, mcp_generator, vibe: false)
|
8
|
+
@config = configuration
|
9
|
+
@generator = mcp_generator
|
10
|
+
@vibe = vibe
|
11
|
+
end
|
12
|
+
|
13
|
+
def start
|
14
|
+
puts "🐝 Starting Claude Swarm: #{@config.swarm_name}"
|
15
|
+
puts "😎 Vibe mode ON" if @vibe
|
16
|
+
puts
|
17
|
+
|
18
|
+
# Set session timestamp for all instances to share the same log file
|
19
|
+
session_timestamp = Time.now.strftime("%Y%m%d_%H%M%S")
|
20
|
+
ENV["CLAUDE_SWARM_SESSION_TIMESTAMP"] = session_timestamp
|
21
|
+
puts "📝 Session logs will be saved to: .claude-swarm/logs/session_#{session_timestamp}.log"
|
22
|
+
puts
|
23
|
+
|
24
|
+
# Generate all MCP configuration files
|
25
|
+
@generator.generate_all
|
26
|
+
puts "✓ Generated MCP configurations in .claude-swarm/"
|
27
|
+
puts
|
28
|
+
|
29
|
+
# Launch the main instance
|
30
|
+
main_instance = @config.main_instance_config
|
31
|
+
puts "🚀 Launching main instance: #{@config.main_instance} (#{main_instance[:role]})"
|
32
|
+
puts " Model: #{main_instance[:model]}"
|
33
|
+
puts " Directory: #{main_instance[:directory]}"
|
34
|
+
puts " Tools: #{main_instance[:tools].join(", ")}" if main_instance[:tools].any?
|
35
|
+
puts " Connections: #{main_instance[:connections].join(", ")}" if main_instance[:connections].any?
|
36
|
+
puts
|
37
|
+
|
38
|
+
command = build_main_command(main_instance)
|
39
|
+
if ENV["DEBUG"]
|
40
|
+
puts "Running: #{command}"
|
41
|
+
puts
|
42
|
+
end
|
43
|
+
|
44
|
+
# Execute the main instance - this will cascade to other instances via MCP
|
45
|
+
exec(command)
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
def build_main_command(instance)
|
51
|
+
parts = []
|
52
|
+
parts << "cd #{Shellwords.escape(instance[:directory])} &&"
|
53
|
+
parts << "claude"
|
54
|
+
parts << "--model #{instance[:model]}"
|
55
|
+
|
56
|
+
if @vibe
|
57
|
+
parts << "--dangerously-skip-permissions"
|
58
|
+
elsif instance[:tools].any?
|
59
|
+
tools_str = instance[:tools].join(",")
|
60
|
+
parts << "--allowedTools '#{tools_str}'"
|
61
|
+
end
|
62
|
+
|
63
|
+
parts << "--append-system-prompt #{Shellwords.escape(instance[:prompt])}" if instance[:prompt]
|
64
|
+
|
65
|
+
mcp_config_path = @generator.mcp_config_path(@config.main_instance)
|
66
|
+
parts << "--mcp-config #{mcp_config_path}"
|
67
|
+
|
68
|
+
parts.join(" ")
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
data/lib/claude_swarm.rb
ADDED
@@ -0,0 +1,10 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "claude_swarm/version"
|
4
|
+
require_relative "claude_swarm/cli"
|
5
|
+
require_relative "claude_swarm/claude_code_executor"
|
6
|
+
require_relative "claude_swarm/claude_mcp_server"
|
7
|
+
|
8
|
+
module ClaudeSwarm
|
9
|
+
class Error < StandardError; end
|
10
|
+
end
|