"""Interactive CLI front door for Vibe-Trading. Responsibilities: 1. Detect whether any provider `.env` candidate exists; if all are missing, run the onboarding wizard (:mod:`cli.onboard`) before doing anything else. 2. Render the startup banner (:mod:`cli.intro`) on interactive entry. 3. For interactive entry (no subcommand, or ``chat``) drive the REPL built on :mod:`cli.input`, :mod:`cli.completer`, :mod:`cli.commands.slash_router`, :mod:`cli.components.working_indicator`, :mod:`cli.components.tool_event`, and :mod:`cli.components.hint_bar`. 4. For every other subcommand delegate to ``cli._legacy.main`` so the long tail of ``serve``, ``run``, ``mcp``, ``sessions``, ``swarm`` etc. keeps working without regression. The console script entry in ``pyproject.toml`` (``vibe-trading = "cli:main"``) hits :func:`main`. """ from __future__ import annotations import importlib import os import sys import threading import time from dataclasses import dataclass, field from pathlib import Path from typing import Any, Dict, List, Optional, Sequence from cli.intro import print_banner from cli.onboard import run_onboarding from cli.theme import Theme, get_console def _register_live_slash_commands() -> None: """Surface connector live-trading slash commands in the shared registry. Discoverability fix (SPEC.md Consent §4 + §9 audit): ``/connector``, ``/halt`` and ``/resume`` are privileged kill-switch / runner surface actions intercepted in the REPL input path (never dispatched to the model), so they were absent from the slash registry — meaning ``/help``, the typeahead completer, and the fuzzy matcher never listed them. We append them here, at this module's import time, which runs on every interactive startup *before* the lazily-imported ``cli.commands.help`` / ``cli.completer`` first read the registry. Both of those read ``slash_router.SLASH_COMMANDS`` (help) / call ``match_commands`` (completer) which resolve the connector module attribute, so the single reassignment here surfaces the commands in all three places. The registry is a tuple of frozen dataclasses, so we build a NEW tuple rather than mutate in place. Registration is idempotent — re-import (e.g. in tests) never duplicates rows. """ from cli.commands import slash_router existing = {cmd.name for cmd in slash_router.SLASH_COMMANDS} additions = ( slash_router.Command( "connector", "Trading connector profiles (status / start / halt)", "cli.main" ), slash_router.Command( "halt", "Kill switch — halt ALL live trading now", "cli.main" ), slash_router.Command( "resume", "Clear the kill switch (re-enable live trading)", "cli.main" ), ) new = tuple(cmd for cmd in additions if cmd.name not in existing) if not new: return # Insert the connector group just before ``quit`` (the conventional last row) so # the kill switch sits with the other safety-relevant commands. commands = list(slash_router.SLASH_COMMANDS) quit_idx = next( (i for i, c in enumerate(commands) if c.name == "quit"), len(commands) ) commands[quit_idx:quit_idx] = list(new) slash_router.SLASH_COMMANDS = tuple(commands) # ``/stop`` is the kill-switch alias of ``/halt`` (SPEC Consent §4) so the # fuzzy matcher / find_exact resolve it. if "stop" not in slash_router._ALIASES: slash_router._ALIASES = {**slash_router._ALIASES, "stop": "halt"} _register_live_slash_commands() # QVERIS-INTEGRATION def _register_data_slash_commands() -> None: # QVERIS-INTEGRATION """Surface data routing mode in the shared slash registry.""" # QVERIS-INTEGRATION from cli.commands import slash_router # QVERIS-INTEGRATION if any(cmd.name == "data" for cmd in slash_router.SLASH_COMMANDS): # QVERIS-INTEGRATION return # QVERIS-INTEGRATION commands = list(slash_router.SLASH_COMMANDS) # QVERIS-INTEGRATION quit_idx = next((i for i, c in enumerate(commands) if c.name == "quit"), len(commands)) # QVERIS-INTEGRATION commands.insert(quit_idx, slash_router.Command("data", "Data routing mode", "cli.main")) # QVERIS-INTEGRATION slash_router.SLASH_COMMANDS = tuple(commands) # QVERIS-INTEGRATION # QVERIS-INTEGRATION _register_data_slash_commands() # QVERIS-INTEGRATION # QVERIS-INTEGRATION _AGENT_DIR = Path(__file__).resolve().parents[1] _ENV_PATH = Path.home() / ".vibe-trading" / ".env" _PROJECT_ENV_PATH = _AGENT_DIR / ".env" _CWD_ENV_PATH = Path.cwd() / ".env" # Best-effort fallbacks used only when the probe genuinely fails (missing # dependency, broken install). The numbers track the actual bundled counts # so a probe failure still shows a plausible banner rather than "0 loaded". _FALLBACK_SKILLS = 77 _FALLBACK_TOOLS = 31 _HISTORY_RETAINED_TURNS = 6 # how many prior turns to feed the agent loop # Cached banner-stats and session-store so ``/clear`` and repeat slash handlers # don't redo the heavy build_registry() / SessionStore construction. _BANNER_STATS_CACHE: Dict[str, Any] = {} _SESSION_STORE_CACHE: Any = None # --------------------------------------------------------------------------- # Stat probes (best-effort, never blocking) # --------------------------------------------------------------------------- def _probe_model_name() -> str: """Return the configured LLM model id, or a placeholder.""" from src.config.accessor import get_env_config name = get_env_config().llm.langchain_model_name or get_env_config().llm.openai_model if name: return name env_path = _first_existing_env_path() if env_path is None: return "unset (use /model to pick one)" try: text = env_path.read_text(encoding="utf-8") for line in text.splitlines(): if line.startswith("LANGCHAIN_MODEL_NAME="): return line.split("=", 1)[1].strip() if line.startswith("OPENAI_MODEL="): return line.split("=", 1)[1].strip() except OSError: pass return "unset (use /model to pick one)" def _probe_tool_count() -> int: """Count registered tools without blocking startup on import errors.""" try: from src.tools import build_registry return len(build_registry()) except Exception: # noqa: BLE001 — never block startup on stats return _FALLBACK_TOOLS def _probe_skill_count() -> int: """Count bundled + user skills without blocking startup on import errors. Reads ``SkillsLoader.skills`` directly — that is the authoritative list populated by :meth:`SkillsLoader._load` from bundled ``agent/skills/`` plus ``~/.vibe-trading/skills/user/``. """ try: from src.agent.skills import SkillsLoader loader = SkillsLoader() return len(loader.skills) except Exception: # noqa: BLE001 return _FALLBACK_SKILLS def _probe_session_count() -> int: """Count recorded sessions from the SQLite store.""" db_path = Path.home() / ".vibe-trading" / "sessions.db" if not db_path.exists(): return 0 try: import sqlite3 with sqlite3.connect(str(db_path)) as conn: row = conn.execute("SELECT COUNT(*) FROM sessions").fetchone() return int(row[0]) if row else 0 except Exception: # noqa: BLE001 return 0 def _collect_banner_stats(*, refresh: bool = False) -> Dict[str, Any]: """Return the four banner stat values, computing them at most once. The interactive launch path runs every probe synchronously (the user is already waiting for the prompt). Subsequent callers (``/clear`` re-render, ``/debug`` summary) reuse the cached values so they don't re-import the tool registry on every keystroke. Args: refresh: When True, recompute and overwrite the cache. Returns: ``{"model": str, "skills": int, "tools": int, "sessions": int}``. """ if _BANNER_STATS_CACHE and not refresh: return dict(_BANNER_STATS_CACHE) stats: Dict[str, Any] = { "model": _probe_model_name(), "skills": _probe_skill_count(), "tools": _probe_tool_count(), "sessions": _probe_session_count(), } _BANNER_STATS_CACHE.clear() _BANNER_STATS_CACHE.update(stats) return dict(stats) # --------------------------------------------------------------------------- # Routing helpers # --------------------------------------------------------------------------- def _is_interactive_invocation(argv: Sequence[str]) -> bool: """Decide whether this invocation should drive the interactive loop. Interactive entry requires: * Both stdin and stdout are TTYs (no piped / redirected I/O). * Either no arguments at all, or exactly the ``chat`` subcommand. Anything else — a flag (``-p``, ``--json``, ``--help``), a recognised subcommand (``serve``, ``run``, ``alpha``, ``hypothesis`` ...), or an unknown positional — is delegated to ``_legacy.main`` so argparse can either dispatch it or produce its standard "unrecognized arguments" error. Routing typos here would silently drop the user into chat, which is worse than the argparse error. Args: argv: The argument list passed to :func:`main`, *excluding* ``sys.argv[0]``. Returns: ``True`` if the caller should drive the interactive loop. """ if not sys.stdin.isatty() or not sys.stdout.isatty(): return False if not argv: return True if argv[0].startswith("-"): return False return _is_supported_chat_invocation(argv) def _is_supported_chat_invocation(argv: Sequence[str]) -> bool: """Return True for chat invocations handled by the interactive front door.""" if not argv or argv[0] != "chat": return False if len(argv) == 1: return True if len(argv) == 2 and argv[1].startswith("--max-iter="): try: int(argv[1].split("=", 1)[1]) return True except ValueError: return False if len(argv) == 3 and argv[1] == "--max-iter": try: int(argv[2]) return True except ValueError: return False return False def _maybe_run_onboarding() -> bool: """Run the first-launch wizard when every provider ``.env`` candidate is missing. Returns: ``True`` if startup should proceed, ``False`` if the user cancelled the wizard cleanly. """ if _first_existing_env_path() is not None: return True console = get_console() written = run_onboarding(console=console) if written is None: return False # Reload env so downstream code picks up the fresh credentials. try: from dotenv import load_dotenv load_dotenv(written, override=True) except Exception: # noqa: BLE001 — legacy will load again later pass return True def _env_candidates() -> tuple[Path, ...]: """Return provider `.env` candidates in the same order as ``src.providers.llm``.""" return (_ENV_PATH, _PROJECT_ENV_PATH, _CWD_ENV_PATH) def _first_existing_env_path() -> Path | None: """Return the first configured `.env` candidate that exists, if any.""" for path in _env_candidates(): if path.exists(): return path return None def _show_banner() -> None: """Print the welcome banner using best-effort stat probes.""" stats = _collect_banner_stats() console = get_console() print_banner(console, **stats) # Discoverability one-liner (SPEC.md §9 audit): point users at the # connector channel + its CLI surface. Live profiles are opt-in and read-only by # default, so this is a pointer, not an enablement. console.print( " [dim]Live trading (opt-in, read-only by default): " "[/dim][bold]/connector[/bold][dim] in chat · " "[/dim][bold]vibe-trading connector --help[/bold][dim] · " "[/dim][bold]/halt[/bold][dim] = kill switch.[/dim]" ) console.print() # --------------------------------------------------------------------------- # Interactive context # --------------------------------------------------------------------------- @dataclass class InteractiveContext: """State bag handed to slash-command handlers and the run loop. Attributes: session_id: Active session id (populated lazily on first turn). history: Compact transcript (role/content pairs) fed back to the agent for follow-up context. max_iter: ReAct iteration ceiling. debug: Whether the ``/debug`` panel is currently shown. last_recap_history_len: Number of history messages covered by the most recently printed deterministic recap. pending_prompt: Optional prompt queued by a slash handler (``/journal``, ``/shadow``) that the loop should execute as the next user turn. Consumed by :func:`_interactive_loop` and cleared. pending_proposal: The most recent live-trading ``mandate.proposal`` payload emitted by the agent during this session, awaiting the user's pick or adjust reply. A numeric pick is intercepted in the REPL input path and committed directly via the commit endpoint — the model never sees the pick (SPEC.md Consent §2). ``None`` when no proposal is outstanding. """ session_id: Optional[str] = None history: List[Dict[str, str]] = field(default_factory=list) max_iter: int = 50 debug: bool = False last_recap_history_len: int = 0 pending_prompt: Optional[str] = None pending_proposal: Optional[Dict[str, Any]] = None # --------------------------------------------------------------------------- # Session-store integration # --------------------------------------------------------------------------- def _session_store() -> Any: """Return a process-wide :class:`SessionStore` rooted at ``agent/sessions``. Cached on the module so repeat ``_append_message`` / ``_new_session`` calls don't re-import ``src.session.store`` every turn. """ global _SESSION_STORE_CACHE if _SESSION_STORE_CACHE is None: from cli._legacy import SESSIONS_DIR # filesystem path constant from src.session.store import SessionStore _SESSION_STORE_CACHE = SessionStore(base_dir=SESSIONS_DIR) return _SESSION_STORE_CACHE def _build_session_history(store: Any, session_id: str) -> list[dict]: """Load and filter recent message history for a session. Returns up to ``_HISTORY_RETAINED_TURNS`` user/assistant messages with non-empty content. """ try: messages = store.get_messages(session_id, limit=_HISTORY_RETAINED_TURNS * 2) except Exception: # noqa: BLE001 — persistence error → empty history return [] history = [ {"role": m.role, "content": m.content} for m in messages if m.role in {"user", "assistant"} and m.content.strip() ] return history[-_HISTORY_RETAINED_TURNS:] def _new_session(prompt_preview: str) -> Optional[str]: """Create a fresh session record. Returns the id, or None on failure. Dual-writes to the filesystem :class:`SessionStore` (canonical JSONL log under ``agent/sessions/``) *and* to the SQLite FTS5 search index (``~/.vibe-trading/sessions.db``) so cross-session search via :class:`SessionSearchIndex` finds turns recorded from the interactive loop. Matches the pattern in :class:`SessionService`. """ title = prompt_preview[:60] or "untitled" try: from src.session.models import Session, SessionStatus store = _session_store() session = Session( title=title, status=SessionStatus.ACTIVE, ) store.create_session(session) except Exception: # noqa: BLE001 — never block the turn on persistence return None # Index in SQLite for FTS5 cross-session search. Best-effort — never # block the turn if the search index is unavailable. try: from src.session.search import get_shared_index get_shared_index().index_session(session.session_id, title) except Exception: # noqa: BLE001 pass return session.session_id def _append_message(session_id: str, role: str, content: str) -> None: """Append a single message to the session JSONL log + FTS5 index. Dual-writes: * Canonical: append the :class:`Message` to ``messages.jsonl`` via the filesystem :class:`SessionStore`. ``_maybe_resume_last_session`` and the legacy ``sessions`` CLI both read from here. * Search index: insert the same row into the SQLite FTS5 index so ``SessionSearchTool`` finds it. Required for the CLAUDE.md promise that cross-session full-text search works. Args: session_id: Active session id. Skipped if empty. role: ``"user"`` / ``"assistant"`` / ``"tool"``. content: Message text. Empty/whitespace strings are skipped. """ if not session_id or not content: return try: from src.session.models import Message store = _session_store() store.append_message( Message(session_id=session_id, role=role, content=content) ) except Exception: # noqa: BLE001 — persistence is best-effort pass # FTS5 cross-session search. Independent try/except so a JSONL write # that succeeded is not retried just because the search index failed. try: from src.session.search import get_shared_index get_shared_index().index_message(session_id, role, content) except Exception: # noqa: BLE001 pass def _maybe_resume_last_session(console: Any) -> Optional[Dict[str, Any]]: """Prompt to resume the most recent session, if any exist. Returns: A dict ``{"session_id": str, "history": list[dict], "title": str}`` when the user opts to resume, otherwise ``None`` (new session). """ try: store = _session_store() sessions = store.list_sessions(limit=1) except Exception: # noqa: BLE001 return None if not sessions: return None last = sessions[0] title = last.title or "(untitled)" console.print() console.print( f"[dim]Resume last session ({title})? (r)esume / (n)ew (default: new)[/dim]" ) try: choice = input("> ").strip().lower() except (EOFError, KeyboardInterrupt): return None if choice not in {"r", "resume", "y", "yes"}: return None return { "session_id": last.session_id, "history": _build_session_history(store, last.session_id), "title": title, } # --------------------------------------------------------------------------- # Async preflight # --------------------------------------------------------------------------- def _start_preflight_async() -> threading.Thread: """Run ``src.preflight.run_preflight`` in a daemon thread. The welcome banner has already painted by the time this runs, so the user sees something useful immediately while credential / network probes happen in the background. We swallow exceptions because the legacy path runs preflight again before any agent invocation — this pre-warm is opportunistic. Audit item 11. """ def _worker() -> None: try: from src.preflight import run_preflight run_preflight(get_console()) except Exception: # noqa: BLE001 pass thread = threading.Thread(target=_worker, daemon=True, name="vibe-preflight") thread.start() return thread # --------------------------------------------------------------------------- # Slash dispatch # --------------------------------------------------------------------------- # Module paths that fan out to multiple commands via ``run(ctx, name, *args)``. _MULTI_COMMAND_MODULES = frozenset({ "cli.commands.chat", "cli.commands.show", "cli.commands.session", }) def _suggest_commands(unknown: str) -> List[str]: """Return up to three "did you mean" suggestions for ``unknown``. Uses :func:`difflib.get_close_matches` (edit-distance based) as the primary signal so single-character typos like ``/historu`` → ``/history`` or ``/jurnal`` → ``/journal`` are caught — the subsequence scorer in ``slash_router.match_commands`` ranks transpositions poorly and is used here only as a fallback to fill any remaining slot. Args: unknown: The bare command token (no leading ``/``). Returns: Deduplicated command names, edit-distance suggestions first, capped at three entries. """ import difflib from cli.commands.slash_router import SLASH_COMMANDS, match_commands all_names = [cmd.name for cmd in SLASH_COMMANDS] primary = difflib.get_close_matches(unknown, all_names, n=3, cutoff=0.6) # Fill remaining slots from the subsequence scorer so very short # typos (``/hi`` → ``/history``) still surface if difflib found # nothing close enough. suggestions: list[str] = list(primary) if len(suggestions) < 3: for cmd in match_commands("/" + unknown): if cmd.name not in suggestions: suggestions.append(cmd.name) if len(suggestions) >= 3: break return suggestions[:3] def _dispatch_slash(line: str, ctx: InteractiveContext) -> int: """Route a slash command line to its handler. Returns: Exit code from the handler. ``2`` is the conventional "user requested quit" sentinel (see ``cmd_quit``). Any other value is treated as continue-the-loop. """ from cli.commands.slash_router import find_exact console = get_console() stripped = line.lstrip().lstrip("/") parts = stripped.split() if not parts: console.print("[dim]Type /help to see available commands.[/dim]") return 0 name, *args = parts cmd = find_exact(name) if cmd is None: # Use edit-distance suggestions (difflib) so single-char typos # surface the right command — the subsequence scorer in # ``slash_router.match_commands`` does not handle transpositions. suggestions = _suggest_commands(name) console.print(f"[bold red]Unknown command:[/] /{name}") if suggestions: preview = ", ".join(f"/{s}" for s in suggestions) console.print(f"[dim]Did you mean: {preview}?[/dim]") console.print("[dim]Type /help to see available commands.[/dim]") return 0 try: module = importlib.import_module(cmd.handler_module) except ImportError as exc: console.print( f"[bold red]Failed to load /{cmd.name} handler ({exc})[/bold red]" ) return 0 try: if cmd.handler_module in _MULTI_COMMAND_MODULES: return int(module.run(ctx, cmd.name, *args)) return int(module.run(ctx, *args)) except SystemExit as exc: # Treat the canonical "user quit" codes (0 / 2 / None) as # a loop break; anything else is a genuine handler failure # and should keep the REPL alive so the user can recover. code = exc.code if code in (None, 0, 2): return 2 console.print(f"[bold red]/{cmd.name} exited with code {code}[/]") return 0 except Exception as exc: # noqa: BLE001 — never let a handler kill the loop console.print(f"[bold red]/{cmd.name} raised {type(exc).__name__}: {exc}[/]") return 0 # --------------------------------------------------------------------------- # Interactive loop # --------------------------------------------------------------------------- def _summarise_tool_result(tool: str, status: str, preview: str) -> str: """Short suffix to render after a finished tool event.""" if status != "ok": return preview[:48].replace("\n", " ") # Delegate to the legacy preview helper which already knows about # backtest sharpe / shadow id / etc. try: from cli._legacy import _format_tool_result_preview, _strip_rich_tags return _strip_rich_tags(_format_tool_result_preview(tool, status, preview))[:48] except Exception: # noqa: BLE001 return "" def _print_debug_summary( console: Any, result: Dict[str, Any], elapsed: float, ctx: InteractiveContext, ) -> None: """Render the one-line ``[debug]`` summary after a turn. Reads the iteration count from ``result`` (populated by :class:`src.agent.loop.AgentLoop`), counts tool events from ``react_trace`` when present, and approximates the post-turn context size from ``ctx.history``. Best-effort — any missing field falls back to ``?`` so the summary still prints. """ iterations = result.get("iterations", "?") trace = result.get("react_trace") or [] if isinstance(trace, list): tools = sum( 1 for entry in trace if isinstance(entry, dict) and entry.get("type") == "tool_result" ) else: tools = "?" history_chars = sum(len(m.get("content", "")) for m in ctx.history) # Rough char-to-token ratio (matches the loop's estimator). approx_tokens = history_chars // 4 console.print( f"[dim][debug] iter={iterations} tools={tools} " f"elapsed={elapsed:.1f}s ctx≈{approx_tokens}tok ({history_chars}ch)[/dim]" ) def _run_one_turn(user_input: str, ctx: InteractiveContext) -> None: """Execute a single agent turn with the Rich dashboard. Routes through :func:`cli._legacy._run_agent` so all tool callbacks, persistent memory, and the ReAct engine remain untouched — we only swap in the Rich dashboard and persist the turn to ``SessionStore``. """ from cli._legacy import _RunDashboard, _run_agent from rich.live import Live console = get_console() if ctx.session_id is None: ctx.session_id = _new_session(user_input) _append_message(ctx.session_id or "", "user", user_input) start = time.perf_counter() dashboard = _RunDashboard(user_input, ctx.max_iter) # Capture the latest live-trading mandate proposal emitted this turn so the # REPL can intercept the user's pick before the model (SPEC.md Consent §2). captured_proposal: Dict[str, Any] = {} def _capture_proposal(payload: Dict[str, Any]) -> None: captured_proposal.clear() captured_proposal.update(payload) # ``transient=False`` — keep the final timeline visible after the run # completes. Audit item 4. try: with Live( dashboard.render(), console=console, refresh_per_second=6, transient=False, ) as live: dashboard.live = live result = _run_agent( user_input, history=ctx.history[-_HISTORY_RETAINED_TURNS:], max_iter=ctx.max_iter, dashboard=dashboard, session_id=ctx.session_id or "", proposal_sink=_capture_proposal, ) dashboard.finish(result, time.perf_counter() - start) except (KeyboardInterrupt, BrokenPipeError): dashboard.close() # BrokenPipe: caller did ``vibe-trading chat | head`` and the # downstream pipe closed mid-render. Print may itself fail on # the closed fd, so swallow that defensively too. try: console.print("\n[yellow]Interrupted[/yellow]") except (BrokenPipeError, OSError): pass return elapsed = time.perf_counter() - start _print_interactive_result(console, result, elapsed) # Render any mandate proposal AFTER the Live dashboard has closed so the # numbered choice block isn't clobbered by the live region, and arm the # REPL to intercept the next reply (SPEC.md Consent §2). if captured_proposal: ctx.pending_proposal = dict(captured_proposal) _render_mandate_proposal(console, ctx.pending_proposal) ctx.history.append({"role": "user", "content": user_input}) answer = (result.get("content") or "").strip() if answer: ctx.history.append({"role": "assistant", "content": answer}) _append_message(ctx.session_id or "", "assistant", answer) if ctx.debug: _print_debug_summary(console, result, elapsed, ctx) def _print_interactive_result(console: Any, result: Dict[str, Any], elapsed: float) -> None: """Print the assistant answer after the rail without boxed run panels.""" from cli.ui.transcript import render_answer, render_elapsed_status content = (result.get("content") or "").strip() if content: console.print(render_answer(content)) console.print() console.print(render_elapsed_status(elapsed)) run_id = result.get("run_id") if run_id: console.print(f"[dim]/show {run_id} · {elapsed:.1f}s[/dim]") def _print_recap_if_needed(console: Any, ctx: InteractiveContext) -> None: """Print a dim recap once per completed turn.""" if len(ctx.history) <= ctx.last_recap_history_len: return from cli.ui.transcript import render_recap recap = render_recap(ctx.history) if recap is not None: console.print() console.print(recap) ctx.last_recap_history_len = len(ctx.history) def _print_input_hint(console: Any, hint: str) -> None: """Render the bottom hint bar in muted style.""" from cli.components.hint_bar import render_hint_bar console.print(render_hint_bar(left=hint, right="Ctrl+D · /quit to exit")) # --------------------------------------------------------------------------- # Live trading channel — REPL intercepts (SPEC.md Consent §2, §4) # # Two privileged surface actions are intercepted in the REPL input path BEFORE # the agent loop is ever entered, so neither depends on the model cooperating: # # 1. Kill switch — a bare "停"/"stop"/"kill" turn, or the /halt /stop slash # commands, trip the HALT sentinel directly. The model never sees them. # 2. Mandate pick — when a `mandate.proposal` is outstanding, a bare numeric # reply ("1"/"2"/"3") is a COMMIT: it calls the commit endpoint directly # and the model never sees the pick. An "按 2,但…"-style adjust reply is # re-routed to the agent (PROPOSE re-render), not committed. # --------------------------------------------------------------------------- #: Bare turns that trip the kill switch when typed alone (case-insensitive). _HALT_WORDS = frozenset({"停", "停止", "stop", "kill", "halt", "停手"}) _DEFAULT_LIVE_BROKER = "robinhood" def _is_halt_turn(text: str) -> bool: """Return True if ``text`` is a bare kill-switch turn. Matches a turn whose entire content (trimmed, trailing punctuation removed, lower-cased) is one of :data:`_HALT_WORDS`. A longer sentence that merely *mentions* "stop" is NOT a halt turn — it routes to the agent normally. Args: text: The raw user input line (already stripped of surrounding space). Returns: ``True`` if the turn should trip the kill switch. """ token = text.strip().strip(".!。! ").lower() return token in _HALT_WORDS def _trip_halt_from_repl(console: Any, *, reason: str) -> None: """Trip the global kill switch from the REPL and print a notice. This is the surface action behind a bare "停"/"stop" turn and the ``/halt`` / ``/stop`` slash commands. It writes the HALT sentinel via :func:`src.live.halt.trip_halt` — independent of the agent loop, so it works even mid-stream. Args: console: Rich console for the confirmation notice. reason: Human-readable reason recorded in the sentinel. """ try: from src.live.halt import trip_halt path = trip_halt(by="cli", reason=reason) except Exception as exc: # noqa: BLE001 — never let a halt failure kill the loop console.print(f"[bold red]Failed to trip kill switch:[/bold red] {exc}") return console.print( "[bold red]Live trading halted[/bold red] — all live order tools are now " "disabled until you run [bold]/resume[/bold] or [bold]vibe-trading connector resume[/bold]." ) console.print(f"[dim]HALT sentinel: {path}[/dim]") def _clear_halt_from_repl(console: Any) -> None: """Clear the global kill switch from the REPL (``/resume``). Clearing the halt is a privileged surface action — an explicit re-enable, never an agent tool (SPEC.md Consent §4). It is intercepted in the input path so the model never performs it. Mirrors ``vibe-trading connector resume`` with no broker (the global scope). Args: console: Rich console for the confirmation notice. """ try: from src.live.halt import clear_halt cleared = clear_halt() except Exception as exc: # noqa: BLE001 — never let a resume failure kill the loop console.print(f"[bold red]Failed to clear kill switch:[/bold red] {exc}") return if cleared: console.print( "[green]Live trading re-enabled[/green] — the global kill switch is cleared." ) else: console.print("[dim]No active global halt to clear.[/dim]") def _run_connector_command_from_repl(console: Any, args: list[str]) -> None: """Run a ``/connector ...`` subcommand from the REPL via the dispatcher. ``/connector`` is a thin in-REPL bridge to the ``vibe-trading connector`` subcommand group (SPEC.md §9 Decision 1): ``/connector status``, ``/connector start``, ``/connector stop``, ``/connector halt``, etc. It parses the arguments through the same argparse surface as the non-interactive CLI and dispatches to the same privileged handlers — none of which is an agent tool. A bare ``/connector`` defaults to ``status`` so the most common read is one keystroke away. Args: console: Rich console for error messages. args: Tokens following ``/connector`` (e.g. ``["status", "robinhood-live-mcp"]``). """ from cli._legacy import _build_parser, _dispatch_connector argv = ["connector", *(args or ["status"])] parser = _build_parser() try: parsed = parser.parse_args(argv) except SystemExit: # argparse already printed usage to stderr; keep the REPL alive. console.print("[dim]Usage: /connector [list|status|start|stop|halt|resume|revoke][/dim]") return try: _dispatch_connector(parsed) except Exception as exc: # noqa: BLE001 — never let a connector command kill the loop console.print(f"[bold red]/connector failed:[/bold red] {exc}") # QVERIS-INTEGRATION def _run_data_command_from_repl(console: Any, args: list[str]) -> None: # QVERIS-INTEGRATION """Run a ``/data ...`` subcommand from the REPL via the CLI dispatcher.""" # QVERIS-INTEGRATION from cli._legacy import _build_parser, _dispatch_data # QVERIS-INTEGRATION argv = ["data", *(args or ["status"])] # QVERIS-INTEGRATION try: # QVERIS-INTEGRATION parsed = _build_parser().parse_args(argv) # QVERIS-INTEGRATION except SystemExit: # QVERIS-INTEGRATION console.print("[dim]Usage: /data [status|mode free|mode paid|usage][/dim]") # QVERIS-INTEGRATION return # QVERIS-INTEGRATION try: # QVERIS-INTEGRATION _dispatch_data(parsed) # QVERIS-INTEGRATION except Exception as exc: # noqa: BLE001 # QVERIS-INTEGRATION console.print(f"[bold red]/data failed:[/bold red] {exc}") # QVERIS-INTEGRATION def _is_numeric_pick(text: str) -> Optional[int]: """Return the chosen ordinal if ``text`` is a bare numeric pick, else None. Only a turn that is *exactly* a positive integer (e.g. ``"2"``) counts as a pick. Anything with extra words ("按 2 但每日笔数提到 10") is an adjust reply, not a pick, and must route back through PROPOSE. Args: text: The raw user input line. Returns: The 1-based ordinal, or ``None`` when ``text`` is not a bare pick. """ token = text.strip().strip(".。 ") if token.isdigit(): value = int(token) if value >= 1: return value return None def _render_mandate_proposal(console: Any, proposal: Dict[str, Any]) -> None: """Print the numbered mandate-proposal choice block (SPEC.md Consent §2). Renders the agent's candidate mandate profiles as a numbered list with their concrete limits, plus the funding note and the kill-switch note. The user replies with a bare number to commit, or an adjust sentence to re-propose. Args: console: Rich console. proposal: The ``mandate.proposal`` event payload. """ intent = proposal.get("intent_normalized") or "live trading" account = proposal.get("account") or {} acct_type = account.get("type") acct_suffix = f" ({acct_type} account)" if acct_type else "" profiles = proposal.get("profiles") or [] console.print() if proposal.get("reauth_for"): console.print( f'[bold]AI proposes widening your mandate for "{intent}"{acct_suffix}:[/bold]' ) else: console.print( f'[bold]AI proposes {len(profiles)} mandate(s) for "{intent}"{acct_suffix}:[/bold]' ) console.print() for profile in profiles: ordinal = profile.get("ordinal", "?") label = profile.get("label", "") universe = profile.get("universe") if isinstance(universe, (list, tuple)): universe = "/".join(str(s) for s in universe) bits = [] if universe: bits.append(f"univ: {universe}") if profile.get("max_order_usd") is not None: bits.append(f"≤${profile['max_order_usd']}/order") if profile.get("daily_trade_cap") is not None: bits.append(f"{profile['daily_trade_cap']} trades/day") leverage = profile.get("leverage") if leverage in (None, "none", "None", 1, 1.0): bits.append("no leverage") elif leverage is not None: bits.append(f"leverage {leverage}") line = " · ".join(bits) console.print(f" [bold cyan][{ordinal}][/bold cyan] {label} {line}") notes = profile.get("notes") if notes: console.print(f" [dim]{notes}[/dim]") console.print() funding_note = proposal.get("funding_note") or ( "Funding is set by YOU in the broker; the agent cannot move money." ) halt_note = proposal.get("halt_note") or '随时一句 "停" = kill switch, halts everything.' console.print(f" [dim]{funding_note}[/dim]") console.print(f" [dim]{halt_note}[/dim]") console.print( ' [bold]Pick a number to commit, or say "按 2 但每日笔数提到 10" to adjust.[/bold]' ) console.print() def _commit_mandate(proposal: Dict[str, Any], selected_ordinal: int) -> Dict[str, Any]: """Commit a mandate selection via the surface commit endpoint. This is the single privileged write that activates a mandate. The pick is a SURFACE action — it goes straight to ``POST /mandate/commit`` (owned by the commit-endpoint parcel) and is NEVER fed to the model. ``consent_ack`` is set here because the user's keypress *is* the affirmative consent. The endpoint base URL is read from ``VIBE_TRADING_API_URL`` (falling back to ``http://127.0.0.1:8000``); a per-request override is not accepted from the proposal payload so the model cannot redirect the commit. Args: proposal: The outstanding ``mandate.proposal`` payload (binds the commit to the exact rendered options via ``proposal_id``). selected_ordinal: The 1-based profile the user picked. Returns: The decoded commit response (``mandate_id`` / ``consent_record_id`` on success), or an ``{"status": "error", ...}`` envelope on failure. """ import httpx from src.config.accessor import get_env_config base = get_env_config().api.vibe_trading_api_url.rstrip("/") body = { "proposal_id": proposal.get("proposal_id"), "selected_ordinal": selected_ordinal, "adjustments": None, "session_id": proposal.get("session_id"), "consent_ack": True, } try: response = httpx.post(f"{base}/mandate/commit", json=body, 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 _handle_proposal_reply(text: str, ctx: InteractiveContext) -> bool: """Intercept a reply while a mandate proposal is outstanding. Must be called only when ``ctx.pending_proposal`` is set. A bare numeric pick is a COMMIT (calls the endpoint directly, the model never sees it); any other reply is an ADJUST and is routed back to the agent for a fresh proposal. Args: text: The raw user input line. ctx: The interactive context (proposal is cleared on a successful pick). Returns: ``True`` if the reply was a numeric pick and was handled here (the caller must NOT route it to the agent). ``False`` for an adjust reply, which the caller routes to the agent normally (keeping the proposal pending until a fresh one replaces it). """ console = get_console() ordinal = _is_numeric_pick(text) if ordinal is None: # Adjust path — re-render via PROPOSE. Leave the proposal pending; the # agent will emit a fresh one that overwrites it. return False proposal = ctx.pending_proposal or {} profiles = proposal.get("profiles") or [] valid_ordinals = {p.get("ordinal") for p in profiles} if valid_ordinals and ordinal not in valid_ordinals: console.print( f"[yellow]No option [{ordinal}] in this proposal.[/yellow] " f"Pick one of: {', '.join(str(o) for o in sorted(o for o in valid_ordinals if o is not None))}, " "or type an adjust sentence." ) return True console.print(f"[dim]Committing mandate option [{ordinal}]…[/dim]") result = _commit_mandate(proposal, ordinal) if result.get("status") == "error": console.print(f"[red]Commit failed:[/red] {result.get('error')}") console.print("[dim]The proposal is still open — pick again once the issue is resolved.[/dim]") return True # Success — clear the proposal so subsequent turns route normally. ctx.pending_proposal = None mandate_id = result.get("mandate_id") or "?" console.print(f"[green]Mandate {mandate_id} active.[/green]") return True def _interactive_loop(max_iter: int, resume_session_id: Optional[str] = None) -> int: """Drive the new interactive REPL. Args: max_iter: Maximum ReAct iterations per turn. resume_session_id: If set, load this specific session instead of prompting to resume the most recent one. Returns: Process exit code (always ``0`` on a clean exit). """ console = get_console() # Keep the first prompt frame uncontested. The preflight renderer writes to # stdout, so running it here races prompt_toolkit on cold start and can make # the prompt appear only after the user presses Enter. ctx = InteractiveContext(max_iter=max_iter) if resume_session_id: # Resume a specific session by ID (``vibe-trading resume ``). try: store = _session_store() session = store.get_session(resume_session_id) except Exception: # noqa: BLE001 session = None if session is None: console.print(f"[red]Session {resume_session_id} not found[/red]") return 1 ctx.session_id = resume_session_id ctx.history = _build_session_history(store, resume_session_id) console.print( f"[dim]Resumed session: {session.title or session.session_id} " f"({len(ctx.history)} prior turns)[/dim]" ) else: # Offer to resume the most recent session. Audit item 8. resume = _maybe_resume_last_session(console) if resume is not None: ctx.session_id = resume["session_id"] ctx.history = list(resume["history"]) console.print( f"[dim]Resumed session: {resume['title']} ({len(ctx.history)} prior turns)[/dim]" ) # Build the prompt session once so history + completer persist. try: from cli.input import ctrl_c_within_window, get_user_input, make_session except Exception as exc: # noqa: BLE001 — fall back gracefully if prompt_toolkit broken console.print(f"[red]Failed to initialise input layer: {exc}[/red]") console.print("[dim]Falling back to legacy interactive loop.[/dim]") from cli._legacy import cmd_interactive try: cmd_interactive(max_iter) except Exception: # noqa: BLE001 return 1 return 0 session = make_session() while True: _print_recap_if_needed(console, ctx) try: user_input = get_user_input(session=session) except KeyboardInterrupt: # Should not reach here — the keybinding raises EOFError instead. continue except EOFError: # Two interpretations: Ctrl+D (always exit), or Ctrl+C on an # empty line (show hint, exit on second press). # # ``ctrl_c_within_window`` reads a press-time decision cached # by the keybinding: True iff the gap between the *previous* # Ctrl+C press and *this* one is < 2 s. First press → False # (no prior press) → we print the hint and continue. Second # press inside the window → True → we break. if ctrl_c_within_window(session, window_sec=2.0): break console.print( "[dim]Press Ctrl+C again within 2s, Ctrl+D, or type /quit to exit[/dim]" ) continue text = user_input.strip() if not text: continue # Kill switch — intercept a bare "停"/"stop"/"kill" turn BEFORE the # agent loop so it trips even if a turn is mid-stream and never depends # on the model cooperating (SPEC.md Consent §4). if _is_halt_turn(text): _trip_halt_from_repl(console, reason=f"repl turn: {text}") ctx.pending_proposal = None continue # Mandate pick — when a proposal is outstanding, a bare numeric reply # is a COMMIT handled here (the model never sees it). An adjust reply # falls through to the agent for a fresh proposal (SPEC.md Consent §2). if ctx.pending_proposal is not None and not text.startswith("/"): if _handle_proposal_reply(text, ctx): continue # Slash command path. if text.startswith("/"): # /halt /stop /resume /connector are privileged live-trading surface # actions handled in the input path, never dispatched to the model # (SPEC.md Consent §4 / §9 Decision 1). slash_tokens = text.lstrip("/").split() slash_name = slash_tokens[0].lower() if slash_tokens else "" if slash_name in {"halt", "stop"}: _trip_halt_from_repl(console, reason=f"/{slash_name}") ctx.pending_proposal = None continue if slash_name == "resume": _clear_halt_from_repl(console) continue if slash_name == "connector": _run_connector_command_from_repl(console, slash_tokens[1:]) continue if slash_name == "data": # QVERIS-INTEGRATION _run_data_command_from_repl(console, slash_tokens[1:]) # QVERIS-INTEGRATION continue # QVERIS-INTEGRATION rc = _dispatch_slash(text, ctx) if rc == 2: break # A handler may queue an agent prompt (``/journal ``, # ``/shadow ...``). Drain it here so the slash command turns # into a real turn without round-tripping through stdin. queued = ctx.pending_prompt if queued: ctx.pending_prompt = None _run_one_turn(queued, ctx) continue # Natural-language path — drive the agent. _run_one_turn(text, ctx) console.print("[dim]Goodbye[/dim]") if ctx.session_id: console.print( f"[dim]To resume this session:[/dim] [bold]vibe-trading resume {ctx.session_id}[/bold]" ) return 0 # --------------------------------------------------------------------------- # Public entry point # --------------------------------------------------------------------------- def main(argv: Optional[list[str]] = None) -> int: """CLI entrypoint returning a process exit code. Behaviour: * Interactive entry (no subcommand, or ``chat`` + TTY): show banner, run onboarding wizard if needed, then drop into the interactive loop driven by ``cli/input.py``, ``cli/completer.py``, and ``cli/commands/*``. * Non-interactive entry (``serve``, ``run -p ...``, ``mcp``, ``swarm``, piped stdin, etc.): pass through to ``cli._legacy.main`` so every existing subcommand keeps working unchanged. Args: argv: Optional argument list (defaults to ``sys.argv[1:]``). Returns: Process exit code. """ raw_argv = list(sys.argv[1:] if argv is None else argv) interactive = _is_interactive_invocation(raw_argv) if interactive: if not _maybe_run_onboarding(): return 0 _show_banner() # Strip the optional ``chat`` token + any ``--max-iter`` flag so # the new loop can read them directly without re-parsing argv. max_iter = _extract_max_iter(raw_argv, default=50) return _interactive_loop(max_iter) # Handle ``vibe-trading resume `` — enter the interactive # loop with a specific session loaded, bypassing the legacy dispatcher. if len(raw_argv) == 2 and raw_argv[0] == "resume": max_iter = _extract_max_iter(raw_argv, default=50) return _interactive_loop(max_iter=max_iter, resume_session_id=raw_argv[1]) # Delegate every other path to the legacy dispatcher. try: from cli import _legacy except ImportError as exc: # pragma: no cover — packaging error get_console().print( f" Internal error: cannot import cli._legacy ({exc}).", style=Theme.danger, ) return 2 return int(_legacy.main(raw_argv)) def _extract_max_iter(argv: Sequence[str], *, default: int) -> int: """Pull ``--max-iter `` (or ``--max-iter=N``) out of ``argv``. The legacy argparse setup accepts ``--max-iter`` both at the top level and as ``chat --max-iter``. We just need the integer; the presence/absence of ``chat`` was already determined upstream. """ it = iter(range(len(argv))) for i in it: token = argv[i] if token == "--max-iter" and i + 1 < len(argv): try: return int(argv[i + 1]) except ValueError: return default if token.startswith("--max-iter="): try: return int(token.split("=", 1)[1]) except ValueError: return default return default def _entrypoint() -> None: """Thin wrapper so the console script and ``python -m cli.main`` agree.""" sys.exit(main()) # --------------------------------------------------------------------------- # Optional typer integration (only used by ``python -m cli.main --help``) # --------------------------------------------------------------------------- def _build_typer_app(): # type: ignore[no-untyped-def] """Build a typer app that mirrors the legacy surface. Best-effort only.""" try: import typer except ImportError: return None app = typer.Typer( add_completion=False, no_args_is_help=False, help="Vibe-Trading — natural-language finance research agent.", rich_markup_mode=None, ) @app.callback(invoke_without_command=True) def _default(ctx: typer.Context) -> None: # noqa: ANN001 if ctx.invoked_subcommand is None: sys.exit(main(ctx.args)) @app.command("chat", help="Start the interactive ReAct chat loop.") def _chat( max_iter: int = typer.Option(50, "--max-iter", help="Maximum ReAct iterations."), ) -> None: sys.exit(main(["chat", "--max-iter", str(max_iter)])) @app.command("serve", help="Start the FastAPI server.") def _serve( host: str = typer.Option("127.0.0.1", "--host"), port: int = typer.Option(8000, "--port"), dev: bool = typer.Option(False, "--dev", help="Also boot the Vite dev server."), ) -> None: forwarded = ["serve", "--host", host, "--port", str(port)] if dev: forwarded.append("--dev") sys.exit(main(forwarded)) @app.command("list", help="List recent runs.") def _list(limit: int = typer.Option(20, "--limit")) -> None: sys.exit(main(["list", "--limit", str(limit)])) @app.command("show", help="Show a recorded run by id.") def _show(run_id: str = typer.Argument(...)) -> None: sys.exit(main(["show", run_id])) @app.command("init", help="Re-run the interactive setup wizard.") def _init() -> None: run_onboarding(console=get_console()) @app.command("setup", help="Install frontend deps and build the production bundle (cross-platform).") def _setup( frontend_dir: str = typer.Option( "frontend", "--frontend-dir", help="Path to the frontend directory (relative to repo root or absolute).", ), ) -> None: sys.exit(main(["setup", "--frontend-dir", frontend_dir])) @app.command("dev", help="Start backend + Vite dev server in one process.") def _dev( port: int = typer.Option(8899, "--port", help="Backend port."), frontend_port: int = typer.Option( 5899, "--frontend-port", help="Vite dev server port (must match vite.config.ts).", ), frontend_dir: str = typer.Option("frontend", "--frontend-dir"), ) -> None: sys.exit( main( [ "dev", "--port", str(port), "--frontend-port", str(frontend_port), "--frontend-dir", frontend_dir, ] ) ) return app # ``python -m cli.main`` support — uses typer help if available, else main(). if __name__ == "__main__": typer_app = _build_typer_app() if typer_app is not None: typer_app() else: _entrypoint() __all__ = ["main", "InteractiveContext"]