claude_swarm 0.3.10 → 1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a44d1b33e538b1efaeca7eb223f09bd617fcb4ba2288d7844773ae5908a10248
4
- data.tar.gz: 28312f81eada550b05bbf568807be51c5077366c08d168b928a35a4d356148da
3
+ metadata.gz: be01b40a18b703189532df1e653c15e406c0b798b220c20fe1f014a35331e213
4
+ data.tar.gz: a4eddd8c10cb1974e9693c27cb9d6660117bd781a4ed16ef52dc1408b4c414d2
5
5
  SHA512:
6
- metadata.gz: 2d8ba31f8a64f91060b980ee90bea57c736e4541410eeb36c4c78bdc602f7c3335ea82a388c0b61847a04c90210fb432c2d525df4f4bfa85cbe3725a0034b260
7
- data.tar.gz: 15180a4a4df3f9ccc55da9dd40769435c1ba650a575b476c10d7c0b16d650bb1f65524b8d44a10d731e9aed3dcda886b0bb63fd2b64e5665c5ba1cc22633182b
6
+ metadata.gz: c8207b22b7d3514e910d9f4c72ba7c99e5f5b2f5034e532fa8708ae5d9e9219047153d54ac148c688114cc3f9bd167a4e7a16ceed9439512fe9f1c60c77f8d63
7
+ data.tar.gz: 53cfc85b8898b8906372e5518913de7c373e98ca2a7de1202f343f1d93f9efc566ed21acee1529f8bc9945e3e60f0cde335040dd49a9d582f723ea739c790c4e
data/CHANGELOG.md CHANGED
@@ -1,3 +1,66 @@
1
+ ## [Unreleased]
2
+
3
+ ## [1.0.0]
4
+
5
+ ### Added
6
+ - **HTTP MCP server configuration support**: Added support for HTTP-type MCP servers alongside existing stdio and SSE types
7
+ - HTTP servers can be configured with a `url` field
8
+ - Properly preserves server type (http/sse) in generated configurations
9
+ - Full compatibility with Claude Code's HTTP MCP server implementation
10
+
11
+ ### Changed
12
+ - **Updated dependency from fast-mcp-annotations to fast-mcp gem**: Migrated to the consolidated fast-mcp gem (~> 1.6)
13
+ - Consolidates MCP functionality into a single, more maintainable gem
14
+ - Maintains all existing functionality with improved performance
15
+ - **Updated claude-code-sdk-ruby dependency**: Minimum version requirement increased to 0.1.6
16
+ - Includes latest SDK improvements and bug fixes
17
+ - Better error handling and stability
18
+ - **Swarm generation improvements**: Generator now avoids creating swarms with circular dependencies
19
+ - Prevents generation of invalid configurations
20
+ - Improves reliability of generated swarm templates
21
+
22
+ ### Fixed
23
+ - **Empty response validation**: Added validation to prevent empty or nil responses from being passed to MCP callers
24
+ - ClaudeCodeExecutor now validates that Claude SDK returns non-empty result content
25
+ - TaskTool validates response structure and content before returning to MCP caller
26
+ - Clear error messages indicate when agent completes execution but provides no response
27
+ - Prevents silent failures when Claude SDK returns empty results
28
+ - **Before commands directory handling**: Fixed error when before commands need to create the main instance directory
29
+ - Smart directory detection: if the main instance directory exists, commands run inside it (for `npm install`, etc.)
30
+ - If the directory doesn't exist, commands run in the parent directory (allowing `mkdir` commands to create it)
31
+ - Works correctly with both regular directories and Git worktrees
32
+ - After commands follow the same logic for consistency
33
+ - Fixes "No such file or directory @ dir_chdir" errors when before commands create directories
34
+
35
+ ### Internal
36
+ - **Centralized JSON handling**: Added JsonHandler class for consistent JSON parsing and generation
37
+ - Improved error handling for malformed JSON files
38
+ - Standardized JSON output formatting across all modules
39
+ - **Centralized CLAUDE_SWARM_HOME handling**: Refactored environment variable management for cleaner code
40
+ - **Enhanced test coverage**: Added comprehensive configuration tests and enabled branch coverage in SimpleCov
41
+ - Extensive validation of edge cases including circular dependencies
42
+ - Better coverage metrics with branch coverage enabled
43
+
44
+ ## [0.3.11]
45
+
46
+ ### Added
47
+ - **Deferred directory validation for before commands**: Directories are now validated after `before` commands run, allowing them to create required directories
48
+ - Automatically skips initial directory validation when `before` commands are present in configuration
49
+ - Validates all directories after `before` commands complete successfully
50
+ - Enables dynamic directory creation workflows without pre-creating directory structures
51
+
52
+ ### Fixed
53
+ - **--root-dir parameter path resolution**: Fixed relative config file paths to be resolved relative to the --root-dir value instead of current directory
54
+ - Config paths are now expanded using the root directory as the base path
55
+ - Allows running claude-swarm from any location with consistent path resolution
56
+ - Absolute paths continue to work as expected regardless of --root-dir setting
57
+
58
+ ### Improved
59
+ - **Enhanced worktree cleanup on errors**: Improved error handling to ensure worktrees are always cleaned up properly
60
+ - Added comprehensive error handling with cleanup at all failure points
61
+ - Worktrees are now cleaned up when worktree setup fails, before commands fail, or directory validation fails
62
+ - Prevents orphaned worktrees that could clutter the system or cause issues with future runs
63
+
1
64
  ## [0.3.10]
