项目文件夹

文件
wehub-resource-sync a2cb1f9821
CI / test (push) Has been cancelled
Wiki / validate (push) Has been cancelled
Deploy Wiki / Deploy to Cloudflare Pages (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:55 +08:00

5485 行
212 KiB
Python

#!/usr/bin/env python3
"""Vibe-Trading CLI for natural-language finance research and 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
# ruff: noqa: E402
import argparse
import csv
import json
import os
import re
import shutil
import signal
import subprocess
import sys
import threading
import time
import uuid
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING, 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 import box
from rich.columns import Columns
from rich.live import Live
from rich.markup import escape as rich_escape
from rich.panel import Panel
from rich.prompt import Confirm, IntPrompt, Prompt
from rich.syntax import Syntax
from rich.table import Table
from rich.text import Text
from cli.theme import get_console
from src.config.accessor import get_env_config, reset_env_config
console = get_console()
AGENT_DIR = Path(__file__).resolve().parents[1]
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"\[/?[^\]]+\]")
SWARM_RUN_USAGE = """--swarm-run PRESET '{"k":"v"}'"""
SWARM_RUN_VARS_PREVIEW_CHARS = 80
from cli._version import __version__ as _VERSION # noqa: E402 — single source of truth
if TYPE_CHECKING:
from src.agent.loop import AgentLoop
# 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] = {}
def _truncate_swarm_vars_preview(value: str) -> str:
"""Return a compact preview for a CLI JSON token."""
if len(value) <= SWARM_RUN_VARS_PREVIEW_CHARS:
return value
return value[: SWARM_RUN_VARS_PREVIEW_CHARS - 3] + "..."
def _print_swarm_vars_json_error(vars_json: str, exc: json.JSONDecodeError) -> None:
"""Print actionable JSON diagnostics for ``--swarm-run`` vars."""
preview = rich_escape(_truncate_swarm_vars_preview(vars_json))
console.print(
"[red]Invalid JSON for --swarm-run VARS.[/red]\n"
f"Offending string: {preview}\n"
f"JSON parse error: {rich_escape(str(exc))}\n"
f"Correct usage: {SWARM_RUN_USAGE}\n"
"shell quoting is the usual culprit; wrap the JSON in single quotes."
)
def _parse_swarm_run_args(values: list[str]) -> tuple[str, Optional[str]] | None:
"""Validate ``--swarm-run`` values before starting the swarm."""
if len(values) > 2:
extras = ", ".join(rich_escape(repr(token)) for token in values[2:])
console.print(
"[red]Invalid --swarm-run arguments:[/red] "
f"unexpected extra token(s): {extras}\n"
f"Correct usage: {SWARM_RUN_USAGE}"
)
return None
preset = values[0]
vars_json = values[1] if len(values) > 1 else None
if vars_json:
try:
json.loads(vars_json)
except json.JSONDecodeError as exc:
_print_swarm_vars_json_error(vars_json, exc)
return None
return preset, vars_json
_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.
"""
_cfg = get_env_config()
provider = _cfg.llm.langchain_provider
model = _cfg.llm.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 {}
def _status_style(status: str) -> str:
"""Return a consistent Rich color for status labels."""
return {
"success": "green",
"completed": "green",
"ready": "green",
"running": "cyan",
"failed": "red",
"error": "red",
"cancelled": "yellow",
"warning": "yellow",
}.get((status or "").lower(), "dim")
def _format_seconds(seconds: float) -> str:
"""Format elapsed seconds for compact terminal display."""
total = max(0, int(seconds))
mins, secs = divmod(total, 60)
if mins >= 60:
hours, mins = divmod(mins, 60)
return f"{hours:d}h {mins:02d}m"
if mins:
return f"{mins:d}m {secs:02d}s"
return f"{secs:d}s"
def _configured_label(value: str | None) -> str:
"""Render a masked configuration state."""
return "[green]configured[/green]" if value else "[yellow]not set[/yellow]"
def _state_badge(value: str | None, *, ready_label: str = "READY") -> str:
"""Render a compact terminal status badge."""
return f"[black on green] {ready_label} [/]" if value else "[black on yellow] MISSING [/]"
def _terminal_width() -> int:
"""Return the active console width with a conservative fallback."""
try:
return max(40, int(console.size.width))
except Exception:
return 80
def _ensure_cli_env() -> None:
"""Load dotenv values before rendering CLI-only settings."""
try:
from src.providers.llm import _ensure_dotenv
_ensure_dotenv()
except Exception:
pass
def _provider_key_env(provider: str | None) -> str | None:
"""Return the credential environment variable for a provider."""
return {
"openrouter": "OPENROUTER_API_KEY",
"requesty": "REQUESTY_API_KEY",
"openai": "OPENAI_API_KEY",
"deepseek": "DEEPSEEK_API_KEY",
"gemini": "GEMINI_API_KEY",
"groq": "GROQ_API_KEY",
"dashscope": "DASHSCOPE_API_KEY",
"qwen": "DASHSCOPE_API_KEY",
"zhipu": "ZHIPU_API_KEY",
"moonshot": "MOONSHOT_API_KEY",
"minimax": "MINIMAX_API_KEY",
"mimo": "MIMO_API_KEY",
"zai": "ZAI_API_KEY",
}.get((provider or "").lower())
def _provider_base_env(provider: str | None) -> str | None:
"""Return the base URL environment variable for a provider."""
return {
"openrouter": "OPENROUTER_BASE_URL",
"requesty": "REQUESTY_BASE_URL",
"openai": "OPENAI_BASE_URL",
"openai-codex": "OPENAI_CODEX_BASE_URL",
"deepseek": "DEEPSEEK_BASE_URL",
"gemini": "GEMINI_BASE_URL",
"groq": "GROQ_BASE_URL",
"dashscope": "DASHSCOPE_BASE_URL",
"qwen": "DASHSCOPE_BASE_URL",
"zhipu": "ZHIPU_BASE_URL",
"moonshot": "MOONSHOT_BASE_URL",
"minimax": "MINIMAX_BASE_URL",
"mimo": "MIMO_BASE_URL",
"zai": "ZAI_BASE_URL",
"ollama": "OLLAMA_BASE_URL",
}.get((provider or "").lower())
def _clip_inline(text: str, limit: int) -> str:
"""Collapse whitespace and clip text for single-line terminal cells."""
clipped = " ".join(str(text or "").split())
if len(clipped) <= limit:
return clipped
return clipped[: max(0, limit - 3)] + "..."
def _fit_cell(text: str, width: int) -> str:
"""Clip and pad text to an exact display cell width."""
width = max(1, width)
return _clip_inline(text, width).ljust(width)
def _styled_line(parts: list[tuple[str, int | None, str]]) -> Text:
"""Build one fixed-width line with per-cell styling."""
line = Text()
for value, width, style in parts:
rendered = value if width is None else _fit_cell(value, width)
line.append(rendered, style=style)
return line
def _stack_text(lines: list[Text]) -> Text:
"""Join Text lines while preserving segment styles."""
out = Text()
for idx, line in enumerate(lines):
if idx:
out.append("\n")
out.append_text(line)
return out
def _welcome_widths(term_width: int) -> dict[str, int]:
"""Calculate welcome-screen column widths from the terminal width."""
content_width = max(34, term_width - 8)
label = 10
right_label = 10
right_value = 8
gap = 2 if term_width < 86 else 4
left_value = max(10, content_width - label - gap - right_label - right_value)
command_gap = 2 if term_width < 86 else 6
pair_width = max(20, (content_width - command_gap) // 2)
action = min(16, max(12, pair_width // 2))
use = max(7, pair_width - action - 1)
return {
"content": content_width,
"label": label,
"left_value": left_value,
"gap": gap,
"right_label": right_label,
"right_value": right_value,
"action": action,
"use": use,
"command_gap": command_gap,
}
def _metric_value_style(key: str, value: str) -> str:
"""Return a compact color style for numeric metric values."""
if key in {"total_return", "sharpe", "excess_return", "information_ratio"}:
try:
return "green" if float(value) >= 0 else "red"
except (TypeError, ValueError):
return "white"
if key == "max_drawdown":
return "yellow"
return "white"
_SPINNER_GLYPHS = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
class _RunDashboard:
"""Render a compact live view for a single agent run."""
def __init__(self, prompt: str, max_iter: int) -> None:
self.prompt = prompt
self.max_iter = max_iter
self.start_time = time.monotonic()
self.iterations = 0
self.current_tool = "thinking"
self.current_args = ""
self.latest_text = ""
self.timeline: list[tuple[str, str, str, float, str]] = []
self.status = "running"
self.live: Optional[Live] = None
# Per-tool live feedback keyed by tool name. Supports parallel
# readonly batches (loop._execute_parallel runs up to 8 tools in
# ThreadPoolExecutor and each gets its own HeartbeatTimer). Each
# entry: {start_ts, elapsed_s, stage, current, total, message,
# prev_stage, stage_started_at}.
self.tool_active: dict[str, dict[str, Any]] = {}
self._spinner_idx = 0
self._last_progress_render: float = 0.0
def refresh(self) -> None:
"""Refresh the live display when attached to a Rich Live context."""
if self.live is not None:
self.live.update(self.render())
def _ensure_entry(self, tool: str) -> dict[str, Any]:
"""Return the active per-tool entry, creating it on first use."""
entry = self.tool_active.get(tool)
if entry is None:
entry = {
"start_ts": time.monotonic(),
"elapsed_s": 0.0,
"stage": "",
"current": None,
"total": None,
"message": "",
"prev_stage": None,
"stage_started_at": time.monotonic(),
}
self.tool_active[tool] = entry
return entry
def handle_event(self, event_type: str, data: Dict[str, Any]) -> None:
"""Update the dashboard from AgentLoop UI events."""
if event_type == "text_delta":
delta = data.get("delta", "")
if delta:
self.latest_text = (self.latest_text + delta).strip()[-260:]
self.refresh()
return
if event_type == "thinking_done":
self.current_tool = "thinking"
self.current_args = ""
self.refresh()
return
if event_type == "tool_call":
tool = data.get("tool", "")
args = data.get("arguments", {})
self.iterations += 1
self.current_tool = tool or "tool"
self.current_args = _strip_rich_tags(_format_tool_call_args(tool, args)).strip()
# If the prior timeline row is still "running" with no active
# entry in self.tool_active (i.e. its HeartbeatTimer is gone but
# no tool_result arrived), downgrade it to a warning (H2). Skip
# this when a parallel batch is still in flight — sibling tools
# legitimately remain "running" while a new call lands.
if self.timeline and self.timeline[-1][0] == "running":
prev_status, prev_tool, prev_args, _prev_el, _prev_pre = self.timeline[-1]
if prev_tool not in self.tool_active:
self.timeline[-1] = (
"warning",
prev_tool,
prev_args,
0.0,
"no result event",
)
# Reset per-tool state on each call (handles repeat invocations).
now = time.monotonic()
self.tool_active[self.current_tool] = {
"start_ts": now,
"elapsed_s": 0.0,
"stage": "",
"current": None,
"total": None,
"message": "",
"prev_stage": None,
"stage_started_at": now,
}
self.timeline.append(("running", self.current_tool, self.current_args, 0.0, ""))
self.timeline = self.timeline[-8:]
self.refresh()
return
if event_type == "tool_heartbeat":
# Keepalive while a long tool runs. Updates elapsed in-place.
tool = data.get("tool") or self.current_tool
entry = self._ensure_entry(tool)
entry["elapsed_s"] = float(data.get("elapsed_s", 0) or 0)
self.refresh()
return
if event_type == "tool_progress":
# Structured stage/current/total emitted from the tool.
tool = data.get("tool") or self.current_tool
entry = self._ensure_entry(tool)
stage = str(data.get("stage", "") or "")
if stage and stage != entry.get("stage"):
entry["prev_stage"] = entry.get("stage") or None
entry["stage_started_at"] = time.monotonic()
entry["stage"] = stage
entry["current"] = data.get("current")
entry["total"] = data.get("total")
entry["message"] = str(data.get("message", "") or "")
elapsed = data.get("elapsed_s")
if elapsed is not None:
entry["elapsed_s"] = float(elapsed)
# Throttle redraws so a chatty tool can't peg the renderer (M1).
now = time.monotonic()
if now - self._last_progress_render >= 0.25:
self._last_progress_render = now
self.refresh()
return
if event_type == "tool_result":
tool = data.get("tool", self.current_tool)
status = data.get("status", "ok")
elapsed_s = float(data.get("elapsed_ms", 0) or 0) / 1000
preview = _strip_rich_tags(_format_tool_result_preview(tool, status, data.get("preview", "")))
row_status = "success" if status == "ok" else "failed"
# Find the matching running row for this tool (may not be the last
# row when tools run in parallel).
matched = False
for idx in range(len(self.timeline) - 1, -1, -1):
row = self.timeline[idx]
if row[0] == "running" and row[1] == tool:
self.timeline[idx] = (row_status, tool, row[2], elapsed_s, preview)
matched = True
break
if not matched:
self.timeline.append((row_status, tool, "", elapsed_s, preview))
self.timeline = self.timeline[-8:]
# Drop the per-tool entry so it disappears from the active list.
self.tool_active.pop(tool, None)
if not self.tool_active:
self.current_tool = "thinking"
self.current_args = ""
self.refresh()
return
if event_type == "compact":
tokens = data.get("tokens_before", "?")
self.timeline.append(("warning", "context", "", 0.0, f"compressed after {tokens} tokens"))
self.timeline = self.timeline[-8:]
self.refresh()
def _render_progress_row(
self,
tool: str,
entry: Dict[str, Any],
spinner: str,
bar_width: int,
compact: bool,
detail_width: int,
) -> str:
"""Render a single active-tool progress row for the Current grid."""
stage = str(entry.get("stage") or "")
current_val = entry.get("current")
total_val = entry.get("total")
message = str(entry.get("message") or "")
elapsed_s = float(entry.get("elapsed_s") or 0.0)
has_count = (
isinstance(current_val, int)
and isinstance(total_val, int)
and total_val > 0
)
has_structured = bool(stage or has_count or message)
if not has_structured and elapsed_s <= 0:
return ""
if not has_structured:
# Heartbeat-only fallback. No bar, no decimal precision (L4).
plain = f"{spinner} {tool} · still running… {elapsed_s:.0f}s elapsed"
return f"[dim]{_clip_inline(plain, detail_width)}[/dim]"
# Build a plain prefix + dim suffix so markup survives clipping.
prefix_plain_parts: list[str] = [spinner]
prefix_styled_parts: list[str] = [f"[cyan]{spinner}[/cyan]"]
if stage:
prefix_plain_parts.append(stage)
prefix_styled_parts.append(f"[bold cyan]{stage}[/bold cyan]")
if has_count:
filled = max(0, min(bar_width, int(bar_width * current_val / total_val)))
bar = "#" * filled + "-" * (bar_width - filled)
prefix_plain_parts.append(f"[{bar}]")
prefix_styled_parts.append(f"[cyan]\\[{bar}][/cyan]")
count_str = f"{current_val}/{total_val}"
prefix_plain_parts.append(count_str)
prefix_styled_parts.append(f"[cyan]{count_str}[/cyan]")
else:
prefix_plain_parts.append(f"{elapsed_s:.1f}s")
prefix_styled_parts.append(f"[cyan]{elapsed_s:.1f}s[/cyan]")
prefix_plain = " ".join(prefix_plain_parts)
prefix_styled = " ".join(prefix_styled_parts)
suffix_plain_parts: list[str] = []
if message:
suffix_plain_parts.append(f{message}")
# ETA: only when count is known, we're past ~10% and at least 3 units,
# and the stage hasn't just changed (L1). Suppressed in compact mode.
if has_count and not compact and current_val >= 3 and current_val >= total_val * 0.1:
stage_started_at = entry.get("stage_started_at")
prev_stage = entry.get("prev_stage")
stable_stage = (
prev_stage is None
or (
stage_started_at is not None
and (time.monotonic() - float(stage_started_at)) >= 1.0
)
)
if stable_stage and elapsed_s > 0:
try:
eta = (elapsed_s / current_val) * (total_val - current_val)
except ZeroDivisionError:
eta = 0.0
if eta > 0 and eta == eta: # NaN check
suffix_plain_parts.append(f"· ~{eta:.0f}s left")
suffix_plain = " ".join(suffix_plain_parts)
# Clip the dim suffix to whatever space is left after the prefix.
remaining = max(0, detail_width - len(prefix_plain) - 1)
if suffix_plain and remaining > 4:
clipped_suffix = _clip_inline(suffix_plain, remaining)
return f"{prefix_styled} [dim]{clipped_suffix}[/dim]"
return prefix_styled
def render(self) -> Panel:
"""Build the Rich renderable shown while the run is active."""
term_width = _terminal_width()
compact = term_width < 86
content_width = max(32, term_width - (6 if compact else 10))
elapsed = _format_seconds(time.monotonic() - self.start_time)
prompt_preview = _clip_inline(self.prompt, min(96, max(22, content_width - 12)))
meta = Table.grid(expand=True)
meta.add_column(ratio=1)
progress = min(1.0, self.iterations / max(1, self.max_iter))
bar_width = 12 if compact else 20
filled = max(1, int(progress * bar_width)) if self.iterations else 0
bar = "#" * filled + "-" * (bar_width - filled)
progress_text = f"[cyan]{elapsed}[/cyan] [dim]{bar} {self.iterations}/{self.max_iter}[/dim]"
if compact:
meta.add_row("[bold cyan]Running agent[/bold cyan]")
meta.add_row(progress_text)
meta.add_row(f"[dim]Request: {prompt_preview}[/dim]")
else:
meta.add_column(justify="right")
meta.add_row("[bold cyan]Running agent[/bold cyan]", progress_text)
meta.add_row(f"[dim]Request: {prompt_preview}[/dim]", "")
current = Table.grid(expand=True)
current.add_column(width=8 if compact else 9, style="dim")
current.add_column(ratio=1)
tool_label = self.current_tool
if self.current_args:
tool_label = f"{tool_label} [dim]{_clip_inline(self.current_args, max(20, content_width - 18))}[/dim]"
current.add_row("Current", f"[cyan]{tool_label}[/cyan]")
# One row per active tool (caps at 3 to keep dashboard height bounded).
# Snapshot via list(...) first: Rich's refresh thread calls render()
# concurrently with heartbeat/worker threads mutating self.tool_active,
# so a bare ``.items()`` would race and may raise "dictionary changed
# size during iteration". list() materialization is GIL-atomic.
active_entries = sorted(
list(self.tool_active.items()), key=lambda kv: kv[1].get("start_ts", 0.0)
)
if len(active_entries) > 3:
active_entries = active_entries[:3]
# Advance the spinner once per render so all active rows step together.
self._spinner_idx = (self._spinner_idx + 1) % len(_SPINNER_GLYPHS)
spinner = _SPINNER_GLYPHS[self._spinner_idx]
bar_width = 6 if compact else 8
detail_width = max(20, content_width - 18)
for tool, entry in active_entries:
row_text = self._render_progress_row(
tool, entry, spinner, bar_width, compact, detail_width
)
if row_text:
current.add_row("Progress", row_text)
timeline = Table(
box=box.SIMPLE,
show_header=True,
header_style="dim",
padding=(0, 1),
expand=True,
)
timeline.add_column("State", width=7 if compact else 8, no_wrap=True)
timeline.add_column("Tool", width=12 if compact else 20, no_wrap=True)
timeline.add_column("Time", width=6 if compact else 8, justify="right")
timeline.add_column("Detail", ratio=1, overflow="fold")
rows = self.timeline[-6:] or [("running", "waiting", "", 0.0, "starting")]
for status, tool, args, elapsed_s, preview in rows:
style = _status_style(status)
label = "running" if status == "running" else ("ok" if status == "success" else "check")
detail = _clip_inline(preview or args, max(18, content_width - (35 if compact else 48)))
timeline.add_row(
f"[{style}]{label}[/{style}]",
_clip_inline(tool, 12 if compact else 20),
f"{elapsed_s:.1f}s" if elapsed_s else "",
detail,
)
latest = self.latest_text.replace("\n", " ").strip()
latest = _clip_inline(latest[-220:], max(24, content_width - 4))
body = Table.grid(expand=True)
body.add_row(meta)
body.add_row("")
body.add_row(current)
body.add_row("")
body.add_row(timeline)
if latest:
body.add_row("")
body.add_row(Panel(Text(latest, style="dim"), title="Latest answer", border_style="dim", padding=(0, 1)))
return Panel(body, title="Vibe-Trading", border_style="cyan", padding=(1, 1 if compact else 2))
from cli.ui.rail import RailRunDashboard as _RunDashboard # noqa: E402,F811
# ---------------------------------------------------------------------------
# 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 ""
# ---------------------------------------------------------------------------
# In-process mandate.proposal relay (CLI mirror of api_server's
# _mandate_proposal_frame_from_tool_result, SPEC.md Consent §1/§2)
# ---------------------------------------------------------------------------
#
# The agent loop emits the propose tool's output only as a generic
# ``tool_result`` event (``loop.py`` ``_finalize_tool_result`` → preview =
# result[:200]); it NEVER emits a top-level ``mandate.proposal`` event. So in
# the in-process REPL path nothing ever arms ``ctx.pending_proposal`` and the
# user's numeric pick falls through to the model as chat. The frontend solved
# the same gap server-side by relaying the propose-tool ``tool_result`` into a
# top-level ``mandate.proposal`` SSE frame (api_server
# ``_mandate_proposal_frame_from_tool_result``). The CLI needs the identical
# relay in its own ``on_event`` handler — done below, WITHOUT touching the
# protected ``loop.py``.
_PROPOSAL_TOOL_NAME = "propose_mandate_profiles"
_PROPOSAL_ID_RE = re.compile(r'"proposal_id"\s*:\s*"(mp_[0-9a-f]{32})"')
def _load_full_proposal(proposal_id: str) -> Optional[Dict[str, Any]]:
"""Reload a persisted ``mandate.proposal`` payload by id, broker-agnostic.
The propose tool persists the full proposal under
``<runtime_root>/live/<broker>/proposals/<proposal_id>.json`` before
returning. The ``tool_result`` preview is only the first 200 chars of the
JSON body, far too short to carry the full proposal, so the relay reloads it
from disk. The broker segment is unknown from the preview alone, so every
broker's proposals directory is searched (mirrors api_server).
Args:
proposal_id: The ``mp_...`` id parsed from the tool_result preview.
Returns:
The full proposal dict, or ``None`` when not found / unreadable.
"""
try:
from src.live.paths import live_root
for proposal_path in live_root().glob(f"*/proposals/{proposal_id}.json"):
try:
data = json.loads(proposal_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
continue
if isinstance(data, dict) and data.get("type") == "mandate.proposal":
return data
except Exception: # noqa: BLE001 — relay must never break the turn
pass
return None
def _mandate_proposal_from_tool_result(data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Recover a full ``mandate.proposal`` payload from a propose-tool result.
Detection mirrors api_server's ``_mandate_proposal_frame_from_tool_result``:
the event must be a successful ``tool_result`` for ``propose_mandate_profiles``
whose preview carries a ``proposal_id``. The full proposal is then reloaded
from disk (the preview is truncated).
Args:
data: The ``tool_result`` event payload (``tool`` / ``status`` /
``preview``).
Returns:
The full proposal dict ready to feed ``proposal_sink`` (arming
``ctx.pending_proposal``), or ``None`` when this is not a recoverable
propose-tool result.
"""
if data.get("tool") != _PROPOSAL_TOOL_NAME or data.get("status") != "ok":
return None
match = _PROPOSAL_ID_RE.search(str(data.get("preview") or ""))
if not match:
return None
return _load_full_proposal(match.group(1))
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,
dashboard: Optional[_RunDashboard] = None,
session_id: str = "",
proposal_sink: Optional[Any] = None,
) -> dict:
"""Build AgentLoop and execute, return result dict.
Args:
proposal_sink: Optional callable invoked with the payload of every
``mandate.proposal`` event the agent emits. The interactive REPL
uses this to capture an outstanding live-trading mandate proposal so
it can intercept the user's numeric pick *before* the model — a pick
is a privileged surface action (commit), never a tool the model can
call (SPEC.md Consent §2).
"""
from src.tools import build_registry
from src.providers.chat import ChatLLM
from src.agent.loop import AgentLoop
# Closure-level state for the no-rich path so dots and progress lines
# don't shoulder-bump each other (M3) and progress prints are throttled
# to ≤1/0.5s per tool (M1).
no_rich_state: dict[str, Any] = {
"dot_pending": False,
"last_progress_ts": {}, # type: ignore[var-annotated]
}
def on_event(event_type: str, data: Dict[str, Any]) -> None:
# Live mandate proposals are surfaced to the REPL out-of-band so the
# user's pick is intercepted before the model (SPEC.md Consent §2).
# This fires regardless of stream_output / rich state — capturing the
# proposal must not depend on rendering.
if event_type == "mandate.proposal" and proposal_sink is not None:
try:
proposal_sink(data)
except Exception: # noqa: BLE001 — capture must never kill the turn
pass
return
# The agent loop never emits a top-level ``mandate.proposal`` — it only
# emits the propose tool's output as a generic ``tool_result``. Relay it
# here (CLI mirror of api_server's SSE relay) so the REPL arms
# ``ctx.pending_proposal`` and intercepts the pick before the model
# (SPEC.md Consent §1/§2). Fires regardless of stream_output / rich
# state — arming must not depend on rendering — and does NOT return:
# the tool_result still flows on to the dashboard / no-rich printers.
if event_type == "tool_result" and proposal_sink is not None:
proposal = _mandate_proposal_from_tool_result(data)
if proposal is not None:
try:
proposal_sink(proposal)
except Exception: # noqa: BLE001 — relay must never kill the turn
pass
if not stream_output:
return
if dashboard is not None and not no_rich:
dashboard.handle_event(event_type, data)
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="")
no_rich_state["dot_pending"] = False
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"
# If a heartbeat dot is open on the line, break it cleanly.
if no_rich_state["dot_pending"]:
no_rich_state["dot_pending"] = False
print(f" {mark} {elapsed_s:.1f}s{_strip_rich_tags(suffix)}")
no_rich_state["last_progress_ts"].pop(tool, None)
return
if no_rich and event_type == "compact":
tokens = data.get("tokens_before", "?")
if no_rich_state["dot_pending"]:
no_rich_state["dot_pending"] = False
print()
print(f"\n context compressed ({tokens} tokens -> summary)\n")
return
if no_rich and event_type == "tool_heartbeat":
# Print a dot per tick so the user sees the tool is alive.
print(".", end="", flush=True)
no_rich_state["dot_pending"] = True
return
if no_rich and event_type == "tool_progress":
tool = data.get("tool", "") or ""
now = time.monotonic()
last_ts = no_rich_state["last_progress_ts"].get(tool, 0.0)
if now - last_ts < 0.5:
# Throttle: max one progress line per 0.5s per tool (M1).
return
no_rich_state["last_progress_ts"][tool] = now
stage = data.get("stage", "")
current_idx = data.get("current")
total = data.get("total")
message = data.get("message", "")
bits = [stage]
if isinstance(current_idx, int) and isinstance(total, int) and total > 0:
bits.append(f"{current_idx}/{total}")
if message:
bits.append(message)
label = " · ".join(b for b in bits if b)
if label:
# Break a pending dot line before printing the progress detail.
if no_rich_state["dot_pending"]:
no_rich_state["dot_pending"] = False
print()
print(f" {label}", flush=True)
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()
from src.config.loader import load_agent_config
agent_config = load_agent_config()
def _mcp_warn(msg: str) -> None:
if no_rich:
print(f"WARNING: {msg}", flush=True)
else:
console.print(f"[yellow]WARNING:[/yellow] {msg}")
agent = AgentLoop(
registry=build_registry(
persistent_memory=pm,
include_shell_tools=True,
agent_config=agent_config,
session_id=session_id or None,
warn_callback=_mcp_warn,
),
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 _run_with_graceful_cancel(
agent,
prompt,
history,
no_rich=no_rich,
session_id=session_id,
)
def _run_with_graceful_cancel(
agent: "AgentLoop",
prompt: str,
history: Optional[List[Dict]],
*,
no_rich: bool,
session_id: str = "",
) -> dict:
"""Run an agent loop with first-Ctrl+C = graceful cancel.
First SIGINT during the run sets ``agent._cancelled`` so the loop exits
cleanly after the current LLM/tool step finishes. A second SIGINT within
two seconds restores the default handler and re-raises ``KeyboardInterrupt``
for hard quit. Outside of a run the parent CLI's normal SIGINT handling
(exit on input prompt) is unaffected — the handler is restored in
``finally``.
Args:
agent: AgentLoop instance ready to ``run()``.
prompt: User prompt.
history: Recent message history.
no_rich: Whether the parent caller is rendering with Rich Live.
Returns:
AgentLoop result dict.
"""
import signal as _signal
state = {"requested": False, "last_ts": 0.0}
try:
original = _signal.getsignal(_signal.SIGINT)
except (ValueError, AttributeError):
# Not on a thread that can receive signals — skip the handler swap.
return agent.run(user_message=prompt, history=history, session_id=session_id)
def _on_sigint(_signum, _frame) -> None:
now = time.time()
if state["requested"] and (now - state["last_ts"]) < 2.0:
# Second Ctrl+C within 2s — hand control back to the default handler.
_signal.signal(_signal.SIGINT, original)
raise KeyboardInterrupt
state["requested"] = True
state["last_ts"] = now
agent.cancel()
notice = "Cancelling… current step will finish, then exit. Ctrl+C again to force quit."
if no_rich:
print(f"\n[{notice}]", flush=True)
else:
console.print(f"\n[yellow]{notice}[/yellow]")
try:
_signal.signal(_signal.SIGINT, _on_sigint)
except (ValueError, OSError):
# signal.signal only works on the main thread of the main interpreter.
return agent.run(user_message=prompt, history=history, session_id=session_id)
try:
return agent.run(user_message=prompt, history=history, session_id=session_id)
finally:
try:
_signal.signal(_signal.SIGINT, original)
except (ValueError, OSError):
pass
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")
style = _status_style(status)
run_dir = result.get("run_dir")
m = _read_metrics(Path(run_dir) / "artifacts" / "metrics.csv") if run_dir else {}
if no_rich:
print(f"Status: {status.upper()}")
print(f"Elapsed: {_format_seconds(elapsed)}")
if result.get("run_id"):
print(f"Run ID: {result['run_id']}")
review = result.get("review")
if review and review.get("overall_score") is not None:
review_status = "PASS" if review.get("passed") else "FAIL"
print(f"Review: {review_status} {review['overall_score']}pts")
if run_dir:
print(f"Run dir: {run_dir}")
if result.get("reason"):
print(f"Reason: {result['reason']}")
metric_parts = [f"{label}={m[key]}" for key, label in (
("total_return", "return"),
("sharpe", "sharpe"),
("max_drawdown", "max_dd"),
("trade_count", "trades"),
) if key in m]
if metric_parts:
print(f"Metrics: {', '.join(metric_parts)}")
content = result.get("content", "").strip()
if content:
print(f"\n{content}")
return
summary = Table.grid(expand=True)
summary.add_column(width=12, style="dim")
summary.add_column(ratio=1)
summary.add_row("Status", f"[bold {style}]{status.upper()}[/bold {style}]")
summary.add_row("Elapsed", _format_seconds(elapsed))
if result.get("run_id"):
summary.add_row("Run ID", f"[cyan]{result['run_id']}[/cyan]")
review = result.get("review")
if review and review.get("overall_score") is not None:
review_status = "PASS" if review.get("passed") else "FAIL"
review_style = "green" if review.get("passed") else "red"
summary.add_row("Review", f"[{review_style}]{review_status}[/{review_style}] {review['overall_score']}pts")
if run_dir:
summary.add_row("Run dir", f"[dim]{run_dir}[/dim]")
if result.get("reason"):
summary.add_row("Reason", f"[red]{result['reason']}[/red]")
panels = [Panel(summary, border_style=style, title="Summary", padding=(0, 1))]
metric_table = Table.grid(expand=True)
metric_table.add_column(width=12, style="dim")
metric_table.add_column(ratio=1)
has_metrics = False
for key, label in (
("total_return", "Return"),
("sharpe", "Sharpe"),
("max_drawdown", "Max DD"),
("trade_count", "Trades"),
):
if key not in m:
continue
value = m[key]
value_style = _metric_value_style(key, value)
metric_table.add_row(label, f"[{value_style}]{value}[/{value_style}]")
has_metrics = True
if has_metrics:
panels.append(Panel(metric_table, border_style="cyan", title="Metrics", padding=(0, 1)))
if result.get("run_id"):
rid = result["run_id"]
actions = Table(box=None, show_header=False, padding=(0, 1))
actions.add_column(style="cyan", no_wrap=True)
actions.add_column(style="dim")
actions.add_row(f"vibe-trading show {rid}", "details")
actions.add_row(f"vibe-trading code {rid}", "generated Python")
actions.add_row(f"vibe-trading continue {rid} \"...\"", "refine this run")
panels.append(Panel(actions, border_style="dim", title="Next", padding=(0, 1)))
if _terminal_width() < 104:
for panel in panels:
console.print(panel)
else:
console.print(Columns(panels, expand=True, equal=True))
# 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),
))
# End 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:
if json_mode or no_rich:
result = _run_agent(prompt, max_iter=max_iter, no_rich=no_rich, stream_output=not json_mode)
else:
dashboard = _RunDashboard(prompt, max_iter)
with Live(dashboard.render(), console=console, refresh_per_second=6, transient=True) as live:
dashboard.live = live
result = _run_agent(prompt, max_iter=max_iter, dashboard=dashboard)
dashboard.finish(result, time.perf_counter() - start)
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
trace_dir = TraceWriter.find_trace_dir(run_dir.name, runs_dir=RUNS_DIR, sessions_dir=SESSIONS_DIR)
if trace_dir is None:
return []
entries = TraceWriter.read(
trace_dir,
resolve_offloads=True,
resolve_fields={"prompt", "content"},
)
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
session_trace_dir = SESSIONS_DIR / run_id
if not run_dir.exists() and not session_trace_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
if not run_dir.exists():
run_dir.mkdir(parents=True, exist_ok=True)
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:
dashboard = _RunDashboard(prompt, max_iter)
with Live(dashboard.render(), console=console, refresh_per_second=6, transient=True) as live:
dashboard.live = live
result = _run_agent(
prompt,
history=history,
run_dir_override=str(run_dir),
max_iter=max_iter,
dashboard=dashboard,
)
dashboard.finish(result, time.perf_counter() - start)
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 _build_welcome_panel(term_width: Optional[int] = None) -> Panel:
"""Build the welcome screen for the given terminal width."""
_ensure_cli_env()
term_width = term_width or _terminal_width()
compact = term_width < 64
widths = _welcome_widths(term_width)
_cfg = get_env_config()
provider = _cfg.llm.langchain_provider or "(not set)"
model = _cfg.llm.langchain_model_name or "(not set)"
key_env = _provider_key_env(provider)
key_value = os.getenv(key_env or "") # noqa: env-gate — dynamic provider key display
credential_ready = provider in {"ollama", "openai-codex"} or bool(key_value)
key_state = "READY" if credential_ready else "MISSING"
recent_runs = len([d for d in RUNS_DIR.iterdir() if d.is_dir()]) if RUNS_DIR.exists() else 0
recent_swarms = len([d for d in SWARM_DIR.iterdir() if d.is_dir()]) if SWARM_DIR.exists() else 0
content_width = widths["content"]
header_lines: list[Text] = []
title = f"Vibe-Trading v{_VERSION}"
subtitle = "finance agent CLI"
if term_width < 78:
header_lines.append(Text(title, style="bold cyan"))
header_lines.append(Text(subtitle, style="dim"))
else:
header_lines.append(
_styled_line(
[
(title, content_width - len(subtitle), "bold cyan"),
(subtitle, None, "dim"),
]
)
)
header_lines.append(Text(_clip_inline("Research, backtest, inspect runs, and coordinate swarm presets.", content_width), style="dim"))
config_lines: list[Text] = []
if compact:
value_width = max(10, content_width - widths["label"] - 1)
rows = [
("Provider", str(provider), "bold cyan"),
("Model", str(model), "white"),
("Credential", key_state, "bold green" if credential_ready else "bold yellow"),
("Runs", str(recent_runs), "cyan"),
("Swarms", str(recent_swarms), "cyan"),
("Workspace", str(AGENT_DIR), "dim"),
]
for label, value, value_style in rows:
config_lines.append(
_styled_line(
[
(label, widths["label"], "dim"),
(" ", None, ""),
(value, value_width, value_style),
]
)
)
else:
gap = " " * widths["gap"]
rows = [
("Provider", str(provider), "bold cyan", "Credential", key_state, "bold green" if credential_ready else "bold yellow"),
("Model", str(model), "white", "Runs", str(recent_runs), "cyan"),
("Workspace", str(AGENT_DIR), "dim", "Swarms", str(recent_swarms), "cyan"),
]
for left_label, left_value, left_style, right_label, right_value, right_style in rows:
config_lines.append(
_styled_line(
[
(left_label, widths["label"], "dim"),
(" ", None, ""),
(left_value, widths["left_value"], left_style),
(gap, None, ""),
(right_label, widths["right_label"], "dim"),
(" ", None, ""),
(right_value, widths["right_value"], right_style),
]
)
)
action_lines: list[Text] = []
if compact:
actions = [
("type a request", "start a run"),
("/settings", "check config"),
("/list", "recent runs"),
("/swarm", "team presets"),
("/help", "all commands"),
("/quit", "exit"),
]
action_width = min(16, max(12, content_width // 2 - 1))
use_width = max(8, content_width - action_width - 1)
for action, use in actions:
action_lines.append(
_styled_line(
[
(action, action_width, "bold cyan"),
(" ", None, ""),
(use, use_width, "white"),
]
)
)
else:
gap = " " * widths["command_gap"]
rows = [
("type a request", "start a run", "/settings", "check config"),
("/list", "recent runs", "/swarm", "team presets"),
("/help", "all commands", "/quit", "exit"),
]
for left_action, left_use, right_action, right_use in rows:
action_lines.append(
_styled_line(
[
(left_action, widths["action"], "bold cyan"),
(" ", None, ""),
(left_use, widths["use"], "white"),
(gap, None, ""),
(right_action, widths["action"], "bold cyan"),
(" ", None, ""),
(right_use, widths["use"], "white"),
]
)
)
body = Table.grid(expand=True)
body.add_row(_stack_text(header_lines))
body.add_row("")
body.add_row(
Panel(
_stack_text(config_lines),
title="[bold green]Current Config[/bold green]",
border_style="green" if credential_ready else "yellow",
padding=(0, 1),
)
)
body.add_row("")
body.add_row(
Panel(
_stack_text(action_lines),
title="[bold magenta]Actions[/bold magenta]",
border_style="magenta",
padding=(0, 1),
)
)
body.add_row("")
body.add_row(Text(_clip_inline("Example: analyze AAPL momentum with risk controls", content_width), style="dim"))
return Panel(body, title="[bold cyan]Vibe-Trading[/bold cyan]", border_style="cyan", padding=(1, 1))
def _print_welcome() -> None:
"""Print the welcome screen."""
console.print(_build_welcome_panel())
def _print_help() -> None:
"""Print all available slash commands."""
table = Table(title="Commands", show_lines=False, border_style="dim", box=box.SIMPLE_HEAVY)
table.add_column("Command", style="cyan", no_wrap=True)
table.add_column("Description")
cmds = [
("/help", "Show this command list"),
("/skills", "List available trading skills"),
("/list", "List recent backtest and research runs"),
("/show <run_id>", "Open a compact run summary"),
("/code <run_id>", "Show generated Python"),
("/pine <run_id>", "Show exported Pine Script"),
("/trace <run_id>", "Replay tool calls and answer events"),
("/continue <run_id> <prompt>", "Refine an existing run"),
("/swarm", "List multi-agent team presets"),
("/swarm run <preset> {vars}", "Run a team preset"),
("/swarm inspect <preset>", "Inspect preset DAG and validation"),
("/swarm list", "List team run history"),
("/swarm show <run_id>", "Show a team run"),
("/swarm cancel <run_id>", "Cancel a team run"),
("/sessions", "List chat sessions"),
("/settings", "Show provider, model, timeout, and credentials"),
("/stop", "How to gracefully cancel a running agent"),
("/clear", "Clear the terminal"),
("/quit", "Exit"),
("", ""),
("[dim]Natural language[/dim]", ""),
('"analyze journal.csv"', "Parse a broker export and diagnose trading behavior"),
('"train my shadow"', "Extract a strategy, backtest it, and create a report"),
]
for cmd, desc in cmds:
table.add_row(cmd, desc)
console.print(table)
def _show_settings() -> None:
"""Show current runtime settings."""
_ensure_cli_env()
term_width = _terminal_width()
compact = term_width < 104
value_limit = max(18, min(56, term_width - 28))
_cfg = get_env_config()
provider = _cfg.llm.langchain_provider or "(not set)"
model = _cfg.llm.langchain_model_name or "(not set)"
provider_key_env = _provider_key_env(provider)
provider_base_env = _provider_base_env(provider)
provider_key = os.getenv(provider_key_env or "") # noqa: env-gate — dynamic provider key display
provider_base_url = os.getenv(provider_base_env or "") or os.getenv("OPENAI_BASE_URL") or os.getenv("OPENAI_API_BASE") or "(not set)" # noqa: env-gate — dynamic provider URL display
provider_table = Table.grid(expand=True)
provider_table.add_column(width=12, style="dim")
provider_table.add_column(ratio=1)
provider_table.add_row("Provider", f"[bold]{provider}[/bold]")
provider_table.add_row("Model", _clip_inline(model, value_limit))
provider_table.add_row("Base URL", _clip_inline(provider_base_url, value_limit))
runtime_table = Table.grid(expand=True)
runtime_table.add_column(width=13, style="dim")
runtime_table.add_column(ratio=1)
runtime_table.add_row("Temperature", str(_cfg.llm.langchain_temperature))
runtime_table.add_row("Timeout", str(_cfg.llm.timeout_seconds) + "s")
runtime_table.add_row("Retries", str(_cfg.llm.max_retries))
credential_table = Table.grid(expand=True)
credential_table.add_column(width=21, style="dim")
credential_table.add_column(ratio=1)
if provider in {"ollama", "openai-codex"}:
credential_table.add_row("Provider key", "[green]not required[/green]")
credential_ready = True
elif provider_key_env:
credential_table.add_row(provider_key_env, "***" if provider_key else "(not set)")
credential_ready = bool(provider_key)
else:
credential_table.add_row("Provider key", "(unknown provider)")
credential_ready = False
credential_table.add_row("TUSHARE_TOKEN", "***" if _cfg.data.tushare_token else "(optional)")
panels = [
Panel(provider_table, title=f"Provider {_state_badge(provider if provider != '(not set)' else None)}", border_style="cyan", padding=(0, 1)),
Panel(runtime_table, title="Runtime", border_style="dim", padding=(0, 1)),
Panel(credential_table, title=f"Credentials {_state_badge('ok' if credential_ready else None)}", border_style="green" if credential_ready else "yellow", padding=(0, 1)),
]
if compact:
for panel in panels:
console.print(panel)
else:
console.print(Columns(panels, expand=True, equal=True))
console.print("[dim]Edit configuration in ~/.vibe-trading/.env, or run vibe-trading init.[/dim]")
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 == "/stop":
console.print(
"[dim]No agent is running. Press [bold]Ctrl+C[/bold] during a run "
"to gracefully cancel — the current step finishes, then the loop "
"exits cleanly. Press Ctrl+C twice within 2 seconds to force quit.[/dim]"
)
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
start = time.perf_counter()
try:
dashboard = _RunDashboard(user_input, max_iter)
with Live(dashboard.render(), console=console, refresh_per_second=6, transient=True) as live:
dashboard.live = live
result = _run_agent(user_input, history=history[-6:], max_iter=max_iter, dashboard=dashboard)
dashboard.finish(result, time.perf_counter() - start)
except KeyboardInterrupt:
console.print("\n[yellow]Interrupted[/yellow]")
continue
stats.last_elapsed = time.perf_counter() - start
stats.tool_count += dashboard.iterations
_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_blocked":
agent["status"] = "blocked"
blocked_by = ", ".join(data.get("blocked_by", []))
self.completed_summaries.append(
(agent["name"], f"[yellow]BLOCKED by: {blocked_by}[/yellow]")
)
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 = "[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) -> Optional[int]:
"""Run a swarm preset with Rich Live dashboard."""
from rich.live import Live
from src.config import load_swarm_agent_config
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:
_print_swarm_vars_json_error(vars_json, exc)
return EXIT_USAGE_ERROR
store = SwarmStore(base_dir=SWARM_DIR)
agent_config = load_swarm_agent_config()
runtime = SwarmRuntime(store=store, agent_config=agent_config)
_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("\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(title="Recent Runs", show_lines=False, border_style="dim", box=box.SIMPLE_HEAVY)
table.add_column("Run ID", style="cyan", no_wrap=True)
table.add_column("Status", width=10)
table.add_column("Return", width=10)
table.add_column("Sharpe", width=8)
table.add_column("Prompt", max_width=58)
for d in dirs:
st = _read_json(d / "state.json").get("status", "?")
m = _read_metrics(d / "artifacts" / "metrics.csv")
c = _status_style(st)
prompt = (_read_json(d / "req.json").get("prompt") or "").replace("\n", " ")
if len(prompt) > 58:
prompt = prompt[:55] + "..."
table.add_row(
d.name,
f"[{c}]{st.upper()}[/{c}]",
m.get("total_return", ""),
m.get("sharpe", ""),
prompt,
)
console.print(table)
console.print("[dim]Use /show <run_id>, /code <run_id>, or /continue <run_id> <prompt>.[/dim]")
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 = _status_style(st)
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
trace_dir = TraceWriter.find_trace_dir(run_id, runs_dir=RUNS_DIR, sessions_dir=SESSIONS_DIR)
entries = (
TraceWriter.read(trace_dir, resolve_offloads=True, resolve_fields={"content"})
if trace_dir
else []
)
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, then 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 src.agent.trace import TraceWriter
trace_dir = TraceWriter.find_trace_dir(run_id, runs_dir=RUNS_DIR, sessions_dir=SESSIONS_DIR)
if trace_dir is None:
console.print(f"[red]{run_id}/trace.jsonl not found[/red]")
return
entries = TraceWriter.read(
trace_dir,
resolve_offloads=True,
resolve_fields={"prompt", "content", "summary"},
)
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}={str(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") or entry.get("result_preview") or entry.get("result") or "")[:80]
size_hint = ""
if entry.get("result_path"):
size_hint = f" [{int(entry.get('result_size') or 0) // 1024}K offloaded]"
console.print(f"[dim]{ts_str}[/dim] {iter_tag}[{color}]{mark} {tool}[/{color}] [dim]{elapsed}ms[/dim] {preview}{size_hint}")
elif etype == "tool_skipped":
console.print(f"[dim]{ts_str}[/dim] {iter_tag}[yellow]\u2298 {entry.get('tool', '')} (skipped)[/yellow]")
elif etype == "message":
role = entry.get("role", "?")
content = entry.get("content") or entry.get("content_preview") or ""
role_color = "cyan" if role == "user" else "green"
console.print(f"\n[dim]{ts_str}[/dim] {iter_tag}[bold {role_color}]{role.upper()}[/bold {role_color}] {content[:120]}")
elif etype == "answer":
content = entry.get("content", "")
console.print(f"\n[dim]{ts_str}[/dim] {iter_tag}[bold green]ANSWER[/bold green]\n{content}")
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) -> Optional[int]:
"""Run swarm preset (legacy polling mode, use cmd_swarm_run_live for streaming)."""
return 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
# ---------------------------------------------------------------------------
# Live connector runtime internals.
#
# Every state-changing verb here is a PRIVILEGED USER-SIDE action: none is
# reachable from the agent loop / tool registry. There is deliberately NO
# `live commit` verb — committing a mandate happens only through the consent
# flow's `POST /mandate/commit`, never a CLI command (the CLI cannot create or
# widen a mandate). The public CLI surface is `vibe-trading connector ...`;
# `cmd_live_*` helpers remain only as the broker-runtime implementation behind
# connector profiles.
# ---------------------------------------------------------------------------
_DEFAULT_LIVE_BROKER = "robinhood"
_LIVE_AUTHORIZE_INIT_TIMEOUT_SECONDS = 300.0
_LIVE_AUTHORIZE_TIMEOUT_ENV = "VIBE_LIVE_AUTHORIZE_TIMEOUT_SECONDS"
def _authorize_timeout_seconds() -> float:
"""Resolve the OAuth authorize handshake deadline in seconds.
Reads ``VIBE_LIVE_AUTHORIZE_TIMEOUT_SECONDS`` and falls back to
:data:`_LIVE_AUTHORIZE_INIT_TIMEOUT_SECONDS` (300 s) when it is unset,
empty, non-numeric, or not strictly positive. This deadline bounds the
interactive ``list_tools`` handshake that drives the broker OAuth flow, so
a multi-minute human sign-in (e.g. Robinhood's face scan) has room to
complete.
Returns:
The authorize deadline in seconds (a positive float).
"""
raw = get_env_config().agent_tuning.vibe_live_authorize_timeout_s
return float(raw) if raw and raw > 0 else float(_LIVE_AUTHORIZE_INIT_TIMEOUT_SECONDS)
def _live_api_base() -> str:
"""Return the base URL of the running API server for runner-control calls.
Mirrors :func:`cli.main._commit_mandate`: the base is read from
``VIBE_TRADING_API_URL`` (falling back to ``http://127.0.0.1:8000``). The
persistent runner (SPEC §7.5) is controlled through the R6 surface endpoints
(``POST /live/runner/start|stop`` / ``GET /live/status``), never from the
agent loop, so the CLI only ever relays intent.
Returns:
The API base URL with any trailing slash removed.
"""
return get_env_config().api.vibe_trading_api_url.rstrip("/")
def _api_auth_headers() -> Dict[str, str]:
"""Return Bearer auth headers for CLI-to-API control calls."""
reset_env_config() # ensure fresh read of auth credentials
key = get_env_config().api.api_auth_key.strip()
return {"Authorization": f"Bearer {key}"} if key else {}
def _live_api_call(method: str, path: str, *, body: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Call an R6 live-runner endpoint and decode the JSON response.
Args:
method: HTTP verb (``"GET"`` or ``"POST"``).
path: Endpoint path beginning with ``/`` (e.g. ``"/live/runner/start"``).
body: JSON request body for ``POST`` calls.
Returns:
The decoded response object on success, or an ``{"status": "error",
"error": ...}`` envelope when the server is unreachable / returns a
non-2xx status — so a caller can surface a clean message instead of a
traceback when no server is running.
"""
import httpx
url = f"{_live_api_base()}{path}"
try:
if method.upper() == "GET":
response = httpx.get(url, timeout=30.0)
else:
response = httpx.post(url, json=body or {}, timeout=30.0)
response.raise_for_status()
return response.json()
except Exception as exc: # noqa: BLE001 — surface a clean error to the user
return {"status": "error", "error": str(exc)}
def _channels_api_call(method: str, path: str, *, body: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Call an IM channel runtime endpoint on the local API server."""
import httpx
url = f"{_live_api_base()}{path}"
headers = _api_auth_headers()
try:
if method.upper() == "GET":
response = httpx.get(url, headers=headers, timeout=10.0)
else:
response = httpx.post(url, json=body or {}, headers=headers, timeout=10.0)
response.raise_for_status()
return response.json()
except Exception as exc: # noqa: BLE001 - CLI should explain offline API cleanly
return {"status": "error", "error": str(exc)}
def _channels_local_status() -> Dict[str, Any]:
"""Build local channel config/import status without starting adapters."""
from src.channels.config import load_channels_config
from src.channels.registry import inspect_channels
config = load_channels_config()
return {
"running": False,
"source": "local_config",
"channels": inspect_channels(config),
}
def _print_channels_status(payload: Dict[str, Any]) -> None:
"""Render IM channel status."""
table = Table(title="IM Channels", box=box.SIMPLE)
table.add_column("Channel")
table.add_column("Configured")
table.add_column("Enabled")
table.add_column("Available")
table.add_column("Loaded")
table.add_column("Running")
table.add_column("Recovery")
channels = payload.get("channels") if isinstance(payload, dict) else {}
if not isinstance(channels, dict):
channels = {}
for name, item in sorted(channels.items()):
if not isinstance(item, dict):
continue
recovery = item.get("install_hint") or item.get("error") or ""
table.add_row(
str(name),
"yes" if item.get("configured") else "no",
"yes" if item.get("enabled") else "no",
"yes" if item.get("available") else "no",
"yes" if item.get("loaded") else "no",
"yes" if item.get("running") else "no",
str(recovery),
)
console.print(table)
if payload.get("status") == "error":
console.print(f"[yellow]API unavailable:[/yellow] {payload.get('error')}")
console.print("[dim]Start the backend with `vibe-trading serve --port 8000`, or inspect local config with this status output.[/dim]")
def cmd_channels_status(*, json_mode: bool = False, local: bool = False) -> int:
"""Show IM channel status."""
payload = _channels_local_status() if local else _channels_api_call("GET", "/channels/status")
if payload.get("status") == "error":
local_payload = _channels_local_status()
local_payload["status"] = "error"
local_payload["error"] = payload.get("error", "")
payload = local_payload
if json_mode:
print(json.dumps(payload, indent=2, ensure_ascii=False))
else:
_print_channels_status(payload)
return EXIT_SUCCESS
def cmd_channels_start(*, json_mode: bool = False) -> int:
"""Start configured IM channels through the API runtime."""
payload = _channels_api_call("POST", "/channels/start")
if json_mode:
print(json.dumps(payload, indent=2, ensure_ascii=False))
elif payload.get("status") == "error":
console.print(f"[red]Failed to start IM channels:[/red] {payload.get('error')}")
console.print("[dim]Run `vibe-trading serve --port 8000` first, or set VIBE_TRADING_API_URL.[/dim]")
return EXIT_RUN_FAILED
else:
console.print("[green]IM channels started.[/green]")
_print_channels_status(payload)
return EXIT_SUCCESS
def cmd_channels_stop(*, json_mode: bool = False) -> int:
"""Stop configured IM channels through the API runtime."""
payload = _channels_api_call("POST", "/channels/stop")
if json_mode:
print(json.dumps(payload, indent=2, ensure_ascii=False))
elif payload.get("status") == "error":
console.print(f"[red]Failed to stop IM channels:[/red] {payload.get('error')}")
console.print("[dim]Run `vibe-trading serve --port 8000` first, or set VIBE_TRADING_API_URL.[/dim]")
return EXIT_RUN_FAILED
else:
console.print("[green]IM channels stopped.[/green]")
_print_channels_status(payload)
return EXIT_SUCCESS
def cmd_channels_pairing(channel: str, command: str) -> int:
"""Run a pairing command against the shared local pairing store."""
from src.channels.pairing import handle_pairing_command
console.print(handle_pairing_command(channel, command))
return EXIT_SUCCESS
def cmd_channels_login(channel_name: str, *, force: bool = False) -> int:
"""Run a channel adapter's interactive login hook when available."""
import asyncio
from src.channels.config import load_channels_config
from src.channels.manager import ChannelManager
from src.channels.bus.queue import MessageBus
config = load_channels_config()
section = dict(config.get(channel_name, {})) if isinstance(config.get(channel_name), dict) else {}
if channel_name == "websocket":
console.print("[green]WebSocket channel does not require interactive login.[/green]")
console.print("[dim]Configure channels.websocket in ~/.vibe-trading/agent.json, then run `vibe-trading channels start`.[/dim]")
return EXIT_SUCCESS
if not section:
console.print(f"[red]No config found for channel '{channel_name}'.[/red]")
console.print("[dim]Add it under channels.<name> in ~/.vibe-trading/agent.json, then retry.[/dim]")
return EXIT_USAGE_ERROR
section["enabled"] = True
manager = ChannelManager({channel_name: section}, MessageBus())
adapter = manager.get_channel(channel_name)
if adapter is None:
status = manager.get_status().get(channel_name, {})
recovery = status.get("install_hint") or status.get("error") or "adapter unavailable"
console.print(f"[red]Channel '{channel_name}' is unavailable.[/red] {recovery}")
return EXIT_RUN_FAILED
ok = asyncio.run(adapter.login(force=force))
if ok:
console.print(f"[green]Channel '{channel_name}' login completed.[/green]")
return EXIT_SUCCESS
console.print(f"[red]Channel '{channel_name}' login failed.[/red]")
return EXIT_RUN_FAILED
def _dispatch_channels(args: argparse.Namespace) -> int:
"""Dispatch IM channel subcommands."""
command = args.channels_command
if command == "status":
return cmd_channels_status(json_mode=args.channels_json, local=args.local)
if command == "start":
return cmd_channels_start(json_mode=args.channels_json)
if command == "stop":
return cmd_channels_stop(json_mode=args.channels_json)
if command == "pairing":
text = " ".join([args.pairing_command, *args.pairing_args]).strip()
return cmd_channels_pairing(args.channel, text or "list")
if command == "login":
return cmd_channels_login(args.channel_name, force=args.force)
console.print("[red]channels requires a subcommand.[/red] Try: vibe-trading channels status")
return EXIT_USAGE_ERROR
# QVERIS-INTEGRATION
def _print_qveris_config(config) -> None: # QVERIS-INTEGRATION
"""Render local QVeris config.""" # QVERIS-INTEGRATION
from src.tools.qveris_tool import SIGNUP_URL, INVITE_CODE, has_qveris_credentials, is_qveris_configured, mask_api_key, normalize_qveris_mode # QVERIS-INTEGRATION
table = Table(title="Data Routing", box=box.SIMPLE) # QVERIS-INTEGRATION
table.add_column("Field") # QVERIS-INTEGRATION
table.add_column("Value") # QVERIS-INTEGRATION
table.add_row("mode", normalize_qveris_mode(config.mode)) # QVERIS-INTEGRATION
table.add_row("free_route", "built-in public data") # QVERIS-INTEGRATION
table.add_row("premium_provider", "QVeris") # QVERIS-INTEGRATION
table.add_row("paid_active", "yes" if is_qveris_configured(config) else "no") # QVERIS-INTEGRATION
table.add_row("premium_key", "yes" if has_qveris_credentials(config) else "no") # QVERIS-INTEGRATION
table.add_row("base_url", config.base_url) # QVERIS-INTEGRATION
table.add_row("api_key", mask_api_key(config.api_key) or "(not set)") # QVERIS-INTEGRATION
table.add_row("budget/session", str(config.budget_credits_per_session)) # QVERIS-INTEGRATION
table.add_row("signup", SIGNUP_URL) # QVERIS-INTEGRATION
table.add_row("invite_code", INVITE_CODE) # QVERIS-INTEGRATION
console.print(table) # QVERIS-INTEGRATION
# QVERIS-INTEGRATION
def cmd_qveris_status() -> int: # QVERIS-INTEGRATION
"""Show QVeris local config and live status when configured.""" # QVERIS-INTEGRATION
from src.tools.qveris_tool import QVerisClient, is_qveris_configured, load_qveris_config # QVERIS-INTEGRATION
config = load_qveris_config() # QVERIS-INTEGRATION
_print_qveris_config(config) # QVERIS-INTEGRATION
if not is_qveris_configured(config): # QVERIS-INTEGRATION
return EXIT_SUCCESS # QVERIS-INTEGRATION
try: # QVERIS-INTEGRATION
payload = QVerisClient(config).search("status", limit=1) # QVERIS-INTEGRATION
console.print(f"[green]QVeris reachable.[/green] remaining_credits={payload.get('remaining_credits')}") # QVERIS-INTEGRATION
return EXIT_SUCCESS # QVERIS-INTEGRATION
except Exception as exc: # noqa: BLE001 # QVERIS-INTEGRATION
console.print(f"[red]QVeris status failed:[/red] {exc}") # QVERIS-INTEGRATION
return EXIT_RUN_FAILED # QVERIS-INTEGRATION
# QVERIS-INTEGRATION
def cmd_qveris_enable(*, key: str | None = None, url: str | None = None) -> int: # QVERIS-INTEGRATION
"""Enable QVeris if an API key is present or supplied.""" # QVERIS-INTEGRATION
from src.tools.qveris_tool import SIGNUP_URL, INVITE_CODE, QVerisConfig, _read_config_file, save_qveris_config # QVERIS-INTEGRATION
existing = _read_config_file() # QVERIS-INTEGRATION
api_key = (key or existing.api_key or "").strip() # QVERIS-INTEGRATION
if not api_key: # QVERIS-INTEGRATION
console.print("[yellow]QVeris API key is required to enable the integration.[/yellow]") # QVERIS-INTEGRATION
console.print(f"[dim]Sign up: {SIGNUP_URL} invite_code={INVITE_CODE}[/dim]") # QVERIS-INTEGRATION
return EXIT_USAGE_ERROR # QVERIS-INTEGRATION
base_url = (url or existing.base_url).strip().rstrip("/") # QVERIS-INTEGRATION
if not base_url.startswith(("http://", "https://")): # QVERIS-INTEGRATION
console.print("[red]--url must start with http:// or https://[/red]") # QVERIS-INTEGRATION
return EXIT_USAGE_ERROR # QVERIS-INTEGRATION
saved = save_qveris_config(QVerisConfig(True, base_url, api_key, "paid", existing.budget_credits_per_session)) # QVERIS-INTEGRATION
console.print("[green]QVeris paid route enabled.[/green]") # QVERIS-INTEGRATION
_print_qveris_config(saved) # QVERIS-INTEGRATION
return EXIT_SUCCESS # QVERIS-INTEGRATION
# QVERIS-INTEGRATION
def cmd_qveris_mode(
*,
mode: str,
budget: float | None = None,
key: str | None = None,
url: str | None = None,
) -> int: # QVERIS-INTEGRATION
"""Switch QVeris between free and paid modes.""" # QVERIS-INTEGRATION
from src.tools.qveris_tool import QVerisConfig, _read_config_file, normalize_qveris_mode, save_qveris_config # QVERIS-INTEGRATION
existing = _read_config_file() # QVERIS-INTEGRATION
next_mode = normalize_qveris_mode(mode) # QVERIS-INTEGRATION
next_budget = existing.budget_credits_per_session if budget is None else max(float(budget), 0.0) # QVERIS-INTEGRATION
base_url = (url or existing.base_url).strip().rstrip("/") # QVERIS-INTEGRATION
if not base_url.startswith(("http://", "https://")): # QVERIS-INTEGRATION
console.print("[red]--url must start with http:// or https://[/red]") # QVERIS-INTEGRATION
return EXIT_USAGE_ERROR # QVERIS-INTEGRATION
api_key = (key or existing.api_key or "").strip() # QVERIS-INTEGRATION
saved = save_qveris_config(QVerisConfig(next_mode == "paid", base_url, api_key, next_mode, next_budget)) # QVERIS-INTEGRATION
console.print(f"[green]QVeris mode set to {next_mode}.[/green]") # QVERIS-INTEGRATION
_print_qveris_config(saved) # QVERIS-INTEGRATION
return EXIT_SUCCESS # QVERIS-INTEGRATION
# QVERIS-INTEGRATION
def cmd_qveris_disable() -> int: # QVERIS-INTEGRATION
"""Disable QVeris without deleting the stored key.""" # QVERIS-INTEGRATION
from src.tools.qveris_tool import QVerisConfig, _read_config_file, save_qveris_config # QVERIS-INTEGRATION
existing = _read_config_file() # QVERIS-INTEGRATION
save_qveris_config(QVerisConfig(False, existing.base_url, existing.api_key, "free", existing.budget_credits_per_session)) # QVERIS-INTEGRATION
console.print("[green]QVeris disabled.[/green]") # QVERIS-INTEGRATION
return EXIT_SUCCESS # QVERIS-INTEGRATION
# QVERIS-INTEGRATION
def cmd_qveris_usage() -> int: # QVERIS-INTEGRATION
"""Show recent QVeris usage events.""" # QVERIS-INTEGRATION
from src.tools.qveris_tool import QVerisClient, is_qveris_configured, load_qveris_config # QVERIS-INTEGRATION
config = load_qveris_config() # QVERIS-INTEGRATION
if not is_qveris_configured(config): # QVERIS-INTEGRATION
console.print("[yellow]QVeris is not configured.[/yellow]") # QVERIS-INTEGRATION
return EXIT_USAGE_ERROR # QVERIS-INTEGRATION
try: # QVERIS-INTEGRATION
payload = QVerisClient(config).usage_history(limit=10, page_size=10) # QVERIS-INTEGRATION
except Exception as exc: # noqa: BLE001 # QVERIS-INTEGRATION
console.print(f"[red]QVeris usage failed:[/red] {exc}") # QVERIS-INTEGRATION
return EXIT_RUN_FAILED # QVERIS-INTEGRATION
print(json.dumps(payload, indent=2, ensure_ascii=False)) # QVERIS-INTEGRATION
return EXIT_SUCCESS # QVERIS-INTEGRATION
def _dispatch_data(args: argparse.Namespace) -> int: # QVERIS-INTEGRATION
"""Dispatch user-facing data-routing commands.""" # QVERIS-INTEGRATION
if args.data_command == "status": # QVERIS-INTEGRATION
return cmd_qveris_status() # QVERIS-INTEGRATION
if args.data_command == "mode": # QVERIS-INTEGRATION
return cmd_qveris_mode(mode=args.mode, budget=args.budget, key=args.key, url=args.url) # QVERIS-INTEGRATION
if args.data_command == "usage": # QVERIS-INTEGRATION
return cmd_qveris_usage() # QVERIS-INTEGRATION
console.print("[red]data requires a subcommand.[/red] Try: vibe-trading data status") # QVERIS-INTEGRATION
return EXIT_USAGE_ERROR # QVERIS-INTEGRATION
# QVERIS-INTEGRATION
def _live_server_config(broker: str):
"""Resolve the protected MCP server config for ``broker``.
The config is read at boot from the user-side protected agent config file
(never from caller input / agent tool args / ``variables``), exactly as the
#142 swarm-config trust template requires.
Args:
broker: Broker key, e.g. ``"robinhood"``.
Returns:
The :class:`MCPServerConfig` for ``broker``, or ``None`` when the broker
has no entry in the protected config.
"""
from src.config.loader import load_agent_config
agent_config = load_agent_config()
servers = getattr(agent_config, "mcp_servers", {}) or {}
return servers.get(broker.strip().lower())
def _raw_live_server_config_entry(broker: str) -> dict[str, Any] | None:
"""Best-effort raw lookup used only to explain invalid live config."""
from src.config.loader import _read_config_file
from src.config.paths import get_config_path
from src.config.schema import live_broker_key_for_url
try:
path = get_config_path()
if not path.exists():
return None
raw = _read_config_file(path)
except Exception: # noqa: BLE001 — diagnostics must not mask the real CLI error
return None
servers = raw.get("mcpServers")
if not isinstance(servers, dict):
servers = raw.get("mcp_servers")
if not isinstance(servers, dict):
return None
key = broker.strip().lower()
for server_key, server in servers.items():
if isinstance(server, dict) and str(server_key).strip().lower() == key:
return server
if key != "robinhood":
return None
for server in servers.values():
if isinstance(server, dict) and live_broker_key_for_url(str(server.get("url") or "")) == key:
return server
return None
def _raw_server_entry_uses_wildcard(entry: dict[str, Any] | None) -> bool:
"""Return whether a raw MCP server entry uses a wildcard enabledTools list."""
if entry is None:
return False
enabled_tools = entry.get("enabledTools", entry.get("enabled_tools"))
if not isinstance(enabled_tools, list):
return False
return "*" in {str(tool).strip() for tool in enabled_tools}
def _print_missing_live_channel_config(key: str) -> None:
"""Print actionable guidance when a live broker config cannot be loaded."""
if key == "robinhood":
from src.config.schema import format_robinhood_mcp_config_guidance
reason = "wildcard" if _raw_server_entry_uses_wildcard(_raw_live_server_config_entry(key)) else "missing"
console.print("[red]Robinhood live channel is not configured safely.[/red]")
console.print(format_robinhood_mcp_config_guidance(reason=reason), markup=False, soft_wrap=True)
return
console.print(
f"[red]No live channel configured for '{key}'.[/red] "
"Add the broker's mcpServers entry to ~/.vibe-trading/agent.json first."
)
def cmd_live_authorize(broker: str) -> int:
"""Bootstrap the OAuth handshake for a live broker channel (desktop only).
Builds the broker's MCP tool wrappers, which forces a connection and — when
no valid token is cached — triggers the native FastMCP OAuth flow: a browser
opens to the broker's authorize page and the token is persisted to the
protected cache. This is the only way to turn the channel on.
Args:
broker: Broker key, e.g. ``"robinhood"``.
Returns:
Process exit code.
"""
key = broker.strip().lower()
server_config = _live_server_config(key)
if server_config is None:
_print_missing_live_channel_config(key)
return EXIT_USAGE_ERROR
if getattr(server_config, "auth", None) is None:
console.print(
f"[red]Live channel '{key}' has no OAuth auth configured[/red] — "
"cannot authorize."
)
return EXIT_USAGE_ERROR
console.print(f"[cyan]Opening browser to authorize {key}…[/cyan]")
console.print(
"[dim]Complete the sign-in in your browser; this terminal will continue "
"once the broker redirects back.[/dim]"
)
try:
from src.tools.mcp import build_mcp_tool_wrappers
# The OAuth flow is driven lazily by the first request to the server —
# the `list_tools` discovery handshake — which is bounded by the
# per-call `tool_timeout` (default 30 s), NOT `init_timeout`. Raise both
# to the authorize deadline so a multi-minute human sign-in (e.g.
# Robinhood's face scan) does not trip the handshake. Raise-only: never
# shrink an already-larger user-configured timeout.
authorize_timeout = _authorize_timeout_seconds()
if hasattr(server_config, "model_copy"):
updates: dict[str, float] = {}
configured_init_timeout = getattr(server_config, "init_timeout", None)
if (
configured_init_timeout is None
or float(configured_init_timeout) < authorize_timeout
):
updates["init_timeout"] = authorize_timeout
configured_tool_timeout = getattr(server_config, "tool_timeout", None)
if (
configured_tool_timeout is None
or float(configured_tool_timeout) < authorize_timeout
):
updates["tool_timeout"] = authorize_timeout
if updates:
server_config = server_config.model_copy(update=updates)
# Single attempt: a transient-retry would open a fresh client context
# that starts a SECOND OAuth callback server on a new port, orphaning
# the sign-in the user just completed against the first one (see #259).
tools = build_mcp_tool_wrappers(
key, server_config, max_list_tools_attempts=1
)
except Exception as exc: # noqa: BLE001 — surface any handshake failure
console.print(f"[red]Authorization failed:[/red] {exc}")
return EXIT_RUN_FAILED
console.print(
f"[green]Authorized {key}[/green] "
f"[dim]({len(tools)} read-only tool(s) available)[/dim]"
)
console.print(
"[dim]The channel is read-only until you commit a mandate and enable "
"order tools. Use `vibe-trading connector status` to check state.[/dim]"
)
return EXIT_SUCCESS
def cmd_provider_doctor() -> int:
"""Print redacted provider diagnostics."""
from src.providers.llm import provider_diagnostics
console.print_json(data=provider_diagnostics())
return EXIT_SUCCESS
def _format_expiry_countdown(expires_at: str) -> str:
"""Return a human-readable countdown to ``expires_at`` (ISO-8601 UTC)."""
from datetime import datetime, timezone
try:
parsed = datetime.fromisoformat(expires_at.replace("Z", "+00:00"))
except ValueError:
return f"{expires_at} (unparseable)"
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
delta = parsed - datetime.now(timezone.utc)
secs = int(delta.total_seconds())
if secs <= 0:
return f"{expires_at} (EXPIRED)"
days, rem = divmod(secs, 86400)
hours, rem = divmod(rem, 3600)
minutes = rem // 60
if days:
human = f"{days}d {hours}h"
elif hours:
human = f"{hours}h {minutes}m"
else:
human = f"{minutes}m"
return f"{expires_at} (in {human})"
def _runner_id_for(broker: str) -> str:
"""Return the persistent-runner identity key for ``broker`` (SPEC §7.5)."""
return f"live-{broker}"
def _add_runner_liveness_rows(table: Table, broker: str) -> None:
"""Append persistent-runner liveness rows to a ``live status`` table.
Liveness is reported from the local heartbeat files via the runtime
contract (:func:`src.live.runtime.liveness.is_runner_alive` /
:func:`~src.live.runtime.liveness.last_tick`), so it works without a running
API server. If the liveness module is not yet present (it lands concurrently
with this parcel), the rows degrade to ``unknown`` rather than crashing the
read-only status command.
Args:
table: The Rich table being built by :func:`cmd_live_status`.
broker: Broker key the runner is bound to.
"""
runner_id = _runner_id_for(broker)
try:
from src.live.runtime.liveness import is_runner_alive, last_tick
except Exception: # noqa: BLE001 — liveness lands concurrently; degrade cleanly
table.add_row("Runner", "[dim]unknown (runtime not available)[/dim]")
return
try:
alive = is_runner_alive(runner_id)
except Exception as exc: # noqa: BLE001 — never let a status read raise
table.add_row("Runner", f"[dim]unknown ({exc})[/dim]")
return
if alive:
table.add_row("Runner", "[green]running[/green]")
else:
table.add_row("Runner", "[yellow]stopped[/yellow]")
try:
tick = last_tick(runner_id)
except Exception: # noqa: BLE001
tick = None
if tick is not None:
table.add_row(" Last tick", _format_last_tick(tick))
def _format_last_tick(tick: Any) -> str:
"""Render a runner's last-tick timestamp as an absolute + relative string.
Args:
tick: A ``datetime`` or ISO-8601 string from the liveness heartbeat.
Returns:
Human-readable ``"<iso> (<n>s ago)"`` (or the raw value if unparseable).
"""
from datetime import datetime, timezone
if isinstance(tick, datetime):
parsed = tick
else:
try:
parsed = datetime.fromisoformat(str(tick).replace("Z", "+00:00"))
except ValueError:
return str(tick)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
ago = int((datetime.now(timezone.utc) - parsed).total_seconds())
iso = parsed.isoformat()
if ago < 0:
return iso
if ago < 60:
return f"{iso} ({ago}s ago)"
if ago < 3600:
return f"{iso} ({ago // 60}m ago)"
return f"{iso} ({ago // 3600}h ago)"
def cmd_live_status(broker: Optional[str] = None) -> int:
"""Show auth state, active mandate, and halt state for live channels.
Read-only: it loads the mandate via :func:`src.live.mandate.store.load_mandate`
and checks the halt sentinel via :func:`src.live.halt.halt_flag_set`; it never
writes anything.
Args:
broker: Limit the report to a single broker. ``None`` reports the
default broker (``robinhood``).
Returns:
Process exit code.
"""
from src.live.halt import halt_flag_set, read_halt
from src.live.mandate.model import MANDATE_SCHEMA_VERSION
from src.live.mandate.store import load_mandate
key = (broker or _DEFAULT_LIVE_BROKER).strip().lower()
table = Table(title=f"Live channel: {key}", box=box.SIMPLE)
table.add_column("Field", style="cyan", no_wrap=True)
table.add_column("Value")
server_config = _live_server_config(key)
authorized = server_config is not None and getattr(server_config, "auth", None) is not None
table.add_row("Configured", "yes" if server_config is not None else "[red]no[/red]")
table.add_row("OAuth auth", "yes" if authorized else "no")
halted = halt_flag_set(key)
if halted:
meta = read_halt(key) or read_halt() or {}
reason = meta.get("reason", "")
by = meta.get("by", "")
detail = f" [dim]({by}: {reason})[/dim]" if (by or reason) else ""
table.add_row("Halt", f"[bold red]HALTED[/bold red]{detail}")
else:
table.add_row("Halt", "[green]clear[/green]")
_add_runner_liveness_rows(table, key)
mandate = load_mandate(key)
if mandate is None:
table.add_row("Mandate", "[yellow]none on file[/yellow] (read-only)")
elif mandate.schema_version != MANDATE_SCHEMA_VERSION:
table.add_row(
"Mandate",
f"[red]unknown schema v{mandate.schema_version}[/red] (gate fail-closed)",
)
else:
caps = mandate.hard_caps
table.add_row("Mandate", "[green]active[/green]")
table.add_row(" Max order", f"${caps.max_order_notional_usd:,.0f}")
table.add_row(" Max exposure", f"${caps.max_total_exposure_usd:,.0f}")
table.add_row(" Max leverage", f"{caps.max_leverage:g}x")
table.add_row(" Trades/day", str(caps.max_trades_per_day))
table.add_row(
" Instruments",
", ".join(i.value for i in caps.allowed_instruments) or "[red]none[/red]",
)
table.add_row(" Expires", _format_expiry_countdown(mandate.consent.expires_at))
console.print(table)
return EXIT_SUCCESS
def cmd_live_mandate(broker: Optional[str] = None) -> int:
"""Print the committed mandate for a broker (read-only).
Args:
broker: Broker key. ``None`` uses the default broker (``robinhood``).
Returns:
Process exit code. ``EXIT_RUN_FAILED`` when no mandate is on file.
"""
from dataclasses import asdict
from src.live.mandate.store import load_mandate
key = (broker or _DEFAULT_LIVE_BROKER).strip().lower()
mandate = load_mandate(key)
if mandate is None:
console.print(
f"[yellow]No committed mandate for '{key}'.[/yellow] "
"The channel is read-only until a mandate is committed via the consent flow."
)
return EXIT_RUN_FAILED
payload = asdict(mandate)
# asdict leaves enums as Enum members; render their string values.
caps = payload["hard_caps"]
caps["allowed_instruments"] = [i.value for i in mandate.hard_caps.allowed_instruments]
payload["universe"]["asset_classes"] = [a.value for a in mandate.universe.asset_classes]
console.print_json(data=payload)
return EXIT_SUCCESS
def cmd_live_halt(broker: Optional[str] = None) -> int:
"""Trip the kill switch — write the HALT sentinel (privileged).
With no broker, trips the global switch (halts all brokers); with a broker,
trips only that broker's sentinel. The gate rejects all order attempts until
the switch is cleared with ``vibe-trading connector resume``.
Args:
broker: Broker key, or ``None`` for the global switch.
Returns:
Process exit code.
"""
from src.live.halt import trip_halt
target = broker.strip().lower() if broker else None
path = trip_halt(by="cli", reason="cli live halt", broker=target)
scope = target or "ALL brokers"
console.print(f"[bold red]Live trading halted[/bold red] for {scope}.")
console.print(f"[dim]Sentinel: {path}[/dim]")
console.print("[dim]Run `vibe-trading connector resume` to re-enable.[/dim]")
return EXIT_SUCCESS
def cmd_live_resume(broker: Optional[str] = None) -> int:
"""Clear a tripped kill switch (privileged, explicit re-enable).
Args:
broker: Broker key, or ``None`` for the global switch. Each scope is
cleared independently.
Returns:
Process exit code.
"""
from src.live.halt import clear_halt
target = broker.strip().lower() if broker else None
cleared = clear_halt(broker=target)
scope = target or "ALL brokers"
if cleared:
console.print(f"[green]Halt cleared[/green] for {scope}.")
else:
console.print(f"[dim]No active halt for {scope}.[/dim]")
return EXIT_SUCCESS
def cmd_live_revoke(broker: str) -> int:
"""Revoke the OAuth token and delete the mandate — full channel off.
Deletes the broker's OAuth token cache directory and its ``mandate.json``
so the channel reverts to fully off. This is a privileged user-side action.
Args:
broker: Broker key, e.g. ``"robinhood"``.
Returns:
Process exit code.
"""
from src.live.paths import broker_dir
key = broker.strip().lower()
try:
base = broker_dir(key)
except ValueError as exc:
console.print(f"[red]{exc}[/red]")
return EXIT_USAGE_ERROR
removed: list[str] = []
# OAuth token cache. Prefer the configured cache_dir; fall back to the
# canonical per-broker oauth/ subtree.
server_config = _live_server_config(key)
cache_dir: Optional[Path] = None
auth = getattr(server_config, "auth", None) if server_config is not None else None
if auth is not None and getattr(auth, "cache_dir", None):
cache_dir = Path(auth.cache_dir).expanduser()
if cache_dir is None or not cache_dir.exists():
cache_dir = base / "oauth"
if cache_dir.exists():
shutil.rmtree(cache_dir, ignore_errors=True)
removed.append(f"OAuth token cache ({cache_dir})")
mandate_path = base / "mandate.json"
if mandate_path.exists():
try:
mandate_path.unlink()
removed.append(f"mandate ({mandate_path})")
except OSError as exc:
console.print(f"[red]Failed to delete mandate: {exc}[/red]")
return EXIT_RUN_FAILED
if removed:
console.print(f"[green]Revoked live channel '{key}'.[/green]")
for item in removed:
console.print(f" [dim]- removed {item}[/dim]")
else:
console.print(f"[dim]Nothing to revoke for '{key}' (no token or mandate on file).[/dim]")
return EXIT_SUCCESS
def cmd_live_start(broker: Optional[str] = None) -> int:
"""Start the persistent live runner in the background (SPEC §7.5).
Relays a start request to the R6 surface endpoint
(``POST /live/runner/start``); the server owns the durable scheduler + job
store. This never touches the agent loop. The runner is read-only until a
mandate is committed (the consent flow), so starting it is safe even before
any mandate exists.
Args:
broker: Broker key, or ``None`` for the default broker (``robinhood``).
Returns:
Process exit code. ``EXIT_RUN_FAILED`` when the server is unreachable.
"""
key = (broker or _DEFAULT_LIVE_BROKER).strip().lower()
result = _live_api_call(
"POST", "/live/runner/start", body={"broker": key, "foreground": False}
)
if result.get("status") == "error":
console.print(f"[red]Could not start the live runner:[/red] {result.get('error')}")
console.print(
"[dim]Is the API server running? Start it with `vibe-trading serve`.[/dim]"
)
return EXIT_RUN_FAILED
runner_id = result.get("runner_id") or _runner_id_for(key)
console.print(f"[green]Live runner started[/green] for {key} [dim]({runner_id})[/dim].")
console.print("[dim]Check it with `vibe-trading connector status`.[/dim]")
return EXIT_SUCCESS
def cmd_live_stop(broker: Optional[str] = None) -> int:
"""Stop the persistent live runner (SPEC §7.5).
Relays a stop request to ``POST /live/runner/stop``. Stopping the runner
halts autonomous activity but does NOT clear a tripped kill switch or revoke
the mandate — use ``connector resume`` / ``connector revoke`` for those.
Args:
broker: Broker key, or ``None`` for the default broker (``robinhood``).
Returns:
Process exit code. ``EXIT_RUN_FAILED`` when the server is unreachable.
"""
key = (broker or _DEFAULT_LIVE_BROKER).strip().lower()
result = _live_api_call("POST", "/live/runner/stop", body={"broker": key})
if result.get("status") == "error":
console.print(f"[red]Could not stop the live runner:[/red] {result.get('error')}")
console.print(
"[dim]Is the API server running? Start it with `vibe-trading serve`.[/dim]"
)
return EXIT_RUN_FAILED
console.print(f"[yellow]Live runner stopped[/yellow] for {key}.")
return EXIT_SUCCESS
def cmd_live_run(broker: Optional[str] = None) -> int:
"""Run the persistent live runner in the foreground (SPEC §7.5).
The foreground variant of ``live start``: it starts the runner via
``POST /live/runner/start`` and then tails its heartbeat in a Rich ``Live``
panel until Ctrl+C, at which point it requests a clean stop. This mirrors
how ``serve`` runs a long-lived process attached to the terminal. The runner
is read-only until a mandate is committed through the consent flow.
Args:
broker: Broker key, or ``None`` for the default broker (``robinhood``).
Returns:
Process exit code.
"""
key = (broker or _DEFAULT_LIVE_BROKER).strip().lower()
runner_id = _runner_id_for(key)
result = _live_api_call(
"POST", "/live/runner/start", body={"broker": key, "foreground": True}
)
if result.get("status") == "error":
console.print(f"[red]Could not start the live runner:[/red] {result.get('error')}")
console.print(
"[dim]Is the API server running? Start it with `vibe-trading serve`.[/dim]"
)
return EXIT_RUN_FAILED
console.print(
f"[green]Live runner running[/green] for {key} [dim]({runner_id})[/dim] — "
"press Ctrl+C to stop."
)
try:
from src.live.runtime.liveness import is_runner_alive, last_tick
except Exception: # noqa: BLE001 — runtime lands concurrently; fall back to a wait
is_runner_alive = None # type: ignore[assignment]
last_tick = None # type: ignore[assignment]
def _panel() -> Panel:
alive = bool(is_runner_alive(runner_id)) if is_runner_alive else True
state = "[green]running[/green]" if alive else "[yellow]stopped[/yellow]"
lines = [f"Runner: {state}", f"Broker: {key}"]
if last_tick:
try:
tick = last_tick(runner_id)
except Exception: # noqa: BLE001
tick = None
if tick is not None:
lines.append(f"Last tick: {_format_last_tick(tick)}")
return Panel("\n".join(lines), title=f"live run · {key}", box=box.ROUNDED)
try:
with Live(_panel(), console=console, refresh_per_second=2, transient=False) as live:
while True:
time.sleep(1.0)
live.update(_panel())
if is_runner_alive and not is_runner_alive(runner_id):
break
except KeyboardInterrupt:
console.print("\n[dim]Stopping live runner…[/dim]")
finally:
stop = _live_api_call("POST", "/live/runner/stop", body={"broker": key})
if stop.get("status") == "error":
console.print(f"[red]Failed to stop the runner cleanly:[/red] {stop.get('error')}")
else:
console.print(f"[yellow]Live runner stopped[/yellow] for {key}.")
return EXIT_SUCCESS
# ---------------------------------------------------------------------------
# Trading connector commands
# ---------------------------------------------------------------------------
def _profile_id(value: Optional[str]) -> Optional[str]:
"""Normalize an optional connector profile id."""
if value is None:
return None
text = value.strip().lower()
return text or None
def _selected_profile_or(value: Optional[str]):
"""Resolve the selected or explicit trading profile."""
from src.trading.profiles import profile_by_id
return profile_by_id(_profile_id(value))
def cmd_connector_list() -> int:
"""List selectable trading connector profiles."""
from src.trading.profiles import list_profiles, load_selected_profile_id
selected = load_selected_profile_id()
table = Table(title="Trading Connectors", box=box.SIMPLE_HEAVY, show_lines=False)
table.add_column("Selected", justify="center", width=8)
table.add_column("Profile")
table.add_column("Connector")
table.add_column("Env")
table.add_column("Transport")
table.add_column("Capabilities")
for profile in list_profiles():
table.add_row(
"[green]*[/green]" if profile.id == selected else "",
f"[cyan]{profile.id}[/cyan]\n[dim]{profile.label}[/dim]",
profile.connector,
profile.environment,
profile.transport,
", ".join(profile.capabilities),
)
console.print(table)
console.print("[dim]Use `vibe-trading connector use <profile>` to set the default profile.[/dim]")
return EXIT_SUCCESS
def cmd_connector_use(profile_id: str) -> int:
"""Select the default trading connector profile."""
from src.trading.profiles import profile_by_id, save_selected_profile_id
try:
profile = profile_by_id(profile_id)
path = save_selected_profile_id(profile.id)
except ValueError as exc:
console.print(f"[red]{exc}[/red]")
return EXIT_USAGE_ERROR
console.print(f"[green]Selected trading connector[/green] {profile.id}")
console.print(f"[dim]{profile.label} · {profile.environment} · {profile.transport}[/dim]")
console.print(f"[dim]Config: {path}[/dim]")
return EXIT_SUCCESS
def cmd_connector_configure(
profile_id: str,
*,
host: str = "127.0.0.1",
port: int | None = None,
client_id: int = 77,
account: str | None = None,
yes: bool = False,
) -> int:
"""Configure a local connector profile."""
from src.trading.connectors.ibkr.local import IBKRLocalConfig, config_path, save_config
try:
profile = _selected_profile_or(profile_id)
except ValueError as exc:
console.print(f"[red]{exc}[/red]")
return EXIT_USAGE_ERROR
if profile.transport != "local_tws" or profile.connector != "ibkr":
console.print(f"[red]{profile.id} is not a local TWS/Gateway profile.[/red]")
return EXIT_USAGE_ERROR
path = config_path()
if path.exists() and not yes:
console.print(f"[yellow]Local connector config already exists:[/yellow] {path}")
try:
if not Confirm.ask("Overwrite it?", default=False):
console.print("[dim]Aborted.[/dim]")
return EXIT_SUCCESS
except EOFError:
console.print("[dim]No input available; use --yes for non-interactive setup.[/dim]")
return EXIT_USAGE_ERROR
cfg = IBKRLocalConfig.from_mapping(
{
**profile.config,
"host": host,
"port": port or profile.config.get("port"),
"clientId": client_id,
"account": account,
"readonly": True,
}
)
path = save_config(cfg)
console.print(f"[green]Configured[/green] {profile.id} [dim]({path})[/dim]")
console.print(f"[dim]Run `vibe-trading connector check {profile.id}` to verify it.[/dim]")
return EXIT_SUCCESS
def cmd_connector_check(
profile_id: Optional[str] = None,
*,
host: str | None = None,
port: int | None = None,
client_id: int | None = None,
account: str | None = None,
) -> int:
"""Check selected or explicit trading connector profile."""
from src.trading.service import check_connection
try:
profile = _selected_profile_or(profile_id)
report = check_connection(profile.id, host=host, port=port, client_id=client_id, account=account)
except Exception as exc: # noqa: BLE001
console.print(f"[red]Connector check failed:[/red] {exc}")
return EXIT_RUN_FAILED
title = f"Trading Connector: {profile.id}"
if profile.transport == "local_tws":
table = Table(title=title, box=box.SIMPLE_HEAVY, show_lines=False)
table.add_column("Endpoint")
table.add_column("Profile")
table.add_column("Address")
table.add_column("State")
for row in report.get("ports", []):
open_state = "[green]open[/green]" if row.get("open") else "[dim]closed[/dim]"
table.add_row(
str(row.get("label")),
str(row.get("profile")),
f"{row.get('host')}:{row.get('port')}",
open_state,
)
console.print(table)
target = report.get("target", {})
target_state = "open" if target.get("open") else "closed"
console.print(f"Target: [bold]{target.get('host')}:{target.get('port')}[/bold] ({target_state})")
sdk = report.get("sdk", {})
if not sdk.get("installed"):
console.print("[yellow]Missing optional dependency:[/yellow] pip install 'ib_async>=2.0'")
if report.get("account"):
accounts = ", ".join(report["account"].get("accounts", [])) or "(none)"
console.print(f"Accounts: [cyan]{rich_escape(accounts)}[/cyan]")
else:
table = Table(title=title, box=box.SIMPLE_HEAVY, show_lines=False)
table.add_column("Field", style="cyan")
table.add_column("Value")
table.add_row("Connector", profile.connector)
table.add_row("Environment", profile.environment)
table.add_row("Transport", profile.transport)
table.add_row("Configured", "yes" if report.get("configured") else "[red]no[/red]")
table.add_row("OAuth token", "present" if report.get("oauth_token_present") else "[yellow]missing[/yellow]")
table.add_row("Capabilities", ", ".join(report.get("capabilities", [])))
console.print(table)
if report.get("status") not in {"ok"}:
console.print(f"[red]{rich_escape(str(report.get('error') or report.get('status') or 'not ready'))}[/red]")
return EXIT_RUN_FAILED
console.print("[green]Connector profile is ready.[/green]")
return EXIT_SUCCESS
def _print_connector_account(result: dict[str, Any]) -> int:
accounts = ", ".join(result.get("accounts", [])) or "(none)"
console.print(f"Accounts: [cyan]{rich_escape(accounts)}[/cyan]")
rows = result.get("summary", [])
if not rows:
console.print("[dim]No account summary returned.[/dim]")
return EXIT_SUCCESS
table = Table(title=f"Account Summary · {result.get('profile_id')}", box=box.SIMPLE_HEAVY, show_lines=False)
table.add_column("Account")
table.add_column("Tag")
table.add_column("Value", justify="right")
table.add_column("Currency")
for row in rows:
table.add_row(
str(row.get("account") or ""),
str(row.get("tag") or ""),
str(row.get("value") or ""),
str(row.get("currency") or ""),
)
console.print(table)
return EXIT_SUCCESS
def cmd_connector_account(
profile_id: Optional[str] = None,
*,
host: str | None = None,
port: int | None = None,
client_id: int | None = None,
account: str | None = None,
) -> int:
"""Print account summary from a connector profile."""
from src.trading.service import get_account
try:
result = get_account(_profile_id(profile_id), host=host, port=port, client_id=client_id, account=account)
except Exception as exc: # noqa: BLE001
console.print(f"[red]Connector account failed:[/red] {exc}")
return EXIT_RUN_FAILED
if result.get("status") == "error":
console.print(f"[red]{rich_escape(str(result.get('error')))}[/red]")
return EXIT_RUN_FAILED
return _print_connector_account(result)
def cmd_connector_positions(
profile_id: Optional[str] = None,
*,
host: str | None = None,
port: int | None = None,
client_id: int | None = None,
account: str | None = None,
) -> int:
"""Print positions from a connector profile."""
from src.trading.service import get_positions
try:
result = get_positions(_profile_id(profile_id), host=host, port=port, client_id=client_id, account=account)
except Exception as exc: # noqa: BLE001
console.print(f"[red]Connector positions failed:[/red] {exc}")
return EXIT_RUN_FAILED
if result.get("status") == "error":
console.print(f"[red]{rich_escape(str(result.get('error')))}[/red]")
return EXIT_RUN_FAILED
rows = result.get("positions", [])
if not rows:
console.print("[dim]No positions returned.[/dim]")
return EXIT_SUCCESS
table = Table(title=f"Positions · {result.get('profile_id')}", box=box.SIMPLE_HEAVY, show_lines=False)
table.add_column("Account")
table.add_column("Symbol")
table.add_column("Type")
table.add_column("Qty", justify="right")
table.add_column("Avg Cost", justify="right")
table.add_column("Currency")
for row in rows:
table.add_row(
str(row.get("account") or ""),
str(row.get("local_symbol") or row.get("symbol") or ""),
str(row.get("sec_type") or ""),
str(row.get("position") or ""),
str(row.get("avg_cost") or ""),
str(row.get("currency") or ""),
)
console.print(table)
return EXIT_SUCCESS
def cmd_connector_orders(
profile_id: Optional[str] = None,
*,
host: str | None = None,
port: int | None = None,
client_id: int | None = None,
account: str | None = None,
include_executions: bool = False,
) -> int:
"""Print open orders from a connector profile."""
from src.trading.service import get_open_orders
try:
result = get_open_orders(
_profile_id(profile_id),
host=host,
port=port,
client_id=client_id,
account=account,
include_executions=include_executions,
)
except Exception as exc: # noqa: BLE001
console.print(f"[red]Connector orders failed:[/red] {exc}")
return EXIT_RUN_FAILED
if result.get("status") == "error":
console.print(f"[red]{rich_escape(str(result.get('error')))}[/red]")
return EXIT_RUN_FAILED
orders = result.get("open_orders", [])
if not orders:
console.print("[dim]No open orders returned.[/dim]")
return EXIT_SUCCESS
table = Table(title=f"Open Orders · {result.get('profile_id')}", box=box.SIMPLE_HEAVY, show_lines=False)
table.add_column("Account")
table.add_column("Symbol")
table.add_column("Action")
table.add_column("Type")
table.add_column("Qty", justify="right")
table.add_column("Limit", justify="right")
table.add_column("Status")
for row in orders:
contract = row.get("contract") or {}
order = row.get("order") or row
order_status = row.get("status") or {}
table.add_row(
str(order.get("account") or ""),
str(contract.get("local_symbol") or contract.get("symbol") or ""),
str(order.get("action") or ""),
str(order.get("order_type") or ""),
str(order.get("total_quantity") or ""),
str(order.get("limit_price") or ""),
str(order_status.get("status") or ""),
)
console.print(table)
return EXIT_SUCCESS
def cmd_connector_quote(
symbol: str,
profile_id: Optional[str] = None,
*,
host: str | None = None,
port: int | None = None,
client_id: int | None = None,
account: str | None = None,
exchange: str = "SMART",
currency: str = "USD",
sec_type: str = "STK",
) -> int:
"""Print a quote from a connector profile."""
from src.trading.service import get_quote
try:
result = get_quote(
symbol,
_profile_id(profile_id),
host=host,
port=port,
client_id=client_id,
account=account,
exchange=exchange,
currency=currency,
sec_type=sec_type,
)
except Exception as exc: # noqa: BLE001
console.print(f"[red]Connector quote failed:[/red] {exc}")
return EXIT_RUN_FAILED
if result.get("status") == "error":
console.print(f"[red]{rich_escape(str(result.get('error')))}[/red]")
return EXIT_RUN_FAILED
quote = result.get("quote", {})
table = Table(title=f"Quote {result.get('symbol', symbol)} · {result.get('profile_id')}", box=box.SIMPLE_HEAVY)
table.add_column("Bid", justify="right")
table.add_column("Ask", justify="right")
table.add_column("Last", justify="right")
table.add_column("Close", justify="right")
table.add_column("Volume", justify="right")
table.add_row(
str(quote.get("bid") or ""),
str(quote.get("ask") or ""),
str(quote.get("last") or ""),
str(quote.get("close") or ""),
str(quote.get("volume") or ""),
)
console.print(table)
return EXIT_SUCCESS
def cmd_connector_history(
symbol: str,
profile_id: Optional[str] = None,
*,
host: str | None = None,
port: int | None = None,
client_id: int | None = None,
account: str | None = None,
exchange: str = "SMART",
currency: str = "USD",
sec_type: str = "STK",
duration: str = "30 D",
bar_size: str = "1 day",
what_to_show: str = "TRADES",
use_rth: bool = True,
period: str = "1d",
limit: int = 90,
) -> int:
"""Print historical bars from a connector profile."""
from src.trading.service import get_history
try:
result = get_history(
symbol,
_profile_id(profile_id),
host=host,
port=port,
client_id=client_id,
account=account,
exchange=exchange,
currency=currency,
sec_type=sec_type,
duration=duration,
bar_size=bar_size,
what_to_show=what_to_show,
use_rth=use_rth,
period=period,
limit=limit,
)
except Exception as exc: # noqa: BLE001
console.print(f"[red]Connector history failed:[/red] {exc}")
return EXIT_RUN_FAILED
if result.get("status") == "error":
console.print(f"[red]{rich_escape(str(result.get('error')))}[/red]")
return EXIT_RUN_FAILED
rows = result.get("bars", [])
if not rows:
console.print("[dim]No historical bars returned.[/dim]")
return EXIT_SUCCESS
table = Table(title=f"History {result.get('symbol', symbol)} · {result.get('profile_id')}", box=box.SIMPLE_HEAVY)
table.add_column("Date")
table.add_column("Open", justify="right")
table.add_column("High", justify="right")
table.add_column("Low", justify="right")
table.add_column("Close", justify="right")
table.add_column("Volume", justify="right")
for row in rows[-20:]:
table.add_row(
str(row.get("date") or ""),
str(row.get("open") or ""),
str(row.get("high") or ""),
str(row.get("low") or ""),
str(row.get("close") or ""),
str(row.get("volume") or ""),
)
console.print(table)
return EXIT_SUCCESS
def _live_profile_connector(
profile_id: Optional[str],
*,
require_runner: bool = False,
) -> tuple[int, Optional[str]]:
"""Resolve a profile to a live-capable connector key."""
try:
profile = _selected_profile_or(profile_id)
except ValueError as exc:
console.print(f"[red]{exc}[/red]")
return EXIT_USAGE_ERROR, None
if profile.environment != "live" or profile.transport != "remote_mcp":
console.print(f"[red]{profile.id} is not a live remote MCP connector profile.[/red]")
return EXIT_USAGE_ERROR, None
if require_runner:
from src.trading.service import profile_supports_live_runner
if not profile_supports_live_runner(profile):
console.print(f"[red]{profile.id} does not support live runner management.[/red]")
return EXIT_USAGE_ERROR, None
return EXIT_SUCCESS, profile.connector
def cmd_connector_authorize(profile_id: Optional[str]) -> int:
"""Authorize a remote MCP connector profile."""
code, broker = _live_profile_connector(profile_id)
if code != EXIT_SUCCESS or broker is None:
return code
return cmd_live_authorize(broker)
def cmd_connector_status(profile_id: Optional[str]) -> int:
"""Show connector status."""
try:
profile = _selected_profile_or(profile_id)
except ValueError as exc:
console.print(f"[red]{exc}[/red]")
return EXIT_USAGE_ERROR
if profile.environment == "live" and profile.transport == "remote_mcp":
check_code = cmd_connector_check(profile.id)
if check_code != EXIT_SUCCESS:
return check_code
return cmd_live_status(profile.connector)
return cmd_connector_check(profile.id)
def cmd_connector_start(profile_id: Optional[str]) -> int:
"""Start a live remote MCP connector runner."""
code, broker = _live_profile_connector(profile_id, require_runner=True)
if code != EXIT_SUCCESS or broker is None:
return code
return cmd_live_start(broker)
def cmd_connector_stop(profile_id: Optional[str]) -> int:
"""Stop a live remote MCP connector runner."""
code, broker = _live_profile_connector(profile_id, require_runner=True)
if code != EXIT_SUCCESS or broker is None:
return code
return cmd_live_stop(broker)
def cmd_connector_halt(profile_id: Optional[str]) -> int:
"""Trip the halt switch for a live remote MCP connector profile."""
code, broker = _live_profile_connector(profile_id, require_runner=True)
if code != EXIT_SUCCESS or broker is None:
return code
return cmd_live_halt(broker)
def cmd_connector_resume(profile_id: Optional[str]) -> int:
"""Clear the halt switch for a live remote MCP connector profile."""
code, broker = _live_profile_connector(profile_id, require_runner=True)
if code != EXIT_SUCCESS or broker is None:
return code
return cmd_live_resume(broker)
def cmd_connector_revoke(profile_id: Optional[str]) -> int:
"""Revoke a live remote MCP connector profile."""
code, broker = _live_profile_connector(profile_id)
if code != EXIT_SUCCESS or broker is None:
return code
return cmd_live_revoke(broker)
def _dispatch_connector(args: argparse.Namespace) -> int:
"""Route parsed ``connector`` subcommands."""
sub = getattr(args, "connector_command", None)
if sub == "list":
return cmd_connector_list()
if sub == "use":
return cmd_connector_use(args.profile)
if sub == "configure":
return cmd_connector_configure(
args.profile,
host=args.host,
port=args.port,
client_id=args.client_id,
account=args.account,
yes=args.yes,
)
if sub == "check":
return cmd_connector_check(
args.profile,
host=args.host,
port=args.port,
client_id=args.client_id,
account=args.account,
)
if sub == "account":
return cmd_connector_account(
args.profile,
host=args.host,
port=args.port,
client_id=args.client_id,
account=args.account,
)
if sub == "positions":
return cmd_connector_positions(
args.profile,
host=args.host,
port=args.port,
client_id=args.client_id,
account=args.account,
)
if sub == "orders":
return cmd_connector_orders(
args.profile,
host=args.host,
port=args.port,
client_id=args.client_id,
account=args.account,
include_executions=args.include_executions,
)
if sub == "quote":
return cmd_connector_quote(
args.symbol,
args.profile,
host=args.host,
port=args.port,
client_id=args.client_id,
account=args.account,
exchange=args.exchange,
currency=args.currency,
sec_type=args.sec_type,
)
if sub == "history":
return cmd_connector_history(
args.symbol,
args.profile,
host=args.host,
port=args.port,
client_id=args.client_id,
account=args.account,
exchange=args.exchange,
currency=args.currency,
sec_type=args.sec_type,
duration=args.duration,
bar_size=args.bar_size,
what_to_show=args.what_to_show,
use_rth=not args.no_rth,
period=args.period,
limit=args.bar_limit,
)
if sub == "authorize":
return cmd_connector_authorize(args.profile)
if sub == "status":
return cmd_connector_status(args.profile)
if sub == "start":
return cmd_connector_start(args.profile)
if sub == "stop":
return cmd_connector_stop(args.profile)
if sub == "halt":
return cmd_connector_halt(args.profile)
if sub == "resume":
return cmd_connector_resume(args.profile)
if sub == "revoke":
return cmd_connector_revoke(args.profile)
console.print("[red]connector requires a subcommand.[/red] Try: vibe-trading connector list")
return EXIT_USAGE_ERROR
# ---------------------------------------------------------------------------
# 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")
provider_subparsers.add_parser("doctor", help="Print redacted provider diagnostics")
# QVERIS-INTEGRATION
data_parser = subparsers.add_parser("data", help="Manage data routing mode") # QVERIS-INTEGRATION
data_subparsers = data_parser.add_subparsers(dest="data_command") # QVERIS-INTEGRATION
data_subparsers.add_parser("status", help="Show active data routing mode") # QVERIS-INTEGRATION
data_mode = data_subparsers.add_parser("mode", help="Switch between free public data and paid data routing") # QVERIS-INTEGRATION
data_mode.add_argument("mode", choices=["free", "paid"], help="free uses built-in public data; paid enables premium data execution") # QVERIS-INTEGRATION
data_mode.add_argument("--budget", type=float, help="Paid-mode credit budget per session") # QVERIS-INTEGRATION
data_mode.add_argument("--key", help="Premium data API key") # QVERIS-INTEGRATION
data_mode.add_argument("--url", help="Premium data API base URL") # QVERIS-INTEGRATION
data_subparsers.add_parser("usage", help="Show recent paid data usage") # QVERIS-INTEGRATION
# QVERIS-INTEGRATION
channels_parser = subparsers.add_parser("channels", help="Manage IM channel adapters")
channels_subparsers = channels_parser.add_subparsers(dest="channels_command")
channels_status = channels_subparsers.add_parser("status", help="Show IM channel status")
channels_status.add_argument("--json", dest="channels_json", action="store_true", help="Print JSON")
channels_status.add_argument("--local", action="store_true", help="Inspect local config without contacting the API")
channels_start = channels_subparsers.add_parser("start", help="Start configured IM channels through the API")
channels_start.add_argument("--json", dest="channels_json", action="store_true", help="Print JSON")
channels_stop = channels_subparsers.add_parser("stop", help="Stop configured IM channels through the API")
channels_stop.add_argument("--json", dest="channels_json", action="store_true", help="Print JSON")
channels_login = channels_subparsers.add_parser("login", help="Run a channel adapter login hook")
channels_login.add_argument("channel_name", help="Channel name, e.g. weixin, feishu, whatsapp")
channels_login.add_argument("--force", action="store_true", help="Ignore existing credentials where supported")
channels_pairing = channels_subparsers.add_parser("pairing", help="Manage IM sender pairing")
channels_pairing.add_argument("--channel", default="telegram", help="Channel context for list/revoke commands")
channels_pairing.add_argument(
"pairing_command",
nargs="?",
default="list",
choices=["list", "approve", "deny", "revoke"],
help="Pairing command",
)
channels_pairing.add_argument("pairing_args", nargs="*", help="Pairing command arguments")
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")
# Cross-platform frontend setup. See cmd_setup() for details.
setup_parser = subparsers.add_parser(
"setup",
help="Install frontend dependencies and build the production bundle",
)
setup_parser.add_argument(
"--frontend-dir",
default=str(AGENT_DIR.parent / "frontend"),
help="Path to the frontend directory (default: <repo>/frontend)",
)
# Cross-platform dev mode. See cmd_dev() for details.
dev_parser = subparsers.add_parser(
"dev",
help="Start backend + frontend dev servers in one process",
)
dev_parser.add_argument(
"--port",
type=int,
default=8899,
help="Backend port (default: 8899)",
)
dev_parser.add_argument(
"--frontend-port",
type=int,
default=5899,
help="Vite dev server port, must match vite.config.ts (default: 5899)",
)
dev_parser.add_argument(
"--frontend-dir",
default=str(AGENT_DIR.parent / "frontend"),
help="Path to the frontend directory (default: <repo>/frontend)",
)
memory_parser = subparsers.add_parser("memory", help="Inspect persistent memory")
memory_subparsers = memory_parser.add_subparsers(dest="memory_command")
memory_list_parser = memory_subparsers.add_parser("list", help="List memory entries")
memory_list_parser.add_argument(
"--type",
dest="memory_type",
choices=MEMORY_TYPES,
help="Filter by memory type",
)
memory_show_parser = memory_subparsers.add_parser("show", help="Show a memory entry")
memory_show_parser.add_argument("name", help="Memory title or filename stem")
memory_search_parser = memory_subparsers.add_parser("search", help="Recall memories for a query")
memory_search_parser.add_argument("query", help="Search text")
memory_search_parser.add_argument(
"--limit", dest="memory_limit", type=int, default=5, help="Maximum matches (default: 5)"
)
memory_forget_parser = memory_subparsers.add_parser("forget", help="Remove a memory entry")
memory_forget_parser.add_argument("name", help="Memory title or filename stem")
memory_forget_parser.add_argument("-y", "--yes", action="store_true", help="Skip confirmation prompt")
connector_parser = subparsers.add_parser("connector", help="Manage trading connector profiles")
connector_subparsers = connector_parser.add_subparsers(dest="connector_command")
connector_subparsers.add_parser("list", help="List selectable connector profiles")
connector_use = connector_subparsers.add_parser("use", help="Select the default connector profile")
connector_use.add_argument("profile", help="Profile id, e.g. ibkr-paper-local")
def _add_connector_profile_arg(p: argparse.ArgumentParser, *, required: bool = False) -> None:
if required:
p.add_argument("profile", help="Connector profile id")
else:
p.add_argument("profile", nargs="?", default=None, help="Connector profile id (default: selected)")
def _add_connector_local(p: argparse.ArgumentParser) -> None:
p.add_argument("--host", default=None)
p.add_argument("--port", type=int, default=None)
p.add_argument("--client-id", dest="client_id", type=int, default=None)
p.add_argument("--account", default=None, help="Optional account code")
def _add_connector_contract(p: argparse.ArgumentParser) -> None:
p.add_argument("--exchange", default="SMART")
p.add_argument("--currency", default="USD")
p.add_argument("--sec-type", dest="sec_type", default="STK")
connector_configure = connector_subparsers.add_parser("configure", help="Configure a local connector profile")
_add_connector_profile_arg(connector_configure, required=True)
connector_configure.add_argument("--host", default="127.0.0.1")
connector_configure.add_argument("--port", type=int, default=None)
connector_configure.add_argument("--client-id", dest="client_id", type=int, default=77)
connector_configure.add_argument("--account", default=None)
connector_configure.add_argument("-y", "--yes", action="store_true", help="Overwrite without prompting")
connector_check = connector_subparsers.add_parser("check", help="Check selected connector readiness")
_add_connector_profile_arg(connector_check)
_add_connector_local(connector_check)
connector_status = connector_subparsers.add_parser("status", help="Show selected connector status")
_add_connector_profile_arg(connector_status)
connector_authorize = connector_subparsers.add_parser("authorize", help="Authorize a remote MCP connector profile")
_add_connector_profile_arg(connector_authorize)
connector_account = connector_subparsers.add_parser("account", help="Read account summary")
_add_connector_profile_arg(connector_account)
_add_connector_local(connector_account)
connector_positions = connector_subparsers.add_parser("positions", help="Read current positions")
_add_connector_profile_arg(connector_positions)
_add_connector_local(connector_positions)
connector_orders = connector_subparsers.add_parser("orders", help="Read open orders")
_add_connector_profile_arg(connector_orders)
_add_connector_local(connector_orders)
connector_orders.add_argument("--include-executions", action="store_true")
connector_quote = connector_subparsers.add_parser("quote", help="Read a quote snapshot")
connector_quote.add_argument("symbol")
_add_connector_profile_arg(connector_quote)
_add_connector_local(connector_quote)
_add_connector_contract(connector_quote)
connector_history = connector_subparsers.add_parser("history", help="Read historical bars")
connector_history.add_argument("symbol")
_add_connector_profile_arg(connector_history)
_add_connector_local(connector_history)
_add_connector_contract(connector_history)
connector_history.add_argument("--duration", default="30 D", help="IBKR (local_tws) duration string")
connector_history.add_argument("--bar-size", dest="bar_size", default="1 day", help="IBKR (local_tws) bar size")
connector_history.add_argument("--what-to-show", dest="what_to_show", default="TRADES")
connector_history.add_argument("--no-rth", action="store_true", help="Include outside-regular-hours data when available")
connector_history.add_argument("--period", default="1d", help="Bar interval for SDK connectors: 1m/5m/15m/30m/1h/4h/1d/1w/1M")
connector_history.add_argument("--limit", dest="bar_limit", type=int, default=90, help="Number of bars for SDK connectors")
for name, help_text in (
("start", "Start the selected live connector runner"),
("stop", "Stop the selected live connector runner"),
("halt", "Trip the selected live connector kill switch"),
("resume", "Clear the selected live connector kill switch"),
("revoke", "Revoke the selected live connector OAuth token and mandate"),
):
p = connector_subparsers.add_parser(name, help=help_text)
_add_connector_profile_arg(p)
# Alpha Zoo subcommands (registered via cli_handlers.add_subparser)
from src.factors.cli_handlers import add_subparser as _add_alpha_subparser
_add_alpha_subparser(subparsers)
# Hypothesis Registry subcommands
from src.hypotheses.cli_handlers import add_subparser as _add_hypothesis_subparser
_add_hypothesis_subparser(subparsers)
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 = Path.home() / ".vibe-trading" / ".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-v4-pro",
"key_prefix": "sk-or-",
"key_placeholder": "sk-or-v1-...",
},
{
"label": "Requesty (OpenAI-compatible gateway - multiple models)",
"provider": "requesty",
"key_env": "REQUESTY_API_KEY",
"base_env": "REQUESTY_BASE_URL",
"base_url": "https://router.requesty.ai/v1",
"model": "openai/gpt-4o-mini",
"key_prefix": None,
"key_placeholder": "api-key...",
},
{
"label": "DeepSeek",
"provider": "deepseek",
"key_env": "DEEPSEEK_API_KEY",
"base_env": "DEEPSEEK_BASE_URL",
"base_url": "https://api.deepseek.com/v1",
"model": "deepseek-v4-pro",
"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-5.5",
"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-3.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": "meta-llama/llama-4-maverick-17b-128e-instruct",
"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-latest",
"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-5.1",
"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.6",
"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-M3",
"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",
"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.4",
"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",
"REQUESTY_API_KEY",
"REQUESTY_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"
from src.memory.persistent import MEMORY_TYPES # noqa: E402 source-of-truth for choices/invariants
_MEMORY_TYPE_STYLES = {
"user": "cyan",
"feedback": "yellow",
"project": "green",
"reference": "magenta",
}
# Invariant: every persisted memory type has a display style. If a new type
# is added in src.memory.persistent.MEMORY_TYPES, this assert fails fast
# instead of silently rendering it in fallback white.
assert set(_MEMORY_TYPE_STYLES) == set(MEMORY_TYPES), (
f"MEMORY_TYPES vs _MEMORY_TYPE_STYLES drift: "
f"types={sorted(MEMORY_TYPES)}, styles={sorted(_MEMORY_TYPE_STYLES)}"
)
def cmd_memory_list(memory_type: Optional[str] = None, *, memory_dir: Optional[Path] = None) -> int:
"""List persisted memory entries."""
from src.memory.persistent import PersistentMemory
pm = PersistentMemory(memory_dir=memory_dir)
entries = pm.list_entries()
if memory_type:
entries = [e for e in entries if e.memory_type == memory_type]
if not entries:
scope = f" type={memory_type}" if memory_type else ""
console.print(f"[dim]No memory entries found{scope}.[/dim]")
return EXIT_SUCCESS
entries.sort(key=lambda e: -e.modified_at)
table = Table(title="Persistent Memory", box=box.SIMPLE_HEAVY, show_lines=False)
table.add_column("Title", style="bold")
table.add_column("Type")
table.add_column("Description", overflow="fold")
table.add_column("Modified", style="dim")
for e in entries:
style = _MEMORY_TYPE_STYLES.get(e.memory_type, "white")
modified = datetime.fromtimestamp(e.modified_at).strftime("%Y-%m-%d %H:%M")
table.add_row(
rich_escape(e.title),
f"[{style}]{e.memory_type}[/{style}]",
rich_escape(e.description) or "—",
modified,
)
console.print(table)
console.print(f"[dim]{len(entries)} entr{'y' if len(entries) == 1 else 'ies'}[/dim]")
return EXIT_SUCCESS
def cmd_memory_show(name: str, *, memory_dir: Optional[Path] = None) -> int:
"""Show full content of a single memory entry."""
from src.memory.persistent import PersistentMemory
pm = PersistentMemory(memory_dir=memory_dir)
entry = pm.find(name)
if entry is None:
console.print(f"[red]Memory not found:[/red] {rich_escape(name)}")
console.print("[dim]Run `vibe-trading memory list` to see available titles.[/dim]")
return EXIT_USAGE_ERROR
style = _MEMORY_TYPE_STYLES.get(entry.memory_type, "white")
header = (
f"[bold]{rich_escape(entry.title)}[/bold]\n"
f"[{style}]{entry.memory_type}[/{style}] • [dim]{rich_escape(entry.path.name)}[/dim]\n"
f"[dim]{rich_escape(entry.description)}[/dim]"
)
console.print(Panel(header, border_style="cyan"))
console.print(rich_escape(entry.body.rstrip()) or "[dim](empty body)[/dim]")
return EXIT_SUCCESS
def cmd_memory_search(query: str, max_results: int = 5, *, memory_dir: Optional[Path] = None) -> int:
"""Run keyword recall and display the top matches."""
from src.memory.persistent import PersistentMemory
pm = PersistentMemory(memory_dir=memory_dir)
results = pm.find_relevant(query, max_results=max_results)
if not results:
console.print(f"[dim]No matches for[/dim] [bold]{rich_escape(query)}[/bold]")
return EXIT_SUCCESS
table = Table(title=f"Recall: {rich_escape(query)}", box=box.SIMPLE_HEAVY, show_lines=False)
table.add_column("Rank", style="dim", width=4)
table.add_column("Title", style="bold")
table.add_column("Type")
table.add_column("Description", overflow="fold")
for rank, e in enumerate(results, start=1):
style = _MEMORY_TYPE_STYLES.get(e.memory_type, "white")
table.add_row(
str(rank),
rich_escape(e.title),
f"[{style}]{e.memory_type}[/{style}]",
rich_escape(e.description) or "—",
)
console.print(table)
return EXIT_SUCCESS
def cmd_memory_forget(name: str, *, yes: bool = False, memory_dir: Optional[Path] = None) -> int:
"""Remove a memory entry by name."""
from src.memory.persistent import PersistentMemory
pm = PersistentMemory(memory_dir=memory_dir)
entry = pm.find(name)
if entry is None:
console.print(f"[red]Memory not found:[/red] {rich_escape(name)}")
return EXIT_USAGE_ERROR
if not yes:
style = _MEMORY_TYPE_STYLES.get(entry.memory_type, "white")
console.print(
f"About to forget [bold]{rich_escape(entry.title)}[/bold] "
f"([{style}]{entry.memory_type}[/{style}], {rich_escape(entry.path.name)})."
)
try:
proceed = Confirm.ask("Proceed?", default=False)
except EOFError:
console.print("[dim]No input available; use --yes for non-interactive deletes.[/dim]")
return EXIT_USAGE_ERROR
if not proceed:
console.print("[dim]Aborted.[/dim]")
return EXIT_SUCCESS
if pm.remove_entry(entry):
console.print(f"[green]Forgot[/green] {rich_escape(entry.title)}")
return EXIT_SUCCESS
console.print(f"[red]Failed to remove[/red] {rich_escape(entry.title)}")
return EXIT_RUN_FAILED
def cmd_init() -> int:
"""Interactive setup: create ~/.vibe-trading/.env."""
console.print(Panel("[bold cyan]Vibe-Trading setup[/bold cyan]\n[dim]Configure the default LLM provider and data tokens.[/dim]", border_style="cyan"))
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
provider_table = Table(title="LLM Providers", box=box.SIMPLE_HEAVY, show_lines=False, border_style="dim")
provider_table.add_column("#", justify="right", style="dim", width=3)
provider_table.add_column("Provider", style="cyan")
provider_table.add_column("Default model", style="dim")
provider_table.add_column("Credential", style="dim")
for idx, option in enumerate(_PROVIDER_CHOICES, start=1):
credential = "OAuth" if option["provider"] == "openai-codex" else "none" if option["key_env"] is None else str(option["key_env"])
provider_table.add_row(str(idx), str(option["label"]), str(option["model"]), credential)
console.print(provider_table)
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.parent.mkdir(parents=True, exist_ok=True)
_INIT_ENV_PATH.write_text(_render_env_content(env_values), encoding="utf-8")
try:
_INIT_ENV_PATH.chmod(0o600)
except OSError:
pass
next_steps = Table.grid(expand=True)
next_steps.add_column(width=10, style="dim")
next_steps.add_column(ratio=1)
next_steps.add_row("Config", f"[cyan]{_INIT_ENV_PATH}[/cyan]")
next_steps.add_row("Run", "[bold]vibe-trading[/bold]")
if provider == "openai-codex":
next_steps.add_row("OAuth", "[bold]vibe-trading provider login openai-codex[/bold]")
console.print(Panel(next_steps, title="Setup complete", border_style="green", padding=(0, 1)))
return 0
# ---------------------------------------------------------------------------
# Cross-platform frontend setup / dev commands.
#
# These exist to bridge a real Windows footgun: the package's frontend uses
# TypeScript, but `npx tsc` on Windows does NOT resolve the locally-installed
# TypeScript binary. Instead, npx hits the npm registry and downloads an
# abandoned 10-year-old package called `tsc@2.0.4` that prints
# "This is not the tsc command you are looking for". The fix is to always
# invoke TypeScript via `npm exec --package=typescript tsc ...` (or
# `npx --package=typescript tsc ...`) on Windows; on POSIX, `npm run build`
# already works because npm prepends ./node_modules/.bin to PATH for local
# scripts.
# ---------------------------------------------------------------------------
def _is_windows() -> bool:
"""True when running on a Windows-like platform (win32, including Cygwin/MSYS)."""
return sys.platform == "win32"
def _resolve_node_and_npm() -> tuple[Optional[str], Optional[str]]:
"""Return ``(node_path, npm_path)`` if both are on PATH, else ``(None, None)``.
Used by ``cmd_setup`` to fail fast with a clear message instead of
surfacing a cryptic ENOENT from npm itself.
"""
node = shutil.which("node")
npm = shutil.which("npm")
return node, npm
def _build_frontend_cmd(frontend_dir: Path) -> list[list[str]]:
"""Return the ordered list of subprocess invocations needed to build the frontend.
On Windows we explicitly pin ``--package=typescript`` / ``--package=vite``
so npm cannot accidentally fetch the abandoned ``tsc`` package from the
registry. On POSIX systems, ``npm run build`` is sufficient because npm
prepends ``./node_modules/.bin`` to ``PATH`` for local scripts.
Each inner list is a single ``subprocess.run`` invocation. Returned as a
list of steps so the caller can stream progress.
"""
is_win = _is_windows()
if is_win:
# `npm exec --package=typescript tsc -b` is the safe form on Windows;
# plain `npx tsc` will fetch the abandoned `tsc@2.0.4` package.
return [
["npm", "install", "--no-audit", "--no-fund"],
["npm", "exec", "--package=typescript", "--", "tsc", "-b"],
["npm", "exec", "--package=vite", "--", "vite", "build"],
]
return [
["npm", "install", "--no-audit", "--no-fund"],
["npm", "run", "build"],
]
def _run_step(
description: str,
cmd: list[str],
cwd: Path,
) -> bool:
"""Run one subprocess step, returning True on success.
Decodes subprocess output as UTF-8 with ``errors="replace"`` so that
non-ASCII bytes emitted by tools like Vite do not raise
``UnicodeDecodeError`` on platforms whose default codec is GBK/CP936
(notably Windows). The captured text is only used to surface a
friendly error message; lossy decoding is acceptable here.
"""
console.print(f"[dim] {description} …[/dim]")
try:
result = subprocess.run(
cmd,
cwd=str(cwd),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
shell=False,
check=False,
)
except FileNotFoundError as exc:
console.print(f"[red] failed:[/red] {exc}")
return False
if result.returncode != 0:
err = (result.stderr or result.stdout or "").strip()
# Show the last 20 lines to keep noise manageable.
tail = "\n".join(err.splitlines()[-20:]) if err else "(no output)"
console.print(f"[red] {description} failed:[/red]\n{tail}")
return False
return True
def cmd_setup(frontend_dir: Path) -> int:
"""Install frontend dependencies and build the production bundle.
Cross-platform wrapper that hides the ``npx tsc`` / ``npm exec tsc``
Windows footgun. Equivalent to running ``cd frontend && npm install
&& npm run build`` from a POSIX shell, but works on Windows without
the user having to know about the abandoned ``tsc`` package on the
npm registry.
"""
console.print(
Panel(
f"[bold cyan]Vibe-Trading frontend setup[/bold cyan]\n"
f"[dim]{frontend_dir}[/dim]",
border_style="cyan",
padding=(0, 1),
)
)
if not frontend_dir.exists():
console.print(
f"[red]Frontend directory not found:[/red] {frontend_dir}\n"
"[dim]Pass --frontend-dir to point at a different location.[/dim]"
)
return EXIT_USAGE_ERROR
node, npm = _resolve_node_and_npm()
if not node or not npm:
missing = [name for name, path_ in (("node", node), ("npm", npm)) if not path_]
console.print(
f"[red]Required tool not on PATH:[/red] {', '.join(missing)}\n"
"[dim]Install Node.js (>= 18) from https://nodejs.org and retry.[/dim]"
)
return EXIT_USAGE_ERROR
# On Windows, ``npm`` is shipped as ``npm.cmd``; ``subprocess.run`` does
# not consult ``PATHEXT`` for bare command names, so it would raise
# ``FileNotFoundError`` even though ``shutil.which("npm")`` returned a
# valid path. Resolve to the full path before invoking.
npm_path = npm
if _is_windows():
steps = [
[npm_path, *step[1:]] if step and step[0] == "npm" else step
for step in _build_frontend_cmd(frontend_dir)
]
else:
steps = _build_frontend_cmd(frontend_dir)
for step in steps:
description = " ".join(step[:3]) # e.g. "npm install --no-audit"
if not _run_step(description, step, frontend_dir):
return EXIT_RUN_FAILED
console.print(
Panel(
"[green]Frontend built.[/green]\n"
f" Artifacts: [cyan]{frontend_dir / 'dist'}[/cyan]\n"
"[dim]Run [bold]vibe-trading serve[/bold] to serve everything on one port.[/dim]",
border_style="green",
padding=(0, 1),
)
)
return EXIT_SUCCESS
def cmd_dev(
backend_port: int = 8899,
frontend_port: int = 5899,
frontend_dir: Optional[Path] = None,
) -> int:
"""Start backend + Vite dev server in one foreground process.
Spawns two child processes:
* The FastAPI backend, launched from ``AGENT_DIR`` so that
``python -m cli._legacy serve`` resolves the in-repo ``cli`` package
(launching it from the repo root would fail with
``ModuleNotFoundError: No module named 'cli'``).
* The Vite dev server, launched from ``frontend_dir`` with the port
from ``vite.config.ts`` (currently 5899). We do NOT hardcode
``5173`` — that would be wrong for this project.
Both children inherit stdout/stderr so their logs are interleaved
with the dev banner. ``Ctrl+C`` (SIGINT) and ``SIGTERM`` cleanly
terminate both children.
"""
frontend_dir = frontend_dir or (AGENT_DIR.parent / "frontend")
if not frontend_dir.exists():
console.print(
f"[red]Frontend directory not found:[/red] {frontend_dir}\n"
"[dim]Pass --frontend-dir to point at a different location.[/dim]"
)
return EXIT_USAGE_ERROR
# `npm run dev` invokes the local Vite binary at
# ``frontend/node_modules/.bin/vite``. If ``node_modules`` does not
# exist (or is missing the Vite package), npm's bare-script
# resolution will print a confusing "vite is not a command" error
# and exit. Detect this case up front and point the user at
# ``vibe-trading setup`` instead.
vite_bin = frontend_dir / "node_modules" / ".bin" / ("vite.cmd" if _is_windows() else "vite")
if not vite_bin.exists():
console.print(
Panel(
f"[red]Frontend dependencies not installed.[/red]\n"
f" Missing: [dim]{frontend_dir / 'node_modules'}[/dim]\n\n"
"Run this first:\n"
" [cyan]vibe-trading setup[/cyan]\n\n"
"[dim]Or, to start the dev mode anyway and install on the fly,\n"
"run [bold]vibe-trading setup[/bold] in another terminal.[/dim]",
title="vibe-trading dev",
border_style="red",
padding=(0, 1),
)
)
return EXIT_USAGE_ERROR
node, npm = _resolve_node_and_npm()
if not node or not npm:
missing = [name for name, path_ in (("node", node), ("npm", npm)) if not path_]
console.print(
f"[red]Required tool not on PATH:[/red] {', '.join(missing)}\n"
"[dim]Install Node.js (>= 18) from https://nodejs.org and retry.[/dim]"
)
return EXIT_USAGE_ERROR
backend_cmd = [sys.executable, "-m", "cli._legacy", "serve", "--port", str(backend_port)]
# On Windows, ``npm`` is typically ``npm.cmd``. ``subprocess.Popen`` does
# not consult ``PATHEXT`` for bare command names, so the call would fail
# with ``FileNotFoundError`` even though ``shutil.which("npm")`` returned
# a path. Use the resolved executable path directly.
npm_executable = npm if _is_windows() else "npm"
frontend_cmd = [npm_executable, "run", "dev", "--", "--port", str(frontend_port)]
console.print(
Panel(
f"[bold cyan]Vibe-Trading dev[/bold cyan]\n"
f" Backend → [cyan]http://127.0.0.1:{backend_port}[/cyan] "
f"(cwd: {AGENT_DIR})\n"
f" Frontend → [cyan]http://localhost:{frontend_port}[/cyan] "
f"(cwd: {frontend_dir})",
border_style="cyan",
padding=(0, 1),
)
)
console.print("[dim]Press Ctrl+C to stop both servers.[/dim]\n")
backend = subprocess.Popen(backend_cmd, cwd=str(AGENT_DIR))
frontend = subprocess.Popen(frontend_cmd, cwd=str(frontend_dir))
children = [backend, frontend]
def _terminate_all() -> None:
for child in children:
if child.poll() is None:
try:
child.terminate()
except OSError:
pass
# Wire signal handlers. On Windows, SIGTERM does not exist and signal
# handlers must be installed from the main thread; KeyboardInterrupt is
# the cross-platform path for Ctrl+C.
if threading.current_thread() is threading.main_thread():
try:
signal.signal(signal.SIGINT, lambda *_: _terminate_all())
except (ValueError, OSError):
pass
try:
signal.signal(signal.SIGTERM, lambda *_: _terminate_all())
except (AttributeError, ValueError, OSError):
pass
try:
# Wait for whichever process exits first; if it's the backend we
# bring the frontend down too, and vice versa.
while True:
time.sleep(0.5)
if backend.poll() is not None or frontend.poll() is not None:
break
except KeyboardInterrupt:
pass
finally:
_terminate_all()
# Give the children a brief grace period, then force-kill.
deadline = time.time() + 5
for child in children:
remaining = max(0.0, deadline - time.time())
try:
child.wait(timeout=remaining)
except subprocess.TimeoutExpired:
try:
child.kill()
except OSError:
pass
return EXIT_SUCCESS
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 not sys.stdout.isatty():
args.no_rich = True
if hasattr(args, "run_no_rich"):
args.run_no_rich = True
if args.command == "init":
return cmd_init()
if args.command == "setup":
return _coerce_exit_code(
cmd_setup(frontend_dir=Path(args.frontend_dir))
)
if args.command == "dev":
return _coerce_exit_code(
cmd_dev(
backend_port=args.port,
frontend_port=args.frontend_port,
frontend_dir=Path(args.frontend_dir),
)
)
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)
if args.provider_command == "doctor":
return cmd_provider_doctor()
console.print("[red]provider requires a subcommand.[/red] Try: vibe-trading provider doctor")
return EXIT_USAGE_ERROR
if args.command == "channels":
return _coerce_exit_code(_dispatch_channels(args))
if args.command == "data": # QVERIS-INTEGRATION
return _coerce_exit_code(_dispatch_data(args)) # QVERIS-INTEGRATION
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.command == "alpha":
from src.factors.cli_handlers import dispatch as _alpha_dispatch
return _coerce_exit_code(_alpha_dispatch(args))
if args.command == "hypothesis":
from src.hypotheses.cli_handlers import dispatch as _hyp_dispatch
return _coerce_exit_code(_hyp_dispatch(args))
if args.command == "connector":
return _coerce_exit_code(_dispatch_connector(args))
if args.command == "memory":
if args.memory_command == "list":
return _coerce_exit_code(cmd_memory_list(args.memory_type))
if args.memory_command == "show":
return _coerce_exit_code(cmd_memory_show(args.name))
if args.memory_command == "search":
return _coerce_exit_code(cmd_memory_search(args.query, args.memory_limit))
if args.memory_command == "forget":
return _coerce_exit_code(cmd_memory_forget(args.name, yes=args.yes))
console.print("[red]memory requires a subcommand.[/red] Try: vibe-trading memory list")
return EXIT_USAGE_ERROR
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:
parsed_swarm_run = _parse_swarm_run_args(args.swarm_run)
if parsed_swarm_run is None:
return EXIT_USAGE_ERROR
preset_name, vars_json = parsed_swarm_run
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 and no subcommand: check for a prompt, otherwise enter 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())