zwarm 0.1.0__py3-none-any.whl

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.
zwarm/cli/main.py ADDED
@@ -0,0 +1,534 @@
1
+ """
2
+ CLI for zwarm orchestration.
3
+
4
+ Commands:
5
+ - zwarm orchestrate: Start an orchestrator session
6
+ - zwarm exec: Run a single executor directly (for testing)
7
+ - zwarm status: Show current state
8
+ - zwarm history: Show event history
9
+ - zwarm configs: Manage configurations
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import sys
16
+ from enum import Enum
17
+ from pathlib import Path
18
+ from typing import Annotated, Optional
19
+
20
+ import typer
21
+ from rich import print as rprint
22
+ from rich.console import Console
23
+ from rich.panel import Panel
24
+ from rich.table import Table
25
+ from rich.text import Text
26
+
27
+ # Create console for rich output
28
+ console = Console()
29
+
30
+
31
+ def _resolve_task(task: str | None, task_file: Path | None) -> str | None:
32
+ """
33
+ Resolve task from multiple sources (priority order):
34
+ 1. --task flag
35
+ 2. --task-file flag
36
+ 3. stdin (if not a tty)
37
+ """
38
+ # Direct task takes priority
39
+ if task:
40
+ return task
41
+
42
+ # Then file
43
+ if task_file:
44
+ if not task_file.exists():
45
+ console.print(f"[red]Error:[/] Task file not found: {task_file}")
46
+ raise typer.Exit(1)
47
+ return task_file.read_text().strip()
48
+
49
+ # Finally stdin (only if piped, not interactive)
50
+ if not sys.stdin.isatty():
51
+ stdin_content = sys.stdin.read().strip()
52
+ if stdin_content:
53
+ return stdin_content
54
+
55
+ return None
56
+
57
+ # Main app with rich help
58
+ app = typer.Typer(
59
+ name="zwarm",
60
+ help="""
61
+ [bold cyan]zwarm[/] - Multi-Agent CLI Orchestration Research Platform
62
+
63
+ [bold]DESCRIPTION[/]
64
+ Orchestrate multiple CLI coding agents (Codex, Claude Code) with
65
+ delegation, conversation, and trajectory alignment (watchers).
66
+
67
+ [bold]QUICK START[/]
68
+ [dim]# Test an executor directly[/]
69
+ $ zwarm exec --task "What is 2+2?"
70
+
71
+ [dim]# Run the orchestrator[/]
72
+ $ zwarm orchestrate --task "Build a hello world function"
73
+
74
+ [dim]# Check state after running[/]
75
+ $ zwarm status
76
+
77
+ [bold]COMMANDS[/]
78
+ [cyan]orchestrate[/] Start orchestrator to delegate tasks to executors
79
+ [cyan]exec[/] Run a single executor directly (for testing)
80
+ [cyan]status[/] Show current state (sessions, tasks, events)
81
+ [cyan]history[/] Show event history log
82
+ [cyan]configs[/] Manage configuration files
83
+
84
+ [bold]CONFIGURATION[/]
85
+ Create [cyan]config.toml[/] or use [cyan]--config[/] flag with YAML files.
86
+ See [cyan]zwarm configs list[/] for available configurations.
87
+
88
+ [bold]ADAPTERS[/]
89
+ [cyan]codex_mcp[/] Codex via MCP server (sync conversations)
90
+ [cyan]claude_code[/] Claude Code CLI
91
+
92
+ [bold]WATCHERS[/] (trajectory aligners)
93
+ [cyan]progress[/] Detects stuck/spinning agents
94
+ [cyan]budget[/] Monitors step/session limits
95
+ [cyan]scope[/] Detects scope creep
96
+ [cyan]pattern[/] Custom regex pattern matching
97
+ [cyan]quality[/] Code quality checks
98
+ """,
99
+ rich_markup_mode="rich",
100
+ no_args_is_help=True,
101
+ add_completion=False,
102
+ )
103
+
104
+ # Configs subcommand group
105
+ configs_app = typer.Typer(
106
+ name="configs",
107
+ help="""
108
+ Manage zwarm configurations.
109
+
110
+ [bold]SUBCOMMANDS[/]
111
+ [cyan]list[/] List available configuration files
112
+ [cyan]show[/] Display a configuration file's contents
113
+ """,
114
+ rich_markup_mode="rich",
115
+ no_args_is_help=True,
116
+ )
117
+ app.add_typer(configs_app, name="configs")
118
+
119
+
120
+ class AdapterType(str, Enum):
121
+ codex_mcp = "codex_mcp"
122
+ claude_code = "claude_code"
123
+
124
+
125
+ class ModeType(str, Enum):
126
+ sync = "sync"
127
+ async_ = "async"
128
+
129
+
130
+ @app.command()
131
+ def orchestrate(
132
+ task: Annotated[Optional[str], typer.Option("--task", "-t", help="The task to accomplish")] = None,
133
+ task_file: Annotated[Optional[Path], typer.Option("--task-file", "-f", help="Read task from file")] = None,
134
+ config: Annotated[Optional[Path], typer.Option("--config", "-c", help="Path to config YAML")] = None,
135
+ overrides: Annotated[Optional[list[str]], typer.Option("--set", help="Override config (key=value)")] = None,
136
+ working_dir: Annotated[Path, typer.Option("--working-dir", "-w", help="Working directory")] = Path("."),
137
+ resume: Annotated[bool, typer.Option("--resume", help="Resume from previous state")] = False,
138
+ max_steps: Annotated[Optional[int], typer.Option("--max-steps", help="Maximum orchestrator steps")] = None,
139
+ verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Show detailed output")] = False,
140
+ ):
141
+ """
142
+ Start an orchestrator session.
143
+
144
+ The orchestrator breaks down tasks and delegates to executor agents
145
+ (Codex, Claude Code). It can have sync conversations or fire-and-forget
146
+ async delegations.
147
+
148
+ [bold]Examples:[/]
149
+ [dim]# Simple task[/]
150
+ $ zwarm orchestrate --task "Add a logout button to the navbar"
151
+
152
+ [dim]# Task from file[/]
153
+ $ zwarm orchestrate -f task.md
154
+
155
+ [dim]# Task from stdin[/]
156
+ $ cat task.md | zwarm orchestrate
157
+ $ zwarm orchestrate < task.md
158
+
159
+ [dim]# With config file[/]
160
+ $ zwarm orchestrate -c configs/base.yaml --task "Refactor auth"
161
+
162
+ [dim]# Override settings[/]
163
+ $ zwarm orchestrate --task "Fix bug" --set executor.adapter=claude_code
164
+
165
+ [dim]# Resume interrupted session[/]
166
+ $ zwarm orchestrate --task "Continue work" --resume
167
+ """
168
+ from zwarm.orchestrator import build_orchestrator
169
+
170
+ # Resolve task from: --task, --task-file, or stdin
171
+ resolved_task = _resolve_task(task, task_file)
172
+ if not resolved_task:
173
+ console.print("[red]Error:[/] No task provided. Use --task, --task-file, or pipe from stdin.")
174
+ raise typer.Exit(1)
175
+
176
+ task = resolved_task
177
+
178
+ # Build overrides list
179
+ override_list = list(overrides or [])
180
+ if max_steps:
181
+ override_list.append(f"orchestrator.max_steps={max_steps}")
182
+
183
+ console.print(f"[bold]Starting orchestrator...[/]")
184
+ console.print(f" Task: {task}")
185
+ console.print(f" Working dir: {working_dir.absolute()}")
186
+ console.print()
187
+
188
+ # Output handler to show orchestrator messages
189
+ def output_handler(msg: str) -> None:
190
+ if msg.strip():
191
+ console.print(f"[dim][orchestrator][/] {msg}")
192
+
193
+ orchestrator = None
194
+ try:
195
+ orchestrator = build_orchestrator(
196
+ config_path=config,
197
+ task=task,
198
+ working_dir=working_dir.absolute(),
199
+ overrides=override_list,
200
+ resume=resume,
201
+ output_handler=output_handler,
202
+ )
203
+
204
+ if resume:
205
+ console.print(" [dim]Resuming from previous state...[/]")
206
+
207
+ # Run the orchestrator loop
208
+ console.print("[bold]--- Orchestrator running ---[/]\n")
209
+ result = orchestrator.run(task=task)
210
+
211
+ console.print(f"\n[bold green]--- Orchestrator finished ---[/]")
212
+ console.print(f" Steps: {result.get('steps', 'unknown')}")
213
+
214
+ # Show exit message if any
215
+ exit_msg = getattr(orchestrator, "_exit_message", "")
216
+ if exit_msg:
217
+ console.print(f" Exit: {exit_msg[:200]}")
218
+
219
+ # Save state for potential resume
220
+ orchestrator.save_state()
221
+
222
+ except KeyboardInterrupt:
223
+ console.print("\n\n[yellow]Interrupted.[/]")
224
+ if orchestrator:
225
+ orchestrator.save_state()
226
+ console.print("[dim]State saved. Use --resume to continue.[/]")
227
+ sys.exit(1)
228
+ except Exception as e:
229
+ console.print(f"\n[red]Error:[/] {e}")
230
+ if verbose:
231
+ console.print_exception()
232
+ sys.exit(1)
233
+
234
+
235
+ @app.command()
236
+ def exec(
237
+ task: Annotated[str, typer.Option("--task", "-t", help="Task to execute")],
238
+ adapter: Annotated[AdapterType, typer.Option("--adapter", "-a", help="Executor adapter")] = AdapterType.codex_mcp,
239
+ mode: Annotated[ModeType, typer.Option("--mode", "-m", help="Execution mode")] = ModeType.sync,
240
+ working_dir: Annotated[Path, typer.Option("--working-dir", "-w", help="Working directory")] = Path("."),
241
+ model: Annotated[Optional[str], typer.Option("--model", help="Model override")] = None,
242
+ ):
243
+ """
244
+ Run a single executor directly (for testing).
245
+
246
+ Useful for testing adapters without the full orchestrator loop.
247
+
248
+ [bold]Examples:[/]
249
+ [dim]# Test Codex[/]
250
+ $ zwarm exec --task "What is 2+2?"
251
+
252
+ [dim]# Test Claude Code[/]
253
+ $ zwarm exec -a claude_code --task "List files in current dir"
254
+
255
+ [dim]# Async mode[/]
256
+ $ zwarm exec --task "Build feature" --mode async
257
+ """
258
+ from zwarm.adapters.codex_mcp import CodexMCPAdapter
259
+ from zwarm.adapters.claude_code import ClaudeCodeAdapter
260
+
261
+ console.print(f"[bold]Running executor directly...[/]")
262
+ console.print(f" Adapter: [cyan]{adapter.value}[/]")
263
+ console.print(f" Mode: {mode.value}")
264
+ console.print(f" Task: {task}")
265
+
266
+ if adapter == AdapterType.codex_mcp:
267
+ executor = CodexMCPAdapter()
268
+ elif adapter == AdapterType.claude_code:
269
+ executor = ClaudeCodeAdapter(model=model)
270
+ else:
271
+ console.print(f"[red]Unknown adapter:[/] {adapter}")
272
+ sys.exit(1)
273
+
274
+ async def run():
275
+ try:
276
+ session = await executor.start_session(
277
+ task=task,
278
+ working_dir=working_dir.absolute(),
279
+ mode=mode.value,
280
+ model=model,
281
+ )
282
+
283
+ console.print(f"\n[green]Session started:[/] {session.id[:8]}")
284
+
285
+ if mode == ModeType.sync:
286
+ response = session.messages[-1].content if session.messages else "(no response)"
287
+ console.print(f"\n[bold]Response:[/]\n{response}")
288
+
289
+ # Interactive loop for sync mode
290
+ while True:
291
+ try:
292
+ user_input = console.input("\n[dim]> (type message or 'exit')[/] ")
293
+ if user_input.lower() == "exit" or not user_input:
294
+ break
295
+
296
+ response = await executor.send_message(session, user_input)
297
+ console.print(f"\n[bold]Response:[/]\n{response}")
298
+ except KeyboardInterrupt:
299
+ break
300
+ else:
301
+ console.print("[dim]Async mode - session running in background.[/]")
302
+ console.print("Use 'zwarm status' to check progress.")
303
+
304
+ finally:
305
+ await executor.cleanup()
306
+
307
+ asyncio.run(run())
308
+
309
+
310
+ @app.command()
311
+ def status(
312
+ working_dir: Annotated[Path, typer.Option("--working-dir", "-w", help="Working directory")] = Path("."),
313
+ ):
314
+ """
315
+ Show current state (sessions, tasks, events).
316
+
317
+ Displays active sessions, pending tasks, and recent events
318
+ from the .zwarm state directory.
319
+
320
+ [bold]Example:[/]
321
+ $ zwarm status
322
+ """
323
+ from zwarm.core.state import StateManager
324
+
325
+ state_dir = working_dir / ".zwarm"
326
+ if not state_dir.exists():
327
+ console.print("[yellow]No zwarm state found in this directory.[/]")
328
+ console.print("[dim]Run 'zwarm orchestrate' to start.[/]")
329
+ return
330
+
331
+ state = StateManager(state_dir)
332
+ state.load()
333
+
334
+ # Sessions table
335
+ sessions = state.list_sessions()
336
+ console.print(f"\n[bold]Sessions[/] ({len(sessions)})")
337
+ if sessions:
338
+ table = Table(show_header=True, header_style="bold")
339
+ table.add_column("ID", style="dim")
340
+ table.add_column("Mode")
341
+ table.add_column("Status")
342
+ table.add_column("Task")
343
+
344
+ for s in sessions:
345
+ status_style = {"active": "green", "completed": "blue", "failed": "red"}.get(s.status.value, "white")
346
+ table.add_row(
347
+ s.id[:8],
348
+ s.mode.value,
349
+ f"[{status_style}]{s.status.value}[/]",
350
+ s.task_description[:50] + "..." if len(s.task_description) > 50 else s.task_description,
351
+ )
352
+ console.print(table)
353
+ else:
354
+ console.print(" [dim](none)[/]")
355
+
356
+ # Tasks table
357
+ tasks = state.list_tasks()
358
+ console.print(f"\n[bold]Tasks[/] ({len(tasks)})")
359
+ if tasks:
360
+ table = Table(show_header=True, header_style="bold")
361
+ table.add_column("ID", style="dim")
362
+ table.add_column("Status")
363
+ table.add_column("Description")
364
+
365
+ for t in tasks:
366
+ status_style = {"pending": "yellow", "in_progress": "cyan", "completed": "green", "failed": "red"}.get(t.status.value, "white")
367
+ table.add_row(
368
+ t.id[:8],
369
+ f"[{status_style}]{t.status.value}[/]",
370
+ t.description[:50] + "..." if len(t.description) > 50 else t.description,
371
+ )
372
+ console.print(table)
373
+ else:
374
+ console.print(" [dim](none)[/]")
375
+
376
+ # Recent events
377
+ events = state.get_events(limit=5)
378
+ console.print(f"\n[bold]Recent Events[/]")
379
+ if events:
380
+ for e in events:
381
+ console.print(f" [dim]{e.timestamp.strftime('%H:%M:%S')}[/] {e.kind}")
382
+ else:
383
+ console.print(" [dim](none)[/]")
384
+
385
+
386
+ @app.command()
387
+ def history(
388
+ working_dir: Annotated[Path, typer.Option("--working-dir", "-w", help="Working directory")] = Path("."),
389
+ kind: Annotated[Optional[str], typer.Option("--kind", "-k", help="Filter by event kind")] = None,
390
+ limit: Annotated[int, typer.Option("--limit", "-n", help="Number of events")] = 20,
391
+ ):
392
+ """
393
+ Show event history.
394
+
395
+ Displays the append-only event log with timestamps and details.
396
+
397
+ [bold]Examples:[/]
398
+ [dim]# Show last 20 events[/]
399
+ $ zwarm history
400
+
401
+ [dim]# Show more events[/]
402
+ $ zwarm history --limit 50
403
+
404
+ [dim]# Filter by kind[/]
405
+ $ zwarm history --kind session_started
406
+ """
407
+ from zwarm.core.state import StateManager
408
+
409
+ state_dir = working_dir / ".zwarm"
410
+ if not state_dir.exists():
411
+ console.print("[yellow]No zwarm state found.[/]")
412
+ return
413
+
414
+ state = StateManager(state_dir)
415
+ events = state.get_events(kind=kind, limit=limit)
416
+
417
+ console.print(f"\n[bold]Event History[/] (last {limit})\n")
418
+
419
+ if not events:
420
+ console.print("[dim]No events found.[/]")
421
+ return
422
+
423
+ table = Table(show_header=True, header_style="bold")
424
+ table.add_column("Time", style="dim")
425
+ table.add_column("Event")
426
+ table.add_column("Session/Task")
427
+ table.add_column("Details")
428
+
429
+ for e in events:
430
+ details = ""
431
+ if e.payload:
432
+ details = ", ".join(f"{k}={str(v)[:30]}" for k, v in list(e.payload.items())[:2])
433
+
434
+ table.add_row(
435
+ e.timestamp.strftime("%Y-%m-%d %H:%M:%S"),
436
+ e.kind,
437
+ (e.session_id or e.task_id or "-")[:8],
438
+ details[:60],
439
+ )
440
+
441
+ console.print(table)
442
+
443
+
444
+ @configs_app.command("list")
445
+ def configs_list(
446
+ config_dir: Annotated[Optional[Path], typer.Option("--dir", "-d", help="Directory to search")] = None,
447
+ ):
448
+ """
449
+ List available agent/experiment configuration files (YAML).
450
+
451
+ Note: config.toml is for user environment settings and is loaded
452
+ automatically - use YAML files for agent configurations.
453
+
454
+ [bold]Example:[/]
455
+ $ zwarm configs list
456
+ """
457
+ search_dirs = [
458
+ config_dir or Path.cwd(),
459
+ Path.cwd() / "configs",
460
+ Path.cwd() / ".zwarm",
461
+ ]
462
+
463
+ console.print("\n[bold]Available Configurations[/]\n")
464
+ found = False
465
+
466
+ for d in search_dirs:
467
+ if not d.exists():
468
+ continue
469
+ for pattern in ["*.yaml", "*.yml"]:
470
+ for f in d.glob(pattern):
471
+ found = True
472
+ try:
473
+ rel = f.relative_to(Path.cwd())
474
+ console.print(f" [cyan]{rel}[/]")
475
+ except ValueError:
476
+ console.print(f" [cyan]{f}[/]")
477
+
478
+ if not found:
479
+ console.print(" [dim]No configuration files found.[/]")
480
+ console.print("\n [dim]Create a YAML config in configs/ to get started.[/]")
481
+
482
+ # Check for config.toml and mention it
483
+ config_toml = Path.cwd() / "config.toml"
484
+ if config_toml.exists():
485
+ console.print(f"\n[dim]Environment: config.toml (loaded automatically)[/]")
486
+
487
+
488
+ @configs_app.command("show")
489
+ def configs_show(
490
+ config_path: Annotated[Path, typer.Argument(help="Path to configuration file")],
491
+ ):
492
+ """
493
+ Show a configuration file's contents.
494
+
495
+ Loads and displays the resolved configuration including
496
+ any inherited values from 'extends:' directives.
497
+
498
+ [bold]Example:[/]
499
+ $ zwarm configs show configs/base.yaml
500
+ """
501
+ from zwarm.core.config import load_config
502
+ import json
503
+
504
+ if not config_path.exists():
505
+ console.print(f"[red]File not found:[/] {config_path}")
506
+ raise typer.Exit(1)
507
+
508
+ try:
509
+ config = load_config(config_path=config_path)
510
+ console.print(f"\n[bold]Configuration:[/] {config_path}\n")
511
+ console.print_json(json.dumps(config.to_dict(), indent=2))
512
+ except Exception as e:
513
+ console.print(f"[red]Error loading config:[/] {e}")
514
+ raise typer.Exit(1)
515
+
516
+
517
+ @app.callback(invoke_without_command=True)
518
+ def main_callback(
519
+ ctx: typer.Context,
520
+ version: Annotated[bool, typer.Option("--version", "-V", help="Show version")] = False,
521
+ ):
522
+ """Main callback for version flag."""
523
+ if version:
524
+ console.print("[bold cyan]zwarm[/] version [green]0.1.0[/]")
525
+ raise typer.Exit()
526
+
527
+
528
+ def main():
529
+ """Entry point for the CLI."""
530
+ app()
531
+
532
+
533
+ if __name__ == "__main__":
534
+ main()
zwarm/core/__init__.py ADDED
File without changes