2
65
 
3
66
  ### Added
data/CLAUDE.md CHANGED
@@ -6,6 +6,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
6
6
 
7
7
  Claude Swarm is a Ruby gem that orchestrates multiple Claude Code instances as a collaborative AI development team. It enables running AI agents with specialized roles, tools, and directory contexts, communicating via MCP (Model Context Protocol).
8
8
 
9
+ SwarmCore is a complete reimagining of Claude Swarm that decouples from Claude Code and runs everything in a single process using RubyLLM for all LLM interactions. It is being developed in `lib/swarm_core`, and using the gemspec swarm-core.gemspec.
10
+
9
11
  ## Development Commands
10
12
 
11
13
  ### Testing
data/README.md CHANGED
@@ -228,6 +228,53 @@ swarm:
228
228
  # Instance definitions...
229
229
  ```
230
230
 
231
+ #### YAML Aliases
232
+
233
+ Claude Swarm supports [YAML aliases](https://yaml.org/spec/1.2.2/#71-alias-nodes) to reduce duplication in your configuration. This is particularly useful for sharing common values like prompts, tool lists, or MCP configurations across multiple instances:
234
+
235
+ ```yaml
236
+ version: 1
237
+ swarm:
238
+ name: "Development Team"
239
+ main: lead
240
+ instances:
241
+ lead:
242
+ description: "Lead developer"
243
+ prompt: &shared_prompt "You are an expert developer following best practices"
244
+ allowed_tools: &standard_tools
245
+ - Read
246
+ - Edit
247
+ - Bash
248
+ - WebSearch
249
+ mcps: &common_mcps
250
+ - name: github
251
+ type: stdio
252
+ command: gh
253
+ args: ["mcp"]
254
+
255
+ frontend:
256
+ description: "Frontend developer"
257
+ prompt: *shared_prompt # Reuses the same prompt
258
+ allowed_tools: *standard_tools # Reuses the same tool list
259
+ mcps: *common_mcps # Reuses the same MCP servers
260
+ directory: ./frontend
261
+
262
+ backend:
263
+ description: "Backend developer"
264
+ prompt: *shared_prompt # Reuses the same prompt
265
+ allowed_tools: *standard_tools # Reuses the same tool list
266
+ mcps: *common_mcps # Reuses the same MCP servers
267
+ directory: ./backend
268
+ ```
269
+
270
+ In this example:
271
+ - `&shared_prompt` defines an anchor for the prompt string
272
+ - `&standard_tools` defines an anchor for the tool array
273
+ - `&common_mcps` defines an anchor for the MCP configuration
274
+ - `*shared_prompt`, `*standard_tools`, and `*common_mcps` reference those anchors
275
+
276
+ This helps maintain consistency across instances and makes configuration updates easier.
277
+
231
278
  #### Environment Variable Interpolation
232
279
 
233
280
  Claude Swarm supports environment variable interpolation in all configuration values using the `${ENV_VAR_NAME}` syntax:
@@ -395,6 +442,17 @@ mcps:
395
442
  X-Custom-Header: "value"
396
443
  ```
