Files
Auto-Trader/agent/cli.py
T

2193 lines
79 KiB
Python

#!/usr/bin/env python3
"""Vibe-Trading CLI — natural-language finance research & backtesting.
Usage:
vibe-trading Interactive mode (default)
vibe-trading -p "Backtest AAPL MACD" Single run
vibe-trading serve --port 8899 Start API server
vibe-trading chat Interactive mode
vibe-trading list List runs
vibe-trading show <run_id> Show run details
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import re
import shutil
import sys
import threading
import time
import uuid
from pathlib import Path
from typing import Any, Dict, List, Optional
import warnings
warnings.filterwarnings("ignore", message=".*Importing verbose from langchain.*")
warnings.filterwarnings("ignore", category=DeprecationWarning, module="langchain")
for _s in ("stdout", "stderr"):
_r = getattr(getattr(sys, _s, None), "reconfigure", None)
if callable(_r):
_r(encoding="utf-8", errors="replace")
from rich.console import Console
from rich.panel import Panel
from rich.prompt import Confirm, IntPrompt, Prompt
from rich.syntax import Syntax
from rich.table import Table
console = Console()
AGENT_DIR = Path(__file__).resolve().parent
RUNS_DIR = AGENT_DIR / "runs"
SWARM_DIR = AGENT_DIR / ".swarm" / "runs"
SESSIONS_DIR = AGENT_DIR / "sessions"
UPLOADS_DIR = AGENT_DIR / "uploads"
EXIT_SUCCESS = 0
EXIT_RUN_FAILED = 1
EXIT_USAGE_ERROR = 2
RICH_TAG_PATTERN = re.compile(r"\[/?[^\]]+\]")
_VERSION = "0.1.7"
# Agent color assignments for swarm display
_AGENT_STYLES = ["cyan", "magenta", "green", "yellow", "blue", "bright_red", "bright_cyan", "bright_magenta"]
_agent_color_map: dict[str, str] = {}
_HAS_PROMPT_TOOLKIT = False
try:
from prompt_toolkit import PromptSession
from prompt_toolkit.formatted_text import FormattedText
from prompt_toolkit.history import InMemoryHistory
_HAS_PROMPT_TOOLKIT = True
except ImportError:
pass
class _SessionStats:
"""Mutable container for interactive session statistics.
Shared between the status bar renderer and the agent loop so that
tool callbacks can update counters in-place.
"""
__slots__ = ("session_start", "last_elapsed", "total_tool_ms", "tool_count")
def __init__(self, session_start: float) -> None:
self.session_start = session_start
self.last_elapsed: Optional[float] = None
self.total_tool_ms = 0
self.tool_count = 0
def _build_status_parts(stats: _SessionStats) -> list[str]:
"""Build plain-text status bar segments.
Args:
stats: Session statistics.
Returns:
List of status text segments.
"""
provider = os.getenv("LANGCHAIN_PROVIDER", "")
model = os.getenv("LANGCHAIN_MODEL_NAME", "")
model_short = model.split("/")[-1] if "/" in model else model
label = f"{provider}/{model_short}" if provider else model_short or "unknown"
session_s = int(time.monotonic() - stats.session_start)
mins, secs = divmod(session_s, 60)
session_str = f"{mins}m{secs:02d}s" if mins else f"{secs}s"
parts = [label, session_str]
if stats.last_elapsed is not None:
parts.append(f"last {stats.last_elapsed:.1f}s")
if stats.tool_count > 0:
total_s = stats.total_tool_ms / 1000
parts.append(f"{stats.tool_count} tools ({total_s:.1f}s)")
return parts
def _ptk_toolbar(stats: _SessionStats) -> FormattedText:
"""prompt_toolkit bottom_toolbar callback — called on every render.
Args:
stats: Session statistics.
Returns:
FormattedText for the toolbar.
"""
segments = _build_status_parts(stats)
text = " │ ".join(segments)
return FormattedText([("class:bottom-toolbar.text", f" {text} ")])
def _print_status_bar(stats: _SessionStats) -> None:
"""Print a static status bar using Rich (fallback without prompt_toolkit).
Args:
stats: Session statistics.
"""
parts = _build_status_parts(stats)
bar = "[dim] │ [/dim]".join(
f"[bold]{parts[0]}[/bold]" if i == 0 else p for i, p in enumerate(parts)
)
console.print(bar)
def _create_prompt_session(stats: _SessionStats) -> Any:
"""Create a prompt_toolkit PromptSession with history and live toolbar.
Args:
stats: Session statistics for the live bottom toolbar.
Returns:
A PromptSession instance, or None if prompt_toolkit is not available.
"""
if not _HAS_PROMPT_TOOLKIT:
return None
return PromptSession(
history=InMemoryHistory(),
bottom_toolbar=lambda: _ptk_toolbar(stats),
refresh_interval=1.0,
)
def _read_input(prompt_session: Any, prompt_str: str = "> ") -> str:
"""Read user input with arrow key support if prompt_toolkit is available.
Falls back to Rich Prompt.ask() when prompt_toolkit is not installed or
when stdin is not a tty.
Args:
prompt_session: A prompt_toolkit PromptSession, or None.
prompt_str: Prompt text to display.
Returns:
User input string (not stripped).
Raises:
EOFError: When the user presses Ctrl-D.
KeyboardInterrupt: When the user presses Ctrl-C.
"""
if prompt_session is not None and sys.stdin.isatty():
return prompt_session.prompt(prompt_str)
return Prompt.ask(f"[bold]{prompt_str}[/bold]")
def serve_main(argv: list[str] | None = None) -> int:
"""Delegate server startup to api_server."""
from api_server import serve_main as api_serve_main
return api_serve_main(argv)
def _strip_rich_tags(text: str) -> str:
"""Remove Rich markup from plain-text output."""
return RICH_TAG_PATTERN.sub("", text)
def _print_json_result(result: dict) -> None:
"""Print a machine-readable run summary."""
payload = {
"status": result.get("status", "unknown"),
"run_id": result.get("run_id"),
"run_dir": result.get("run_dir"),
"reason": result.get("reason"),
}
print(json.dumps(payload, ensure_ascii=False))
def _result_exit_code(result: dict) -> int:
"""Map run results to stable exit codes."""
return EXIT_SUCCESS if result.get("status") == "success" else EXIT_RUN_FAILED
def _coerce_exit_code(value: Optional[int]) -> int:
"""Normalize command return values to an integer exit code."""
return EXIT_SUCCESS if value is None else int(value)
def _read_prompt_source(
prompt: Optional[str],
prompt_file: Optional[Path],
*,
no_rich: bool,
allow_interactive: bool = True,
) -> tuple[Optional[str], Optional[str]]:
"""Resolve prompt text from CLI args, file, stdin, or interactive input."""
if prompt is not None:
return prompt.strip(), None
if prompt_file is not None:
try:
return prompt_file.read_text(encoding="utf-8").strip(), None
except OSError as exc:
return None, f"Failed to read prompt file: {exc}"
if not sys.stdin.isatty():
return sys.stdin.read().strip(), None
if not allow_interactive:
return None, "A prompt is required."
try:
if no_rich:
return input("Enter strategy request: ").strip(), None
return Prompt.ask("Enter strategy request").strip(), None
except (EOFError, KeyboardInterrupt):
return None, "Prompt input cancelled."
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _read_json(path: Path) -> dict:
"""Safely read JSON."""
if not path.exists():
return {}
try:
return json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return {}
def _read_metrics(path: Path) -> dict:
"""Read metrics from metrics.csv, return formatted string dict."""
if not path.exists():
return {}
try:
with path.open(encoding="utf-8") as f:
rows = list(csv.DictReader(f))
if not rows:
return {}
out = {}
for k, v in rows[0].items():
if not v:
continue
try:
fv = float(v)
out[k] = f"{fv:.4f}" if abs(fv) < 100 else f"{fv:.0f}"
except ValueError:
out[k] = v
return out
except Exception:
return {}
# ---------------------------------------------------------------------------
# Agent execution core
# ---------------------------------------------------------------------------
def _format_tool_call_args(tool: str, args: Dict[str, str]) -> str:
"""Smart-format tool argument summary."""
if tool == "load_skill":
return f'("{args.get("name", "")}")'
if tool in ("write_file", "read_file", "edit_file"):
return f' {args.get("path", args.get("file_path", ""))}'
if tool in ("bash", "background_run"):
cmd = args.get("command", "")[:80]
return f' [yellow]{cmd}[/yellow]'
if tool == "check_background":
tid = args.get("task_id", "")
return f' {tid}' if tid else ""
if tool in ("backtest", "compact"):
return ""
for v in args.values():
if v and v != "None":
return f" {v[:60]}"
return ""
def _format_tool_result_preview(tool: str, status: str, preview: str) -> str:
"""Smart-format tool result preview."""
if status != "ok":
return f"[red]{preview[:80]}[/red]"
if tool == "backtest":
sharpe = re.search(r'"sharpe":\s*([\d.eE+-]+)', preview)
ret = re.search(r'"total_return":\s*([\d.eE+-]+)', preview)
parts = []
if sharpe:
parts.append(f"sharpe={sharpe.group(1)}")
if ret:
parts.append(f"return={float(ret.group(1))*100:.1f}%")
return ", ".join(parts) if parts else ""
if tool == "render_shadow_report":
url = re.search(r'"report_url":\s*"([^"]+)"', preview)
if url:
return f"[bold cyan]report:[/bold cyan] [link]{url.group(1)}[/link]"
return ""
if tool in ("extract_shadow_strategy", "run_shadow_backtest"):
sid = re.search(r'"shadow_id":\s*"([^"]+)"', preview)
return f"shadow_id={sid.group(1)}" if sid else ""
if tool in ("bash", "background_run"):
if "OK" in preview[:50]:
return "OK"
return preview[:60].replace("\n", " ")
if tool in ("read_file", "load_skill", "compact"):
return ""
return ""
def _run_agent(
prompt: str,
history: Optional[List[Dict]] = None,
run_dir_override: Optional[str] = None,
max_iter: int = 50,
*,
no_rich: bool = False,
stream_output: bool = True,
) -> dict:
"""Build AgentLoop and execute, return result dict."""
from src.tools import build_registry
from src.providers.chat import ChatLLM
from src.agent.loop import AgentLoop
def on_event(event_type: str, data: Dict[str, Any]) -> None:
if not stream_output:
return
if no_rich and event_type == "thinking_done":
print()
return
if no_rich and event_type == "tool_call":
tool = data.get("tool", "")
args = data.get("arguments", {})
args_preview = _format_tool_call_args(tool, args)
print(f" - {tool}{_strip_rich_tags(args_preview)}", end="")
return
if no_rich and event_type == "tool_result":
tool = data.get("tool", "")
status = data.get("status", "ok")
elapsed_ms = data.get("elapsed_ms", 0)
elapsed_s = elapsed_ms / 1000
preview = _format_tool_result_preview(tool, status, data.get("preview", ""))
suffix = f" {preview}" if preview else ""
mark = "OK" if status == "ok" else "FAIL"
print(f" {mark} {elapsed_s:.1f}s{_strip_rich_tags(suffix)}")
return
if no_rich and event_type == "compact":
tokens = data.get("tokens_before", "?")
print(f"\n context compressed ({tokens} tokens -> summary)\n")
return
if event_type == "text_delta":
if no_rich:
print(data.get("delta", ""), end="")
else:
console.print(data.get("delta", ""), end="", style="dim")
elif event_type == "thinking_done":
console.print()
elif event_type == "tool_call":
tool = data.get("tool", "")
args = data.get("arguments", {})
args_preview = _format_tool_call_args(tool, args)
console.print(f" [cyan]\u25b6 {tool}[/cyan]{args_preview}", end="")
elif event_type == "tool_result":
tool = data.get("tool", "")
status = data.get("status", "ok")
elapsed_ms = data.get("elapsed_ms", 0)
elapsed_s = elapsed_ms / 1000
ok = status == "ok"
mark = "[green]\u2713[/green]" if ok else "[red]\u2717[/red]"
preview = _format_tool_result_preview(tool, status, data.get("preview", ""))
suffix = f" {preview}" if preview else ""
console.print(f" {mark} [dim]{elapsed_s:.1f}s[/dim]{suffix}")
elif event_type == "compact":
tokens = data.get("tokens_before", "?")
console.print(f"\n [yellow]\u27f3 context compressed[/yellow] [dim]({tokens} tokens \u2192 summary)[/dim]\n")
from src.memory.persistent import PersistentMemory
pm = PersistentMemory()
agent = AgentLoop(
registry=build_registry(persistent_memory=pm, include_shell_tools=True),
llm=ChatLLM(),
event_callback=on_event,
max_iterations=max_iter,
persistent_memory=pm,
)
if run_dir_override:
agent.memory.run_dir = run_dir_override
return agent.run(user_message=prompt, history=history)
def _build_benchmark_table(m: dict) -> Optional[Table]:
"""Build a benchmark comparison table from metrics dict.
Args:
m: Metrics dictionary (from _read_metrics or result dict).
Returns:
Rich Table, or None if no benchmark data is present.
"""
bench_ticker = m.get("benchmark_ticker")
bench_ret_str = m.get("benchmark_return")
bench_ret_raw = m.get("_benchmark_return_raw")
# Fall back to equity.csv if benchmark cols not in metrics.csv yet
if not bench_ticker:
return None
# Parse benchmark return
if bench_ret_raw is not None:
bench_ret = bench_ret_raw
elif bench_ret_str is not None:
try:
bench_ret = float(bench_ret_str)
except (ValueError, TypeError):
bench_ret = None
else:
bench_ret = None
strategy_ret_str = m.get("total_return")
strategy_ret = float(strategy_ret_str) if strategy_ret_str else None
table = Table(show_header=False, padding=(0, 2))
table.add_column("Label", style="dim", width=20)
table.add_column("Value", style="white no_wrap")
table.add_row("[dim]Benchmark[/dim]", bench_ticker)
if bench_ret is not None:
table.add_row("[dim]Benchmark Return[/dim]", f"{bench_ret * 100:+.2f}%")
if strategy_ret is not None and bench_ret is not None:
excess = strategy_ret - bench_ret
sign = "+" if excess >= 0 else ""
style = "green" if excess >= 0 else "red"
table.add_row(
"[dim]vs Benchmark[/dim]",
f"[{style}]{sign}{excess * 100:+.2f}%[/{style}]",
)
ir_str = m.get("information_ratio")
if ir_str:
table.add_row("[dim]Info Ratio[/dim]", ir_str)
excess_str = m.get("excess_return")
if excess_str and excess_str != "0" and excess_str != "0.0000":
table.add_row("[dim]Excess Return[/dim]", f"{float(excess_str) * 100:+.2f}%")
return table
def _print_result(result: dict, elapsed: float, *, no_rich: bool = False) -> None:
"""Print execution result panel."""
status = result.get("status", "unknown")
ok = status == "success"
style = "green" if ok else "red"
lines = [f"Status: [bold {style}]{status.upper()}[/bold {style}] Time: {elapsed:.1f}s"]
if result.get("run_id"):
lines.append(f"ID: {result['run_id']}")
review = result.get("review")
if review and review.get("overall_score") is not None:
check = "\u2713" if review.get("passed") else "\u2717"
lines.append(f"Review: {review['overall_score']}pts {check}")
run_dir = result.get("run_dir")
m = {}
if run_dir:
m = _read_metrics(Path(run_dir) / "artifacts" / "metrics.csv")
parts = [f"{k}={m[k]}" for k in ("total_return", "sharpe", "max_drawdown", "trade_count") if k in m]
if parts:
lines.append(f"Metrics: {', '.join(parts)}")
if result.get("reason"):
lines.append(f"Reason: {result['reason']}")
console.print(Panel("\n".join(lines), border_style=style, title="Result"))
# ── Benchmark comparison panel ─────────────────────────────────────────────
bench_table = _build_benchmark_table(m)
if bench_table:
console.print(Panel(
bench_table,
border_style="cyan",
title="Benchmark Comparison",
padding=(0, 1),
))
# ── Benchmark comparison panel ─────────────────────────────────────────
content = result.get("content", "").strip()
if content:
console.print(f"\n{content}")
# ---------------------------------------------------------------------------
# Subcommands
# ---------------------------------------------------------------------------
def cmd_run(prompt: str, max_iter: int, *, json_mode: bool = False, no_rich: bool = False) -> int:
"""Single run."""
if not json_mode:
from src.preflight import run_preflight
results = run_preflight(console)
if any(r.critical and r.status != "ready" for r in results):
return EXIT_RUN_FAILED
if not json_mode:
preview = prompt[:120]
suffix = "..." if len(prompt) > 120 else ""
if no_rich:
print(f"Prompt: {preview}{suffix}\n")
else:
console.print(f"[dim]Prompt:[/dim] {preview}{suffix}\n")
start = time.perf_counter()
try:
result = _run_agent(prompt, max_iter=max_iter, no_rich=no_rich, stream_output=not json_mode)
except KeyboardInterrupt:
if json_mode:
_print_json_result({"status": "cancelled", "run_id": None, "run_dir": None, "reason": "Interrupted"})
return EXIT_RUN_FAILED
if no_rich:
print("\nInterrupted")
return EXIT_RUN_FAILED
console.print("\n[yellow]Interrupted[/yellow]")
return EXIT_RUN_FAILED
if json_mode:
_print_json_result(result)
return _result_exit_code(result)
_print_result(result, time.perf_counter() - start, no_rich=no_rich)
if result.get("run_id"):
tip = f"--show {result['run_id']} | --continue {result['run_id']} \"...\" | --code {result['run_id']} | --pine {result['run_id']}"
if no_rich:
print(tip)
else:
console.print(f"[dim]{tip}[/dim]")
return _result_exit_code(result)
def _build_history_from_trace(run_dir: Path) -> List[Dict[str, str]]:
"""Build conversation history from trace.jsonl."""
from src.agent.trace import TraceWriter
entries = TraceWriter.read(run_dir)
history: List[Dict[str, str]] = []
for e in entries:
if e.get("type") == "start" and e.get("prompt"):
history.append({"role": "user", "content": e["prompt"]})
elif e.get("type") == "answer" and e.get("content"):
history.append({"role": "assistant", "content": e["content"]})
return history
def cmd_continue(
run_id: str,
prompt: str,
max_iter: int,
*,
json_mode: bool = False,
no_rich: bool = False,
) -> int:
"""Continue an existing run."""
run_dir = RUNS_DIR / run_id
if not run_dir.exists():
if no_rich:
print(f"Run {run_id} not found")
return EXIT_USAGE_ERROR
console.print(f"[red]Run {run_id} not found[/red]")
return EXIT_USAGE_ERROR
history = _build_history_from_trace(run_dir)
if not json_mode and no_rich:
print(f"Continue {run_id}: {prompt[:120]}\n")
if json_mode or no_rich:
start = time.perf_counter()
try:
result = _run_agent(
prompt,
history=history,
run_dir_override=str(run_dir),
max_iter=max_iter,
no_rich=no_rich,
stream_output=not json_mode,
)
except KeyboardInterrupt:
if json_mode:
_print_json_result(
{"status": "cancelled", "run_id": run_id, "run_dir": str(run_dir), "reason": "Interrupted"}
)
else:
print("\nInterrupted")
return EXIT_RUN_FAILED
if json_mode:
_print_json_result(result)
return _result_exit_code(result)
_print_result(result, time.perf_counter() - start, no_rich=True)
return _result_exit_code(result)
console.print(f"[dim]Continue {run_id}:[/dim] {prompt[:120]}\n")
start = time.perf_counter()
try:
result = _run_agent(prompt, history=history, run_dir_override=str(run_dir), max_iter=max_iter)
except KeyboardInterrupt:
console.print("\n[yellow]Interrupted[/yellow]")
return EXIT_RUN_FAILED
_print_result(result, time.perf_counter() - start)
return _result_exit_code(result)
# ---------------------------------------------------------------------------
# Interactive mode (Welcome + Slash commands + Swarm streaming)
# ---------------------------------------------------------------------------
def _print_welcome() -> None:
"""Print the welcome screen."""
welcome = (
f"[bold cyan]Vibe-Trading[/bold cyan] [dim]v{_VERSION}[/dim]\n"
"[dim]Vibe trading with your professional financial agent team[/dim]\n"
"\n"
"Type a natural-language request to start, or use [bold]/commands[/bold]:\n"
"\n"
" [cyan]/skills[/cyan] List available skills [cyan]/swarm[/cyan] Agent team presets\n"
" [cyan]/list[/cyan] Recent runs [cyan]/settings[/cyan] Runtime config\n"
" [cyan]/help[/cyan] All commands [cyan]/quit[/cyan] Exit\n"
"\n"
"[dim]Try: \"analyze my trade journal\" or \"train my shadow account\"[/dim]"
)
console.print(Panel(welcome, border_style="cyan", padding=(1, 2)))
def _print_help() -> None:
"""Print all available slash commands."""
table = Table(title="Commands", show_lines=False, border_style="dim")
table.add_column("Command", style="cyan", no_wrap=True)
table.add_column("Description")
cmds = [
("/help", "Show this help"),
("/skills", "List all skills"),
("/list", "List recent runs"),
("/show <run_id>", "Show run details"),
("/code <run_id>", "Show generated code"),
("/pine <run_id>", "Show Pine Script for TradingView"),
("/trace <run_id>", "Replay run trace"),
("/continue <run_id> <prompt>", "Continue an existing run"),
("/swarm", "List swarm team presets"),
("/swarm run <preset> {vars}", "Run a swarm team"),
("/swarm inspect <preset>", "Inspect preset DAG and validation"),
("/swarm list", "List swarm run history"),
("/swarm show <run_id>", "Show swarm run details"),
("/swarm cancel <run_id>", "Cancel a swarm run"),
("/sessions", "List chat sessions"),
("/settings", "Show runtime settings"),
("/clear", "Clear screen"),
("/quit", "Exit"),
("", ""),
("[dim]--- Natural language ---[/dim]", ""),
('"analyze journal.csv"', "Parse broker export → profile + 4 behavior diagnostics"),
('"train my shadow"', "Extract strategy → backtest → HTML/PDF report"),
]
for cmd, desc in cmds:
table.add_row(cmd, desc)
console.print(table)
def _show_settings() -> None:
"""Show current runtime settings."""
table = Table(title="Settings", show_lines=False, border_style="dim")
table.add_column("Key", style="cyan")
table.add_column("Value")
settings = {
"Provider": os.getenv("LANGCHAIN_PROVIDER", "(not set)"),
"Model": os.getenv("LANGCHAIN_MODEL_NAME", "(not set)"),
"Temperature": os.getenv("LANGCHAIN_TEMPERATURE", "0.0"),
"Timeout": os.getenv("TIMEOUT_SECONDS", "2400") + "s",
"Tushare Token": "***" if os.getenv("TUSHARE_TOKEN") else "(not set)",
}
for k, v in settings.items():
table.add_row(k, v)
console.print(table)
def _handle_slash_command(input_str: str, *, max_iter: int) -> None:
"""Parse and route a slash command."""
parts = input_str.split(maxsplit=1)
cmd = parts[0].lower()
arg = parts[1].strip() if len(parts) > 1 else ""
if cmd == "/help":
_print_help()
elif cmd == "/skills":
cmd_skills()
elif cmd == "/list":
cmd_list()
elif cmd == "/show":
if arg:
cmd_show(arg)
else:
console.print("[red]Usage: /show <run_id>[/red]")
elif cmd == "/code":
if arg:
cmd_code(arg)
else:
console.print("[red]Usage: /code <run_id>[/red]")
elif cmd == "/pine":
if arg:
cmd_pine(arg)
else:
console.print("[red]Usage: /pine <run_id>[/red]")
elif cmd == "/trace":
if arg:
cmd_trace(arg)
else:
console.print("[red]Usage: /trace <run_id>[/red]")
elif cmd == "/continue":
cont_parts = arg.split(maxsplit=1)
if len(cont_parts) >= 2:
cmd_continue(cont_parts[0], cont_parts[1], max_iter)
else:
console.print("[red]Usage: /continue <run_id> <prompt>[/red]")
elif cmd == "/swarm":
_handle_swarm_command(arg)
elif cmd == "/sessions":
cmd_sessions()
elif cmd == "/settings":
_show_settings()
elif cmd == "/clear":
console.clear()
_print_welcome()
elif cmd in ("/quit", "/exit"):
raise EOFError
else:
console.print(f"[red]Unknown command: {cmd}[/red] — type [cyan]/help[/cyan] for available commands")
def _handle_swarm_command(arg: str) -> None:
"""Route swarm sub-commands."""
if not arg:
cmd_swarm_presets()
return
parts = arg.split(maxsplit=1)
sub = parts[0].lower()
sub_arg = parts[1].strip() if len(parts) > 1 else ""
if sub == "run":
run_parts = sub_arg.split(maxsplit=1)
if not run_parts:
console.print("[red]Usage: /swarm run <preset> [vars_json][/red]")
return
preset = run_parts[0]
vars_json = run_parts[1] if len(run_parts) > 1 else None
cmd_swarm_run_live(preset, vars_json)
elif sub == "inspect":
if sub_arg:
cmd_swarm_inspect(sub_arg)
else:
console.print("[red]Usage: /swarm inspect <preset>[/red]")
elif sub == "list":
cmd_swarm_list()
elif sub == "show":
if sub_arg:
cmd_swarm_show(sub_arg)
else:
console.print("[red]Usage: /swarm show <run_id>[/red]")
elif sub == "cancel":
if sub_arg:
cmd_swarm_cancel(sub_arg)
else:
console.print("[red]Usage: /swarm cancel <run_id>[/red]")
else:
console.print(f"[red]Unknown swarm command: {sub}[/red]")
def cmd_interactive(max_iter: int) -> None:
"""Interactive mode with welcome screen, slash commands, and agent conversation."""
_print_welcome()
from src.preflight import run_preflight
results = run_preflight(console)
if any(r.critical and r.status != "ready" for r in results):
return
history: List[Dict[str, str]] = []
stats = _SessionStats(session_start=time.monotonic())
prompt_session = _create_prompt_session(stats)
while True:
if prompt_session is None:
_print_status_bar(stats)
try:
user_input = _read_input(prompt_session).strip()
except (KeyboardInterrupt, EOFError):
break
if not user_input:
continue
if user_input.lower() in ("q", "quit", "exit"):
break
# Slash commands
if user_input.startswith("/"):
try:
_handle_slash_command(user_input, max_iter=max_iter)
except EOFError:
break
continue
# Natural language -> agent
from src.tools import build_registry
from src.providers.chat import ChatLLM
from src.agent.loop import AgentLoop
from src.memory.persistent import PersistentMemory
run_start = time.perf_counter()
run_state = {"label": "connecting"}
pending_tool = {"name": "", "args_preview": ""}
stop_timer = threading.Event()
def _status_event_callback(event_type: str, data: Dict[str, Any]) -> None:
if event_type == "tool_result":
stats.total_tool_ms += data.get("elapsed_ms", 0)
stats.tool_count += 1
run_state["label"] = "thinking"
elif event_type == "tool_call":
run_state["label"] = f"running {data.get('tool', '?')}"
elif event_type == "text_delta":
run_state["label"] = "streaming"
elif event_type == "compact":
run_state["label"] = "compressing context"
_on_event_interactive(event_type, data)
def _on_event_interactive(event_type: str, data: Dict[str, Any]) -> None:
if event_type == "text_delta":
console.print(data.get("delta", ""), end="", style="dim")
elif event_type == "thinking_done":
console.print()
elif event_type == "tool_call":
pending_tool["name"] = data.get("tool", "")
pending_tool["args_preview"] = _format_tool_call_args(
data.get("tool", ""), data.get("arguments", {})
)
elif event_type == "tool_result":
tool = data.get("tool", "") or pending_tool["name"]
name = tool
args_preview = pending_tool["args_preview"]
status_str = data.get("status", "ok")
elapsed_ms = data.get("elapsed_ms", 0)
elapsed_s = elapsed_ms / 1000
ok = status_str == "ok"
mark = "[green]\u2713[/green]" if ok else "[red]\u2717[/red]"
preview = _format_tool_result_preview(tool, status_str, data.get("preview", ""))
suffix = f" {preview}" if preview else ""
console.print(f" [cyan]\u25b6 {name}[/cyan]{args_preview} {mark} [dim]{elapsed_s:.1f}s[/dim]{suffix}")
pending_tool["name"] = ""
pending_tool["args_preview"] = ""
elif event_type == "compact":
tokens = data.get("tokens_before", "?")
console.print(f"\n [yellow]\u27f3 context compressed[/yellow] [dim]({tokens} tokens \u2192 summary)[/dim]\n")
def _timer_loop(status_ref: Any) -> None:
while not stop_timer.is_set():
elapsed = time.perf_counter() - run_start
label = run_state["label"]
try:
status_ref.update(f"[bold cyan]\u23f3 {label}... {elapsed:.1f}s[/bold cyan]")
except Exception:
pass
stop_timer.wait(1.0)
with console.status("[bold cyan]\u23f3 Connecting...[/bold cyan]") as spinner:
timer_thread = threading.Thread(target=_timer_loop, args=(spinner,), daemon=True)
timer_thread.start()
try:
pm = PersistentMemory()
agent = AgentLoop(
registry=build_registry(persistent_memory=pm),
llm=ChatLLM(),
event_callback=_status_event_callback,
max_iterations=max_iter,
persistent_memory=pm,
)
result = agent.run(user_message=user_input, history=history[-6:])
except KeyboardInterrupt:
console.print("\n[yellow]Interrupted[/yellow]")
continue
finally:
stop_timer.set()
timer_thread.join(timeout=1)
stats.last_elapsed = time.perf_counter() - run_start
_print_result(result, stats.last_elapsed)
history.append({"role": "user", "content": user_input})
if result.get("content"):
history.append({"role": "assistant", "content": result["content"]})
console.print("[dim]Goodbye[/dim]")
# ---------------------------------------------------------------------------
# Swarm live streaming (Rich Live panel)
# ---------------------------------------------------------------------------
def _get_agent_style(agent_id: str) -> str:
"""Assign a consistent color to each agent."""
if agent_id not in _agent_color_map:
idx = len(_agent_color_map) % len(_AGENT_STYLES)
_agent_color_map[agent_id] = _AGENT_STYLES[idx]
return _agent_color_map[agent_id]
class _SwarmDashboard:
"""Track swarm state and render a Rich Live panel."""
def __init__(self, preset: str, run_id: str) -> None:
self.preset = preset
self.run_id = run_id
self.start_time = time.monotonic()
self.current_layer = 0
self.total_layers = 0
self.agents: Dict[str, Dict[str, Any]] = {}
self.agent_order: List[str] = []
self.completed_summaries: List[tuple[str, str]] = []
self.finished = False
self.final_status = ""
def _ensure_agent(self, agent_id: str) -> str:
"""Register an agent by its ID if not already tracked. Return its key."""
if agent_id in self.agents:
return agent_id
self.agents[agent_id] = {
"name": agent_id, "status": "waiting",
"tool": "\u2014", "elapsed": 0.0, "iters": 0,
"started_at": 0.0, "layer": self.current_layer,
"last_text": "",
}
self.agent_order.append(agent_id)
return agent_id
def handle_event(self, event) -> None:
"""Process a swarm event and update internal state."""
agent_id = event.agent_id or ""
etype = event.type
data = event.data
if etype == "layer_started":
self.current_layer = data.get("layer", 0)
self.total_layers = max(self.total_layers, self.current_layer + 1)
return
if etype == "run_completed":
self.finished = True
self.final_status = data.get("status", "unknown")
return
if not agent_id:
return
key = self._ensure_agent(agent_id)
agent = self.agents[key]
if etype == "task_started":
agent["status"] = "running"
agent["started_at"] = time.monotonic()
elif etype == "tool_call":
agent["tool"] = data.get("tool", "?")
agent["iters"] += 1
elif etype == "tool_result":
agent["elapsed"] = (time.monotonic() - agent["started_at"]) if agent["started_at"] else 0
tool_name = agent["tool"]
status_char = "\u2713" if data.get("status", "ok") == "ok" else "\u2717"
agent["tool"] = f"{tool_name} {status_char}"
elif etype == "task_completed":
agent["status"] = "done"
agent["elapsed"] = (time.monotonic() - agent["started_at"]) if agent["started_at"] else 0
agent["iters"] = data.get("iterations", agent["iters"])
summary = data.get("summary", "")
if summary:
self.completed_summaries.append((agent["name"], summary))
elif etype == "task_failed":
agent["status"] = "failed"
agent["elapsed"] = (time.monotonic() - agent["started_at"]) if agent["started_at"] else 0
error = data.get("error", "")[:80]
self.completed_summaries.append((agent["name"], f"[red]FAILED: {error}[/red]"))
elif etype == "task_retry":
attempt = data.get("attempt", "?")
agent["status"] = "retry"
agent["tool"] = f"retry {attempt}"
elif etype == "worker_text":
content = data.get("content", "").strip()
if content:
# Keep last non-empty line for display
last_line = content.split("\n")[-1].strip()
if last_line:
agent["last_text"] = last_line[:60]
def build_table(self) -> Table:
"""Build the Rich Table for the live panel."""
elapsed_total = time.monotonic() - self.start_time
mins, secs = divmod(int(elapsed_total), 60)
if self.finished:
color = "green" if self.final_status == "completed" else "red"
title_status = f"[{color}]{self.final_status.upper()}[/{color}]"
else:
title_status = f"[cyan]RUNNING[/cyan]"
title = f"{self.preset} {title_status} {mins}:{secs:02d}"
table = Table(
title=title,
border_style="cyan" if not self.finished else ("green" if self.final_status == "completed" else "red"),
show_lines=False,
pad_edge=True,
expand=True,
)
table.add_column("Agent", style="bold", width=20, no_wrap=True)
table.add_column("Status", width=12, justify="center")
table.add_column("Tool", width=14, no_wrap=True)
table.add_column("Time", width=7, justify="right")
table.add_column("Iters", width=5, justify="right")
table.add_column("Output", no_wrap=True, style="dim")
for agent_key in self.agent_order:
agent = self.agents[agent_key]
name = agent["name"]
style = _get_agent_style(name)
styled_name = f"[{style}]{name}[/{style}]"
status = agent["status"]
if status == "running":
status_str = "[\u25b6 running]"
elapsed = time.monotonic() - agent["started_at"] if agent["started_at"] else 0
elif status == "done":
status_str = "[green][\u2713 done ][/green]"
elapsed = agent["elapsed"]
elif status == "failed":
status_str = "[red][\u2717 failed][/red]"
elapsed = agent["elapsed"]
elif status == "retry":
status_str = "[yellow][\u21bb retry ][/yellow]"
elapsed = time.monotonic() - agent["started_at"] if agent["started_at"] else 0
else:
status_str = "[dim][\u25cb waiting][/dim]"
elapsed = 0
time_str = f"{elapsed:.1f}s" if elapsed > 0 else "\u2014"
iter_str = str(agent["iters"]) if agent["iters"] > 0 else "\u2014"
last_text = agent.get("last_text", "")
table.add_row(styled_name, status_str, agent["tool"], time_str, iter_str, last_text)
# Progress bar row
done_count = sum(1 for a in self.agents.values() if a["status"] in ("done", "failed"))
total_count = len(self.agents) or 1
pct = int(done_count / total_count * 100)
bar_width = 40
filled = int(bar_width * pct / 100)
bar = "\u2501" * filled + "[dim]" + "\u2501" * (bar_width - filled) + "[/dim]"
if self.finished:
bar_color = "green" if self.final_status == "completed" else "red"
progress_label = f"[{bar_color}]{self.final_status.upper()}[/{bar_color}]"
else:
progress_label = f"Layer {self.current_layer}"
table.add_section()
table.add_row(
progress_label,
f"{bar}",
f"[bold]{pct}%[/bold]",
f"{mins}:{secs:02d}",
"",
"",
)
return table
def cmd_swarm_run_live(preset: str, vars_json: Optional[str] = None) -> None:
"""Run a swarm preset with Rich Live dashboard."""
from rich.live import Live
from src.swarm.runtime import SwarmRuntime
from src.swarm.store import SwarmStore
from src.swarm.models import RunStatus
user_vars: Dict[str, str] = {}
if vars_json:
try:
user_vars = json.loads(vars_json)
except json.JSONDecodeError as exc:
console.print(f"[red]Invalid JSON: {exc}[/red]")
return
store = SwarmStore(base_dir=SWARM_DIR)
runtime = SwarmRuntime(store=store)
_agent_color_map.clear()
console.print(f"\n[dim]Starting swarm:[/dim] [cyan]{preset}[/cyan]")
if user_vars:
console.print(f"[dim]Variables:[/dim] {json.dumps(user_vars, ensure_ascii=False)}")
dashboard = _SwarmDashboard(preset, "")
try:
run = runtime.start_run(
preset,
user_vars,
live_callback=dashboard.handle_event,
include_shell_tools=True,
)
except FileNotFoundError as exc:
console.print(f"[red]{exc}[/red]")
return
except ValueError as exc:
console.print(f"[red]DAG validation failed: {exc}[/red]")
return
dashboard.run_id = run.id
with Live(dashboard.build_table(), console=console, refresh_per_second=4, transient=False) as live:
try:
while True:
time.sleep(0.25)
live.update(dashboard.build_table())
current = store.load_run(run.id)
if current is None:
console.print("[red]Run record lost[/red]")
return
if current.status in (RunStatus.completed, RunStatus.failed, RunStatus.cancelled):
dashboard.finished = True
dashboard.final_status = current.status.value
live.update(dashboard.build_table())
break
except KeyboardInterrupt:
console.print("\n[yellow]Cancelling...[/yellow]")
runtime.cancel_run(run.id)
time.sleep(1)
current = store.load_run(run.id)
if current is None:
return
# Print completed agent summaries
for agent_name, summary in dashboard.completed_summaries:
style = _get_agent_style(agent_name)
console.print(f"\n[{style}]\u2500\u2500 {agent_name} \u2500\u2500[/{style}]")
# Truncate to first meaningful chunk
lines = summary.strip().split("\n")
preview = "\n".join(lines[:8])
if len(lines) > 8:
preview += "\n[dim]...[/dim]"
console.print(preview)
# Final report
status_color = {
RunStatus.completed: "green",
RunStatus.failed: "red",
RunStatus.cancelled: "yellow",
}.get(current.status, "dim")
elapsed_total = time.monotonic() - dashboard.start_time
mins, secs = divmod(int(elapsed_total), 60)
tokens_in = current.total_input_tokens
tokens_out = current.total_output_tokens
token_str = ""
if tokens_in or tokens_out:
token_str = f"\nTokens: ~{tokens_in + tokens_out:,} (in: {tokens_in:,} out: {tokens_out:,})"
if current.final_report:
console.print(f"\n[bold]\u2500\u2500 Final Report \u2500\u2500[/bold]")
console.print(current.final_report[:2000])
console.print(f"\n[{status_color}]{current.status.value.upper()}[/{status_color}] Time: {mins}m {secs}s{token_str}")
# ---------------------------------------------------------------------------
# Legacy subcommands (used by flags and slash commands)
# ---------------------------------------------------------------------------
def cmd_chat(max_iter: int) -> None:
"""Interactive mode (delegates to cmd_interactive)."""
cmd_interactive(max_iter)
def cmd_list(limit: int = 20) -> None:
"""List run history."""
if not RUNS_DIR.exists():
console.print("[dim]No runs yet[/dim]")
return
dirs = sorted([d for d in RUNS_DIR.iterdir() if d.is_dir()], key=lambda d: d.name, reverse=True)[:limit]
if not dirs:
console.print("[dim]No runs yet[/dim]")
return
table = Table(show_lines=False)
table.add_column("Run ID", style="cyan", no_wrap=True)
table.add_column("Status", width=8)
table.add_column("Return", width=10)
table.add_column("Sharpe", width=8)
table.add_column("Prompt", max_width=40)
for d in dirs:
st = _read_json(d / "state.json").get("status", "?")
m = _read_metrics(d / "artifacts" / "metrics.csv")
c = "green" if st == "success" else "red" if st == "failed" else "dim"
table.add_row(d.name, f"[{c}]{st.upper()}[/{c}]", m.get("total_return", ""), m.get("sharpe", ""), (_read_json(d / "req.json").get("prompt") or "")[:40])
console.print(table)
def cmd_show(run_id: str) -> None:
"""Show run details."""
run_dir = RUNS_DIR / run_id
if not run_dir.exists():
console.print(f"[red]{run_id} not found[/red]")
return
state = _read_json(run_dir / "state.json")
req = _read_json(run_dir / "req.json")
metrics = _read_metrics(run_dir / "artifacts" / "metrics.csv")
st = state.get("status", "unknown")
c = "green" if st == "success" else "red"
lines = [f"[bold]Status:[/bold] [{c}]{st.upper()}[/{c}]"]
if req.get("prompt"):
lines.append(f"[bold]Prompt:[/bold] {req['prompt'][:500]}{'...' if len(req['prompt']) > 500 else ''}")
if metrics:
lines.append("\n[bold]Metrics:[/bold]")
lines.extend(f" {k}: {v}" for k, v in metrics.items())
from src.agent.trace import TraceWriter
entries = TraceWriter.read(run_dir)
answers = [e["content"] for e in entries if e.get("type") == "answer" and e.get("content")]
if answers:
summary = answers[-1][:200]
lines.append(f"\n[bold]Answer:[/bold] {summary}{'...' if len(answers[-1]) > 200 else ''}")
if state.get("reason"):
lines.append(f"\n[bold]Reason:[/bold] {state['reason']}")
console.print(Panel("\n".join(lines), border_style=c, title=run_id))
console.print(f"[dim]{run_dir}[/dim]")
def cmd_code(run_id: str) -> None:
"""Show generated code."""
code_dir = RUNS_DIR / run_id / "code"
if not code_dir.exists():
console.print(f"[red]{run_id}/code not found[/red]")
return
for name in ("signal_engine.py",):
path = code_dir / name
if path.exists():
code = path.read_text(encoding="utf-8")
console.print(Syntax(code, "python", theme="monokai", line_numbers=True), width=120)
console.print()
def cmd_pine(run_id: str) -> None:
"""Show Pine Script for a run."""
pine_path = RUNS_DIR / run_id / "artifacts" / "strategy.pine"
if not pine_path.exists():
console.print(f"[red]{run_id}/artifacts/strategy.pine not found[/red]")
console.print("[dim]Ask the agent: \"export this strategy to Pine Script\"[/dim]")
return
code = pine_path.read_text(encoding="utf-8")
console.print(Syntax(code, "javascript", theme="monokai", line_numbers=True), width=120)
console.print()
console.print("[dim]Copy and paste into TradingView Pine Editor → Add to Chart[/dim]")
def cmd_skills() -> None:
"""List available skills."""
from src.agent.skills import SkillsLoader
loader = SkillsLoader()
table = Table(title="Skills", show_lines=False)
table.add_column("Name", style="cyan")
table.add_column("Description")
for s in loader.skills:
table.add_row(s.name, s.description)
console.print(table)
def cmd_trace(run_id: str) -> None:
"""Replay trace.jsonl to show full execution."""
from datetime import datetime
from src.agent.trace import TraceWriter
run_dir = RUNS_DIR / run_id
if not run_dir.exists():
console.print(f"[red]{run_id} not found[/red]")
return
entries = TraceWriter.read(run_dir)
if not entries:
console.print(f"[red]{run_id}/trace.jsonl is empty or missing[/red]")
return
console.print(Panel(f"[bold]Trace replay: {run_id}[/bold] ({len(entries)} entries)", border_style="cyan"))
for entry in entries:
etype = entry.get("type", "?")
ts = entry.get("ts", 0)
ts_str = datetime.fromtimestamp(ts).strftime("%H:%M:%S") if ts else ""
it = entry.get("iter", "")
iter_tag = f"[dim]#{it}[/dim] " if it else ""
if etype == "start":
console.print(f"\n[bold cyan]{ts_str}[/bold cyan] {iter_tag}[bold]START[/bold] {entry.get('prompt', '')[:120]}")
elif etype == "thinking":
content = entry.get("content", "")
console.print(f"[dim]{ts_str}[/dim] {iter_tag}[dim italic]{content[:200]}[/dim italic]")
elif etype == "tool_call":
tool = entry.get("tool", "")
args = entry.get("args", {})
args_str = ", ".join(f"{k}={v[:40]}" for k, v in args.items()) if args else ""
console.print(f"[dim]{ts_str}[/dim] {iter_tag}[cyan]\u25b6 {tool}[/cyan]({args_str})")
elif etype == "tool_result":
tool = entry.get("tool", "")
status = entry.get("status", "ok")
elapsed = entry.get("elapsed_ms", 0)
ok = status == "ok"
mark = "\u2713" if ok else "\u2717"
color = "green" if ok else "red"
preview = entry.get("preview", "")[:80]
console.print(f"[dim]{ts_str}[/dim] {iter_tag}[{color}]{mark} {tool}[/{color}] [dim]{elapsed}ms[/dim] {preview}")
elif etype == "tool_skipped":
console.print(f"[dim]{ts_str}[/dim] {iter_tag}[yellow]\u2298 {entry.get('tool', '')} (skipped)[/yellow]")
elif etype == "answer":
content = entry.get("content", "")
console.print(f"\n[dim]{ts_str}[/dim] {iter_tag}[bold green]ANSWER[/bold green]\n{content[:500]}")
elif etype == "end":
status = entry.get("status", "?")
iters = entry.get("iterations", "?")
color = "green" if status == "success" else "red"
console.print(f"\n[bold {color}]{ts_str} END[/bold {color}] status={status} iterations={iters}")
console.print()
# ---------------------------------------------------------------------------
# Swarm subcommands
# ---------------------------------------------------------------------------
def cmd_swarm_presets() -> None:
"""List available swarm presets."""
from src.swarm.presets import list_presets
presets = list_presets()
if not presets:
console.print("[dim]No presets available[/dim]")
return
table = Table(title="Swarm Presets", show_lines=False)
table.add_column("Name", style="cyan", no_wrap=True)
table.add_column("Title")
table.add_column("Agents", width=8, justify="right")
table.add_column("Variables")
table.add_column("Description", max_width=40)
for p in presets:
raw_vars = p.get("variables", [])
var_names = [
v["name"] if isinstance(v, dict) else str(v) for v in raw_vars
]
vars_str = ", ".join(var_names)
table.add_row(
p["name"],
p.get("title", ""),
str(p.get("agent_count", 0)),
vars_str,
p.get("description", "")[:40],
)
console.print(table)
def cmd_swarm_run(preset: str, vars_json: Optional[str] = None) -> None:
"""Run swarm preset (legacy polling mode, use cmd_swarm_run_live for streaming)."""
cmd_swarm_run_live(preset, vars_json)
def cmd_swarm_inspect(preset: str) -> int:
"""Inspect a swarm preset without starting workers."""
from src.swarm.presets import inspect_preset
try:
report = inspect_preset(preset)
except FileNotFoundError as exc:
console.print(f"[red]{exc}[/red]")
return EXIT_USAGE_ERROR
except Exception as exc:
console.print(f"[red]Failed to inspect preset:[/red] {exc}")
return EXIT_RUN_FAILED
status = "OK" if report["valid"] else "INVALID"
status_color = "green" if report["valid"] else "red"
lines = [
f"[bold]Preset:[/bold] {report['name']}",
f"[bold]Title:[/bold] {report.get('title') or '-'}",
f"[bold]Status:[/bold] [{status_color}]{status}[/{status_color}]",
f"[bold]Agents:[/bold] {len(report['agents'])}",
f"[bold]Tasks:[/bold] {len(report['tasks'])}",
f"[bold]Variables:[/bold] {', '.join(report['variables']) or '-'}",
]
if report.get("description"):
lines.append(f"[bold]Description:[/bold] {report['description']}")
console.print(Panel("\n".join(lines), border_style=status_color, title="Swarm Preset Inspect"))
agent_table = Table(title="Agents", show_lines=False)
agent_table.add_column("ID", style="cyan", no_wrap=True)
agent_table.add_column("Role")
agent_table.add_column("Tools", max_width=40)
for agent in report["agents"]:
agent_table.add_row(
agent["id"],
agent.get("role", ""),
", ".join(agent.get("tools", [])),
)
console.print(agent_table)
dag_table = Table(title="DAG Execution Plan", show_lines=False)
dag_table.add_column("Layer", justify="right", width=6)
dag_table.add_column("Task", style="cyan")
dag_table.add_column("Agent")
dag_table.add_column("Depends On")
task_details = {task["id"]: task for task in report["tasks"]}
for idx, layer in enumerate(report["layers"], start=1):
for item in layer:
task = task_details[item["task_id"]]
dag_table.add_row(
str(idx),
item["task_id"],
item["agent_id"],
", ".join(task.get("depends_on", [])) or "-",
)
console.print(dag_table)
validation_table = Table(title="Validation", show_lines=False)
validation_table.add_column("Level", width=8)
validation_table.add_column("Message")
if report["errors"]:
for error in report["errors"]:
validation_table.add_row("[red]ERROR[/red]", error)
if report["warnings"]:
for warning in report["warnings"]:
validation_table.add_row("[yellow]WARN[/yellow]", warning)
if not report["errors"] and not report["warnings"]:
validation_table.add_row("[green]OK[/green]", "No issues found")
console.print(validation_table)
return EXIT_SUCCESS if report["valid"] else EXIT_RUN_FAILED
def cmd_swarm_list() -> None:
"""List swarm run history."""
from src.swarm.store import SwarmStore
store = SwarmStore(base_dir=SWARM_DIR)
runs = store.list_runs()
if not runs:
console.print("[dim]No swarm runs yet[/dim]")
return
table = Table(title="Swarm Runs", show_lines=False)
table.add_column("Run ID", style="cyan", no_wrap=True)
table.add_column("Preset")
table.add_column("Status", width=12)
table.add_column("Tasks", width=6, justify="right")
table.add_column("Created", width=20)
for run in runs:
sc = {
"completed": "green",
"failed": "red",
"cancelled": "yellow",
"running": "blue",
}.get(run.status.value, "dim")
table.add_row(
run.id,
run.preset_name,
f"[{sc}]{run.status.value}[/{sc}]",
str(len(run.tasks)),
run.created_at[:19],
)
console.print(table)
def cmd_swarm_show(run_id: str) -> None:
"""Show swarm run details."""
from src.swarm.store import SwarmStore
from src.swarm.models import TaskStatus
store = SwarmStore(base_dir=SWARM_DIR)
run = store.load_run(run_id)
if run is None:
console.print(f"[red]Swarm run {run_id} not found[/red]")
return
status_color = {
"completed": "green",
"failed": "red",
"cancelled": "yellow",
"running": "blue",
}.get(run.status.value, "dim")
lines = [
f"[bold]Status:[/bold] [{status_color}]{run.status.value.upper()}[/{status_color}]",
f"[bold]Preset:[/bold] {run.preset_name}",
f"[bold]Created:[/bold] {run.created_at}",
]
if run.completed_at:
lines.append(f"[bold]Completed:[/bold] {run.completed_at}")
if run.user_vars:
lines.append(f"[bold]Variables:[/bold] {json.dumps(run.user_vars, ensure_ascii=False)}")
tokens_in = run.total_input_tokens
tokens_out = run.total_output_tokens
if tokens_in or tokens_out:
lines.append(f"[bold]Tokens:[/bold] ~{tokens_in + tokens_out:,} (in: {tokens_in:,} out: {tokens_out:,})")
lines.append(f"\n[bold]Tasks ({len(run.tasks)}):[/bold]")
for task in run.tasks:
tc = "green" if task.status == TaskStatus.completed else "red" if task.status == TaskStatus.failed else "dim"
dep_str = f" (deps: {', '.join(task.depends_on)})" if task.depends_on else ""
task_line = f" [{tc}]{task.id}[/{tc}] -> {task.agent_id}{dep_str} [{task.status.value}]"
lines.append(task_line)
if task.summary:
lines.append(f" {task.summary[:100]}")
if task.error:
lines.append(f" [red]{task.error[:100]}[/red]")
if run.final_report:
lines.append(f"\n[bold]Final Report:[/bold]\n{run.final_report[:800]}")
console.print(Panel("\n".join(lines), border_style=status_color, title=run_id))
def cmd_swarm_cancel(run_id: str) -> None:
"""Cancel a swarm run."""
from src.swarm.runtime import SwarmRuntime
from src.swarm.store import SwarmStore
store = SwarmStore(base_dir=SWARM_DIR)
runtime = SwarmRuntime(store=store)
if runtime.cancel_run(run_id):
console.print(f"[yellow]Cancel signal sent: {run_id}[/yellow]")
else:
console.print(f"[red]Run {run_id} not found or already finished[/red]")
# ---------------------------------------------------------------------------
# Session subcommands
# ---------------------------------------------------------------------------
def cmd_sessions() -> None:
"""List chat sessions."""
from src.session.store import SessionStore
store = SessionStore(base_dir=SESSIONS_DIR)
sessions = store.list_sessions()
if not sessions:
console.print("[dim]No sessions yet[/dim]")
return
table = Table(title="Sessions", show_lines=False)
table.add_column("Session ID", style="cyan", no_wrap=True)
table.add_column("Title", max_width=30)
table.add_column("Status", width=10)
table.add_column("Messages", width=8, justify="right")
table.add_column("Updated", width=20)
for s in sessions:
messages = store.get_messages(s.session_id)
sc = "green" if s.status.value == "active" else "dim"
table.add_row(
s.session_id,
s.title or "[dim]untitled[/dim]",
f"[{sc}]{s.status.value}[/{sc}]",
str(len(messages)),
s.updated_at[:19],
)
console.print(table)
def cmd_session_chat(session_id: str, max_iter: int) -> None:
"""Continue a session chat."""
from src.session.store import SessionStore
store = SessionStore(base_dir=SESSIONS_DIR)
session = store.get_session(session_id)
if session is None:
console.print(f"[red]Session {session_id} not found[/red]")
return
messages = store.get_messages(session_id)
history: List[Dict[str, str]] = []
for msg in messages:
if msg.role in ("user", "assistant") and msg.content.strip():
history.append({"role": msg.role, "content": msg.content})
console.print(Panel(
f"[bold cyan]Session: {session.title or session_id}[/bold cyan]\n"
f"[dim]History: {len(messages)} messages | Type q to exit[/dim]",
border_style="cyan",
))
stats = _SessionStats(session_start=time.monotonic())
prompt_session = _create_prompt_session(stats)
while True:
if prompt_session is None:
_print_status_bar(stats)
try:
prompt = _read_input(prompt_session).strip()
except (KeyboardInterrupt, EOFError):
break
if not prompt or prompt.lower() in ("q", "quit", "exit"):
break
run_start = time.perf_counter()
_run_state = {"label": "running"}
_stop_timer = threading.Event()
def _session_event_timer(status_ref: Any) -> None:
while not _stop_timer.is_set():
elapsed = time.perf_counter() - run_start
label = _run_state["label"]
try:
status_ref.update(f"[bold cyan]\u23f3 {label}... {elapsed:.1f}s[/bold cyan]")
except Exception:
pass
_stop_timer.wait(1.0)
with console.status("[bold cyan]\u23f3 Running...[/bold cyan]") as spinner:
_timer = threading.Thread(target=_session_event_timer, args=(spinner,), daemon=True)
_timer.start()
try:
result = _run_agent(prompt, history=history[-6:], max_iter=max_iter)
except KeyboardInterrupt:
console.print("\n[yellow]Interrupted[/yellow]")
continue
finally:
_stop_timer.set()
_timer.join(timeout=1)
stats.last_elapsed = time.perf_counter() - run_start
_print_result(result, stats.last_elapsed)
history.append({"role": "user", "content": prompt})
if result.get("content"):
history.append({"role": "assistant", "content": result["content"]})
console.print("[dim]Goodbye[/dim]")
# ---------------------------------------------------------------------------
# Upload subcommand
# ---------------------------------------------------------------------------
def cmd_upload(file_path: str) -> None:
"""Upload a file to the server."""
src = Path(file_path)
if not src.exists():
console.print(f"[red]File not found: {file_path}[/red]")
return
if not src.is_file():
console.print(f"[red]Not a file: {file_path}[/red]")
return
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
ext = src.suffix
dest_name = f"{uuid.uuid4().hex[:12]}{ext}"
dest = UPLOADS_DIR / dest_name
shutil.copy2(str(src), str(dest))
console.print(f"[green]Uploaded:[/green] {dest}")
def cmd_provider_login(provider: str) -> int:
"""Authenticate OAuth-backed LLM providers."""
normalized = provider.strip().lower().replace("_", "-")
if normalized != "openai-codex":
console.print("[red]Unknown OAuth provider.[/red] Supported: openai-codex")
return EXIT_USAGE_ERROR
try:
from src.providers.openai_codex import login_openai_codex
console.print("[cyan]Starting OpenAI Codex OAuth login...[/cyan]\n")
token = login_openai_codex(
print_fn=lambda text: console.print(text),
prompt_fn=lambda text: Prompt.ask(text),
)
account = getattr(token, "account_id", None) or "ChatGPT"
console.print(f"[green]Authenticated with OpenAI Codex[/green] [dim]{account}[/dim]")
return EXIT_SUCCESS
except Exception as exc:
console.print(f"[red]Authentication error:[/red] {exc}")
return EXIT_RUN_FAILED
# ---------------------------------------------------------------------------
# CLI entrypoint
# ---------------------------------------------------------------------------
def _build_parser() -> argparse.ArgumentParser:
"""Build the CLI parser with subcommands and compatibility flags."""
parser = argparse.ArgumentParser(description="Vibe-Trading CLI")
parser.add_argument("--version", action="version", version=f"vibe-trading {_VERSION}")
parser.add_argument("-p", "--prompt", type=str, help="Prompt text")
parser.add_argument("-f", "--prompt-file", type=Path, help="Read prompt text from a file")
parser.add_argument("--json", action="store_true", help="Print machine-readable JSON output")
parser.add_argument("--no-rich", action="store_true", help="Disable Rich formatting")
parser.add_argument("--chat", action="store_true", help="Interactive chat mode")
parser.add_argument("--continue", dest="cont", nargs=2, metavar=("RUN_ID", "PROMPT"), help="Continue a run")
parser.add_argument("--list", action="store_true", help="List runs")
parser.add_argument("--show", metavar="RUN_ID", help="Show run details")
parser.add_argument("--code", metavar="RUN_ID", help="Show generated code")
parser.add_argument("--pine", metavar="RUN_ID", help="Show Pine Script for TradingView")
parser.add_argument("--trace", metavar="RUN_ID", help="Replay a run trace")
parser.add_argument("--skills", action="store_true", help="List skills")
parser.add_argument("--max-iter", type=int, default=50, help="Maximum agent iterations")
parser.add_argument("--swarm-presets", action="store_true", help="List swarm presets")
parser.add_argument("--swarm-inspect", metavar="PRESET", help="Inspect a swarm preset without running it")
parser.add_argument("--swarm-run", nargs="+", metavar=("PRESET", "VARS"), help="Run a swarm preset")
parser.add_argument("--swarm-list", action="store_true", help="List swarm runs")
parser.add_argument("--swarm-show", metavar="RUN_ID", help="Show a swarm run")
parser.add_argument("--swarm-cancel", metavar="RUN_ID", help="Cancel a swarm run")
parser.add_argument("--sessions", action="store_true", help="List sessions")
parser.add_argument("--session-chat", metavar="SESSION_ID", help="Continue a session chat")
parser.add_argument("--upload", metavar="FILE_PATH", help="Upload a file")
subparsers = parser.add_subparsers(dest="command")
run_parser = subparsers.add_parser("run", help="Run a prompt")
run_parser.add_argument("-p", "--prompt", dest="run_prompt", type=str, help="Prompt text")
run_parser.add_argument("-f", "--prompt-file", dest="run_prompt_file", type=Path, help="Read prompt text from a file")
run_parser.add_argument("--json", dest="run_json", action="store_true", help="Print machine-readable JSON output")
run_parser.add_argument("--no-rich", dest="run_no_rich", action="store_true", help="Disable Rich formatting")
run_parser.add_argument("--max-iter", dest="run_max_iter", type=int, default=50, help="Maximum agent iterations")
serve_parser = subparsers.add_parser("serve", help="Start the API server")
serve_parser.add_argument("--host", default="0.0.0.0", help="Bind address")
serve_parser.add_argument("--port", type=int, default=8000, help="Listen port")
serve_parser.add_argument("--dev", action="store_true", help="Start the Vite dev server")
provider_parser = subparsers.add_parser("provider", help="Manage OAuth providers")
provider_subparsers = provider_parser.add_subparsers(dest="provider_command")
login_parser = provider_subparsers.add_parser("login", help="Authenticate with an OAuth provider")
login_parser.add_argument("provider", help="OAuth provider name, e.g. openai-codex")
list_parser = subparsers.add_parser("list", help="List runs")
list_parser.add_argument("--limit", dest="list_limit", type=int, default=20, help="Maximum number of runs")
show_parser = subparsers.add_parser("show", help="Show run details")
show_parser.add_argument("run_id", help="Run identifier")
chat_parser = subparsers.add_parser("chat", help="Interactive chat mode")
chat_parser.add_argument("--max-iter", dest="chat_max_iter", type=int, default=50, help="Maximum agent iterations")
subparsers.add_parser("init", help="Interactive setup: create ~/.vibe-trading/.env")
return parser
def _handle_prompt_command(
prompt: Optional[str],
prompt_file: Optional[Path],
*,
max_iter: int,
json_mode: bool,
no_rich: bool,
) -> int:
"""Resolve a prompt and execute it."""
resolved_prompt, error_message = _read_prompt_source(prompt, prompt_file, no_rich=no_rich)
if error_message:
if json_mode:
_print_json_result({"status": "failed", "run_id": None, "run_dir": None, "reason": error_message})
else:
message = error_message if no_rich else f"[red]{error_message}[/red]"
print(error_message) if no_rich else console.print(message)
return EXIT_USAGE_ERROR
if not resolved_prompt:
if json_mode:
_print_json_result({"status": "failed", "run_id": None, "run_dir": None, "reason": "Prompt cannot be empty"})
else:
print("Prompt cannot be empty") if no_rich else console.print("[red]Prompt cannot be empty[/red]")
return EXIT_USAGE_ERROR
return cmd_run(resolved_prompt, max_iter, json_mode=json_mode, no_rich=no_rich)
_INIT_ENV_PATH = AGENT_DIR / ".env"
_PROVIDER_CHOICES: list[dict[str, str | None]] = [
{
"label": "OpenRouter (recommended - multiple models)",
"provider": "openrouter",
"key_env": "OPENROUTER_API_KEY",
"base_env": "OPENROUTER_BASE_URL",
"base_url": "https://openrouter.ai/api/v1",
"model": "deepseek/deepseek-v3.2",
"key_prefix": "sk-or-",
"key_placeholder": "sk-or-v1-...",
},
{
"label": "DeepSeek",
"provider": "deepseek",
"key_env": "DEEPSEEK_API_KEY",
"base_env": "DEEPSEEK_BASE_URL",
"base_url": "https://api.deepseek.com/v1",
"model": "deepseek-chat",
"key_prefix": "sk-",
"key_placeholder": "sk-...",
},
{
"label": "OpenAI",
"provider": "openai",
"key_env": "OPENAI_API_KEY",
"base_env": "OPENAI_BASE_URL",
"base_url": "https://api.openai.com/v1",
"model": "gpt-4o",
"key_prefix": "sk-",
"key_placeholder": "sk-...",
},
{
"label": "Gemini",
"provider": "gemini",
"key_env": "GEMINI_API_KEY",
"base_env": "GEMINI_BASE_URL",
"base_url": "https://generativelanguage.googleapis.com/v1beta/openai/",
"model": "gemini-2.5-flash",
"key_prefix": None,
"key_placeholder": "api-key...",
},
{
"label": "Groq",
"provider": "groq",
"key_env": "GROQ_API_KEY",
"base_env": "GROQ_BASE_URL",
"base_url": "https://api.groq.com/openai/v1",
"model": "llama-3.3-70b-versatile",
"key_prefix": "gsk_",
"key_placeholder": "gsk_...",
},
{
"label": "DashScope / Qwen",
"provider": "dashscope",
"key_env": "DASHSCOPE_API_KEY",
"base_env": "DASHSCOPE_BASE_URL",
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"model": "qwen-plus",
"key_prefix": "sk-",
"key_placeholder": "sk-...",
},
{
"label": "Zhipu",
"provider": "zhipu",
"key_env": "ZHIPU_API_KEY",
"base_env": "ZHIPU_BASE_URL",
"base_url": "https://open.bigmodel.cn/api/paas/v4",
"model": "glm-4-plus",
"key_prefix": None,
"key_placeholder": "api-key...",
},
{
"label": "Moonshot / Kimi",
"provider": "moonshot",
"key_env": "MOONSHOT_API_KEY",
"base_env": "MOONSHOT_BASE_URL",
"base_url": "https://api.moonshot.ai/v1",
"model": "kimi-k2.5",
"key_prefix": "sk-",
"key_placeholder": "sk-...",
},
{
"label": "MiniMax",
"provider": "minimax",
"key_env": "MINIMAX_API_KEY",
"base_env": "MINIMAX_BASE_URL",
"base_url": "https://api.minimax.io/v1",
"model": "MiniMax-Text-01",
"key_prefix": None,
"key_placeholder": "api-key...",
},
{
"label": "Xiaomi MIMO",
"provider": "mimo",
"key_env": "MIMO_API_KEY",
"base_env": "MIMO_BASE_URL",
"base_url": "https://api.xiaomimimo.com/v1",
"model": "MiMo-72B-A27B",
"key_prefix": None,
"key_placeholder": "api-key...",
},
{
"label": "Z.ai (Coding platform)",
"provider": "zai",
"key_env": "ZAI_API_KEY",
"base_env": "ZAI_BASE_URL",
"base_url": "https://api.z.ai/api/coding/paas/v4",
"model": "glm-5.1",
"key_prefix": None,
"key_placeholder": "api-key...",
},
{
"label": "Ollama (local, free)",
"provider": "ollama",
"key_env": None,
"base_env": "OLLAMA_BASE_URL",
"base_url": "http://localhost:11434/v1",
"model": "qwen2.5:32b",
"key_prefix": None,
"key_placeholder": None,
},
{
"label": "OpenAI Codex (ChatGPT OAuth)",
"provider": "openai-codex",
"key_env": None,
"base_env": "OPENAI_CODEX_BASE_URL",
"base_url": "https://chatgpt.com/backend-api/codex/responses",
"model": "openai-codex/gpt-5.3-codex",
"key_prefix": None,
"key_placeholder": None,
},
]
def _validate_api_key(api_key: str, expected_prefix: str | None) -> bool:
"""Basic API-key format validation used during interactive setup."""
if expected_prefix is None:
return True
return api_key.startswith(expected_prefix)
def _render_env_content(config: dict[str, str]) -> str:
"""Render .env content with stable ordering."""
ordered_keys = [
"LANGCHAIN_TEMPERATURE",
"LANGCHAIN_PROVIDER",
"OPENROUTER_API_KEY",
"OPENROUTER_BASE_URL",
"DEEPSEEK_API_KEY",
"DEEPSEEK_BASE_URL",
"OPENAI_API_KEY",
"OPENAI_BASE_URL",
"OPENAI_CODEX_BASE_URL",
"GEMINI_API_KEY",
"GEMINI_BASE_URL",
"GROQ_API_KEY",
"GROQ_BASE_URL",
"DASHSCOPE_API_KEY",
"DASHSCOPE_BASE_URL",
"ZHIPU_API_KEY",
"ZHIPU_BASE_URL",
"MOONSHOT_API_KEY",
"MOONSHOT_BASE_URL",
"MINIMAX_API_KEY",
"MINIMAX_BASE_URL",
"MIMO_API_KEY",
"MIMO_BASE_URL",
"ZAI_API_KEY",
"ZAI_BASE_URL",
"OLLAMA_BASE_URL",
"LANGCHAIN_MODEL_NAME",
"TUSHARE_TOKEN",
"TIMEOUT_SECONDS",
"MAX_RETRIES",
]
lines: list[str] = []
for key in ordered_keys:
value = config.get(key)
if value:
lines.append(f"{key}={value}")
return "\n".join(lines) + "\n"
def cmd_init() -> int:
"""Interactive setup: create agent/.env."""
console.print("[bold cyan]Welcome to Vibe-Trading setup![/bold cyan]\n")
if _INIT_ENV_PATH.exists():
console.print(f"[yellow]Config already exists:[/yellow] {_INIT_ENV_PATH}")
if not Confirm.ask("Overwrite it?", default=False):
console.print("[dim]Aborted.[/dim]")
return 0
console.print("Select your LLM provider:")
for idx, option in enumerate(_PROVIDER_CHOICES, start=1):
console.print(f" {idx}. {option['label']}")
choice = IntPrompt.ask(
"Provider",
choices=[str(i) for i in range(1, len(_PROVIDER_CHOICES) + 1)],
default=1,
show_choices=False,
)
selected = _PROVIDER_CHOICES[choice - 1]
provider = str(selected["provider"])
key_env = selected["key_env"]
base_env = str(selected["base_env"])
default_base_url = str(selected["base_url"])
default_model = str(selected["model"])
key_prefix = selected["key_prefix"]
key_placeholder = selected["key_placeholder"]
env_values: dict[str, str] = {
"LANGCHAIN_TEMPERATURE": "0.0",
"LANGCHAIN_PROVIDER": provider,
"LANGCHAIN_MODEL_NAME": default_model,
"TIMEOUT_SECONDS": "120",
"MAX_RETRIES": "2",
}
if key_env is not None:
while True:
api_key = Prompt.ask(
f"Enter your {provider.capitalize()} API key",
default=str(key_placeholder),
password=True,
show_default=False,
).strip()
if _validate_api_key(api_key, str(key_prefix) if key_prefix is not None else None):
env_values[str(key_env)] = api_key
break
console.print(
f"[red]That key doesn't look right.[/red] Expected it to start with [bold]{key_prefix}[/bold]."
)
elif provider == "openai-codex":
console.print("[dim]OpenAI Codex uses ChatGPT OAuth, not an API key.[/dim]")
console.print("[dim]After setup, run: vibe-trading provider login openai-codex[/dim]")
else:
console.print("[dim]Ollama does not require an API key.[/dim]")
env_values[base_env] = Prompt.ask(
"Base URL",
default=default_base_url,
show_default=True,
).strip()
env_values["LANGCHAIN_MODEL_NAME"] = Prompt.ask(
"Select default model",
default=default_model,
show_default=True,
).strip()
tushare_token = Prompt.ask(
"(Optional) Enter Tushare token for China A-share data",
default="",
show_default=False,
).strip()
if tushare_token:
env_values["TUSHARE_TOKEN"] = tushare_token
_INIT_ENV_PATH.write_text(_render_env_content(env_values), encoding="utf-8")
console.print(f"\n[green]✅ Created {_INIT_ENV_PATH} — you're ready to go![/green]")
console.print("[dim]Run:[/dim] [bold]vibe-trading[/bold]")
return 0
def main(argv: list[str] | None = None) -> int:
"""CLI entrypoint returning a process exit code."""
raw_argv = list(sys.argv[1:] if argv is None else argv)
parser = _build_parser()
try:
args = parser.parse_args(raw_argv)
except SystemExit as exc:
return int(exc.code) if isinstance(exc.code, int) else EXIT_USAGE_ERROR
if args.command == "init":
return cmd_init()
if args.command == "serve":
return serve_main(raw_argv[1:])
if args.command == "provider":
if args.provider_command == "login":
return cmd_provider_login(args.provider)
console.print("[red]provider requires a subcommand.[/red] Try: vibe-trading provider login openai-codex")
return EXIT_USAGE_ERROR
if args.command == "run":
return _handle_prompt_command(
args.run_prompt,
args.run_prompt_file,
max_iter=args.run_max_iter,
json_mode=args.run_json,
no_rich=args.run_no_rich,
)
if args.command == "list":
return _coerce_exit_code(cmd_list(args.list_limit))
if args.command == "show":
return _coerce_exit_code(cmd_show(args.show))
if args.command == "chat":
return _coerce_exit_code(cmd_interactive(args.chat_max_iter))
if args.list:
return _coerce_exit_code(cmd_list())
if args.show:
return _coerce_exit_code(cmd_show(args.show))
if args.code:
return _coerce_exit_code(cmd_code(args.code))
if args.pine:
return _coerce_exit_code(cmd_pine(args.pine))
if args.trace:
return _coerce_exit_code(cmd_trace(args.trace))
if args.skills:
return _coerce_exit_code(cmd_skills())
if args.swarm_presets:
return _coerce_exit_code(cmd_swarm_presets())
if args.swarm_inspect:
return _coerce_exit_code(cmd_swarm_inspect(args.swarm_inspect))
if args.swarm_run:
preset_name = args.swarm_run[0]
vars_json = args.swarm_run[1] if len(args.swarm_run) > 1 else None
return _coerce_exit_code(cmd_swarm_run_live(preset_name, vars_json))
if args.swarm_list:
return _coerce_exit_code(cmd_swarm_list())
if args.swarm_show:
return _coerce_exit_code(cmd_swarm_show(args.swarm_show))
if args.swarm_cancel:
return _coerce_exit_code(cmd_swarm_cancel(args.swarm_cancel))
if args.sessions:
return _coerce_exit_code(cmd_sessions())
if args.session_chat:
return _coerce_exit_code(cmd_session_chat(args.session_chat, args.max_iter))
if args.upload:
return _coerce_exit_code(cmd_upload(args.upload))
if args.chat:
return _coerce_exit_code(cmd_interactive(args.max_iter))
if args.cont:
return _coerce_exit_code(cmd_continue(args.cont[0], args.cont[1], args.max_iter, json_mode=args.json, no_rich=args.no_rich))
# No flags, no subcommand → check if prompt provided, otherwise interactive mode
if args.prompt or args.prompt_file or not sys.stdin.isatty():
return _handle_prompt_command(
args.prompt,
args.prompt_file,
max_iter=args.max_iter,
json_mode=args.json,
no_rich=args.no_rich,
)
# Default: interactive mode
return _coerce_exit_code(cmd_interactive(args.max_iter))
if __name__ == "__main__":
raise SystemExit(main())