Class: ClaudeSwarm::CLI

Inherits:
Thor
  • Object
show all
Includes:
SystemUtils
Defined in:
lib/claude_swarm/cli.rb

Class Method Summary collapse

Instance Method Summary collapse

Methods included from SystemUtils

#last_status, #system!, #system_with_pid!

Class Method Details

.exit_on_failure?Boolean

Returns:

  • (Boolean)


8
9
10
# File 'lib/claude_swarm/cli.rb', line 8

def exit_on_failure?
  true
end

Instance Method Details

#cleanObject



368
369
370
371
372
373
374
375
376
377
378
379
380
381
# File 'lib/claude_swarm/cli.rb', line 368

def clean
  # Clean stale symlinks
  cleaned_symlinks = clean_stale_symlinks(options[:days])

  # Clean orphaned worktrees
  cleaned_worktrees = clean_orphaned_worktrees(options[:days])

  if cleaned_symlinks.positive? || cleaned_worktrees.positive?
    say("Cleaned #{cleaned_symlinks} stale symlink#{"s" unless cleaned_symlinks == 1}", :green)
    say("Cleaned #{cleaned_worktrees} orphaned worktree#{"s" unless cleaned_worktrees == 1}", :green)
  else
    say("No cleanup needed", :green)
  end
end

#generateObject



318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
# File 'lib/claude_swarm/cli.rb', line 318

def generate
  # Check if claude command exists
  begin
    system!("command -v claude > /dev/null 2>&1")
  rescue Error
    error("Claude CLI is not installed or not in PATH")
    error("To install Claude CLI, visit: https://docs.anthropic.com/en/docs/claude-code")
    exit(1)
  end

  # Read README for context about claude-swarm capabilities
  readme_path = File.join(__dir__, "../../README.md")
  readme_content = File.exist?(readme_path) ? File.read(readme_path) : ""

  # Build the pre-prompt
  preprompt = build_generation_prompt(readme_content, options[:output])

  # Launch Claude in interactive mode with the initial prompt
  cmd = [
    "claude",
    "--model",
    options[:model],
    preprompt,
  ]

  # Execute and let the user take over
  exec(*cmd)
end

#initObject



239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
# File 'lib/claude_swarm/cli.rb', line 239

def init
  config_path = "claude-swarm.yml"

  if File.exist?(config_path) && !options[:force]
    error("Configuration file already exists: #{config_path}")
    error("Use --force to overwrite")
    exit(1)
  end

  template = <<~YAML
    version: 1
    swarm:
      name: "Swarm Name"
      main: lead_developer
      # before:  # Optional: commands to run before launching swarm (executed in sequence)
      #   - "echo 'Setting up environment...'"
      #   - "npm install"
      #   - "docker-compose up -d"
      instances:
        lead_developer:
          description: "Lead developer who coordinates the team and makes architectural decisions"
          directory: .
          model: sonnet
          prompt: |
            You are the lead developer coordinating the team
          allowed_tools: [Read, Edit, Bash, Write]
          # connections: [frontend_dev, backend_dev]

        # Example instances (uncomment and modify as needed):

        # frontend_dev:
        #   description: "Frontend developer specializing in React and modern web technologies"
        #   directory: ./frontend
        #   model: sonnet
        #   prompt: |
        #     You specialize in frontend development with React, TypeScript, and modern web technologies
        #   allowed_tools: [Read, Edit, Write, "Bash(npm:*)", "Bash(yarn:*)", "Bash(pnpm:*)"]

        # backend_dev:
        #   description: |
        #     Backend developer focusing on APIs, databases, and server architecture
        #   directory: ../other-app/backend
        #   model: sonnet
        #   prompt: |
        #     You specialize in backend development, APIs, databases, and server architecture
        #   allowed_tools: [Read, Edit, Write, Bash]

        # devops_engineer:
        #   description: "DevOps engineer managing infrastructure, CI/CD, and deployments"
        #   directory: .
        #   model: sonnet
        #   prompt: |
        #     You specialize in infrastrujcture, CI/CD, containerization, and deployment
        #   allowed_tools: [Read, Edit, Write, "Bash(docker:*)", "Bash(kubectl:*)", "Bash(terraform:*)"]

        # qa_engineer:
        #   description: "QA engineer ensuring quality through comprehensive testing"
        #   directory: ./tests
        #   model: sonnet
        #   prompt: |
        #     You specialize in testing, quality assurance, and test automation
        #   allowed_tools: [Read, Edit, Write, Bash]
  YAML

  File.write(config_path, template)
  say("Created #{config_path}", :green)
  say("Edit this file to configure your swarm, then run 'claude-swarm' to start")