397
444
 
445
+ #### http (HTTP-based MCP)
446
+ ```yaml
447
+ mcps:
448
+ - name: http_service
449
+ type: http
450
+ url: "https://api.example.com/mcp-endpoint"
451
+ headers: # Optional: custom headers for authentication
452
+ Authorization: "Bearer ${API_TOKEN}"
453
+ X-API-Key: "${API_KEY}"
454
+ ```
455
+
398
456
  ### Hooks Configuration
399
457
 
400
458
  Claude Swarm supports configuring [Claude Code hooks](https://docs.anthropic.com/en/docs/claude-code/hooks) for each instance. Hooks allow you to run custom scripts before/after tools, on prompt submission, and more. Each instance can have its own hooks configuration.
@@ -40,6 +40,11 @@ module ClaudeSwarm
40
40
  # Assistant messages only contain content blocks
41
41
  # No need to track for result extraction - result comes from Result message
42
42
  when ClaudeSDK::Messages::Result
43
+ # Validate that we have actual result content
44
+ if message.result.nil? || (message.result.is_a?(String) && message.result.strip.empty?)
45
+ raise ExecutionError, "Claude SDK returned an empty result. The agent completed execution but provided no response content."
46
+ end
47
+
43
48
  # Build result response in expected format
44
49
  result_response = {
45
50
  "type" => "result",
@@ -91,7 +96,7 @@ module ClaudeSwarm
91
96
  updated_at: Time.now.iso8601,
92
97
  }
93
98
 
94
- File.write(state_file, JSON.pretty_generate(state_data))
99
+ JsonHandler.write_file!(state_file, state_data)
95
100
  logger.info { "Wrote instance state for #{@instance_name} (#{@instance_id}) with session ID: #{@session_id}" }
96
101
  rescue StandardError => e
97
102
  logger.error { "Failed to write instance state for #{@instance_name} (#{@instance_id}): #{e.message}" }
@@ -114,13 +119,13 @@ module ClaudeSwarm
114
119
  end
115
120
 
116
121
  def log_system_message(event)
117
- logger.debug { "SYSTEM: #{JSON.pretty_generate(event)}" }
122
+ logger.debug { "SYSTEM: #{JsonHandler.pretty_generate!(event)}" }
118
123
  end
119
124
 
120
125
  def log_assistant_message(msg)
121
126
  # Assistant messages don't have stop_reason in SDK - they only have content
122
127
  content = msg["content"]
123
- logger.debug { "ASSISTANT: #{JSON.pretty_generate(content)}" } if content
128
+ logger.debug { "ASSISTANT: #{JsonHandler.pretty_generate!(content)}" } if content
124
129
 
125
130
  # Log tool calls
126
131
  tool_calls = content&.select { |c| c["type"] == "tool_use" } || []
@@ -141,7 +146,7 @@ module ClaudeSwarm
141
146
  end
142
147
 
143
148
  def log_user_message(content)
144
- logger.debug { "USER: #{JSON.pretty_generate(content)}" }
149
+ logger.debug { "USER: #{JsonHandler.pretty_generate!(content)}" }
145
150
  end
146
151
 
147
152
  def build_sdk_options(prompt, options)
@@ -193,7 +198,7 @@ module ClaudeSwarm
193
198
 
194
199
  def parse_mcp_config(config_path)
195
200
  # Parse MCP JSON config file and convert to SDK format
196
- config = JSON.parse(File.read(config_path))
201
+ config = JsonHandler.parse_file!(config_path)
197
202
  mcp_servers = {}
198
203
 
199
204
  config["mcpServers"]&.each do |name, server_config|
@@ -42,16 +42,19 @@ module ClaudeSwarm
42
42
  type: :string,
43
43
  desc: "Root directory for resolving relative paths (defaults to current directory)"
44
44
  def start(config_file = nil)
45
+ # Set root directory early so it's available to all components
46
+ root_dir = options[:root_dir] || Dir.pwd
47
+ ENV["CLAUDE_SWARM_ROOT_DIR"] = File.expand_path(root_dir)
48
+
49
+ # Resolve config path relative to root directory
45
50
  config_path = config_file || "claude-swarm.yml"
51
+ config_path = File.expand_path(config_path, root_dir)
52
+
46
53
  unless File.exist?(config_path)
47
54
  error("Configuration file not found: #{config_path}")
48
55
  exit(1)
49
56
  end
50
57
 
51
- # Set root directory early so it's available to all components
52
- root_dir = options[:root_dir] || Dir.pwd
53
- ENV["CLAUDE_SWARM_ROOT_DIR"] = File.expand_path(root_dir)
54
-
55
58
  say("Starting Claude Swarm from #{config_path}...") unless options[:prompt]
56
59
 
57
60
  # Validate stream_logs option
@@ -331,7 +334,7 @@ module ClaudeSwarm
331
334
  system!("command -v claude > /dev/null 2>&1")
332
335
  rescue Error
333
336
  error("Claude CLI is not installed or not in PATH")
334
- say("To install Claude CLI, visit: https://docs.anthropic.com/en/docs/claude-code")
337
+ error("To install Claude CLI, visit: https://docs.anthropic.com/en/docs/claude-code")
335
338
  exit(1)
336
339
  end
337
340
 
@@ -403,12 +406,12 @@ module ClaudeSwarm
403
406
  desc: "Number of lines to show initially"
404
407
  def watch(session_id)
405
408
  # Find session path
406
- run_symlink = File.join(File.expand_path("~/.claude-swarm/run"), session_id)
409
+ run_symlink = ClaudeSwarm.joined_run_dir(session_id)
407
410
  session_path = if File.symlink?(run_symlink)
408
411
  File.readlink(run_symlink)
409
412
  else
410
413
  # Search in sessions directory
411
- Dir.glob(File.expand_path("~/.claude-swarm/sessions/*/*")).find do |path|
414
+ Dir.glob(ClaudeSwarm.joined_sessions_dir("*", "*")).find do |path|
412
415
  File.basename(path) == session_id
413
416
  end
414
417
  end
@@ -434,7 +437,7 @@ module ClaudeSwarm
434
437
  default: 10,
435
438
  desc: "Maximum number of sessions to display"
436
439
  def list_sessions
437
- sessions_dir = File.expand_path("~/.claude-swarm/sessions")
440
+ sessions_dir = ClaudeSwarm.joined_sessions_dir
438
441
  unless Dir.exist?(sessions_dir)
439
442
  say("No sessions found", :yellow)
440
443
  return
@@ -508,7 +511,7 @@ module ClaudeSwarm
508
511
  private
509
512
 
510
513
  def error(message)
511
- say(message, :red)
514
+ $stderr.puts(Thor::Shell::Color.new.set_color(message, :red))
512
515
  end
513
516
 
514
517
  def restore_session(session_id)
@@ -559,12 +562,10 @@ module ClaudeSwarm
559
562
  # Load session metadata if it exists to check for worktree info
560
563
  session_metadata_file = File.join(session_path, "session_metadata.json")
561
564
  worktree_name = nil
562
- if File.exist?(session_metadata_file)
563
- metadata = JSON.parse(File.read(session_metadata_file))
564
- if metadata["worktree"] && metadata["worktree"]["enabled"]
565
- worktree_name = metadata["worktree"]["name"]
566
- say("Restoring with worktree: #{worktree_name}", :green) unless options[:prompt]
567
- end
565
+ metadata = JsonHandler.parse_file(session_metadata_file)
566
+ if metadata && metadata["worktree"] && metadata["worktree"]["enabled"]
567
+ worktree_name = metadata["worktree"]["name"]
568
+ say("Restoring with worktree: #{worktree_name}", :green) unless options[:prompt]
568
569
  end
569
570
 
570
571
  # Create orchestrator with restoration mode
@@ -589,7 +590,7 @@ module ClaudeSwarm
589
590
  end
590
591
 
591
592
  def find_session_path(session_id)
592
- sessions_dir = File.expand_path("~/.claude-swarm/sessions")
593
+ sessions_dir = ClaudeSwarm.joined_sessions_dir
593
594
 
594
595
  # Search for the session ID in all projects
595
596
  Dir.glob("#{sessions_dir}/*/#{session_id}").each do |path|
@@ -601,7 +602,7 @@ module ClaudeSwarm
601
602
  end
602
603
 
603
604
  def clean_stale_symlinks(days)
604
- run_dir = File.expand_path("~/.claude-swarm/run")
605
+ run_dir = ClaudeSwarm.joined_run_dir
605
606
  return 0 unless Dir.exist?(run_dir)
606
607
 
607
608
  cleaned = 0
@@ -630,10 +631,10 @@ module ClaudeSwarm
630
631
  end
631
632
 
632
633
  def clean_orphaned_worktrees(days)
633
- worktrees_dir = File.expand_path("~/.claude-swarm/worktrees")
634
+ worktrees_dir = ClaudeSwarm.joined_worktrees_dir
634
635
  return 0 unless Dir.exist?(worktrees_dir)
635
636
 
636
- sessions_dir = File.expand_path("~/.claude-swarm/sessions")
637
+ sessions_dir = ClaudeSwarm.joined_sessions_dir
637
638
  cleaned = 0
638
639
 
639
640
  Dir.glob("#{worktrees_dir}/*").each do |session_worktree_dir|
@@ -3,10 +3,9 @@
3
3
  module ClaudeSwarm
4
4
  module Commands
5
5
  class Ps
6
- RUN_DIR = File.expand_path("~/.claude-swarm/run")
7
-
8
6
  def execute
9
- unless Dir.exist?(RUN_DIR)
7
+ run_dir = ClaudeSwarm.joined_run_dir
8
+ unless Dir.exist?(run_dir)
10
9
  puts "No active sessions"
11
10
  return
12
11
  end
@@ -14,7 +13,7 @@ module ClaudeSwarm
14
13
  sessions = []
15
14
 
16
15
  # Read all symlinks in run directory
17
- Dir.glob("#{RUN_DIR}/*").each do |symlink|
16
+ Dir.glob("#{run_dir}/*").each do |symlink|
18
17
  next unless File.symlink?(symlink)
19
18
 
20
19
  begin
@@ -147,9 +146,10 @@ module ClaudeSwarm
147
146
  def get_start_time(session_dir)
148
147
  # Try to get from session metadata first
149
148
  metadata_file = File.join(session_dir, "session_metadata.json")
150
- if File.exist?(metadata_file)
151
- metadata = JSON.parse(File.read(metadata_file))
152
- return Time.parse(metadata["start_time"]) if metadata["start_time"]
149
+ metadata = JsonHandler.parse_file(metadata_file)
150
+
151
+ if metadata && metadata["start_time"]
152
+ return Time.parse(metadata["start_time"])
153
153
  end
154
154
 
155
155
  # Fallback to directory creation time
@@ -179,7 +179,7 @@ module ClaudeSwarm
179
179
  session_metadata_file = File.join(session_dir, "session_metadata.json")
180
180
  return directories unless File.exist?(session_metadata_file)
181
181
 
182
- metadata = JSON.parse(File.read(session_metadata_file))
182
+ metadata = JsonHandler.parse_file!(session_metadata_file)
183
183
  worktree_info = metadata["worktree"]
184
184
  return directories unless worktree_info && worktree_info["enabled"]
185
185
 
@@ -63,23 +63,22 @@ module ClaudeSwarm
63
63
 
64
64
  def find_session_path(session_id)
65
65
  # First check the run directory
66
- run_symlink = File.join(File.expand_path("~/.claude-swarm/run"), session_id)
66
+ run_symlink = ClaudeSwarm.joined_run_dir(session_id)
67
67
  if File.symlink?(run_symlink)
68
68
  target = File.readlink(run_symlink)
69
69
  return target if Dir.exist?(target)
70
70
  end
71
71
 
72
72
  # Fall back to searching all sessions
73
- Dir.glob(File.expand_path("~/.claude-swarm/sessions/*/*")).find do |path|
73
+ Dir.glob(ClaudeSwarm.joined_sessions_dir("*", "*")).find do |path|
74
74
  File.basename(path) == session_id
75
75
  end
76
76
  end
77
77
 
78
78
  def get_runtime_info(session_path)
79
79
  metadata_file = File.join(session_path, "session_metadata.json")
80
- return unless File.exist?(metadata_file)
81
-
82
- metadata = JSON.parse(File.read(metadata_file))
80
+ metadata = JsonHandler.parse_file(metadata_file)
81
+ return unless metadata
83
82
 
84
83
  if metadata["duration_seconds"]
85
84
  # Session has completed
@@ -43,15 +43,30 @@ module ClaudeSwarm
43
43
  @swarm["after"] || []
44
44
  end
45
45
 
46
+ def validate_directories
47
+ @instances.each do |name, instance|
48
+ # Validate all directories in the directories array
49
+ instance[:directories].each do |directory|
50
+ raise Error, "Directory '#{directory}' for instance '#{name}' does not exist" unless File.directory?(directory)
51
+ end
52
+ end
53
+ end
54
+
46
55
  private
47
56
 
57
+ def has_before_commands?
58
+ @swarm && @swarm["before"] && !@swarm["before"].empty?
59
+ end
60
+
48
61
  def load_and_validate
49
- @config = YAML.load_file(@config_path)
62
+ @config = YAML.load_file(@config_path, aliases: true)
50
63
  interpolate_env_vars!(@config)
51
64
  validate_version
52
65
  validate_swarm
53
66
  parse_swarm
54
- validate_directories
67
+ # Skip directory validation if before commands are present
68
+ # They might create the directories
69
+ validate_directories unless has_before_commands?
55
70
  rescue Errno::ENOENT
56
71
  raise Error, "Configuration file not found: #{@config_path}"
57
72
  rescue Psych::SyntaxError => e
@@ -233,7 +248,7 @@ module ClaudeSwarm
233
248
  case mcp["type"]
234
249
  when "stdio"
235
250
  raise Error, "MCP '#{mcp["name"]}' missing 'command'" unless mcp["command"]
236
- when "sse"
251
+ when "sse", "http"
237
252
  raise Error, "MCP '#{mcp["name"]}' missing 'url'" unless mcp["url"]
238
253
  else
239
254
  raise Error, "Unknown MCP type '#{mcp["type"]}' for '#{mcp["name"]}'"
@@ -273,15 +288,6 @@ module ClaudeSwarm
273
288
  visited.add(instance_name)
274
289
  end
275
290
 
276
- def validate_directories
277
- @instances.each do |name, instance|
278
- # Validate all directories in the directories array
279
- instance[:directories].each do |directory|
280
- raise Error, "Directory '#{directory}' for instance '#{name}' does not exist" unless File.directory?(directory)
281
- end
282
- end
283
- end
284
-
285
291
  def validate_tool_field(instance_name, config, field_name)
286
292
  return unless config.key?(field_name)
287
293
 
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeSwarm
4
+ # Centralized JSON handling for the Claude Swarm codebase
5
+ class JsonHandler
6
+ class << self
7
+ # Parse JSON string into Ruby object
8
+ # @param json_string [String] The JSON string to parse
9
+ # @param raise_on_error [Boolean] Whether to raise exception on error (default: false)
10
+ # @return [Object] The parsed Ruby object, or original string if parsing fails and raise_on_error is false
11
+ # @raise [JSON::ParserError] If the JSON is invalid and raise_on_error is true
12
+ def parse(json_string, raise_on_error: false)
13
+ JSON.parse(json_string)
14
+ rescue JSON::ParserError => e
15
+ raise e if raise_on_error
16
+
17
+ json_string
18
+ end
19
+
20
+ # Parse JSON string with exception raising
21
+ # @param json_string [String] The JSON string to parse
22
+ # @return [Object] The parsed Ruby object
23
+ # @raise [JSON::ParserError] If the JSON is invalid
24
+ def parse!(json_string)
25
+ parse(json_string, raise_on_error: true)
26
+ end
27
+
28
+ # Parse JSON from a file with exception raising
29
+ # @param file_path [String] Path to the JSON file
30
+ # @return [Object] The parsed Ruby object
31
+ # @raise [Errno::ENOENT] If the file does not exist
32
+ # @raise [JSON::ParserError] If the file contains invalid JSON
33
+ def parse_file!(file_path)
34
+ content = File.read(file_path)
35
+ parse!(content)
36
+ end
37
+
38
+ # Parse JSON from a file, returning nil on error
39
+ # @param file_path [String] Path to the JSON file
40
+ # @return [Object, nil] The parsed Ruby object or nil if file doesn't exist or contains invalid JSON
41
+ def parse_file(file_path)
42
+ parse_file!(file_path)
43
+ rescue Errno::ENOENT, JSON::ParserError
44
+ nil
45
+ end
46
+
47
+ # Generate pretty-formatted JSON string
48
+ # @param object [Object] The Ruby object to convert to JSON
49
+ # @param raise_on_error [Boolean] Whether to raise exception on error (default: false)
50
+ # @return [String, nil] The pretty-formatted JSON string, or nil if generation fails and raise_on_error is false
51
+ # @raise [JSON::GeneratorError] If the object cannot be converted to JSON and raise_on_error is true
52
+ def pretty_generate(object, raise_on_error: false)
53
+ JSON.pretty_generate(object)
54
+ rescue JSON::GeneratorError, JSON::NestingError => e
55
+ raise e if raise_on_error
56
+
57
+ nil
58
+ end
59
+
60
+ # Generate pretty-formatted JSON string with exception raising
61
+ # @param object [Object] The Ruby object to convert to JSON
62
+ # @return [String] The pretty-formatted JSON string
63
+ # @raise [JSON::GeneratorError] If the object cannot be converted to JSON
64
+ def pretty_generate!(object)
65
+ pretty_generate(object, raise_on_error: true)
66
+ end
67
+
68
+ # Write Ruby object to a JSON file with pretty formatting
69
+ # @param file_path [String] Path to the JSON file
70
+ # @param object [Object] The Ruby object to write
71
+ # @return [Boolean] True if successful, false if generation or write fails
72
+ def write_file(file_path, object)
73
+ json_string = pretty_generate!(object)
74
+ File.write(file_path, json_string)
75
+ true
76
+ rescue JSON::GeneratorError, JSON::NestingError, SystemCallError
77
+ false
78
+ end
79
+
80
+ # Write Ruby object to a JSON file with exception raising
81
+ # @param file_path [String] Path to the JSON file
82
+ # @param object [Object] The Ruby object to write
83
+ # @raise [JSON::GeneratorError] If the object cannot be converted to JSON
84
+ # @raise [SystemCallError] If the file cannot be written
85
+ def write_file!(file_path, object)
86
+ json_string = pretty_generate!(object)
87
+ File.write(file_path, json_string)
88
+ end
89
+ end
90
+ end
91
+ end
@@ -73,7 +73,7 @@ module ClaudeSwarm
73
73
  "mcpServers" => mcp_servers,
74
74
  }
