zwarm 1.1.1__py3-none-any.whl → 1.3.2__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/adapters/__init__.py +21 -0
- zwarm/adapters/claude_code.py +5 -3
- zwarm/adapters/codex_mcp.py +140 -12
- zwarm/adapters/registry.py +69 -0
- zwarm/adapters/test_codex_mcp.py +50 -0
- zwarm/adapters/test_registry.py +68 -0
- zwarm/cli/main.py +650 -27
- zwarm/core/config.py +23 -2
- zwarm/core/state.py +143 -12
- zwarm/orchestrator.py +47 -17
- zwarm/tools/delegation.py +115 -3
- {zwarm-1.1.1.dist-info → zwarm-1.3.2.dist-info}/METADATA +7 -5
- {zwarm-1.1.1.dist-info → zwarm-1.3.2.dist-info}/RECORD +15 -13
- {zwarm-1.1.1.dist-info → zwarm-1.3.2.dist-info}/WHEEL +0 -0
- {zwarm-1.1.1.dist-info → zwarm-1.3.2.dist-info}/entry_points.txt +0 -0
zwarm/cli/main.py
CHANGED
|
@@ -76,7 +76,7 @@ app = typer.Typer(
|
|
|
76
76
|
$ zwarm status
|
|
77
77
|
|
|
78
78
|
[bold]COMMANDS[/]
|
|
79
|
-
[cyan]init[/] Initialize zwarm (
|
|
79
|
+
[cyan]init[/] Initialize zwarm (creates .zwarm/ with config)
|
|
80
80
|
[cyan]reset[/] Reset state and optionally config files
|
|
81
81
|
[cyan]orchestrate[/] Start orchestrator to delegate tasks to executors
|
|
82
82
|
[cyan]exec[/] Run a single executor directly (for testing)
|
|
@@ -85,7 +85,8 @@ app = typer.Typer(
|
|
|
85
85
|
[cyan]configs[/] Manage configuration files
|
|
86
86
|
|
|
87
87
|
[bold]CONFIGURATION[/]
|
|
88
|
-
|
|
88
|
+
Config lives in [cyan].zwarm/config.toml[/] (created by init).
|
|
89
|
+
Use [cyan]--config[/] flag for YAML files.
|
|
89
90
|
See [cyan]zwarm configs list[/] for available configurations.
|
|
90
91
|
|
|
91
92
|
[bold]ADAPTERS[/]
|
|
@@ -140,6 +141,8 @@ def orchestrate(
|
|
|
140
141
|
resume: Annotated[bool, typer.Option("--resume", help="Resume from previous state")] = False,
|
|
141
142
|
max_steps: Annotated[Optional[int], typer.Option("--max-steps", help="Maximum orchestrator steps")] = None,
|
|
142
143
|
verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Show detailed output")] = False,
|
|
144
|
+
instance: Annotated[Optional[str], typer.Option("--instance", "-i", help="Instance ID (for isolation/resume)")] = None,
|
|
145
|
+
instance_name: Annotated[Optional[str], typer.Option("--name", "-n", help="Human-readable instance name")] = None,
|
|
143
146
|
):
|
|
144
147
|
"""
|
|
145
148
|
Start an orchestrator session.
|
|
@@ -148,6 +151,9 @@ def orchestrate(
|
|
|
148
151
|
(Codex, Claude Code). It can have sync conversations or fire-and-forget
|
|
149
152
|
async delegations.
|
|
150
153
|
|
|
154
|
+
Each run creates an isolated instance to prevent conflicts when running
|
|
155
|
+
multiple orchestrators in the same directory.
|
|
156
|
+
|
|
151
157
|
[bold]Examples:[/]
|
|
152
158
|
[dim]# Simple task[/]
|
|
153
159
|
$ zwarm orchestrate --task "Add a logout button to the navbar"
|
|
@@ -165,8 +171,14 @@ def orchestrate(
|
|
|
165
171
|
[dim]# Override settings[/]
|
|
166
172
|
$ zwarm orchestrate --task "Fix bug" --set executor.adapter=claude_code
|
|
167
173
|
|
|
168
|
-
[dim]#
|
|
169
|
-
$ zwarm orchestrate --task "
|
|
174
|
+
[dim]# Named instance (easier to track)[/]
|
|
175
|
+
$ zwarm orchestrate --task "Add tests" --name test-work
|
|
176
|
+
|
|
177
|
+
[dim]# Resume a specific instance[/]
|
|
178
|
+
$ zwarm orchestrate --resume --instance abc123
|
|
179
|
+
|
|
180
|
+
[dim]# List all instances[/]
|
|
181
|
+
$ zwarm instances
|
|
170
182
|
"""
|
|
171
183
|
from zwarm.orchestrator import build_orchestrator
|
|
172
184
|
|
|
@@ -186,6 +198,8 @@ def orchestrate(
|
|
|
186
198
|
console.print(f"[bold]Starting orchestrator...[/]")
|
|
187
199
|
console.print(f" Task: {task}")
|
|
188
200
|
console.print(f" Working dir: {working_dir.absolute()}")
|
|
201
|
+
if instance:
|
|
202
|
+
console.print(f" Instance: {instance}" + (f" ({instance_name})" if instance_name else ""))
|
|
189
203
|
console.print()
|
|
190
204
|
|
|
191
205
|
# Output handler to show orchestrator messages
|
|
@@ -202,11 +216,17 @@ def orchestrate(
|
|
|
202
216
|
overrides=override_list,
|
|
203
217
|
resume=resume,
|
|
204
218
|
output_handler=output_handler,
|
|
219
|
+
instance_id=instance,
|
|
220
|
+
instance_name=instance_name,
|
|
205
221
|
)
|
|
206
222
|
|
|
207
223
|
if resume:
|
|
208
224
|
console.print(" [dim]Resuming from previous state...[/]")
|
|
209
225
|
|
|
226
|
+
# Show instance ID if auto-generated
|
|
227
|
+
if orchestrator.instance_id and not instance:
|
|
228
|
+
console.print(f" [dim]Instance: {orchestrator.instance_id[:8]}[/]")
|
|
229
|
+
|
|
210
230
|
# Run the orchestrator loop
|
|
211
231
|
console.print("[bold]--- Orchestrator running ---[/]\n")
|
|
212
232
|
result = orchestrator.run(task=task)
|
|
@@ -222,16 +242,35 @@ def orchestrate(
|
|
|
222
242
|
# Save state for potential resume
|
|
223
243
|
orchestrator.save_state()
|
|
224
244
|
|
|
245
|
+
# Update instance status
|
|
246
|
+
if orchestrator.instance_id:
|
|
247
|
+
from zwarm.core.state import update_instance_status
|
|
248
|
+
update_instance_status(
|
|
249
|
+
orchestrator.instance_id,
|
|
250
|
+
"completed",
|
|
251
|
+
working_dir / ".zwarm",
|
|
252
|
+
)
|
|
253
|
+
console.print(f" [dim]Instance {orchestrator.instance_id[:8]} marked completed[/]")
|
|
254
|
+
|
|
225
255
|
except KeyboardInterrupt:
|
|
226
256
|
console.print("\n\n[yellow]Interrupted.[/]")
|
|
227
257
|
if orchestrator:
|
|
228
258
|
orchestrator.save_state()
|
|
229
259
|
console.print("[dim]State saved. Use --resume to continue.[/]")
|
|
260
|
+
# Keep instance as "active" so it can be resumed
|
|
230
261
|
sys.exit(1)
|
|
231
262
|
except Exception as e:
|
|
232
263
|
console.print(f"\n[red]Error:[/] {e}")
|
|
233
264
|
if verbose:
|
|
234
265
|
console.print_exception()
|
|
266
|
+
# Update instance status to failed
|
|
267
|
+
if orchestrator and orchestrator.instance_id:
|
|
268
|
+
from zwarm.core.state import update_instance_status
|
|
269
|
+
update_instance_status(
|
|
270
|
+
orchestrator.instance_id,
|
|
271
|
+
"failed",
|
|
272
|
+
working_dir / ".zwarm",
|
|
273
|
+
)
|
|
235
274
|
sys.exit(1)
|
|
236
275
|
|
|
237
276
|
|
|
@@ -258,20 +297,17 @@ def exec(
|
|
|
258
297
|
[dim]# Async mode[/]
|
|
259
298
|
$ zwarm exec --task "Build feature" --mode async
|
|
260
299
|
"""
|
|
261
|
-
from zwarm.adapters
|
|
262
|
-
from zwarm.adapters.claude_code import ClaudeCodeAdapter
|
|
300
|
+
from zwarm.adapters import get_adapter
|
|
263
301
|
|
|
264
302
|
console.print(f"[bold]Running executor directly...[/]")
|
|
265
303
|
console.print(f" Adapter: [cyan]{adapter.value}[/]")
|
|
266
304
|
console.print(f" Mode: {mode.value}")
|
|
267
305
|
console.print(f" Task: {task}")
|
|
268
306
|
|
|
269
|
-
|
|
270
|
-
executor =
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
else:
|
|
274
|
-
console.print(f"[red]Unknown adapter:[/] {adapter}")
|
|
307
|
+
try:
|
|
308
|
+
executor = get_adapter(adapter.value, model=model)
|
|
309
|
+
except ValueError as e:
|
|
310
|
+
console.print(f"[red]Error:[/] {e}")
|
|
275
311
|
sys.exit(1)
|
|
276
312
|
|
|
277
313
|
async def run():
|
|
@@ -386,6 +422,63 @@ def status(
|
|
|
386
422
|
console.print(" [dim](none)[/]")
|
|
387
423
|
|
|
388
424
|
|
|
425
|
+
@app.command()
|
|
426
|
+
def instances(
|
|
427
|
+
working_dir: Annotated[Path, typer.Option("--working-dir", "-w", help="Working directory")] = Path("."),
|
|
428
|
+
all_instances: Annotated[bool, typer.Option("--all", "-a", help="Show all instances (including completed)")] = False,
|
|
429
|
+
):
|
|
430
|
+
"""
|
|
431
|
+
List all orchestrator instances.
|
|
432
|
+
|
|
433
|
+
Shows instances that have been run in this directory. Use --all to include
|
|
434
|
+
completed instances.
|
|
435
|
+
|
|
436
|
+
[bold]Examples:[/]
|
|
437
|
+
[dim]# List active instances[/]
|
|
438
|
+
$ zwarm instances
|
|
439
|
+
|
|
440
|
+
[dim]# List all instances[/]
|
|
441
|
+
$ zwarm instances --all
|
|
442
|
+
"""
|
|
443
|
+
from zwarm.core.state import list_instances as get_instances
|
|
444
|
+
|
|
445
|
+
state_dir = working_dir / ".zwarm"
|
|
446
|
+
all_inst = get_instances(state_dir)
|
|
447
|
+
|
|
448
|
+
if not all_inst:
|
|
449
|
+
console.print("[dim]No instances found.[/]")
|
|
450
|
+
console.print("[dim]Run 'zwarm orchestrate' to start a new instance.[/]")
|
|
451
|
+
return
|
|
452
|
+
|
|
453
|
+
# Filter if not showing all
|
|
454
|
+
if not all_instances:
|
|
455
|
+
all_inst = [i for i in all_inst if i.get("status") == "active"]
|
|
456
|
+
|
|
457
|
+
if not all_inst:
|
|
458
|
+
console.print("[dim]No active instances. Use --all to see completed ones.[/]")
|
|
459
|
+
return
|
|
460
|
+
|
|
461
|
+
console.print(f"[bold]Instances[/] ({len(all_inst)} total)\n")
|
|
462
|
+
|
|
463
|
+
for inst in all_inst:
|
|
464
|
+
status = inst.get("status", "unknown")
|
|
465
|
+
status_icon = {"active": "[green]●[/]", "completed": "[dim]✓[/]", "failed": "[red]✗[/]"}.get(status, "[dim]?[/]")
|
|
466
|
+
|
|
467
|
+
inst_id = inst.get("id", "unknown")[:8]
|
|
468
|
+
name = inst.get("name", "")
|
|
469
|
+
task = (inst.get("task") or "")[:60]
|
|
470
|
+
updated = inst.get("updated_at", "")[:19] if inst.get("updated_at") else ""
|
|
471
|
+
|
|
472
|
+
console.print(f" {status_icon} [bold]{inst_id}[/]" + (f" ({name})" if name and name != inst_id else ""))
|
|
473
|
+
if task:
|
|
474
|
+
console.print(f" [dim]{task}[/]")
|
|
475
|
+
if updated:
|
|
476
|
+
console.print(f" [dim]Updated: {updated}[/]")
|
|
477
|
+
console.print()
|
|
478
|
+
|
|
479
|
+
console.print("[dim]Use --instance <id> with 'orchestrate --resume' to resume an instance.[/]")
|
|
480
|
+
|
|
481
|
+
|
|
389
482
|
@app.command()
|
|
390
483
|
def history(
|
|
391
484
|
working_dir: Annotated[Path, typer.Option("--working-dir", "-w", help="Working directory")] = Path("."),
|
|
@@ -482,10 +575,13 @@ def configs_list(
|
|
|
482
575
|
console.print(" [dim]No configuration files found.[/]")
|
|
483
576
|
console.print("\n [dim]Create a YAML config in configs/ to get started.[/]")
|
|
484
577
|
|
|
485
|
-
# Check for config.toml and mention it
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
578
|
+
# Check for config.toml and mention it (check both locations)
|
|
579
|
+
new_config = Path.cwd() / ".zwarm" / "config.toml"
|
|
580
|
+
legacy_config = Path.cwd() / "config.toml"
|
|
581
|
+
if new_config.exists():
|
|
582
|
+
console.print(f"\n[dim]Environment: .zwarm/config.toml (loaded automatically)[/]")
|
|
583
|
+
elif legacy_config.exists():
|
|
584
|
+
console.print(f"\n[dim]Environment: config.toml (legacy location, loaded automatically)[/]")
|
|
489
585
|
|
|
490
586
|
|
|
491
587
|
@configs_app.command("show")
|
|
@@ -530,9 +626,9 @@ def init(
|
|
|
530
626
|
Run this once per project to set up zwarm.
|
|
531
627
|
|
|
532
628
|
[bold]Creates:[/]
|
|
533
|
-
[cyan]
|
|
534
|
-
[cyan].zwarm/[/]
|
|
535
|
-
[cyan]zwarm.yaml[/]
|
|
629
|
+
[cyan].zwarm/[/] State directory for sessions and events
|
|
630
|
+
[cyan].zwarm/config.toml[/] Runtime settings (weave, adapter, watchers)
|
|
631
|
+
[cyan]zwarm.yaml[/] Project config (optional, with --with-project)
|
|
536
632
|
|
|
537
633
|
[bold]Examples:[/]
|
|
538
634
|
[dim]# Interactive setup[/]
|
|
@@ -546,13 +642,25 @@ def init(
|
|
|
546
642
|
"""
|
|
547
643
|
console.print("\n[bold cyan]zwarm init[/] - Initialize zwarm configuration\n")
|
|
548
644
|
|
|
549
|
-
config_toml_path = working_dir / "config.toml"
|
|
550
|
-
zwarm_yaml_path = working_dir / "zwarm.yaml"
|
|
551
645
|
state_dir = working_dir / ".zwarm"
|
|
646
|
+
config_toml_path = state_dir / "config.toml"
|
|
647
|
+
zwarm_yaml_path = working_dir / "zwarm.yaml"
|
|
648
|
+
|
|
649
|
+
# Check for existing config (also check old location for migration)
|
|
650
|
+
old_config_path = working_dir / "config.toml"
|
|
651
|
+
if old_config_path.exists() and not config_toml_path.exists():
|
|
652
|
+
console.print(f"[yellow]Note:[/] Found config.toml in project root.")
|
|
653
|
+
console.print(f" Config now lives in .zwarm/config.toml")
|
|
654
|
+
if not non_interactive:
|
|
655
|
+
migrate = typer.confirm(" Move to new location?", default=True)
|
|
656
|
+
if migrate:
|
|
657
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
658
|
+
old_config_path.rename(config_toml_path)
|
|
659
|
+
console.print(f" [green]✓[/] Moved config.toml to .zwarm/")
|
|
552
660
|
|
|
553
661
|
# Check for existing files
|
|
554
662
|
if config_toml_path.exists():
|
|
555
|
-
console.print(f"[yellow]Warning:[/] config.toml already exists")
|
|
663
|
+
console.print(f"[yellow]Warning:[/] .zwarm/config.toml already exists")
|
|
556
664
|
if not non_interactive:
|
|
557
665
|
overwrite = typer.confirm("Overwrite?", default=False)
|
|
558
666
|
if not overwrite:
|
|
@@ -625,7 +733,7 @@ def init(
|
|
|
625
733
|
(state_dir / "orchestrator").mkdir(exist_ok=True)
|
|
626
734
|
console.print(f" [green]✓[/] Created .zwarm/")
|
|
627
735
|
|
|
628
|
-
# Create config.toml
|
|
736
|
+
# Create config.toml inside .zwarm/
|
|
629
737
|
if config_toml_path:
|
|
630
738
|
toml_content = _generate_config_toml(
|
|
631
739
|
weave_project=weave_project,
|
|
@@ -633,7 +741,7 @@ def init(
|
|
|
633
741
|
watchers=watchers_enabled,
|
|
634
742
|
)
|
|
635
743
|
config_toml_path.write_text(toml_content)
|
|
636
|
-
console.print(f" [green]✓[/] Created config.toml")
|
|
744
|
+
console.print(f" [green]✓[/] Created .zwarm/config.toml")
|
|
637
745
|
|
|
638
746
|
# Create zwarm.yaml
|
|
639
747
|
if create_project_config:
|
|
@@ -790,7 +898,8 @@ def reset(
|
|
|
790
898
|
console.print("\n[bold cyan]zwarm reset[/] - Reset zwarm state\n")
|
|
791
899
|
|
|
792
900
|
state_dir = working_dir / ".zwarm"
|
|
793
|
-
config_toml_path =
|
|
901
|
+
config_toml_path = state_dir / "config.toml" # New location
|
|
902
|
+
old_config_toml_path = working_dir / "config.toml" # Legacy location
|
|
794
903
|
zwarm_yaml_path = working_dir / "zwarm.yaml"
|
|
795
904
|
|
|
796
905
|
# Expand --all flag
|
|
@@ -803,8 +912,12 @@ def reset(
|
|
|
803
912
|
to_delete = []
|
|
804
913
|
if state and state_dir.exists():
|
|
805
914
|
to_delete.append((".zwarm/", state_dir))
|
|
806
|
-
|
|
807
|
-
|
|
915
|
+
# Config: check both new and legacy locations (but skip if state already deletes it)
|
|
916
|
+
if config and not state:
|
|
917
|
+
if config_toml_path.exists():
|
|
918
|
+
to_delete.append((".zwarm/config.toml", config_toml_path))
|
|
919
|
+
if old_config_toml_path.exists():
|
|
920
|
+
to_delete.append(("config.toml (legacy)", old_config_toml_path))
|
|
808
921
|
if project and zwarm_yaml_path.exists():
|
|
809
922
|
to_delete.append(("zwarm.yaml", zwarm_yaml_path))
|
|
810
923
|
|
|
@@ -975,6 +1088,516 @@ def clean(
|
|
|
975
1088
|
console.print(f"\n[bold green]Cleanup complete.[/] Killed {killed}, failed {failed}.\n")
|
|
976
1089
|
|
|
977
1090
|
|
|
1091
|
+
@app.command()
|
|
1092
|
+
def interactive(
|
|
1093
|
+
default_adapter: Annotated[AdapterType, typer.Option("--adapter", "-a", help="Default executor adapter")] = AdapterType.codex_mcp,
|
|
1094
|
+
default_dir: Annotated[Path, typer.Option("--dir", "-d", help="Default working directory")] = Path("."),
|
|
1095
|
+
model: Annotated[Optional[str], typer.Option("--model", help="Default model override")] = None,
|
|
1096
|
+
state_dir: Annotated[Path, typer.Option("--state-dir", help="State directory for persistence")] = Path(".zwarm"),
|
|
1097
|
+
):
|
|
1098
|
+
"""
|
|
1099
|
+
Universal multi-agent CLI for commanding coding agents.
|
|
1100
|
+
|
|
1101
|
+
Spawn multiple agents (Codex, Claude Code) across different directories,
|
|
1102
|
+
manage them interactively, and view their outputs. You are the orchestrator.
|
|
1103
|
+
|
|
1104
|
+
[bold]Commands:[/]
|
|
1105
|
+
[cyan]spawn[/] "task" [opts] Start a session (see spawn --help)
|
|
1106
|
+
[cyan]ls[/] / [cyan]list[/] Dashboard of all sessions
|
|
1107
|
+
[cyan]?[/] / [cyan]check[/] ID Check session status & response
|
|
1108
|
+
[cyan]c[/] / [cyan]converse[/] ID "msg" Continue sync conversation
|
|
1109
|
+
[cyan]view[/] ID Open session output in $EDITOR
|
|
1110
|
+
[cyan]kill[/] ID Stop a session
|
|
1111
|
+
[cyan]killall[/] Stop all running sessions
|
|
1112
|
+
[cyan]refresh[/] Poll all async sessions
|
|
1113
|
+
[cyan]q[/] / [cyan]quit[/] Exit
|
|
1114
|
+
|
|
1115
|
+
[bold]Spawn Options:[/]
|
|
1116
|
+
spawn "task" --dir ~/project --adapter claude_code --async
|
|
1117
|
+
|
|
1118
|
+
[bold]Examples:[/]
|
|
1119
|
+
$ zwarm interactive
|
|
1120
|
+
> spawn "Build auth module" --dir ~/api --async
|
|
1121
|
+
> spawn "Fix tests" --dir ~/api --async
|
|
1122
|
+
> spawn "Add docs" --dir ~/docs
|
|
1123
|
+
> ls
|
|
1124
|
+
> ? abc123
|
|
1125
|
+
> view abc123
|
|
1126
|
+
"""
|
|
1127
|
+
from zwarm.adapters import get_adapter, list_adapters
|
|
1128
|
+
from zwarm.core.models import ConversationSession
|
|
1129
|
+
import argparse
|
|
1130
|
+
import tempfile
|
|
1131
|
+
import subprocess as sp
|
|
1132
|
+
|
|
1133
|
+
console.print("\n[bold cyan]zwarm interactive[/] - Universal Multi-Agent CLI\n")
|
|
1134
|
+
console.print(f" Default adapter: [cyan]{default_adapter.value}[/]")
|
|
1135
|
+
console.print(f" Default dir: {default_dir.absolute()}")
|
|
1136
|
+
console.print(f" Available adapters: {', '.join(list_adapters())}")
|
|
1137
|
+
console.print("\n Type [cyan]help[/] for commands, [cyan]quit[/] to exit.\n")
|
|
1138
|
+
|
|
1139
|
+
# Adapter cache (lazy-loaded per adapter type)
|
|
1140
|
+
adapters: dict[str, Any] = {}
|
|
1141
|
+
|
|
1142
|
+
def get_or_create_adapter(adapter_name: str, adapter_model: str | None = None) -> Any:
|
|
1143
|
+
"""Get cached adapter or create new one."""
|
|
1144
|
+
key = f"{adapter_name}:{adapter_model or 'default'}"
|
|
1145
|
+
if key not in adapters:
|
|
1146
|
+
adapters[key] = get_adapter(adapter_name, model=adapter_model)
|
|
1147
|
+
return adapters[key]
|
|
1148
|
+
|
|
1149
|
+
# Session tracking with metadata
|
|
1150
|
+
sessions: dict[str, ConversationSession] = {}
|
|
1151
|
+
session_dirs: dict[str, Path] = {} # session_id -> working_dir
|
|
1152
|
+
session_adapters: dict[str, str] = {} # session_id -> adapter_name
|
|
1153
|
+
|
|
1154
|
+
# Persistence directory
|
|
1155
|
+
interactive_state_dir = state_dir / "interactive"
|
|
1156
|
+
interactive_state_dir.mkdir(parents=True, exist_ok=True)
|
|
1157
|
+
|
|
1158
|
+
def short_id(session_id: str) -> str:
|
|
1159
|
+
"""Get short display ID."""
|
|
1160
|
+
return session_id[:8]
|
|
1161
|
+
|
|
1162
|
+
def find_session(query: str) -> tuple[ConversationSession | None, str | None]:
|
|
1163
|
+
"""Find session by full or partial ID. Returns (session, full_id)."""
|
|
1164
|
+
if query in sessions:
|
|
1165
|
+
return sessions[query], query
|
|
1166
|
+
for sid, session in sessions.items():
|
|
1167
|
+
if sid.startswith(query):
|
|
1168
|
+
return session, sid
|
|
1169
|
+
return None, None
|
|
1170
|
+
|
|
1171
|
+
def save_session_output(session: ConversationSession) -> Path:
|
|
1172
|
+
"""Save session output to file for viewing."""
|
|
1173
|
+
session_dir = interactive_state_dir / short_id(session.id)
|
|
1174
|
+
session_dir.mkdir(exist_ok=True)
|
|
1175
|
+
|
|
1176
|
+
output_file = session_dir / "output.txt"
|
|
1177
|
+
with open(output_file, "w") as f:
|
|
1178
|
+
f.write(f"Session: {session.id}\n")
|
|
1179
|
+
f.write(f"Task: {session.task_description}\n")
|
|
1180
|
+
f.write(f"Adapter: {session.adapter}\n")
|
|
1181
|
+
f.write(f"Model: {session.model}\n")
|
|
1182
|
+
f.write(f"Status: {session.status.value}\n")
|
|
1183
|
+
f.write(f"Mode: {session.mode.value}\n")
|
|
1184
|
+
f.write(f"Working Dir: {session.working_dir}\n")
|
|
1185
|
+
f.write(f"Tokens: {session.token_usage}\n")
|
|
1186
|
+
f.write("\n" + "="*60 + "\n\n")
|
|
1187
|
+
|
|
1188
|
+
for msg in session.messages:
|
|
1189
|
+
f.write(f"[{msg.role.upper()}]\n")
|
|
1190
|
+
f.write(msg.content)
|
|
1191
|
+
f.write("\n\n" + "-"*40 + "\n\n")
|
|
1192
|
+
|
|
1193
|
+
return output_file
|
|
1194
|
+
|
|
1195
|
+
def show_help():
|
|
1196
|
+
help_table = Table(show_header=False, box=None, padding=(0, 2))
|
|
1197
|
+
help_table.add_column("Command", style="cyan", width=35)
|
|
1198
|
+
help_table.add_column("Description")
|
|
1199
|
+
help_table.add_row('spawn "task" [options]', "Start new session")
|
|
1200
|
+
help_table.add_row(" --dir PATH", "Working directory for this session")
|
|
1201
|
+
help_table.add_row(" --adapter NAME", f"Adapter ({', '.join(list_adapters())})")
|
|
1202
|
+
help_table.add_row(" --async / -a", "Fire-and-forget mode")
|
|
1203
|
+
help_table.add_row(" --model NAME", "Model override")
|
|
1204
|
+
help_table.add_row("", "")
|
|
1205
|
+
help_table.add_row("ls / list", "Dashboard of all sessions")
|
|
1206
|
+
help_table.add_row("? / check ID", "Check session status & messages")
|
|
1207
|
+
help_table.add_row("c / converse ID \"msg\"", "Continue sync conversation")
|
|
1208
|
+
help_table.add_row("view ID", "Open session output in $EDITOR")
|
|
1209
|
+
help_table.add_row("raw ID", "Show raw session JSON")
|
|
1210
|
+
help_table.add_row("kill ID", "Stop a session")
|
|
1211
|
+
help_table.add_row("killall", "Stop all running sessions")
|
|
1212
|
+
help_table.add_row("refresh", "Poll all async sessions for updates")
|
|
1213
|
+
help_table.add_row("q / quit", "Exit interactive mode")
|
|
1214
|
+
console.print(help_table)
|
|
1215
|
+
|
|
1216
|
+
def show_sessions():
|
|
1217
|
+
if not sessions:
|
|
1218
|
+
console.print(" [dim]No sessions yet. Use 'spawn \"task\"' to start one.[/]")
|
|
1219
|
+
return
|
|
1220
|
+
|
|
1221
|
+
table = Table(title="Sessions Dashboard", show_header=True)
|
|
1222
|
+
table.add_column("ID", style="cyan", width=10)
|
|
1223
|
+
table.add_column("Status", width=12)
|
|
1224
|
+
table.add_column("Adapter", width=12)
|
|
1225
|
+
table.add_column("Dir", width=20)
|
|
1226
|
+
table.add_column("Tokens", width=8, justify="right")
|
|
1227
|
+
table.add_column("Task", width=35)
|
|
1228
|
+
|
|
1229
|
+
for sid, s in sessions.items():
|
|
1230
|
+
status_icons = {
|
|
1231
|
+
"active": "[green]● active[/]",
|
|
1232
|
+
"completed": "[dim]✓ done[/]",
|
|
1233
|
+
"failed": "[red]✗ failed[/]",
|
|
1234
|
+
}
|
|
1235
|
+
status = status_icons.get(s.status.value, f"? {s.status.value}")
|
|
1236
|
+
|
|
1237
|
+
# Add mode indicator
|
|
1238
|
+
if s.mode.value == "async" and s.status.value == "active":
|
|
1239
|
+
status = "[yellow]⟳ running[/]"
|
|
1240
|
+
|
|
1241
|
+
work_dir = session_dirs.get(sid, s.working_dir)
|
|
1242
|
+
dir_display = str(work_dir.name) if work_dir else "."
|
|
1243
|
+
|
|
1244
|
+
table.add_row(
|
|
1245
|
+
short_id(sid),
|
|
1246
|
+
status,
|
|
1247
|
+
s.adapter,
|
|
1248
|
+
dir_display,
|
|
1249
|
+
str(s.token_usage.get("total_tokens", 0)),
|
|
1250
|
+
s.task_description[:32] + "..." if len(s.task_description) > 32 else s.task_description,
|
|
1251
|
+
)
|
|
1252
|
+
|
|
1253
|
+
console.print(table)
|
|
1254
|
+
|
|
1255
|
+
def parse_spawn_args(args: list[str]) -> dict:
|
|
1256
|
+
"""Parse spawn command arguments."""
|
|
1257
|
+
parser = argparse.ArgumentParser(add_help=False)
|
|
1258
|
+
parser.add_argument("task", nargs="*")
|
|
1259
|
+
parser.add_argument("--dir", "-d", type=Path, default=None)
|
|
1260
|
+
parser.add_argument("--adapter", default=None)
|
|
1261
|
+
parser.add_argument("--model", "-m", default=None)
|
|
1262
|
+
parser.add_argument("--async", "-a", dest="is_async", action="store_true")
|
|
1263
|
+
|
|
1264
|
+
try:
|
|
1265
|
+
parsed, _ = parser.parse_known_args(args)
|
|
1266
|
+
return {
|
|
1267
|
+
"task": " ".join(parsed.task) if parsed.task else "",
|
|
1268
|
+
"dir": parsed.dir,
|
|
1269
|
+
"adapter": parsed.adapter,
|
|
1270
|
+
"model": parsed.model,
|
|
1271
|
+
"is_async": parsed.is_async,
|
|
1272
|
+
}
|
|
1273
|
+
except SystemExit:
|
|
1274
|
+
return {"error": "Invalid spawn arguments"}
|
|
1275
|
+
|
|
1276
|
+
def do_spawn(args: list[str]):
|
|
1277
|
+
"""Spawn a new session with optional per-session config."""
|
|
1278
|
+
parsed = parse_spawn_args(args)
|
|
1279
|
+
|
|
1280
|
+
if "error" in parsed:
|
|
1281
|
+
console.print(f" [red]{parsed['error']}[/]")
|
|
1282
|
+
console.print(" [dim]Usage: spawn \"task\" [--dir PATH] [--adapter NAME] [--async][/]")
|
|
1283
|
+
return
|
|
1284
|
+
|
|
1285
|
+
if not parsed["task"]:
|
|
1286
|
+
console.print(" [red]Task required[/]")
|
|
1287
|
+
console.print(" [dim]Usage: spawn \"task\" [--dir PATH] [--adapter NAME] [--async][/]")
|
|
1288
|
+
return
|
|
1289
|
+
|
|
1290
|
+
task = parsed["task"]
|
|
1291
|
+
work_dir = (parsed["dir"] or default_dir).absolute()
|
|
1292
|
+
adapter_name = parsed["adapter"] or default_adapter.value
|
|
1293
|
+
adapter_model = parsed["model"] or model
|
|
1294
|
+
mode = "async" if parsed["is_async"] else "sync"
|
|
1295
|
+
|
|
1296
|
+
# Validate directory exists
|
|
1297
|
+
if not work_dir.exists():
|
|
1298
|
+
console.print(f" [red]Directory not found:[/] {work_dir}")
|
|
1299
|
+
return
|
|
1300
|
+
|
|
1301
|
+
console.print(f"\n[dim]Spawning {mode} session...[/]")
|
|
1302
|
+
console.print(f" [dim]Dir: {work_dir}[/]")
|
|
1303
|
+
console.print(f" [dim]Adapter: {adapter_name}[/]")
|
|
1304
|
+
|
|
1305
|
+
try:
|
|
1306
|
+
executor = get_or_create_adapter(adapter_name, adapter_model)
|
|
1307
|
+
session = asyncio.run(
|
|
1308
|
+
executor.start_session(
|
|
1309
|
+
task=task,
|
|
1310
|
+
working_dir=work_dir,
|
|
1311
|
+
mode=mode,
|
|
1312
|
+
model=adapter_model,
|
|
1313
|
+
)
|
|
1314
|
+
)
|
|
1315
|
+
|
|
1316
|
+
sessions[session.id] = session
|
|
1317
|
+
session_dirs[session.id] = work_dir
|
|
1318
|
+
session_adapters[session.id] = adapter_name
|
|
1319
|
+
|
|
1320
|
+
console.print(f" [green]✓[/] Session: [cyan]{short_id(session.id)}[/]")
|
|
1321
|
+
console.print(f" Tokens: {session.token_usage.get('total_tokens', 0)}")
|
|
1322
|
+
|
|
1323
|
+
if mode == "sync" and session.messages:
|
|
1324
|
+
for msg in session.messages:
|
|
1325
|
+
if msg.role == "assistant":
|
|
1326
|
+
console.print(f"\n[bold]Response:[/]")
|
|
1327
|
+
content = msg.content[:2000] + ("..." if len(msg.content) > 2000 else "")
|
|
1328
|
+
console.print(Panel(content, border_style="green"))
|
|
1329
|
+
else:
|
|
1330
|
+
console.print(f" [dim]Async session started. Use '? {short_id(session.id)}' to check status.[/]")
|
|
1331
|
+
|
|
1332
|
+
# Save output for viewing
|
|
1333
|
+
save_session_output(session)
|
|
1334
|
+
|
|
1335
|
+
except Exception as e:
|
|
1336
|
+
console.print(f" [red]Error:[/] {e}")
|
|
1337
|
+
import traceback
|
|
1338
|
+
console.print(f" [dim]{traceback.format_exc()}[/]")
|
|
1339
|
+
|
|
1340
|
+
def do_converse(session_id: str, message: str):
|
|
1341
|
+
session, sid = find_session(session_id)
|
|
1342
|
+
if not session:
|
|
1343
|
+
console.print(f" [red]Session not found:[/] {session_id}")
|
|
1344
|
+
console.print(f" [dim]Use 'ls' to see available sessions[/]")
|
|
1345
|
+
return
|
|
1346
|
+
|
|
1347
|
+
if session.mode.value != "sync":
|
|
1348
|
+
console.print(f" [yellow]Warning:[/] Session is async, can't converse")
|
|
1349
|
+
return
|
|
1350
|
+
|
|
1351
|
+
if session.status.value != "active":
|
|
1352
|
+
console.print(f" [yellow]Warning:[/] Session is {session.status.value}")
|
|
1353
|
+
return
|
|
1354
|
+
|
|
1355
|
+
console.print(f"\n[dim]Sending message to {short_id(session.id)}...[/]")
|
|
1356
|
+
|
|
1357
|
+
try:
|
|
1358
|
+
adapter_name = session_adapters.get(sid, default_adapter.value)
|
|
1359
|
+
executor = get_or_create_adapter(adapter_name)
|
|
1360
|
+
response = asyncio.run(executor.send_message(session, message))
|
|
1361
|
+
console.print(f" [green]✓[/] Response received")
|
|
1362
|
+
console.print(f" Tokens: {session.token_usage.get('total_tokens', 0)}")
|
|
1363
|
+
console.print(f"\n[bold]Response:[/]")
|
|
1364
|
+
console.print(Panel(response[:2000] + ("..." if len(response) > 2000 else ""), border_style="green"))
|
|
1365
|
+
|
|
1366
|
+
# Update saved output
|
|
1367
|
+
save_session_output(session)
|
|
1368
|
+
|
|
1369
|
+
except Exception as e:
|
|
1370
|
+
console.print(f" [red]Error:[/] {e}")
|
|
1371
|
+
|
|
1372
|
+
def do_check(session_id: str):
|
|
1373
|
+
session, sid = find_session(session_id)
|
|
1374
|
+
if not session:
|
|
1375
|
+
console.print(f" [red]Session not found:[/] {session_id}")
|
|
1376
|
+
return
|
|
1377
|
+
|
|
1378
|
+
work_dir = session_dirs.get(sid, session.working_dir)
|
|
1379
|
+
|
|
1380
|
+
console.print(f"\n[bold]Session {short_id(session.id)}[/]")
|
|
1381
|
+
console.print(f" Status: {session.status.value}")
|
|
1382
|
+
console.print(f" Mode: {session.mode.value}")
|
|
1383
|
+
console.print(f" Adapter: {session.adapter}")
|
|
1384
|
+
console.print(f" Model: {session.model}")
|
|
1385
|
+
console.print(f" Directory: {work_dir}")
|
|
1386
|
+
console.print(f" Messages: {len(session.messages)}")
|
|
1387
|
+
console.print(f" Tokens: {session.token_usage}")
|
|
1388
|
+
console.print(f" Task: {session.task_description}")
|
|
1389
|
+
|
|
1390
|
+
if session.mode.value == "async" and session.status.value == "active":
|
|
1391
|
+
try:
|
|
1392
|
+
adapter_name = session_adapters.get(sid, default_adapter.value)
|
|
1393
|
+
executor = get_or_create_adapter(adapter_name)
|
|
1394
|
+
status = asyncio.run(executor.check_status(session))
|
|
1395
|
+
console.print(f"\n[bold]Async status:[/]")
|
|
1396
|
+
for k, v in status.items():
|
|
1397
|
+
if k != "events": # Skip verbose events
|
|
1398
|
+
val_str = str(v)[:200] + "..." if len(str(v)) > 200 else str(v)
|
|
1399
|
+
console.print(f" {k}: {val_str}")
|
|
1400
|
+
|
|
1401
|
+
# Update saved output after status check
|
|
1402
|
+
save_session_output(session)
|
|
1403
|
+
|
|
1404
|
+
except Exception as e:
|
|
1405
|
+
console.print(f" [red]Status check error:[/] {e}")
|
|
1406
|
+
|
|
1407
|
+
# Show messages
|
|
1408
|
+
if session.messages:
|
|
1409
|
+
console.print(f"\n[bold]Messages:[/]")
|
|
1410
|
+
for i, msg in enumerate(session.messages):
|
|
1411
|
+
role_color = {"user": "blue", "assistant": "green", "system": "yellow"}.get(msg.role, "white")
|
|
1412
|
+
content_preview = msg.content[:500] + "..." if len(msg.content) > 500 else msg.content
|
|
1413
|
+
console.print(f" [{role_color}]{msg.role}[/]: {content_preview}")
|
|
1414
|
+
|
|
1415
|
+
def do_view(session_id: str):
|
|
1416
|
+
"""Open session output in $EDITOR."""
|
|
1417
|
+
session, sid = find_session(session_id)
|
|
1418
|
+
if not session:
|
|
1419
|
+
console.print(f" [red]Session not found:[/] {session_id}")
|
|
1420
|
+
return
|
|
1421
|
+
|
|
1422
|
+
output_file = save_session_output(session)
|
|
1423
|
+
editor = os.environ.get("EDITOR", "less")
|
|
1424
|
+
|
|
1425
|
+
console.print(f" [dim]Opening {output_file} in {editor}...[/]")
|
|
1426
|
+
try:
|
|
1427
|
+
sp.run([editor, str(output_file)])
|
|
1428
|
+
except Exception as e:
|
|
1429
|
+
console.print(f" [red]Error opening editor:[/] {e}")
|
|
1430
|
+
console.print(f" [dim]File saved at: {output_file}[/]")
|
|
1431
|
+
|
|
1432
|
+
def do_raw(session_id: str):
|
|
1433
|
+
"""Show raw session data for debugging."""
|
|
1434
|
+
session, _ = find_session(session_id)
|
|
1435
|
+
if not session:
|
|
1436
|
+
console.print(f" [red]Session not found:[/] {session_id}")
|
|
1437
|
+
return
|
|
1438
|
+
|
|
1439
|
+
import json
|
|
1440
|
+
console.print(f"\n[bold]Raw session data for {short_id(session.id)}:[/]")
|
|
1441
|
+
console.print(json.dumps(session.to_dict(), indent=2, default=str))
|
|
1442
|
+
|
|
1443
|
+
def do_kill(session_id: str):
|
|
1444
|
+
session, sid = find_session(session_id)
|
|
1445
|
+
if not session:
|
|
1446
|
+
console.print(f" [red]Session not found:[/] {session_id}")
|
|
1447
|
+
return
|
|
1448
|
+
|
|
1449
|
+
try:
|
|
1450
|
+
adapter_name = session_adapters.get(sid, default_adapter.value)
|
|
1451
|
+
executor = get_or_create_adapter(adapter_name)
|
|
1452
|
+
asyncio.run(executor.stop(session))
|
|
1453
|
+
console.print(f" [green]✓[/] Session {short_id(session.id)} killed")
|
|
1454
|
+
save_session_output(session)
|
|
1455
|
+
except Exception as e:
|
|
1456
|
+
console.print(f" [red]Error:[/] {e}")
|
|
1457
|
+
|
|
1458
|
+
def do_killall():
|
|
1459
|
+
"""Kill all running sessions."""
|
|
1460
|
+
killed = 0
|
|
1461
|
+
for sid, session in sessions.items():
|
|
1462
|
+
if session.status.value == "active":
|
|
1463
|
+
try:
|
|
1464
|
+
adapter_name = session_adapters.get(sid, default_adapter.value)
|
|
1465
|
+
executor = get_or_create_adapter(adapter_name)
|
|
1466
|
+
asyncio.run(executor.stop(session))
|
|
1467
|
+
killed += 1
|
|
1468
|
+
except Exception:
|
|
1469
|
+
pass
|
|
1470
|
+
console.print(f" [green]✓[/] Killed {killed} sessions")
|
|
1471
|
+
|
|
1472
|
+
def do_refresh():
|
|
1473
|
+
"""Poll all async sessions for status updates."""
|
|
1474
|
+
updated = 0
|
|
1475
|
+
for sid, session in sessions.items():
|
|
1476
|
+
if session.mode.value == "async" and session.status.value == "active":
|
|
1477
|
+
try:
|
|
1478
|
+
adapter_name = session_adapters.get(sid, default_adapter.value)
|
|
1479
|
+
executor = get_or_create_adapter(adapter_name)
|
|
1480
|
+
status = asyncio.run(executor.check_status(session))
|
|
1481
|
+
save_session_output(session)
|
|
1482
|
+
updated += 1
|
|
1483
|
+
|
|
1484
|
+
if status.get("status") in ("completed", "failed"):
|
|
1485
|
+
icon = "[green]✓[/]" if status["status"] == "completed" else "[red]✗[/]"
|
|
1486
|
+
console.print(f" {icon} {short_id(sid)}: {status['status']}")
|
|
1487
|
+
except Exception as e:
|
|
1488
|
+
console.print(f" [red]✗[/] {short_id(sid)}: {e}")
|
|
1489
|
+
|
|
1490
|
+
if updated == 0:
|
|
1491
|
+
console.print(" [dim]No async sessions to refresh[/]")
|
|
1492
|
+
else:
|
|
1493
|
+
console.print(f" [dim]Refreshed {updated} sessions[/]")
|
|
1494
|
+
|
|
1495
|
+
# REPL loop
|
|
1496
|
+
import shlex
|
|
1497
|
+
|
|
1498
|
+
while True:
|
|
1499
|
+
try:
|
|
1500
|
+
raw_input = console.input("[bold cyan]>[/] ").strip()
|
|
1501
|
+
if not raw_input:
|
|
1502
|
+
continue
|
|
1503
|
+
|
|
1504
|
+
# Parse command
|
|
1505
|
+
try:
|
|
1506
|
+
parts = shlex.split(raw_input)
|
|
1507
|
+
except ValueError:
|
|
1508
|
+
# Handle unclosed quotes
|
|
1509
|
+
parts = raw_input.split()
|
|
1510
|
+
|
|
1511
|
+
cmd = parts[0].lower()
|
|
1512
|
+
args = parts[1:]
|
|
1513
|
+
|
|
1514
|
+
if cmd in ("q", "quit", "exit"):
|
|
1515
|
+
# Offer to kill running sessions
|
|
1516
|
+
running = [s for s in sessions.values() if s.status.value == "active"]
|
|
1517
|
+
if running:
|
|
1518
|
+
console.print(f" [yellow]Warning:[/] {len(running)} sessions still running")
|
|
1519
|
+
console.print(" [dim]Use 'killall' first, or they'll continue in background[/]")
|
|
1520
|
+
console.print("\n[dim]Goodbye![/]\n")
|
|
1521
|
+
break
|
|
1522
|
+
|
|
1523
|
+
elif cmd in ("h", "help"):
|
|
1524
|
+
show_help()
|
|
1525
|
+
|
|
1526
|
+
elif cmd in ("ls", "list"):
|
|
1527
|
+
show_sessions()
|
|
1528
|
+
|
|
1529
|
+
elif cmd == "spawn":
|
|
1530
|
+
do_spawn(args)
|
|
1531
|
+
|
|
1532
|
+
# Keep old shortcuts for convenience
|
|
1533
|
+
elif cmd in ("d", "delegate"):
|
|
1534
|
+
if not args:
|
|
1535
|
+
console.print(" [red]Usage:[/] d \"task\" or spawn \"task\" [options]")
|
|
1536
|
+
else:
|
|
1537
|
+
do_spawn(args) # Sync by default
|
|
1538
|
+
|
|
1539
|
+
elif cmd in ("a", "async"):
|
|
1540
|
+
if not args:
|
|
1541
|
+
console.print(" [red]Usage:[/] a \"task\" or spawn \"task\" --async")
|
|
1542
|
+
else:
|
|
1543
|
+
do_spawn(args + ["--async"])
|
|
1544
|
+
|
|
1545
|
+
elif cmd in ("c", "converse"):
|
|
1546
|
+
if len(args) < 2:
|
|
1547
|
+
console.print(" [red]Usage:[/] c SESSION_ID \"message\"")
|
|
1548
|
+
else:
|
|
1549
|
+
do_converse(args[0], " ".join(args[1:]))
|
|
1550
|
+
|
|
1551
|
+
elif cmd in ("?", "check"):
|
|
1552
|
+
if not args:
|
|
1553
|
+
console.print(" [red]Usage:[/] ? SESSION_ID")
|
|
1554
|
+
else:
|
|
1555
|
+
do_check(args[0])
|
|
1556
|
+
|
|
1557
|
+
elif cmd == "view":
|
|
1558
|
+
if not args:
|
|
1559
|
+
console.print(" [red]Usage:[/] view SESSION_ID")
|
|
1560
|
+
else:
|
|
1561
|
+
do_view(args[0])
|
|
1562
|
+
|
|
1563
|
+
elif cmd == "raw":
|
|
1564
|
+
if not args:
|
|
1565
|
+
console.print(" [red]Usage:[/] raw SESSION_ID")
|
|
1566
|
+
else:
|
|
1567
|
+
do_raw(args[0])
|
|
1568
|
+
|
|
1569
|
+
elif cmd == "kill":
|
|
1570
|
+
if not args:
|
|
1571
|
+
console.print(" [red]Usage:[/] kill SESSION_ID")
|
|
1572
|
+
else:
|
|
1573
|
+
do_kill(args[0])
|
|
1574
|
+
|
|
1575
|
+
elif cmd == "killall":
|
|
1576
|
+
do_killall()
|
|
1577
|
+
|
|
1578
|
+
elif cmd == "refresh":
|
|
1579
|
+
do_refresh()
|
|
1580
|
+
|
|
1581
|
+
elif cmd in ("e", "end"):
|
|
1582
|
+
# Alias for kill
|
|
1583
|
+
if not args:
|
|
1584
|
+
console.print(" [red]Usage:[/] kill SESSION_ID")
|
|
1585
|
+
else:
|
|
1586
|
+
do_kill(args[0])
|
|
1587
|
+
|
|
1588
|
+
else:
|
|
1589
|
+
console.print(f" [yellow]Unknown command:[/] {cmd}")
|
|
1590
|
+
console.print(" [dim]Type 'help' for available commands[/]")
|
|
1591
|
+
|
|
1592
|
+
except KeyboardInterrupt:
|
|
1593
|
+
console.print("\n[dim](Ctrl+C to exit, or type 'quit')[/]")
|
|
1594
|
+
except EOFError:
|
|
1595
|
+
console.print("\n[dim]Goodbye![/]\n")
|
|
1596
|
+
break
|
|
1597
|
+
except Exception as e:
|
|
1598
|
+
console.print(f" [red]Error:[/] {e}")
|
|
1599
|
+
|
|
1600
|
+
|
|
978
1601
|
@app.callback(invoke_without_command=True)
|
|
979
1602
|
def main_callback(
|
|
980
1603
|
ctx: typer.Context,
|