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