75
75
 
76
- File.write(mcp_config_path(name), JSON.pretty_generate(config))
76
+ JsonHandler.write_file!(mcp_config_path(name), config)
77
77
  end
78
78
 
79
79
  def build_mcp_server_config(mcp)
@@ -86,9 +86,9 @@ module ClaudeSwarm
86
86
  }.tap do |config|
87
87
  config["env"] = mcp["env"] if mcp["env"]
88
88
  end
89
- when "sse"
89
+ when "sse", "http"
90
90
  {
91
- "type" => "sse",
91
+ "type" => mcp["type"],
92
92
  "url" => mcp["url"],
93
93
  }.tap do |config|
94
94
  config["headers"] = mcp["headers"] if mcp["headers"]
@@ -220,7 +220,7 @@ module ClaudeSwarm
220
220
  return unless Dir.exist?(state_dir)
221
221
 
222
222
  Dir.glob(File.join(state_dir, "*.json")).each do |state_file|
223
- data = JSON.parse(File.read(state_file))
223
+ data = JsonHandler.parse_file!(state_file)
224
224
  instance_name = data["instance_name"]
225
225
  instance_id = data["instance_id"]
226
226
 
@@ -83,7 +83,7 @@ module ClaudeSwarm
83
83
  parameters[:tools] = @mcp_client.to_openai_tools if @available_tools&.any? && @mcp_client