end

#list_sessionsObject



426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
# File 'lib/claude_swarm/cli.rb', line 426

def list_sessions
  sessions_dir = ClaudeSwarm.joined_sessions_dir
  unless Dir.exist?(sessions_dir)
    say("No sessions found", :yellow)
    return
  end

  # Find all sessions with MCP configs
  sessions = []
  Dir.glob("#{sessions_dir}/*/*/*.mcp.json").each do |mcp_path|
    session_path = File.dirname(mcp_path)
    session_id = File.basename(session_path)
    project_name = File.basename(File.dirname(session_path))

    # Skip if we've already processed this session
    next if sessions.any? { |s| s[:path] == session_path }

    # Try to load session info
    config_file = File.join(session_path, "config.yml")
    next unless File.exist?(config_file)

    # Load the config to get swarm info
    begin
      config_data = YamlLoader.load_config_file(config_file)
      swarm_name = config_data.dig("swarm", "name") || "Unknown"
      main_instance = config_data.dig("swarm", "main") || "Unknown"
    rescue ClaudeSwarm::Error => e
      # Warn about corrupted config files but continue
      say_error("⚠️ Skipping session #{session_id} - #{e.message}")
      next
    end

    mcp_files = Dir.glob(File.join(session_path, "*.mcp.json"))

    # Get creation time from directory
    created_at = File.stat(session_path).ctime

    sessions << {
      path: session_path,
      id: session_id,
      project: project_name,
      created_at: created_at,
      main_instance: main_instance,
      instances_count: mcp_files.size,
      swarm_name: swarm_name,
      config_path: config_file,
    }
  rescue StandardError
    # Skip invalid manifests
    next
  end

  if sessions.empty?
    say("No sessions found", :yellow)
    return
  end

  # Sort by creation time (newest first)
  sessions.sort_by! { |s| -s[:created_at].to_i }
  sessions = sessions.first(options[:limit])

  # Display sessions
  say("\nAvailable sessions (newest first):\n", :bold)
  sessions.each do |session|
    say("\n#{session[:project]}/#{session[:id]}", :green)
    say("  Created: #{session[:created_at].strftime("%Y-%m-%d %H:%M:%S")}")
    say("  Main: #{session[:main_instance]}")
    say("  Instances: #{session[:instances_count]}")
    say("  Swarm: #{session[:swarm_name]}")
    say("  Config: #{session[:config_path]}", :cyan)
  end

  say("\nTo resume a session, run:", :bold)
  say("  claude-swarm restore <session-id>", :cyan)
end

#mcp_serveObject



179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
# File 'lib/claude_swarm/cli.rb', line 179

