spych.cli

spych CLI entry point.

Usage: spych [options]

Examples: spych ollama --model llama3.2:latest spych --theme light claude_code_cli spych claude_code_sdk --setting-sources user project local spych codex_cli --listen-duration 8 spych antigravity_cli spych opencode_cli --model anthropic/claude-sonnet-4-5

# Live transcription
spych live
spych live --output-path my_transcript --output-format srt
spych live --stop-key q --terminate-words "stop recording"
spych live --no-timestamps --whisper-model small.en

# Multi-agent: run several agents under different wake words at once
spych multi --agents claude_code_sdk ollama --ollama-model llama3.2:latest

# Voice management
spych profile_my_voice --name my_voice
spych profile_my_voice --name my_voice --alternate-output-file ./my_voice_backup.wav

# User management
spych users
spych claude --user UserA
spych claude --user none
   1"""
   2spych CLI entry point.
   3
   4Usage:
   5    spych <agent> [options]
   6
   7Examples:
   8    spych ollama --model llama3.2:latest
   9    spych --theme light claude_code_cli
  10    spych claude_code_sdk --setting-sources user project local
  11    spych codex_cli --listen-duration 8
  12    spych antigravity_cli
  13    spych opencode_cli --model anthropic/claude-sonnet-4-5
  14
  15    # Live transcription
  16    spych live
  17    spych live --output-path my_transcript --output-format srt
  18    spych live --stop-key q --terminate-words "stop recording"
  19    spych live --no-timestamps --whisper-model small.en
  20
  21    # Multi-agent: run several agents under different wake words at once
  22    spych multi --agents claude_code_sdk ollama --ollama-model llama3.2:latest
  23
  24    # Voice management
  25    spych profile_my_voice --name my_voice
  26    spych profile_my_voice --name my_voice --alternate-output-file ./my_voice_backup.wav
  27
  28    # User management
  29    spych users
  30    spych claude --user UserA
  31    spych claude --user none
  32"""
  33
  34import argparse
  35import os
  36import sys
  37from importlib.metadata import version
  38
  39
  40def _parse_bool(value: str) -> bool:
  41    if value.lower() in ("true", "1", "yes"):
  42        return True
  43    if value.lower() in ("false", "0", "no"):
  44        return False
  45    raise argparse.ArgumentTypeError(f"Boolean value expected, got: {value!r}")
  46
  47
  48def _add_shared_args(parser: argparse.ArgumentParser) -> None:
  49    """Args shared by all agents."""
  50    parser.add_argument(
  51        "--personality",
  52        default=None,
  53        metavar="NAME",
  54        help=(
  55            "Apply a named personality preset (e.g. jarvis). "
  56            "Sets default wake words, voice, name, and response style. "
  57            "Any explicit flag overrides the preset."
  58        ),
  59    )
  60    parser.add_argument(
  61        "--name",
  62        metavar="NAME",
  63        help="Custom display name for the agent",
  64    )
  65    parser.add_argument(
  66        "--wake-words",
  67        nargs="+",
  68        metavar="WORD",
  69        help="One or more wake words that trigger the agent",
  70    )
  71    parser.add_argument(
  72        "--terminate-words",
  73        nargs="+",
  74        metavar="WORD",
  75        default=["terminate"],
  76        help="Words that stop the listener (default: terminate)",
  77    )
  78    parser.add_argument(
  79        "--listen-duration",
  80        type=float,
  81        metavar="SECONDS",
  82        help="Seconds to listen after wake word (default: 5)",
  83    )
  84    parser.add_argument(
  85        "--follow-up-listen-duration",
  86        type=float,
  87        metavar="SECONDS",
  88        help="Seconds to listen for follow-up answers (default: 0)",
  89    )
  90    parser.add_argument(
  91        "--inactivity-timeout",
  92        type=float,
  93        default=4.0,
  94        metavar="SECONDS",
  95        help="Seconds of inactivity before pivoting back to wake word (default: 4.0)",
  96    )
  97    parser.add_argument(
  98        "--response-style",
  99        default="",
 100        metavar="STYLE",
 101        help=(
 102            "Style for reformatting output. "
 103            "Choices: military, five_year_old, fast, pirate, news_anchor, haiku, shakespearean, robot, "
 104            "caveman, yoda"
 105        ),
 106    )
 107    parser.add_argument(
 108        "--use-speaker",
 109        type=_parse_bool,
 110        default=None,
 111        metavar="BOOL",
 112        help="Speak responses aloud via TTS (default: true)",
 113    )
 114    parser.add_argument(
 115        "--speaker-voice",
 116        default=None,
 117        metavar="VOICE",
 118        help=(
 119            "Voice name for spoken responses (default: af_heart). "
 120            "Works for both Chatterbox (wave voices) and Kokoro (pt voices). "
 121            "See: https://github.com/connor-makowski/spych/tree/main/voices"
 122        ),
 123    )
 124    parser.add_argument(
 125        "--speaker-backend",
 126        default="",
 127        choices=["chatterbox", "kokoro"],
 128        metavar="BACKEND",
 129        help="Explicit TTS backend to use (default: priority Chatterbox then Kokoro)",
 130    )
 131    parser.add_argument(
 132        "--verbose",
 133        action="store_true",
 134        default=False,
 135        help=(
 136            "Use verbose scroll output (spinner + full log) instead of the "
 137            "TUI dashboard (default: false)"
 138        ),
 139    )
 140    parser.add_argument(
 141        "--user",
 142        default=None,
 143        metavar="NAME",
 144        help="The user name to use for tailored responses (default: default user from settings)",
 145    )
 146    parser.add_argument(
 147        "--intermediate-responses",
 148        type=_parse_bool,
 149        default=True,
 150        metavar="BOOL",
 151        help="Enable intermediate response chaining for long-running tasks (default: true)",
 152    )
 153
 154
 155def _add_agent_args(parser: argparse.ArgumentParser) -> None:
 156    """Args shared by all coding agents (non-Ollama)."""
 157    parser.add_argument(
 158        "--continue-conversation",
 159        type=_parse_bool,
 160        metavar="BOOL",
 161        default=True,
 162        help="Resume the most recent session (default: true)",
 163    )
 164    parser.add_argument(
 165        "--show-tool-events",
 166        type=_parse_bool,
 167        metavar="BOOL",
 168        default=True,
 169        help="Print live tool start/end events (default: true)",
 170    )
 171
 172
 173def _build_shared_kwargs(args: argparse.Namespace) -> dict:
 174    kwargs = {}
 175    # Personality preset provides base defaults; explicit CLI flags override.
 176    if getattr(args, "personality", None):
 177        from spych.utils import get_personality
 178
 179        kwargs.update(get_personality(args.personality))
 180    if args.name is not None:
 181        kwargs["name"] = args.name
 182    if args.wake_words:
 183        kwargs["wake_words"] = args.wake_words
 184    if args.terminate_words:
 185        kwargs["terminate_words"] = args.terminate_words
 186    if args.listen_duration is not None:
 187        kwargs["listen_duration"] = args.listen_duration
 188    if getattr(args, "follow_up_listen_duration", None) is not None:
 189        kwargs["follow_up_listen_duration"] = args.follow_up_listen_duration
 190    if getattr(args, "inactivity_timeout", 4.0) is not None:
 191        kwargs["inactivity_timeout"] = args.inactivity_timeout
 192    if args.use_speaker is not None:
 193        kwargs["use_speaker"] = args.use_speaker
 194    if args.speaker_voice is not None:
 195        kwargs["speaker_voice"] = args.speaker_voice
 196    if getattr(args, "speaker_backend", ""):
 197        kwargs["speaker_backend"] = args.speaker_backend
 198    if args.response_style:
 199        kwargs["response_style"] = args.response_style
 200    if args.user:
 201        kwargs["user"] = args.user
 202    if not args.intermediate_responses:
 203        kwargs["allow_intermediate_responses"] = False
 204    return kwargs
 205
 206
 207def _build_agent_kwargs(args: argparse.Namespace) -> dict:
 208    kwargs = _build_shared_kwargs(args)
 209    kwargs["continue_conversation"] = args.continue_conversation
 210    kwargs["show_tool_events"] = args.show_tool_events
 211    return kwargs
 212
 213
 214def main():
 215    __version__ = version("spych")
 216    parser = argparse.ArgumentParser(
 217        prog="spych",
 218        description=f"spych {__version__}: Launch a voice agent from the terminal.",
 219        formatter_class=argparse.RawDescriptionHelpFormatter,
 220        epilog=__doc__,
 221    )
 222
 223    parser.add_argument(
 224        "-v",
 225        "--version",
 226        action="version",
 227        version=f"spych {__version__}",
 228        help="Show the version number and exit",
 229    )
 230
 231    parser.add_argument(
 232        "--theme",
 233        default="dark",
 234        choices=["dark", "light", "solarized", "mono"],
 235        metavar="THEME",
 236        help=(
 237            "Colour theme for terminal output. "
 238            "Choices: dark (default), light, solarized, mono"
 239        ),
 240    )
 241
 242    subparsers = parser.add_subparsers(dest="agent", metavar="agent")
 243    subparsers.required = True
 244
 245    # Aliases → canonical name; used to normalise args.agent after parsing.
 246    _AGENT_ALIASES: dict[str, str] = {
 247        "claude": "claude_code_sdk",
 248        "codex": "codex_cli",
 249        "gemini": "antigravity_cli",
 250        "google": "antigravity_cli",
 251        "agy": "antigravity_cli",
 252        "agy_cli": "antigravity_cli",
 253        "antigravity": "antigravity_cli",
 254        "opencode": "opencode_cli",
 255    }
 256
 257    # ------------------------------------------------------------------ #
 258    # ollama                                                               #
 259    # ------------------------------------------------------------------ #
 260    p_ollama = subparsers.add_parser(
 261        "ollama", help="Talk to a local Ollama model"
 262    )
 263    _add_shared_args(p_ollama)
 264    p_ollama.add_argument(
 265        "--model",
 266        default="llama3.2:latest",
 267        metavar="MODEL",
 268        help="Ollama model name (default: llama3.2:latest)",
 269    )
 270    p_ollama.add_argument(
 271        "--history-length",
 272        type=int,
 273        default=10,
 274        metavar="N",
 275        help="Past interactions to include in context (default: 10)",
 276    )
 277    p_ollama.add_argument(
 278        "--host",
 279        default="http://localhost:11434",
 280        metavar="URL",
 281        help="Ollama instance URL (default: http://localhost:11434)",
 282    )
 283
 284    # ------------------------------------------------------------------ #
 285    # claude_code_cli                                                      #
 286    # ------------------------------------------------------------------ #
 287    p_claude_cli = subparsers.add_parser(
 288        "claude_code_cli",
 289        help="Voice-control Claude Code via the CLI",
 290    )
 291    _add_shared_args(p_claude_cli)
 292    _add_agent_args(p_claude_cli)
 293
 294    # ------------------------------------------------------------------ #
 295    # claude_code_sdk                                                      #
 296    # ------------------------------------------------------------------ #
 297    p_claude_sdk = subparsers.add_parser(
 298        "claude_code_sdk",
 299        aliases=["claude"],
 300        help="Voice-control Claude Code via the Agent SDK",
 301    )
 302    _add_shared_args(p_claude_sdk)
 303    _add_agent_args(p_claude_sdk)
 304    p_claude_sdk.add_argument(
 305        "--setting-sources",
 306        nargs="+",
 307        metavar="SOURCE",
 308        default=["user", "project", "local"],
 309        help="Claude Code settings sources to load (default: user project local)",
 310    )
 311
 312    # ------------------------------------------------------------------ #
 313    # codex_cli                                                            #
 314    # ------------------------------------------------------------------ #
 315    p_codex = subparsers.add_parser(
 316        "codex_cli",
 317        aliases=["codex"],
 318        help="Voice-control the OpenAI Codex agent",
 319    )
 320    _add_shared_args(p_codex)
 321    _add_agent_args(p_codex)
 322
 323    # ------------------------------------------------------------------ #
 324    # antigravity_cli                                                      #
 325    # ------------------------------------------------------------------ #
 326    p_antigravity = subparsers.add_parser(
 327        "antigravity_cli",
 328        aliases=["antigravity", "agy", "agy_cli", "google", "gemini"],
 329        help="Voice-control the Antigravity agent (agy)",
 330    )
 331    _add_shared_args(p_antigravity)
 332    _add_agent_args(p_antigravity)
 333
 334    # ------------------------------------------------------------------ #
 335    # opencode_cli                                                         #
 336    # ------------------------------------------------------------------ #
 337    p_opencode = subparsers.add_parser(
 338        "opencode_cli",
 339        aliases=["opencode"],
 340        help="Voice-control the OpenCode agent",
 341    )
 342    _add_shared_args(p_opencode)
 343    _add_agent_args(p_opencode)
 344    p_opencode.add_argument(
 345        "--model",
 346        default=None,
 347        metavar="MODEL",
 348        help="Model in provider/model format, e.g. anthropic/claude-sonnet-4-5",
 349    )
 350
 351    # ------------------------------------------------------------------ #
 352    # live — continuous transcription to file                             #
 353    # ------------------------------------------------------------------ #
 354    p_live = subparsers.add_parser(
 355        "live",
 356        help="Continuously transcribe speech to .txt and/or .srt files",
 357        formatter_class=argparse.RawDescriptionHelpFormatter,
 358        description=(
 359            "Start a live transcription session. Records continuously using VAD\n"
 360            "and writes output to disk in real time.\n\n"
 361            "Stop by pressing the stop key (default: q + Enter), saying a\n"
 362            "terminate word, or pressing Ctrl+C."
 363        ),
 364    )
 365    p_live.add_argument(
 366        "--output-path",
 367        default="transcript",
 368        metavar="PATH",
 369        help="Base output file path without extension (default: transcript)",
 370    )
 371    p_live.add_argument(
 372        "--output-format",
 373        default="both",
 374        choices=["txt", "srt", "both"],
 375        metavar="FORMAT",
 376        help="Output format: txt, srt, or both (default: both)",
 377    )
 378    p_live.add_argument(
 379        "--no-timestamps",
 380        action="store_true",
 381        help="Omit timestamps from terminal and .txt output",
 382    )
 383    p_live.add_argument(
 384        "--stop-key",
 385        default="q",
 386        metavar="KEY",
 387        help="Key to type (then Enter) to stop the session (default: q)",
 388    )
 389    p_live.add_argument(
 390        "--terminate-words",
 391        nargs="+",
 392        metavar="WORD",
 393        help="Spoken words that stop the session (e.g. 'stop recording')",
 394    )
 395    p_live.add_argument(
 396        "--device-index",
 397        type=int,
 398        default=-1,
 399        metavar="N",
 400        help="Microphone device index; -1 uses system default (default: -1)",
 401    )
 402    p_live.add_argument(
 403        "--whisper-model",
 404        default="base.en",
 405        metavar="MODEL",
 406        help="faster-whisper model name (default: base.en)",
 407    )
 408    p_live.add_argument(
 409        "--whisper-device",
 410        default="auto",
 411        choices=["auto", "cpu", "cuda"],
 412        metavar="DEVICE",
 413        help="Device for whisper inference: auto, cpu, or cuda (default: auto). auto uses cuda when Python <=3.13 and CUDA is available, otherwise cpu. cuda requires nvidia-cublas-cu12 + nvidia-cudnn-cu12.",
 414    )
 415    p_live.add_argument(
 416        "--whisper-compute-type",
 417        default="int8",
 418        choices=["int8", "float16", "float32"],
 419        metavar="TYPE",
 420        help="Compute type for whisper: int8, float16, float32 (default: int8)",
 421    )
 422    p_live.add_argument(
 423        "--no-speech-threshold",
 424        type=float,
 425        default=0.3,
 426        metavar="FLOAT",
 427        help="Whisper no_speech_prob cutoff — segments above this are dropped (default: 0.3)",
 428    )
 429    p_live.add_argument(
 430        "--speech-threshold",
 431        type=float,
 432        default=0.5,
 433        metavar="FLOAT",
 434        help="VAD speech onset probability (default: 0.5)",
 435    )
 436    p_live.add_argument(
 437        "--silence-threshold",
 438        type=float,
 439        default=0.35,
 440        metavar="FLOAT",
 441        help="VAD silence probability during speech (default: 0.35)",
 442    )
 443    p_live.add_argument(
 444        "--silence-frames",
 445        type=int,
 446        default=20,
 447        metavar="N",
 448        help="Consecutive silent frames required to end a segment (~32ms each, default: 20)",
 449    )
 450    p_live.add_argument(
 451        "--speech-pad-frames",
 452        type=int,
 453        default=5,
 454        metavar="N",
 455        help="Pre-roll frames and onset confirmation count (default: 5)",
 456    )
 457    p_live.add_argument(
 458        "--max-speech-duration",
 459        type=float,
 460        default=30.0,
 461        metavar="SECONDS",
 462        help="Hard cap on a single segment in seconds (default: 30.0)",
 463    )
 464    p_live.add_argument(
 465        "--context-words",
 466        type=int,
 467        default=32,
 468        metavar="N",
 469        help="Trailing words passed as whisper initial_prompt for context (default: 32)",
 470    )
 471
 472    # ------------------------------------------------------------------ #
 473    p_live_translation = subparsers.add_parser(
 474        "live-translation",
 475        help="Bidirectional live translation between two languages",
 476        formatter_class=argparse.RawDescriptionHelpFormatter,
 477        description=(
 478            "Start a bidirectional live translation session. Either participant\n"
 479            "can speak in either language; Whisper transcribes and Ollama detects\n"
 480            "which language was spoken then translates to the other.\n\n"
 481            "Each utterance is shown as two lines prefixed with [HH:MM:SS](lang):\n"
 482            "  [00:00:05](en) Hello, how are you?\n"
 483            "  [00:00:05](es) Hola, ¿cómo estás?\n\n"
 484            "Stop by pressing the stop key (default: q + Enter), saying a\n"
 485            "terminate word, or pressing Ctrl+C."
 486        ),
 487    )
 488    p_live_translation.add_argument(
 489        "--languages",
 490        required=True,
 491        nargs=2,
 492        metavar="LANG",
 493        help="Two BCP-47 language codes for the conversation pair (e.g. en es)",
 494    )
 495    p_live_translation.add_argument(
 496        "--ollama-host",
 497        default="http://localhost:11434",
 498        metavar="URL",
 499        help="Ollama HTTP base URL for translation (default: http://localhost:11434)",
 500    )
 501    p_live_translation.add_argument(
 502        "--ollama-translation-model",
 503        default="llama3.2",
 504        metavar="MODEL",
 505        help="Ollama model name used for translation (default: llama3.2)",
 506    )
 507    p_live_translation.add_argument(
 508        "--no-speaker",
 509        action="store_true",
 510        help="Disable TTS — do not speak translated segments aloud (speaker is on by default)",
 511    )
 512    p_live_translation.add_argument(
 513        "--speaker-voice",
 514        default="",
 515        metavar="VOICE",
 516        help="Wave voice name for zero-shot cloning; omit to use the model's built-in default voice",
 517    )
 518    p_live_translation.add_argument(
 519        "--output-path",
 520        default="transcript",
 521        metavar="PATH",
 522        help="Base output file path without extension (default: transcript)",
 523    )
 524    p_live_translation.add_argument(
 525        "--output-format",
 526        default=None,
 527        choices=["txt", "srt", "both"],
 528        metavar="FORMAT",
 529        help="Save transcript to file: txt, srt, or both (default: no file output)",
 530    )
 531    p_live_translation.add_argument(
 532        "--no-timestamps",
 533        action="store_true",
 534        help="Omit timestamps from terminal and .txt output",
 535    )
 536    p_live_translation.add_argument(
 537        "--stop-key",
 538        default="q",
 539        metavar="KEY",
 540        help="Key to type (then Enter) to stop the session (default: q)",
 541    )
 542    p_live_translation.add_argument(
 543        "--terminate-words",
 544        nargs="+",
 545        metavar="WORD",
 546        help="Spoken words that stop the session (e.g. 'stop recording')",
 547    )
 548    p_live_translation.add_argument(
 549        "--device-index",
 550        type=int,
 551        default=-1,
 552        metavar="N",
 553        help="Microphone device index; -1 uses system default (default: -1)",
 554    )
 555    p_live_translation.add_argument(
 556        "--whisper-model",
 557        default="small",
 558        metavar="MODEL",
 559        help="faster-whisper model name; .en suffix stripped automatically (default: small)",
 560    )
 561    p_live_translation.add_argument(
 562        "--whisper-device",
 563        default="auto",
 564        choices=["auto", "cpu", "cuda"],
 565        metavar="DEVICE",
 566        help="Device for whisper inference: auto, cpu, or cuda (default: auto). auto uses cuda when Python <=3.13 and CUDA is available, otherwise cpu. cuda requires nvidia-cublas-cu12 + nvidia-cudnn-cu12.",
 567    )
 568    p_live_translation.add_argument(
 569        "--whisper-compute-type",
 570        default="int8",
 571        choices=["int8", "float16", "float32"],
 572        metavar="TYPE",
 573        help="Compute type for whisper: int8, float16, float32 (default: int8)",
 574    )
 575    p_live_translation.add_argument(
 576        "--no-speech-threshold",
 577        type=float,
 578        default=0.3,
 579        metavar="FLOAT",
 580        help="Whisper no_speech_prob cutoff — segments above this are dropped (default: 0.3)",
 581    )
 582    p_live_translation.add_argument(
 583        "--speech-threshold",
 584        type=float,
 585        default=0.5,
 586        metavar="FLOAT",
 587        help="VAD speech onset probability (default: 0.5)",
 588    )
 589    p_live_translation.add_argument(
 590        "--silence-threshold",
 591        type=float,
 592        default=0.35,
 593        metavar="FLOAT",
 594        help="VAD silence probability during speech (default: 0.35)",
 595    )
 596    p_live_translation.add_argument(
 597        "--silence-frames",
 598        type=int,
 599        default=20,
 600        metavar="N",
 601        help="Consecutive silent frames required to end a segment (~32ms each, default: 20)",
 602    )
 603    p_live_translation.add_argument(
 604        "--speech-pad-frames",
 605        type=int,
 606        default=5,
 607        metavar="N",
 608        help="Pre-roll frames and onset confirmation count (default: 5)",
 609    )
 610    p_live_translation.add_argument(
 611        "--max-speech-duration",
 612        type=float,
 613        default=30.0,
 614        metavar="SECONDS",
 615        help="Hard cap on a single segment in seconds (default: 30.0)",
 616    )
 617    # ------------------------------------------------------------------ #
 618    p_multi = subparsers.add_parser(
 619        "multi",
 620        help="Run multiple agents simultaneously under different wake words",
 621        formatter_class=argparse.RawDescriptionHelpFormatter,
 622        description=(
 623            "Run several agents at once. Each agent uses its own default wake "
 624            "words unless overridden.\n\n"
 625            "Example:\n"
 626            "  spych multi --agents claude_code_cli antigravity_cli\n"
 627            "  spych multi --agents claude_code_cli ollama --ollama-model llama3.2:latest\n"
 628            "  spych multi --agents claude_code_sdk codex_cli --listen-duration 8"
 629        ),
 630    )
 631    p_multi.add_argument(
 632        "--agents",
 633        nargs="+",
 634        required=True,
 635        metavar="AGENT",
 636        choices=[
 637            "claude_code_cli",
 638            "claude",
 639            "claude_code_sdk",
 640            "claude_sdk",
 641            "codex_cli",
 642            "codex",
 643            "antigravity_cli",
 644            "antigravity",
 645            "agy_cli",
 646            "agy",
 647            "opencode_cli",
 648            "opencode",
 649            "ollama",
 650        ],
 651        help=(
 652            "Agents to run. Choices: claude (claude_code_cli), "
 653            "claude_sdk (claude_code_sdk), codex (codex_cli), "
 654            "antigravity (antigravity_cli), opencode (opencode_cli), ollama"
 655        ),
 656    )
 657    p_multi.add_argument(
 658        "--terminate-words",
 659        nargs="+",
 660        metavar="WORD",
 661        default=["terminate"],
 662        help="Words that stop all agents (default: terminate)",
 663    )
 664    p_multi.add_argument(
 665        "--listen-duration",
 666        type=float,
 667        default=5,
 668        metavar="SECONDS",
 669        help="Seconds to listen after a wake word (default: 5)",
 670    )
 671    p_multi.add_argument(
 672        "--follow-up-listen-duration",
 673        type=float,
 674        default=0,
 675        metavar="SECONDS",
 676        help="Seconds to listen for follow-up answers (default: 0)",
 677    )
 678    p_multi.add_argument(
 679        "--inactivity-timeout",
 680        type=float,
 681        default=4.0,
 682        metavar="SECONDS",
 683        help="Seconds of inactivity before pivoting back to wake word (default: 4.0)",
 684    )
 685    p_multi.add_argument(
 686        "--continue-conversation",
 687        type=_parse_bool,
 688        default=True,
 689        metavar="BOOL",
 690        help="Resume most recent session for each coding agent (default: true)",
 691    )
 692    p_multi.add_argument(
 693        "--show-tool-events",
 694        type=_parse_bool,
 695        default=True,
 696        metavar="BOOL",
 697        help="Print live tool start/end events (default: true)",
 698    )
 699    p_multi.add_argument(
 700        "--speaker-backend",
 701        default="",
 702        choices=["chatterbox", "kokoro"],
 703        metavar="BACKEND",
 704        help="Explicit TTS backend to use (default: priority Chatterbox then Kokoro)",
 705    )
 706    p_multi.add_argument(
 707        "--use-speaker",
 708        type=_parse_bool,
 709        default=True,
 710        metavar="BOOL",
 711        help="Speak responses aloud via TTS (default: true)",
 712    )
 713    p_multi.add_argument(
 714        "--verbose",
 715        action="store_true",
 716        default=False,
 717        help=(
 718            "Use verbose scroll output (spinner + full log) instead of the "
 719            "TUI dashboard (default: false)"
 720        ),
 721    )
 722    p_multi.add_argument(
 723        "--user",
 724        default=None,
 725        metavar="NAME",
 726        help="The user name to use for tailored responses (default: default user from settings)",
 727    )
 728    # ollama-specific flags (only used when 'ollama' is in --agents)
 729    p_multi.add_argument(
 730        "--ollama-model",
 731        default="llama3.2:latest",
 732        metavar="MODEL",
 733        help="Ollama model (default: llama3.2:latest). Only used when ollama is in --agents.",
 734    )
 735    p_multi.add_argument(
 736        "--ollama-host",
 737        default="http://localhost:11434",
 738        metavar="URL",
 739        help="Ollama instance URL (default: http://localhost:11434). Only used when ollama is in --agents.",
 740    )
 741    p_multi.add_argument(
 742        "--ollama-history-length",
 743        type=int,
 744        default=10,
 745        metavar="N",
 746        help="Ollama context history length (default: 10). Only used when ollama is in --agents.",
 747    )
 748    # opencode-specific flag
 749    p_multi.add_argument(
 750        "--opencode-model",
 751        default=None,
 752        metavar="MODEL",
 753        help="OpenCode model in provider/model format. Only used when opencode_cli is in --agents.",
 754    )
 755    # claude_code_sdk-specific flag
 756    p_multi.add_argument(
 757        "--setting-sources",
 758        nargs="+",
 759        metavar="SOURCE",
 760        default=["user", "project", "local"],
 761        help="Claude Code SDK setting sources (default: user project local). Only used when claude_code_sdk is in --agents.",
 762    )
 763
 764    # ------------------------------------------------------------------ #
 765    # profile_my_voice — Record a custom voice profile                   #
 766    # ------------------------------------------------------------------ #
 767    p_profile = subparsers.add_parser(
 768        "profile_my_voice",
 769        help="Record a 10-second voice sample to create a custom profile",
 770    )
 771    p_profile.add_argument(
 772        "--name",
 773        required=True,
 774        metavar="NAME",
 775        help="The name to save this voice profile as (e.g. 'my_voice')",
 776    )
 777    p_profile.add_argument(
 778        "--device-index",
 779        type=int,
 780        default=-1,
 781        metavar="N",
 782        help="Microphone device index; -1 uses system default (default: -1)",
 783    )
 784    p_profile.add_argument(
 785        "--alternate-output-file",
 786        default=None,
 787        metavar="PATH",
 788        help="An alternate file path to save the voice profile to (e.g. './my_voice.wav')",
 789    )
 790
 791    # ------------------------------------------------------------------ #
 792    # users — manage user profiles                                       #
 793    # ------------------------------------------------------------------ #
 794    p_users = subparsers.add_parser(
 795        "users",
 796        help="Manage user profiles and global settings",
 797        description=(
 798            "Launch an interactive menu to manage user profiles and global "
 799            "preferences. Profiles store personal info (name, age, extra context) "
 800            "used to tailor agent responses. You can also set the default user "
 801            "and terminal theme here."
 802        ),
 803    )
 804
 805    # ------------------------------------------------------------------ #
 806    # Dispatch                                                             #
 807    # ------------------------------------------------------------------ #
 808    args = parser.parse_args()
 809
 810    # Normalise any alias back to the canonical agent name so the dispatch
 811    # block below only needs to handle one name per agent.
 812    args.agent = _AGENT_ALIASES.get(args.agent, args.agent)
 813
 814    # Apply color theme as early as possible so all subsequent output uses it.
 815    if args.theme != "dark":
 816        from spych.cli_tools import set_theme
 817
 818        set_theme(args.theme)
 819
 820    # ------------------------------------------------------------------ #
 821    # Single-agent dispatch                                                #
 822    # ------------------------------------------------------------------ #
 823
 824    # Default wake words per agent — mirrors the factory function defaults.
 825    _DEFAULT_WAKE_WORDS: dict[str, list[str]] = {
 826        "ollama": ["llama", "ollama", "lama"],
 827        "claude_code_cli": ["claude", "clod", "cloud", "clawed"],
 828        "claude_code_sdk": ["claude", "clod", "cloud", "clawed"],
 829        "codex_cli": ["codex"],
 830        "antigravity_cli": ["antigravity", "gravity", "google", "gemini"],
 831        "opencode_cli": ["opencode", "open code"],
 832    }
 833
 834    _AGENT_RESPONDERS: dict[str, str] = {
 835        "ollama": "Ollama",
 836        "claude_code_cli": "Claude Code CLI",
 837        "claude_code_sdk": "Claude Code SDK",
 838        "codex_cli": "Codex CLI",
 839        "antigravity_cli": "Antigravity CLI",
 840        "opencode_cli": "OpenCode CLI",
 841    }
 842
 843    def _start_dashboard(agent_name: str, responder_name: str, kwargs: dict):
 844        """Create a dashboard and inject it into kwargs; start is deferred until healthchecks pass."""
 845        from spych.dashboard import AgentDashboard
 846        from spych.utils import get_user, get_default_user
 847
 848        user_name = kwargs.get("user") or get_default_user()
 849        profile_name = "User"
 850        if user_name and user_name.lower() != "none":
 851            profile = get_user(user_name)
 852            if profile:
 853                profile_name = profile.get("name", "User") or "User"
 854
 855        wake_words = kwargs.get(
 856            "wake_words", _DEFAULT_WAKE_WORDS.get(args.agent, [])
 857        )
 858
 859        display_responder = _AGENT_RESPONDERS.get(args.agent, responder_name)
 860        kwargs["display_name"] = display_responder
 861
 862        dashboard = AgentDashboard(
 863            agent_name=kwargs.get("name", agent_name),
 864            wake_words=wake_words,
 865            responder_name=display_responder,
 866            response_style=kwargs.get("response_style", ""),
 867            use_speaker=kwargs.get("use_speaker", True),
 868            speaker_voice=kwargs.get("speaker_voice", "af_heart"),
 869            user_name=profile_name,
 870        )
 871        print("  ◌ Running healthchecks...")
 872        kwargs["dashboard"] = dashboard
 873        return dashboard
 874
 875    if args.agent == "ollama":
 876        from spych.agents import ollama
 877
 878        kwargs = _build_shared_kwargs(args)
 879        kwargs["model"] = args.model
 880        kwargs["history_length"] = args.history_length
 881        kwargs["host"] = args.host
 882        dashboard = (
 883            _start_dashboard("Ollama", "OllamaResponder", kwargs)
 884            if not args.verbose
 885            else None
 886        )
 887        try:
 888            ollama(**kwargs)
 889        finally:
 890            if dashboard is not None:
 891                dashboard.stop()
 892
 893    elif args.agent == "claude_code_cli":
 894        from spych.agents import claude_code_cli
 895
 896        kwargs = _build_agent_kwargs(args)
 897        dashboard = (
 898            _start_dashboard("Claude", "LocalClaudeCodeCLIResponder", kwargs)
 899            if not args.verbose
 900            else None
 901        )
 902        try:
 903            claude_code_cli(**kwargs)
 904        finally:
 905            if dashboard is not None:
 906                dashboard.stop()
 907
 908    elif args.agent == "claude_code_sdk":
 909        from spych.agents import claude_code_sdk
 910
 911        kwargs = _build_agent_kwargs(args)
 912        kwargs["setting_sources"] = args.setting_sources
 913        dashboard = (
 914            _start_dashboard("Claude", "LocalClaudeCodeSDKResponder", kwargs)
 915            if not args.verbose
 916            else None
 917        )
 918        try:
 919            claude_code_sdk(**kwargs)
 920        finally:
 921            if dashboard is not None:
 922                dashboard.stop()
 923
 924    elif args.agent == "codex_cli":
 925        from spych.agents import codex_cli
 926
 927        kwargs = _build_agent_kwargs(args)
 928        dashboard = (
 929            _start_dashboard("Codex", "LocalCodexCLIResponder", kwargs)
 930            if not args.verbose
 931            else None
 932        )
 933        try:
 934            codex_cli(**kwargs)
 935        finally:
 936            if dashboard is not None:
 937                dashboard.stop()
 938
 939    elif args.agent in ("antigravity_cli", "agy_cli", "antigravity", "agy"):
 940        from spych.agents import antigravity_cli
 941
 942        kwargs = _build_agent_kwargs(args)
 943        dashboard = (
 944            _start_dashboard(
 945                "Antigravity", "LocalAntigravityCLIResponder", kwargs
 946            )
 947            if not args.verbose
 948            else None
 949        )
 950        try:
 951            antigravity_cli(**kwargs)
 952        finally:
 953            if dashboard is not None:
 954                dashboard.stop()
 955
 956    elif args.agent == "opencode_cli":
 957        from spych.agents import opencode_cli
 958
 959        kwargs = _build_agent_kwargs(args)
 960        if args.model is not None:
 961            kwargs["model"] = args.model
 962        dashboard = (
 963            _start_dashboard("OpenCode", "LocalOpenCodeCLIResponder", kwargs)
 964            if not args.verbose
 965            else None
 966        )
 967        try:
 968            opencode_cli(**kwargs)
 969        finally:
 970            if dashboard is not None:
 971                dashboard.stop()
 972
 973    elif args.agent == "live":
 974        from spych.live import SpychLive
 975
 976        SpychLive(
 977            output_format=args.output_format,
 978            output_path=args.output_path,
 979            show_timestamps=not args.no_timestamps,
 980            stop_key=args.stop_key,
 981            terminate_words=args.terminate_words,
 982            device_index=args.device_index,
 983            whisper_model=args.whisper_model,
 984            whisper_device=args.whisper_device,
 985            whisper_compute_type=args.whisper_compute_type,
 986            no_speech_threshold=args.no_speech_threshold,
 987            speech_threshold=args.speech_threshold,
 988            silence_threshold=args.silence_threshold,
 989            silence_frames_threshold=args.silence_frames,
 990            speech_pad_frames=args.speech_pad_frames,
 991            max_speech_duration_s=args.max_speech_duration,
 992            context_words=args.context_words,
 993        ).start()
 994
 995    elif args.agent == "live-translation":
 996        from spych.live_translation import SpychLiveTranslation
 997
 998        SpychLiveTranslation(
 999            lang_a=args.languages[0],
1000            lang_b=args.languages[1],
1001            output_format=args.output_format or "",
1002            output_path=args.output_path,
1003            show_timestamps=not args.no_timestamps,
1004            stop_key=args.stop_key,
1005            terminate_words=args.terminate_words,
1006            device_index=args.device_index,
1007            whisper_model=args.whisper_model,
1008            whisper_device=args.whisper_device,
1009            whisper_compute_type=args.whisper_compute_type,
1010            no_speech_threshold=args.no_speech_threshold,
1011            speech_threshold=args.speech_threshold,
1012            silence_threshold=args.silence_threshold,
1013            silence_frames_threshold=args.silence_frames,
1014            speech_pad_frames=args.speech_pad_frames,
1015            max_speech_duration_s=args.max_speech_duration,
1016            ollama_host=args.ollama_host,
1017            ollama_translation_model=args.ollama_translation_model,
1018            use_speaker=not args.no_speaker,
1019            speaker_voice=args.speaker_voice,
1020        ).start()
1021
1022    elif args.agent == "profile_my_voice":
1023        from spych.voice_manager import profile_my_voice
1024
1025        profile_my_voice(
1026            name=args.name,
1027            device_index=args.device_index,
1028            alternate_output_file=args.alternate_output_file,
1029        )
1030
1031    elif args.agent == "users":
1032        from spych.utils import (
1033            get_all_users,
1034            get_user,
1035            set_user,
1036            set_default_user,
1037            get_default_user,
1038            set_setting,
1039            get_setting,
1040            get_cache_dir,
1041        )
1042        from spych.cli_tools import set_theme
1043
1044        def users_menu():
1045            while True:
1046                print("\n  " + "=" * 20)
1047                print("  SPYCH USER MANAGEMENT")
1048                print("  " + "=" * 20)
1049
1050                users = get_all_users()
1051                default_user = get_default_user()
1052                current_theme = get_setting("theme", "dark")
1053
1054                print(f"\n  Default User: {default_user or 'None'}")
1055                print(f"  Current Theme: {current_theme}")
1056                print("\n  Users:")
1057                if not users:
1058                    print("    (No users found)")
1059                for u in users:
1060                    print(
1061                        f"    - {u}{' (default)' if u == default_user else ''}"
1062                    )
1063
1064                print("\n  Options:")
1065                print("    1. Create new user")
1066                print("    2. Edit user")
1067                print("    3. Delete user")
1068                print("    4. Set default user")
1069                print("    5. Set theme")
1070                print("    6. Exit")
1071
1072                choice = input("\n  Choice: ").strip()
1073
1074                if choice == "1":
1075                    name = input("  User name: ").strip()
1076                    if name:
1077                        data = {
1078                            "name": input("  Full name: ").strip(),
1079                            "age": input("  Age: ").strip(),
1080                            "gender": input("  Gender: ").strip(),
1081                            "extra": input("  Extra info: ").strip(),
1082                        }
1083                        set_user(name, data)
1084                        print(f"  User '{name}' created.")
1085
1086                elif choice == "2":
1087                    name = input("  User name to edit: ").strip()
1088                    user = get_user(name)
1089                    if user:
1090                        print(f"  Editing {name} (leave blank to keep current)")
1091                        user["name"] = input(
1092                            f"    Full name [{user.get('name', '')}]: "
1093                        ).strip() or user.get("name", "")
1094                        user["age"] = input(
1095                            f"    Age [{user.get('age', '')}]: "
1096                        ).strip() or user.get("age", "")
1097                        user["gender"] = input(
1098                            f"    Gender [{user.get('gender', '')}]: "
1099                        ).strip() or user.get("gender", "")
1100                        user["extra"] = input(
1101                            f"    Extra info [{user.get('extra', '')}]: "
1102                        ).strip() or user.get("extra", "")
1103                        set_user(name, user)
1104                        print(f"  User '{name}' updated.")
1105                    else:
1106                        print("  User not found.")
1107
1108                elif choice == "3":
1109                    name = input("  User name to delete: ").strip()
1110                    path = os.path.join(get_cache_dir("users"), f"{name}.json")
1111                    if os.path.exists(path):
1112                        os.remove(path)
1113                        if get_default_user() == name:
1114                            set_default_user(None)
1115                        print(f"  User '{name}' deleted.")
1116                    else:
1117                        print("  User not found.")
1118
1119                elif choice == "4":
1120                    name = input("  Default user name (or 'none'): ").strip()
1121                    if name.lower() == "none":
1122                        set_default_user(None)
1123                        print("  Default user cleared.")
1124                    elif name in get_all_users():
1125                        set_default_user(name)
1126                        print(f"  Default user set to '{name}'.")
1127                    else:
1128                        print("  User not found.")
1129
1130                elif choice == "5":
1131                    theme = (
1132                        input("  Theme (dark, light, solarized, mono): ")
1133                        .strip()
1134                        .lower()
1135                    )
1136                    if theme in ["dark", "light", "solarized", "mono"]:
1137                        set_setting("theme", theme)
1138                        set_theme(theme)
1139                        print(f"  Theme set to '{theme}'.")
1140                    else:
1141                        print("  Invalid theme.")
1142
1143                elif choice == "6":
1144                    break
1145
1146        users_menu()
1147
1148    # ------------------------------------------------------------------ #
1149    # Multi-agent dispatch                                                 #
1150    # ------------------------------------------------------------------ #
1151    elif args.agent == "multi":
1152        from spych.core import Spych
1153        from spych.orchestrator import SpychOrchestrator
1154
1155        # A single Spych transcription object shared by all responders.
1156        spych_object = Spych(whisper_model="base.en")
1157
1158        # Build dashboard before responders so it can be injected.
1159        multi_dashboard = None
1160        if not args.verbose:
1161            from spych.dashboard import AgentDashboard
1162            from spych.utils import get_user, get_default_user
1163
1164            user_name = args.user or get_default_user()
1165            profile_name = "User"
1166            if user_name and user_name.lower() != "none":
1167                profile = get_user(user_name)
1168                if profile:
1169                    profile_name = profile.get("name", "User") or "User"
1170
1171            first_agent = _AGENT_ALIASES.get(args.agents[0], args.agents[0])
1172            _multi_name_map = {
1173                "claude_code_cli": "Claude",
1174                "claude_code_sdk": "Claude",
1175                "codex_cli": "Codex",
1176                "antigravity_cli": "Antigravity",
1177                "opencode_cli": "OpenCode",
1178                "ollama": "Ollama",
1179            }
1180            multi_dashboard = AgentDashboard(
1181                agent_name=_multi_name_map.get(first_agent, first_agent),
1182                wake_words=_DEFAULT_WAKE_WORDS.get(first_agent, []),
1183                responder_name=_AGENT_RESPONDERS.get(first_agent, ""),
1184                use_speaker=args.use_speaker,
1185                user_name=profile_name,
1186            )
1187            print("  ◌ Running healthchecks...")
1188
1189        entries = []
1190
1191        for agent_name in [_AGENT_ALIASES.get(a, a) for a in args.agents]:
1192            if agent_name == "claude_code_cli":
1193                from spych.agents.claude import LocalClaudeCodeCLIResponder
1194
1195                entries.append(
1196                    {
1197                        "responder": LocalClaudeCodeCLIResponder(
1198                            spych_object=spych_object,
1199                            continue_conversation=args.continue_conversation,
1200                            listen_duration=args.listen_duration,
1201                            follow_up_listen_duration=args.follow_up_listen_duration,
1202                            inactivity_timeout=args.inactivity_timeout,
1203                            speaker_backend=args.speaker_backend,
1204                            use_speaker=args.use_speaker,
1205                            show_tool_events=args.show_tool_events,
1206                            dashboard=multi_dashboard,
1207                            user=args.user,
1208                            display_name=_AGENT_RESPONDERS.get(
1209                                "claude_code_cli"
1210                            ),
1211                        ),
1212                        "wake_words": ["claude", "clod", "cloud", "clawed"],
1213                        "terminate_words": args.terminate_words,
1214                    }
1215                )
1216
1217            elif agent_name == "claude_code_sdk":
1218                from spych.agents.claude import LocalClaudeCodeSDKResponder
1219
1220                entries.append(
1221                    {
1222                        "responder": LocalClaudeCodeSDKResponder(
1223                            spych_object=spych_object,
1224                            continue_conversation=args.continue_conversation,
1225                            listen_duration=args.listen_duration,
1226                            follow_up_listen_duration=args.follow_up_listen_duration,
1227                            inactivity_timeout=args.inactivity_timeout,
1228                            speaker_backend=args.speaker_backend,
1229                            use_speaker=args.use_speaker,
1230                            setting_sources=args.setting_sources,
1231                            show_tool_events=args.show_tool_events,
1232                            dashboard=multi_dashboard,
1233                            user=args.user,
1234                            display_name=_AGENT_RESPONDERS.get(
1235                                "claude_code_sdk"
1236                            ),
1237                        ),
1238                        "wake_words": ["claude", "clod", "cloud", "clawed"],
1239                        "terminate_words": args.terminate_words,
1240                    }
1241                )
1242
1243            elif agent_name == "codex_cli":
1244                from spych.agents.codex import LocalCodexCLIResponder
1245
1246                entries.append(
1247                    {
1248                        "responder": LocalCodexCLIResponder(
1249                            spych_object=spych_object,
1250                            continue_conversation=args.continue_conversation,
1251                            listen_duration=args.listen_duration,
1252                            follow_up_listen_duration=args.follow_up_listen_duration,
1253                            inactivity_timeout=args.inactivity_timeout,
1254                            speaker_backend=args.speaker_backend,
1255                            use_speaker=args.use_speaker,
1256                            show_tool_events=args.show_tool_events,
1257                            dashboard=multi_dashboard,
1258                            user=args.user,
1259                            display_name=_AGENT_RESPONDERS.get("codex_cli"),
1260                        ),
1261                        "wake_words": ["codex"],
1262                        "terminate_words": args.terminate_words,
1263                    }
1264                )
1265
1266            elif agent_name in (
1267                "antigravity_cli",
1268                "agy_cli",
1269                "antigravity",
1270                "agy",
1271            ):
1272                from spych.agents.agy import LocalAntigravityCLIResponder
1273
1274                entries.append(
1275                    {
1276                        "responder": LocalAntigravityCLIResponder(
1277                            spych_object=spych_object,
1278                            continue_conversation=args.continue_conversation,
1279                            listen_duration=args.listen_duration,
1280                            follow_up_listen_duration=args.follow_up_listen_duration,
1281                            inactivity_timeout=args.inactivity_timeout,
1282                            speaker_backend=args.speaker_backend,
1283                            use_speaker=args.use_speaker,
1284                            show_tool_events=args.show_tool_events,
1285                            dashboard=multi_dashboard,
1286                            user=args.user,
1287                            display_name=_AGENT_RESPONDERS.get(
1288                                "antigravity_cli"
1289                            ),
1290                        ),
1291                        "wake_words": [
1292                            "antigravity",
1293                            "gravity",
1294                            "google",
1295                            "gemini",
1296                        ],
1297                        "terminate_words": args.terminate_words,
1298                    }
1299                )
1300
1301            elif agent_name == "opencode_cli":
1302                from spych.agents.opencode import LocalOpenCodeCLIResponder
1303
1304                entries.append(
1305                    {
1306                        "responder": LocalOpenCodeCLIResponder(
1307                            spych_object=spych_object,
1308                            continue_conversation=args.continue_conversation,
1309                            listen_duration=args.listen_duration,
1310                            follow_up_listen_duration=args.follow_up_listen_duration,
1311                            inactivity_timeout=args.inactivity_timeout,
1312                            speaker_backend=args.speaker_backend,
1313                            use_speaker=args.use_speaker,
1314                            show_tool_events=args.show_tool_events,
1315                            model=args.opencode_model,
1316                            dashboard=multi_dashboard,
1317                            user=args.user,
1318                            display_name=_AGENT_RESPONDERS.get("opencode_cli"),
1319                        ),
1320                        "wake_words": ["opencode", "open code"],
1321                        "terminate_words": args.terminate_words,
1322                    }
1323                )
1324
1325            elif agent_name == "ollama":
1326                from spych.agents.ollama import OllamaResponder
1327
1328                entries.append(
1329                    {
1330                        "responder": OllamaResponder(
1331                            spych_object=spych_object,
1332                            model=args.ollama_model,
1333                            history_length=args.ollama_history_length,
1334                            host=args.ollama_host,
1335                            listen_duration=args.listen_duration,
1336                            follow_up_listen_duration=args.follow_up_listen_duration,
1337                            inactivity_timeout=args.inactivity_timeout,
1338                            speaker_backend=args.speaker_backend,
1339                            use_speaker=args.use_speaker,
1340                            dashboard=multi_dashboard,
1341                            user=args.user,
1342                            display_name=_AGENT_RESPONDERS.get("ollama"),
1343                        ),
1344                        "wake_words": ["llama", "ollama", "lama"],
1345                        "terminate_words": args.terminate_words,
1346                    }
1347                )
1348
1349        try:
1350            SpychOrchestrator(entries=entries).start()
1351        finally:
1352            if multi_dashboard is not None:
1353                multi_dashboard.stop()
1354
1355    else:
1356        parser.print_help()
1357        sys.exit(1)
1358
1359
1360if __name__ == "__main__":
1361    main()
def main():
 215def main():
 216    __version__ = version("spych")
 217    parser = argparse.ArgumentParser(
 218        prog="spych",
 219        description=f"spych {__version__}: Launch a voice agent from the terminal.",
 220        formatter_class=argparse.RawDescriptionHelpFormatter,
 221        epilog=__doc__,
 222    )
 223
 224    parser.add_argument(
 225        "-v",
 226        "--version",
 227        action="version",
 228        version=f"spych {__version__}",
 229        help="Show the version number and exit",
 230    )
 231
 232    parser.add_argument(
 233        "--theme",
 234        default="dark",
 235        choices=["dark", "light", "solarized", "mono"],
 236        metavar="THEME",
 237        help=(
 238            "Colour theme for terminal output. "
 239            "Choices: dark (default), light, solarized, mono"
 240        ),
 241    )
 242
 243    subparsers = parser.add_subparsers(dest="agent", metavar="agent")
 244    subparsers.required = True
 245
 246    # Aliases → canonical name; used to normalise args.agent after parsing.
 247    _AGENT_ALIASES: dict[str, str] = {
 248        "claude": "claude_code_sdk",
 249        "codex": "codex_cli",
 250        "gemini": "antigravity_cli",
 251        "google": "antigravity_cli",
 252        "agy": "antigravity_cli",
 253        "agy_cli": "antigravity_cli",
 254        "antigravity": "antigravity_cli",
 255        "opencode": "opencode_cli",
 256    }
 257
 258    # ------------------------------------------------------------------ #
 259    # ollama                                                               #
 260    # ------------------------------------------------------------------ #
 261    p_ollama = subparsers.add_parser(
 262        "ollama", help="Talk to a local Ollama model"
 263    )
 264    _add_shared_args(p_ollama)
 265    p_ollama.add_argument(
 266        "--model",
 267        default="llama3.2:latest",
 268        metavar="MODEL",
 269        help="Ollama model name (default: llama3.2:latest)",
 270    )
 271    p_ollama.add_argument(
 272        "--history-length",
 273        type=int,
 274        default=10,
 275        metavar="N",
 276        help="Past interactions to include in context (default: 10)",
 277    )
 278    p_ollama.add_argument(
 279        "--host",
 280        default="http://localhost:11434",
 281        metavar="URL",
 282        help="Ollama instance URL (default: http://localhost:11434)",
 283    )
 284
 285    # ------------------------------------------------------------------ #
 286    # claude_code_cli                                                      #
 287    # ------------------------------------------------------------------ #
 288    p_claude_cli = subparsers.add_parser(
 289        "claude_code_cli",
 290        help="Voice-control Claude Code via the CLI",
 291    )
 292    _add_shared_args(p_claude_cli)
 293    _add_agent_args(p_claude_cli)
 294
 295    # ------------------------------------------------------------------ #
 296    # claude_code_sdk                                                      #
 297    # ------------------------------------------------------------------ #
 298    p_claude_sdk = subparsers.add_parser(
 299        "claude_code_sdk",
 300        aliases=["claude"],
 301        help="Voice-control Claude Code via the Agent SDK",
 302    )
 303    _add_shared_args(p_claude_sdk)
 304    _add_agent_args(p_claude_sdk)
 305    p_claude_sdk.add_argument(
 306        "--setting-sources",
 307        nargs="+",
 308        metavar="SOURCE",
 309        default=["user", "project", "local"],
 310        help="Claude Code settings sources to load (default: user project local)",
 311    )
 312
 313    # ------------------------------------------------------------------ #
 314    # codex_cli                                                            #
 315    # ------------------------------------------------------------------ #
 316    p_codex = subparsers.add_parser(
 317        "codex_cli",
 318        aliases=["codex"],
 319        help="Voice-control the OpenAI Codex agent",
 320    )
 321    _add_shared_args(p_codex)
 322    _add_agent_args(p_codex)
 323
 324    # ------------------------------------------------------------------ #
 325    # antigravity_cli                                                      #
 326    # ------------------------------------------------------------------ #
 327    p_antigravity = subparsers.add_parser(
 328        "antigravity_cli",
 329        aliases=["antigravity", "agy", "agy_cli", "google", "gemini"],
 330        help="Voice-control the Antigravity agent (agy)",
 331    )
 332    _add_shared_args(p_antigravity)
 333    _add_agent_args(p_antigravity)
 334
 335    # ------------------------------------------------------------------ #
 336    # opencode_cli                                                         #
 337    # ------------------------------------------------------------------ #
 338    p_opencode = subparsers.add_parser(
 339        "opencode_cli",
 340        aliases=["opencode"],
 341        help="Voice-control the OpenCode agent",
 342    )
 343    _add_shared_args(p_opencode)
 344    _add_agent_args(p_opencode)
 345    p_opencode.add_argument(
 346        "--model",
 347        default=None,
 348        metavar="MODEL",
 349        help="Model in provider/model format, e.g. anthropic/claude-sonnet-4-5",
 350    )
 351
 352    # ------------------------------------------------------------------ #
 353    # live — continuous transcription to file                             #
 354    # ------------------------------------------------------------------ #
 355    p_live = subparsers.add_parser(
 356        "live",
 357        help="Continuously transcribe speech to .txt and/or .srt files",
 358        formatter_class=argparse.RawDescriptionHelpFormatter,
 359        description=(
 360            "Start a live transcription session. Records continuously using VAD\n"
 361            "and writes output to disk in real time.\n\n"
 362            "Stop by pressing the stop key (default: q + Enter), saying a\n"
 363            "terminate word, or pressing Ctrl+C."
 364        ),
 365    )
 366    p_live.add_argument(
 367        "--output-path",
 368        default="transcript",
 369        metavar="PATH",
 370        help="Base output file path without extension (default: transcript)",
 371    )
 372    p_live.add_argument(
 373        "--output-format",
 374        default="both",
 375        choices=["txt", "srt", "both"],
 376        metavar="FORMAT",
 377        help="Output format: txt, srt, or both (default: both)",
 378    )
 379    p_live.add_argument(
 380        "--no-timestamps",
 381        action="store_true",
 382        help="Omit timestamps from terminal and .txt output",
 383    )
 384    p_live.add_argument(
 385        "--stop-key",
 386        default="q",
 387        metavar="KEY",
 388        help="Key to type (then Enter) to stop the session (default: q)",
 389    )
 390    p_live.add_argument(
 391        "--terminate-words",
 392        nargs="+",
 393        metavar="WORD",
 394        help="Spoken words that stop the session (e.g. 'stop recording')",
 395    )
 396    p_live.add_argument(
 397        "--device-index",
 398        type=int,
 399        default=-1,
 400        metavar="N",
 401        help="Microphone device index; -1 uses system default (default: -1)",
 402    )
 403    p_live.add_argument(
 404        "--whisper-model",
 405        default="base.en",
 406        metavar="MODEL",
 407        help="faster-whisper model name (default: base.en)",
 408    )
 409    p_live.add_argument(
 410        "--whisper-device",
 411        default="auto",
 412        choices=["auto", "cpu", "cuda"],
 413        metavar="DEVICE",
 414        help="Device for whisper inference: auto, cpu, or cuda (default: auto). auto uses cuda when Python <=3.13 and CUDA is available, otherwise cpu. cuda requires nvidia-cublas-cu12 + nvidia-cudnn-cu12.",
 415    )
 416    p_live.add_argument(
 417        "--whisper-compute-type",
 418        default="int8",
 419        choices=["int8", "float16", "float32"],
 420        metavar="TYPE",
 421        help="Compute type for whisper: int8, float16, float32 (default: int8)",
 422    )
 423    p_live.add_argument(
 424        "--no-speech-threshold",
 425        type=float,
 426        default=0.3,
 427        metavar="FLOAT",
 428        help="Whisper no_speech_prob cutoff — segments above this are dropped (default: 0.3)",
 429    )
 430    p_live.add_argument(
 431        "--speech-threshold",
 432        type=float,
 433        default=0.5,
 434        metavar="FLOAT",
 435        help="VAD speech onset probability (default: 0.5)",
 436    )
 437    p_live.add_argument(
 438        "--silence-threshold",
 439        type=float,
 440        default=0.35,
 441        metavar="FLOAT",
 442        help="VAD silence probability during speech (default: 0.35)",
 443    )
 444    p_live.add_argument(
 445        "--silence-frames",
 446        type=int,
 447        default=20,
 448        metavar="N",
 449        help="Consecutive silent frames required to end a segment (~32ms each, default: 20)",
 450    )
 451    p_live.add_argument(
 452        "--speech-pad-frames",
 453        type=int,
 454        default=5,
 455        metavar="N",
 456        help="Pre-roll frames and onset confirmation count (default: 5)",
 457    )
 458    p_live.add_argument(
 459        "--max-speech-duration",
 460        type=float,
 461        default=30.0,
 462        metavar="SECONDS",
 463        help="Hard cap on a single segment in seconds (default: 30.0)",
 464    )
 465    p_live.add_argument(
 466        "--context-words",
 467        type=int,
 468        default=32,
 469        metavar="N",
 470        help="Trailing words passed as whisper initial_prompt for context (default: 32)",
 471    )
 472
 473    # ------------------------------------------------------------------ #
 474    p_live_translation = subparsers.add_parser(
 475        "live-translation",
 476        help="Bidirectional live translation between two languages",
 477        formatter_class=argparse.RawDescriptionHelpFormatter,
 478        description=(
 479            "Start a bidirectional live translation session. Either participant\n"
 480            "can speak in either language; Whisper transcribes and Ollama detects\n"
 481            "which language was spoken then translates to the other.\n\n"
 482            "Each utterance is shown as two lines prefixed with [HH:MM:SS](lang):\n"
 483            "  [00:00:05](en) Hello, how are you?\n"
 484            "  [00:00:05](es) Hola, ¿cómo estás?\n\n"
 485            "Stop by pressing the stop key (default: q + Enter), saying a\n"
 486            "terminate word, or pressing Ctrl+C."
 487        ),
 488    )
 489    p_live_translation.add_argument(
 490        "--languages",
 491        required=True,
 492        nargs=2,
 493        metavar="LANG",
 494        help="Two BCP-47 language codes for the conversation pair (e.g. en es)",
 495    )
 496    p_live_translation.add_argument(
 497        "--ollama-host",
 498        default="http://localhost:11434",
 499        metavar="URL",
 500        help="Ollama HTTP base URL for translation (default: http://localhost:11434)",
 501    )
 502    p_live_translation.add_argument(
 503        "--ollama-translation-model",
 504        default="llama3.2",
 505        metavar="MODEL",
 506        help="Ollama model name used for translation (default: llama3.2)",
 507    )
 508    p_live_translation.add_argument(
 509        "--no-speaker",
 510        action="store_true",
 511        help="Disable TTS — do not speak translated segments aloud (speaker is on by default)",
 512    )
 513    p_live_translation.add_argument(
 514        "--speaker-voice",
 515        default="",
 516        metavar="VOICE",
 517        help="Wave voice name for zero-shot cloning; omit to use the model's built-in default voice",
 518    )
 519    p_live_translation.add_argument(
 520        "--output-path",
 521        default="transcript",
 522        metavar="PATH",
 523        help="Base output file path without extension (default: transcript)",
 524    )
 525    p_live_translation.add_argument(
 526        "--output-format",
 527        default=None,
 528        choices=["txt", "srt", "both"],
 529        metavar="FORMAT",
 530        help="Save transcript to file: txt, srt, or both (default: no file output)",
 531    )
 532    p_live_translation.add_argument(
 533        "--no-timestamps",
 534        action="store_true",
 535        help="Omit timestamps from terminal and .txt output",
 536    )
 537    p_live_translation.add_argument(
 538        "--stop-key",
 539        default="q",
 540        metavar="KEY",
 541        help="Key to type (then Enter) to stop the session (default: q)",
 542    )
 543    p_live_translation.add_argument(
 544        "--terminate-words",
 545        nargs="+",
 546        metavar="WORD",
 547        help="Spoken words that stop the session (e.g. 'stop recording')",
 548    )
 549    p_live_translation.add_argument(
 550        "--device-index",
 551        type=int,
 552        default=-1,
 553        metavar="N",
 554        help="Microphone device index; -1 uses system default (default: -1)",
 555    )
 556    p_live_translation.add_argument(
 557        "--whisper-model",
 558        default="small",
 559        metavar="MODEL",
 560        help="faster-whisper model name; .en suffix stripped automatically (default: small)",
 561    )
 562    p_live_translation.add_argument(
 563        "--whisper-device",
 564        default="auto",
 565        choices=["auto", "cpu", "cuda"],
 566        metavar="DEVICE",
 567        help="Device for whisper inference: auto, cpu, or cuda (default: auto). auto uses cuda when Python <=3.13 and CUDA is available, otherwise cpu. cuda requires nvidia-cublas-cu12 + nvidia-cudnn-cu12.",
 568    )
 569    p_live_translation.add_argument(
 570        "--whisper-compute-type",
 571        default="int8",
 572        choices=["int8", "float16", "float32"],
 573        metavar="TYPE",
 574        help="Compute type for whisper: int8, float16, float32 (default: int8)",
 575    )
 576    p_live_translation.add_argument(
 577        "--no-speech-threshold",
 578        type=float,
 579        default=0.3,
 580        metavar="FLOAT",
 581        help="Whisper no_speech_prob cutoff — segments above this are dropped (default: 0.3)",
 582    )
 583    p_live_translation.add_argument(
 584        "--speech-threshold",
 585        type=float,
 586        default=0.5,
 587        metavar="FLOAT",
 588        help="VAD speech onset probability (default: 0.5)",
 589    )
 590    p_live_translation.add_argument(
 591        "--silence-threshold",
 592        type=float,
 593        default=0.35,
 594        metavar="FLOAT",
 595        help="VAD silence probability during speech (default: 0.35)",
 596    )
 597    p_live_translation.add_argument(
 598        "--silence-frames",
 599        type=int,
 600        default=20,
 601        metavar="N",
 602        help="Consecutive silent frames required to end a segment (~32ms each, default: 20)",
 603    )
 604    p_live_translation.add_argument(
 605        "--speech-pad-frames",
 606        type=int,
 607        default=5,
 608        metavar="N",
 609        help="Pre-roll frames and onset confirmation count (default: 5)",
 610    )
 611    p_live_translation.add_argument(
 612        "--max-speech-duration",
 613        type=float,
 614        default=30.0,
 615        metavar="SECONDS",
 616        help="Hard cap on a single segment in seconds (default: 30.0)",
 617    )
 618    # ------------------------------------------------------------------ #
 619    p_multi = subparsers.add_parser(
 620        "multi",
 621        help="Run multiple agents simultaneously under different wake words",
 622        formatter_class=argparse.RawDescriptionHelpFormatter,
 623        description=(
 624            "Run several agents at once. Each agent uses its own default wake "
 625            "words unless overridden.\n\n"
 626            "Example:\n"
 627            "  spych multi --agents claude_code_cli antigravity_cli\n"
 628            "  spych multi --agents claude_code_cli ollama --ollama-model llama3.2:latest\n"
 629            "  spych multi --agents claude_code_sdk codex_cli --listen-duration 8"
 630        ),
 631    )
 632    p_multi.add_argument(
 633        "--agents",
 634        nargs="+",
 635        required=True,
 636        metavar="AGENT",
 637        choices=[
 638            "claude_code_cli",
 639            "claude",
 640            "claude_code_sdk",
 641            "claude_sdk",
 642            "codex_cli",
 643            "codex",
 644            "antigravity_cli",
 645            "antigravity",
 646            "agy_cli",
 647            "agy",
 648            "opencode_cli",
 649            "opencode",
 650            "ollama",
 651        ],
 652        help=(
 653            "Agents to run. Choices: claude (claude_code_cli), "
 654            "claude_sdk (claude_code_sdk), codex (codex_cli), "
 655            "antigravity (antigravity_cli), opencode (opencode_cli), ollama"
 656        ),
 657    )
 658    p_multi.add_argument(
 659        "--terminate-words",
 660        nargs="+",
 661        metavar="WORD",
 662        default=["terminate"],
 663        help="Words that stop all agents (default: terminate)",
 664    )
 665    p_multi.add_argument(
 666        "--listen-duration",
 667        type=float,
 668        default=5,
 669        metavar="SECONDS",
 670        help="Seconds to listen after a wake word (default: 5)",
 671    )
 672    p_multi.add_argument(
 673        "--follow-up-listen-duration",
 674        type=float,
 675        default=0,
 676        metavar="SECONDS",
 677        help="Seconds to listen for follow-up answers (default: 0)",
 678    )
 679    p_multi.add_argument(
 680        "--inactivity-timeout",
 681        type=float,
 682        default=4.0,
 683        metavar="SECONDS",
 684        help="Seconds of inactivity before pivoting back to wake word (default: 4.0)",
 685    )
 686    p_multi.add_argument(
 687        "--continue-conversation",
 688        type=_parse_bool,
 689        default=True,
 690        metavar="BOOL",
 691        help="Resume most recent session for each coding agent (default: true)",
 692    )
 693    p_multi.add_argument(
 694        "--show-tool-events",
 695        type=_parse_bool,
 696        default=True,
 697        metavar="BOOL",
 698        help="Print live tool start/end events (default: true)",
 699    )
 700    p_multi.add_argument(
 701        "--speaker-backend",
 702        default="",
 703        choices=["chatterbox", "kokoro"],
 704        metavar="BACKEND",
 705        help="Explicit TTS backend to use (default: priority Chatterbox then Kokoro)",
 706    )
 707    p_multi.add_argument(
 708        "--use-speaker",
 709        type=_parse_bool,
 710        default=True,
 711        metavar="BOOL",
 712        help="Speak responses aloud via TTS (default: true)",
 713    )
 714    p_multi.add_argument(
 715        "--verbose",
 716        action="store_true",
 717        default=False,
 718        help=(
 719            "Use verbose scroll output (spinner + full log) instead of the "
 720            "TUI dashboard (default: false)"
 721        ),
 722    )
 723    p_multi.add_argument(
 724        "--user",
 725        default=None,
 726        metavar="NAME",
 727        help="The user name to use for tailored responses (default: default user from settings)",
 728    )
 729    # ollama-specific flags (only used when 'ollama' is in --agents)
 730    p_multi.add_argument(
 731        "--ollama-model",
 732        default="llama3.2:latest",
 733        metavar="MODEL",
 734        help="Ollama model (default: llama3.2:latest). Only used when ollama is in --agents.",
 735    )
 736    p_multi.add_argument(
 737        "--ollama-host",
 738        default="http://localhost:11434",
 739        metavar="URL",
 740        help="Ollama instance URL (default: http://localhost:11434). Only used when ollama is in --agents.",
 741    )
 742    p_multi.add_argument(
 743        "--ollama-history-length",
 744        type=int,
 745        default=10,
 746        metavar="N",
 747        help="Ollama context history length (default: 10). Only used when ollama is in --agents.",
 748    )
 749    # opencode-specific flag
 750    p_multi.add_argument(
 751        "--opencode-model",
 752        default=None,
 753        metavar="MODEL",
 754        help="OpenCode model in provider/model format. Only used when opencode_cli is in --agents.",
 755    )
 756    # claude_code_sdk-specific flag
 757    p_multi.add_argument(
 758        "--setting-sources",
 759        nargs="+",
 760        metavar="SOURCE",
 761        default=["user", "project", "local"],
 762        help="Claude Code SDK setting sources (default: user project local). Only used when claude_code_sdk is in --agents.",
 763    )
 764
 765    # ------------------------------------------------------------------ #
 766    # profile_my_voice — Record a custom voice profile                   #
 767    # ------------------------------------------------------------------ #
 768    p_profile = subparsers.add_parser(
 769        "profile_my_voice",
 770        help="Record a 10-second voice sample to create a custom profile",
 771    )
 772    p_profile.add_argument(
 773        "--name",
 774        required=True,
 775        metavar="NAME",
 776        help="The name to save this voice profile as (e.g. 'my_voice')",
 777    )
 778    p_profile.add_argument(
 779        "--device-index",
 780        type=int,
 781        default=-1,
 782        metavar="N",
 783        help="Microphone device index; -1 uses system default (default: -1)",
 784    )
 785    p_profile.add_argument(
 786        "--alternate-output-file",
 787        default=None,
 788        metavar="PATH",
 789        help="An alternate file path to save the voice profile to (e.g. './my_voice.wav')",
 790    )
 791
 792    # ------------------------------------------------------------------ #
 793    # users — manage user profiles                                       #
 794    # ------------------------------------------------------------------ #
 795    p_users = subparsers.add_parser(
 796        "users",
 797        help="Manage user profiles and global settings",
 798        description=(
 799            "Launch an interactive menu to manage user profiles and global "
 800            "preferences. Profiles store personal info (name, age, extra context) "
 801            "used to tailor agent responses. You can also set the default user "
 802            "and terminal theme here."
 803        ),
 804    )
 805
 806    # ------------------------------------------------------------------ #
 807    # Dispatch                                                             #
 808    # ------------------------------------------------------------------ #
 809    args = parser.parse_args()
 810
 811    # Normalise any alias back to the canonical agent name so the dispatch
 812    # block below only needs to handle one name per agent.
 813    args.agent = _AGENT_ALIASES.get(args.agent, args.agent)
 814
 815    # Apply color theme as early as possible so all subsequent output uses it.
 816    if args.theme != "dark":
 817        from spych.cli_tools import set_theme
 818
 819        set_theme(args.theme)
 820
 821    # ------------------------------------------------------------------ #
 822    # Single-agent dispatch                                                #
 823    # ------------------------------------------------------------------ #
 824
 825    # Default wake words per agent — mirrors the factory function defaults.
 826    _DEFAULT_WAKE_WORDS: dict[str, list[str]] = {
 827        "ollama": ["llama", "ollama", "lama"],
 828        "claude_code_cli": ["claude", "clod", "cloud", "clawed"],
 829        "claude_code_sdk": ["claude", "clod", "cloud", "clawed"],
 830        "codex_cli": ["codex"],
 831        "antigravity_cli": ["antigravity", "gravity", "google", "gemini"],
 832        "opencode_cli": ["opencode", "open code"],
 833    }
 834
 835    _AGENT_RESPONDERS: dict[str, str] = {
 836        "ollama": "Ollama",
 837        "claude_code_cli": "Claude Code CLI",
 838        "claude_code_sdk": "Claude Code SDK",
 839        "codex_cli": "Codex CLI",
 840        "antigravity_cli": "Antigravity CLI",
 841        "opencode_cli": "OpenCode CLI",
 842    }
 843
 844    def _start_dashboard(agent_name: str, responder_name: str, kwargs: dict):
 845        """Create a dashboard and inject it into kwargs; start is deferred until healthchecks pass."""
 846        from spych.dashboard import AgentDashboard
 847        from spych.utils import get_user, get_default_user
 848
 849        user_name = kwargs.get("user") or get_default_user()
 850        profile_name = "User"
 851        if user_name and user_name.lower() != "none":
 852            profile = get_user(user_name)
 853            if profile:
 854                profile_name = profile.get("name", "User") or "User"
 855
 856        wake_words = kwargs.get(
 857            "wake_words", _DEFAULT_WAKE_WORDS.get(args.agent, [])
 858        )
 859
 860        display_responder = _AGENT_RESPONDERS.get(args.agent, responder_name)
 861        kwargs["display_name"] = display_responder
 862
 863        dashboard = AgentDashboard(
 864            agent_name=kwargs.get("name", agent_name),
 865            wake_words=wake_words,
 866            responder_name=display_responder,
 867            response_style=kwargs.get("response_style", ""),
 868            use_speaker=kwargs.get("use_speaker", True),
 869            speaker_voice=kwargs.get("speaker_voice", "af_heart"),
 870            user_name=profile_name,
 871        )
 872        print("  ◌ Running healthchecks...")
 873        kwargs["dashboard"] = dashboard
 874        return dashboard
 875
 876    if args.agent == "ollama":
 877        from spych.agents import ollama
 878
 879        kwargs = _build_shared_kwargs(args)
 880        kwargs["model"] = args.model
 881        kwargs["history_length"] = args.history_length
 882        kwargs["host"] = args.host
 883        dashboard = (
 884            _start_dashboard("Ollama", "OllamaResponder", kwargs)
 885            if not args.verbose
 886            else None
 887        )
 888        try:
 889            ollama(**kwargs)
 890        finally:
 891            if dashboard is not None:
 892                dashboard.stop()
 893
 894    elif args.agent == "claude_code_cli":
 895        from spych.agents import claude_code_cli
 896
 897        kwargs = _build_agent_kwargs(args)
 898        dashboard = (
 899            _start_dashboard("Claude", "LocalClaudeCodeCLIResponder", kwargs)
 900            if not args.verbose
 901            else None
 902        )
 903        try:
 904            claude_code_cli(**kwargs)
 905        finally:
 906            if dashboard is not None:
 907                dashboard.stop()
 908
 909    elif args.agent == "claude_code_sdk":
 910        from spych.agents import claude_code_sdk
 911
 912        kwargs = _build_agent_kwargs(args)
 913        kwargs["setting_sources"] = args.setting_sources
 914        dashboard = (
 915            _start_dashboard("Claude", "LocalClaudeCodeSDKResponder", kwargs)
 916            if not args.verbose
 917            else None
 918        )
 919        try:
 920            claude_code_sdk(**kwargs)
 921        finally:
 922            if dashboard is not None:
 923                dashboard.stop()
 924
 925    elif args.agent == "codex_cli":
 926        from spych.agents import codex_cli
 927
 928        kwargs = _build_agent_kwargs(args)
 929        dashboard = (
 930            _start_dashboard("Codex", "LocalCodexCLIResponder", kwargs)
 931            if not args.verbose
 932            else None
 933        )
 934        try:
 935            codex_cli(**kwargs)
 936        finally:
 937            if dashboard is not None:
 938                dashboard.stop()
 939
 940    elif args.agent in ("antigravity_cli", "agy_cli", "antigravity", "agy"):
 941        from spych.agents import antigravity_cli
 942
 943        kwargs = _build_agent_kwargs(args)
 944        dashboard = (
 945            _start_dashboard(
 946                "Antigravity", "LocalAntigravityCLIResponder", kwargs
 947            )
 948            if not args.verbose
 949            else None
 950        )
 951        try:
 952            antigravity_cli(**kwargs)
 953        finally:
 954            if dashboard is not None:
 955                dashboard.stop()
 956
 957    elif args.agent == "opencode_cli":
 958        from spych.agents import opencode_cli
 959
 960        kwargs = _build_agent_kwargs(args)
 961        if args.model is not None:
 962            kwargs["model"] = args.model
 963        dashboard = (
 964            _start_dashboard("OpenCode", "LocalOpenCodeCLIResponder", kwargs)
 965            if not args.verbose
 966            else None
 967        )
 968        try:
 969            opencode_cli(**kwargs)
 970        finally:
 971            if dashboard is not None:
 972                dashboard.stop()
 973
 974    elif args.agent == "live":
 975        from spych.live import SpychLive
 976
 977        SpychLive(
 978            output_format=args.output_format,
 979            output_path=args.output_path,
 980            show_timestamps=not args.no_timestamps,
 981            stop_key=args.stop_key,
 982            terminate_words=args.terminate_words,
 983            device_index=args.device_index,
 984            whisper_model=args.whisper_model,
 985            whisper_device=args.whisper_device,
 986            whisper_compute_type=args.whisper_compute_type,
 987            no_speech_threshold=args.no_speech_threshold,
 988            speech_threshold=args.speech_threshold,
 989            silence_threshold=args.silence_threshold,
 990            silence_frames_threshold=args.silence_frames,
 991            speech_pad_frames=args.speech_pad_frames,
 992            max_speech_duration_s=args.max_speech_duration,
 993            context_words=args.context_words,
 994        ).start()
 995
 996    elif args.agent == "live-translation":
 997        from spych.live_translation import SpychLiveTranslation
 998
 999        SpychLiveTranslation(
1000            lang_a=args.languages[0],
1001            lang_b=args.languages[1],
1002            output_format=args.output_format or "",
1003            output_path=args.output_path,
1004            show_timestamps=not args.no_timestamps,
1005            stop_key=args.stop_key,
1006            terminate_words=args.terminate_words,
1007            device_index=args.device_index,
1008            whisper_model=args.whisper_model,
1009            whisper_device=args.whisper_device,
1010            whisper_compute_type=args.whisper_compute_type,
1011            no_speech_threshold=args.no_speech_threshold,
1012            speech_threshold=args.speech_threshold,
1013            silence_threshold=args.silence_threshold,
1014            silence_frames_threshold=args.silence_frames,
1015            speech_pad_frames=args.speech_pad_frames,
1016            max_speech_duration_s=args.max_speech_duration,
1017            ollama_host=args.ollama_host,
1018            ollama_translation_model=args.ollama_translation_model,
1019            use_speaker=not args.no_speaker,
1020            speaker_voice=args.speaker_voice,
1021        ).start()
1022
1023    elif args.agent == "profile_my_voice":
1024        from spych.voice_manager import profile_my_voice
1025
1026        profile_my_voice(
1027            name=args.name,
1028            device_index=args.device_index,
1029            alternate_output_file=args.alternate_output_file,
1030        )
1031
1032    elif args.agent == "users":
1033        from spych.utils import (
1034            get_all_users,
1035            get_user,
1036            set_user,
1037            set_default_user,
1038            get_default_user,
1039            set_setting,
1040            get_setting,
1041            get_cache_dir,
1042        )
1043        from spych.cli_tools import set_theme
1044
1045        def users_menu():
1046            while True:
1047                print("\n  " + "=" * 20)
1048                print("  SPYCH USER MANAGEMENT")
1049                print("  " + "=" * 20)
1050
1051                users = get_all_users()
1052                default_user = get_default_user()
1053                current_theme = get_setting("theme", "dark")
1054
1055                print(f"\n  Default User: {default_user or 'None'}")
1056                print(f"  Current Theme: {current_theme}")
1057                print("\n  Users:")
1058                if not users:
1059                    print("    (No users found)")
1060                for u in users:
1061                    print(
1062                        f"    - {u}{' (default)' if u == default_user else ''}"
1063                    )
1064
1065                print("\n  Options:")
1066                print("    1. Create new user")
1067                print("    2. Edit user")
1068                print("    3. Delete user")
1069                print("    4. Set default user")
1070                print("    5. Set theme")
1071                print("    6. Exit")
1072
1073                choice = input("\n  Choice: ").strip()
1074
1075                if choice == "1":
1076                    name = input("  User name: ").strip()
1077                    if name:
1078                        data = {
1079                            "name": input("  Full name: ").strip(),
1080                            "age": input("  Age: ").strip(),
1081                            "gender": input("  Gender: ").strip(),
1082                            "extra": input("  Extra info: ").strip(),
1083                        }
1084                        set_user(name, data)
1085                        print(f"  User '{name}' created.")
1086
1087                elif choice == "2":
1088                    name = input("  User name to edit: ").strip()
1089                    user = get_user(name)
1090                    if user:
1091                        print(f"  Editing {name} (leave blank to keep current)")
1092                        user["name"] = input(
1093                            f"    Full name [{user.get('name', '')}]: "
1094                        ).strip() or user.get("name", "")
1095                        user["age"] = input(
1096                            f"    Age [{user.get('age', '')}]: "
1097                        ).strip() or user.get("age", "")
1098                        user["gender"] = input(
1099                            f"    Gender [{user.get('gender', '')}]: "
1100                        ).strip() or user.get("gender", "")
1101                        user["extra"] = input(
1102                            f"    Extra info [{user.get('extra', '')}]: "
1103                        ).strip() or user.get("extra", "")
1104                        set_user(name, user)
1105                        print(f"  User '{name}' updated.")
1106                    else:
1107                        print("  User not found.")
1108
1109                elif choice == "3":
1110                    name = input("  User name to delete: ").strip()
1111                    path = os.path.join(get_cache_dir("users"), f"{name}.json")
1112                    if os.path.exists(path):
1113                        os.remove(path)
1114                        if get_default_user() == name:
1115                            set_default_user(None)
1116                        print(f"  User '{name}' deleted.")
1117                    else:
1118                        print("  User not found.")
1119
1120                elif choice == "4":
1121                    name = input("  Default user name (or 'none'): ").strip()
1122                    if name.lower() == "none":
1123                        set_default_user(None)
1124                        print("  Default user cleared.")
1125                    elif name in get_all_users():
1126                        set_default_user(name)
1127                        print(f"  Default user set to '{name}'.")
1128                    else:
1129                        print("  User not found.")
1130
1131                elif choice == "5":
1132                    theme = (
1133                        input("  Theme (dark, light, solarized, mono): ")
1134                        .strip()
1135                        .lower()
1136                    )
1137                    if theme in ["dark", "light", "solarized", "mono"]:
1138                        set_setting("theme", theme)
1139                        set_theme(theme)
1140                        print(f"  Theme set to '{theme}'.")
1141                    else:
1142                        print("  Invalid theme.")
1143
1144                elif choice == "6":
1145                    break
1146
1147        users_menu()
1148
1149    # ------------------------------------------------------------------ #
1150    # Multi-agent dispatch                                                 #
1151    # ------------------------------------------------------------------ #
1152    elif args.agent == "multi":
1153        from spych.core import Spych
1154        from spych.orchestrator import SpychOrchestrator
1155
1156        # A single Spych transcription object shared by all responders.
1157        spych_object = Spych(whisper_model="base.en")
1158
1159        # Build dashboard before responders so it can be injected.
1160        multi_dashboard = None
1161        if not args.verbose:
1162            from spych.dashboard import AgentDashboard
1163            from spych.utils import get_user, get_default_user
1164
1165            user_name = args.user or get_default_user()
1166            profile_name = "User"
1167            if user_name and user_name.lower() != "none":
1168                profile = get_user(user_name)
1169                if profile:
1170                    profile_name = profile.get("name", "User") or "User"
1171
1172            first_agent = _AGENT_ALIASES.get(args.agents[0], args.agents[0])
1173            _multi_name_map = {
1174                "claude_code_cli": "Claude",
1175                "claude_code_sdk": "Claude",
1176                "codex_cli": "Codex",
1177                "antigravity_cli": "Antigravity",
1178                "opencode_cli": "OpenCode",
1179                "ollama": "Ollama",
1180            }
1181            multi_dashboard = AgentDashboard(
1182                agent_name=_multi_name_map.get(first_agent, first_agent),
1183                wake_words=_DEFAULT_WAKE_WORDS.get(first_agent, []),
1184                responder_name=_AGENT_RESPONDERS.get(first_agent, ""),
1185                use_speaker=args.use_speaker,
1186                user_name=profile_name,
1187            )
1188            print("  ◌ Running healthchecks...")
1189
1190        entries = []
1191
1192        for agent_name in [_AGENT_ALIASES.get(a, a) for a in args.agents]:
1193            if agent_name == "claude_code_cli":
1194                from spych.agents.claude import LocalClaudeCodeCLIResponder
1195
1196                entries.append(
1197                    {
1198                        "responder": LocalClaudeCodeCLIResponder(
1199                            spych_object=spych_object,
1200                            continue_conversation=args.continue_conversation,
1201                            listen_duration=args.listen_duration,
1202                            follow_up_listen_duration=args.follow_up_listen_duration,
1203                            inactivity_timeout=args.inactivity_timeout,
1204                            speaker_backend=args.speaker_backend,
1205                            use_speaker=args.use_speaker,
1206                            show_tool_events=args.show_tool_events,
1207                            dashboard=multi_dashboard,
1208                            user=args.user,
1209                            display_name=_AGENT_RESPONDERS.get(
1210                                "claude_code_cli"
1211                            ),
1212                        ),
1213                        "wake_words": ["claude", "clod", "cloud", "clawed"],
1214                        "terminate_words": args.terminate_words,
1215                    }
1216                )
1217
1218            elif agent_name == "claude_code_sdk":
1219                from spych.agents.claude import LocalClaudeCodeSDKResponder
1220
1221                entries.append(
1222                    {
1223                        "responder": LocalClaudeCodeSDKResponder(
1224                            spych_object=spych_object,
1225                            continue_conversation=args.continue_conversation,
1226                            listen_duration=args.listen_duration,
1227                            follow_up_listen_duration=args.follow_up_listen_duration,
1228                            inactivity_timeout=args.inactivity_timeout,
1229                            speaker_backend=args.speaker_backend,
1230                            use_speaker=args.use_speaker,
1231                            setting_sources=args.setting_sources,
1232                            show_tool_events=args.show_tool_events,
1233                            dashboard=multi_dashboard,
1234                            user=args.user,
1235                            display_name=_AGENT_RESPONDERS.get(
1236                                "claude_code_sdk"
1237                            ),
1238                        ),
1239                        "wake_words": ["claude", "clod", "cloud", "clawed"],
1240                        "terminate_words": args.terminate_words,
1241                    }
1242                )
1243
1244            elif agent_name == "codex_cli":
1245                from spych.agents.codex import LocalCodexCLIResponder
1246
1247                entries.append(
1248                    {
1249                        "responder": LocalCodexCLIResponder(
1250                            spych_object=spych_object,
1251                            continue_conversation=args.continue_conversation,
1252                            listen_duration=args.listen_duration,
1253                            follow_up_listen_duration=args.follow_up_listen_duration,
1254                            inactivity_timeout=args.inactivity_timeout,
1255                            speaker_backend=args.speaker_backend,
1256                            use_speaker=args.use_speaker,
1257                            show_tool_events=args.show_tool_events,
1258                            dashboard=multi_dashboard,
1259                            user=args.user,
1260                            display_name=_AGENT_RESPONDERS.get("codex_cli"),
1261                        ),
1262                        "wake_words": ["codex"],
1263                        "terminate_words": args.terminate_words,
1264                    }
1265                )
1266
1267            elif agent_name in (
1268                "antigravity_cli",
1269                "agy_cli",
1270                "antigravity",
1271                "agy",
1272            ):
1273                from spych.agents.agy import LocalAntigravityCLIResponder
1274
1275                entries.append(
1276                    {
1277                        "responder": LocalAntigravityCLIResponder(
1278                            spych_object=spych_object,
1279                            continue_conversation=args.continue_conversation,
1280                            listen_duration=args.listen_duration,
1281                            follow_up_listen_duration=args.follow_up_listen_duration,
1282                            inactivity_timeout=args.inactivity_timeout,
1283                            speaker_backend=args.speaker_backend,
1284                            use_speaker=args.use_speaker,
1285                            show_tool_events=args.show_tool_events,
1286                            dashboard=multi_dashboard,
1287                            user=args.user,
1288                            display_name=_AGENT_RESPONDERS.get(
1289                                "antigravity_cli"
1290                            ),
1291                        ),
1292                        "wake_words": [
1293                            "antigravity",
1294                            "gravity",
1295                            "google",
1296                            "gemini",
1297                        ],
1298                        "terminate_words": args.terminate_words,
1299                    }
1300                )
1301
1302            elif agent_name == "opencode_cli":
1303                from spych.agents.opencode import LocalOpenCodeCLIResponder
1304
1305                entries.append(
1306                    {
1307                        "responder": LocalOpenCodeCLIResponder(
1308                            spych_object=spych_object,
1309                            continue_conversation=args.continue_conversation,
1310                            listen_duration=args.listen_duration,
1311                            follow_up_listen_duration=args.follow_up_listen_duration,
1312                            inactivity_timeout=args.inactivity_timeout,
1313                            speaker_backend=args.speaker_backend,
1314                            use_speaker=args.use_speaker,
1315                            show_tool_events=args.show_tool_events,
1316                            model=args.opencode_model,
1317                            dashboard=multi_dashboard,
1318                            user=args.user,
1319                            display_name=_AGENT_RESPONDERS.get("opencode_cli"),
1320                        ),
1321                        "wake_words": ["opencode", "open code"],
1322                        "terminate_words": args.terminate_words,
1323                    }
1324                )
1325
1326            elif agent_name == "ollama":
1327                from spych.agents.ollama import OllamaResponder
1328
1329                entries.append(
1330                    {
1331                        "responder": OllamaResponder(
1332                            spych_object=spych_object,
1333                            model=args.ollama_model,
1334                            history_length=args.ollama_history_length,
1335                            host=args.ollama_host,
1336                            listen_duration=args.listen_duration,
1337                            follow_up_listen_duration=args.follow_up_listen_duration,
1338                            inactivity_timeout=args.inactivity_timeout,
1339                            speaker_backend=args.speaker_backend,
1340                            use_speaker=args.use_speaker,
1341                            dashboard=multi_dashboard,
1342                            user=args.user,
1343                            display_name=_AGENT_RESPONDERS.get("ollama"),
1344                        ),
1345                        "wake_words": ["llama", "ollama", "lama"],
1346                        "terminate_words": args.terminate_words,
1347                    }
1348                )
1349
1350        try:
1351            SpychOrchestrator(entries=entries).start()
1352        finally:
1353            if multi_dashboard is not None:
1354                multi_dashboard.stop()
1355
1356    else:
1357        parser.print_help()
1358        sys.exit(1)