84
84
 
85
85
  # Log the request parameters
86
- @executor.logger.info { "Chat API Request (depth=#{depth}): #{JSON.pretty_generate(parameters)}" }
86
+ @executor.logger.info { "Chat API Request (depth=#{depth}): #{JsonHandler.pretty_generate!(parameters)}" }
87
87
 
88
88
  # Append to session JSON
89
89
  append_to_session_json({
@@ -98,7 +98,7 @@ module ClaudeSwarm
98
98
  response = @openai_client.chat(parameters: parameters)
99
99
  rescue StandardError => e
100
100
  @executor.logger.error { "Chat API error: #{e.class} - #{e.message}" }
101
- @executor.logger.error { "Request parameters: #{JSON.pretty_generate(parameters)}" }
101
+ @executor.logger.error { "Request parameters: #{JsonHandler.pretty_generate!(parameters)}" }
102
102
 
103
103
  # Try to extract and log the response body for better debugging
104
104
  if e.respond_to?(:response)
@@ -127,7 +127,7 @@ module ClaudeSwarm
127
127
  end
128
128
 
129
129
  # Log the response
130
- @executor.logger.info { "Chat API Response (depth=#{depth}): #{JSON.pretty_generate(response)}" }
130
+ @executor.logger.info { "Chat API Response (depth=#{depth}): #{JsonHandler.pretty_generate!(response)}" }
131
131
 
132
132
  # Append to session JSON
133
133
  append_to_session_json({
@@ -169,7 +169,7 @@ module ClaudeSwarm
169
169
 
170
170
  def execute_and_append_tool_results(tool_calls, messages)
171
171
  # Log tool calls
172
- @executor.logger.info { "Executing tool calls: #{JSON.pretty_generate(tool_calls)}" }
172
+ @executor.logger.info { "Executing tool calls: #{JsonHandler.pretty_generate!(tool_calls)}" }
173
173
 
174
174
  # Append to session JSON
175
175
  append_to_session_json({
@@ -186,10 +186,10 @@ module ClaudeSwarm
186
186
 
187
187
  begin
188
188
  # Parse arguments
189
- tool_args = tool_args_str.is_a?(String) ? JSON.parse(tool_args_str) : tool_args_str
189
+ tool_args = tool_args_str.is_a?(String) ? JsonHandler.parse!(tool_args_str) : tool_args_str
190
190
 
191
191
  # Log tool execution
192
- @executor.logger.info { "Executing tool: #{tool_name} with args: #{JSON.pretty_generate(tool_args)}" }
192
+ @executor.logger.info { "Executing tool: #{tool_name} with args: #{JsonHandler.pretty_generate!(tool_args)}" }
193
193
 
194
194
  # Execute tool via MCP
195
195
  result = @mcp_client.call_tool(tool_name, tool_args)
@@ -122,7 +122,7 @@ module ClaudeSwarm
122
122
  return unless @mcp_config && File.exist?(@mcp_config)
123
123
 
124
124
  # Read MCP config to find MCP servers
125
- mcp_data = JSON.parse(File.read(@mcp_config))
125
+ mcp_data = JsonHandler.parse_file!(@mcp_config)
126
126
 
127
127
  # Build MCP configurations from servers
128
128
  mcp_configs = build_mcp_configs(mcp_data["mcpServers"])