def mcp_serve
  # Validate reasoning_effort if provided
  if options[:reasoning_effort]
    # Only validate if provider is openai (or not specified, since it could be set elsewhere)
    if options[:provider] && options[:provider] != "openai"
      error("reasoning_effort is only supported for OpenAI models")
      exit(1)
    end

    # Validate the value
    unless ClaudeSwarm::Configuration::VALID_REASONING_EFFORTS.include?(options[:reasoning_effort])
      error("reasoning_effort must be 'low', 'medium', or 'high'")
      exit(1)
    end
  end

  instance_config = {
    name: options[:name],
    directory: options[:directory],
    directories: options[:directories] || [options[:directory]],
    model: options[:model],
    prompt: options[:prompt],
    description: options[:description],
    allowed_tools: options[:allowed_tools] || [],
    disallowed_tools: options[:disallowed_tools] || [],
    connections: options[:connections] || [],
    mcp_config_path: options[:mcp_config_path],
    vibe: options[:vibe] || false,
    instance_id: options[:instance_id],
    claude_session_id: options[:claude_session_id],
    provider: options[:provider],
    temperature: options[:temperature],
    api_version: options[:api_version],
    openai_token_env: options[:openai_token_env],
    base_url: options[:base_url],
    reasoning_effort: options[:reasoning_effort],
    zdr: options[:zdr],
  }

  begin
    server = ClaudeMcpServer.new(
      instance_config,
      calling_instance: options[:calling_instance],
      calling_instance_id: options[:calling_instance_id],
      debug: options[:debug],
    )
    server.start
  rescue StandardError => e
    error("Error starting MCP server: #{e.message}")
    error(e.backtrace.join("\n")) if options[:debug]
    exit(1)
  end
end

#psObject



353
354
355
# File 'lib/claude_swarm/cli.rb', line 353

def ps
  Commands::Ps.new.execute
end

#restore(session_id) ⇒ Object



384
385
386
# File 'lib/claude_swarm/cli.rb', line 384

def restore(session_id)
  restore_session(session_id)
end

#show(session_id) ⇒ Object



358
359
360
# File 'lib/claude_swarm/cli.rb', line 358

def show(session_id)
  Commands::Show.new.execute(session_id)
end

#start(config_file = nil) ⇒ Object



45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
# File 'lib/claude_swarm/cli.rb', line 45

def start(config_file = nil)
  # Determine root directory for this session
  root_dir = File.expand_path(options[:root_dir] || Dir.pwd)

  # Resolve config path relative to root directory
  config_path = config_file || "claude-swarm.yml"
  config_path = File.expand_path(config_path, root_dir)

  unless File.exist?(config_path)
    error("Configuration file not found: #{config_path}")
    exit(1)
  end

  say("Starting Claude Swarm from #{config_path}...") unless options[:prompt]

  # Validate stream_logs option
  if options[:stream_logs] && !options[:prompt]
    error("--stream-logs can only be used with -p/--prompt")
    exit(1)
  end

  # Validate conflicting options
  if options[:prompt] && options[:interactive]
    error("Cannot use both -p/--prompt and -i/--interactive")
    exit(1)
  end

  begin
    config = Configuration.new(config_path, base_dir: root_dir, options: options)
    generator = McpGenerator.new(config, vibe: options[:vibe])
    orchestrator = Orchestrator.new(
      config,
      generator,
      vibe: options[:vibe],
      prompt: options[:prompt],
      interactive_prompt: options[:interactive],
      stream_logs: options[:stream_logs],
      debug: options[:debug],
      worktree: options[:worktree],
      session_id: options[:session_id],
    )
    orchestrator.start
  rescue Error => e
    error(e.message)
    exit(1)
  rescue StandardError => e
    error("Unexpected error: #{e.message}")
    error(e.backtrace.join("\n")) if options[:verbose]
    exit(1)
  end
end

#versionObject



348
349
350
# File 'lib/claude_swarm/cli.rb', line 348

def version
  say("Claude Swarm #{VERSION}")
end

#watch(session_id) ⇒ Object



394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
# File 'lib/claude_swarm/cli.rb', line 394

def watch(session_id)
  # Find session path
  run_symlink = ClaudeSwarm.joined_run_dir(session_id)
  session_path = if File.symlink?(run_symlink)
    File.readlink(run_symlink)
  else
    # Search in sessions directory
    Dir.glob(ClaudeSwarm.joined_sessions_dir("*", "*")).find do |path|
      File.basename(path) == session_id
    end
  end

  unless session_path && Dir.exist?(session_path)
    error("Session not found: #{session_id}")
    exit(1)
  end

  log_file = File.join(session_path, "session.log")
  unless File.exist?(log_file)
    error("Log file not found for session: #{session_id}")
    exit(1)
  end

  exec("tail", "-f", "-n", options[:lines].to_s, log_file)
end