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="cpu",
 407        choices=["cpu", "cuda"],
 408        metavar="DEVICE",
 409        help="Device for whisper inference: cpu or cuda (default: cpu)",
 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_multi = subparsers.add_parser(
 470        "multi",
 471        help="Run multiple agents simultaneously under different wake words",
 472        formatter_class=argparse.RawDescriptionHelpFormatter,
 473        description=(
 474            "Run several agents at once. Each agent uses its own default wake "
 475            "words unless overridden.\n\n"
 476            "Example:\n"
 477            "  spych multi --agents claude_code_cli gemini_cli\n"
 478            "  spych multi --agents claude_code_cli ollama --ollama-model llama3.2:latest\n"
 479            "  spych multi --agents claude_code_sdk codex_cli --listen-duration 8"
 480        ),
 481    )
 482    p_multi.add_argument(
 483        "--agents",
 484        nargs="+",
 485        required=True,
 486        metavar="AGENT",
 487        choices=[
 488            "claude_code_cli",
 489            "claude",
 490            "claude_code_sdk",
 491            "claude_sdk",
 492            "codex_cli",
 493            "codex",
 494            "gemini_cli",
 495            "gemini",
 496            "opencode_cli",
 497            "opencode",
 498            "ollama",
 499        ],
 500        help=(
 501            "Agents to run. Choices: claude (claude_code_cli), "
 502            "claude_sdk (claude_code_sdk), codex (codex_cli), "
 503            "gemini (gemini_cli), opencode (opencode_cli), ollama"
 504        ),
 505    )
 506    p_multi.add_argument(
 507        "--terminate-words",
 508        nargs="+",
 509        metavar="WORD",
 510        default=["terminate"],
 511        help="Words that stop all agents (default: terminate)",
 512    )
 513    p_multi.add_argument(
 514        "--listen-duration",
 515        type=float,
 516        default=5,
 517        metavar="SECONDS",
 518        help="Seconds to listen after a wake word (default: 5)",
 519    )
 520    p_multi.add_argument(
 521        "--follow-up-listen-duration",
 522        type=float,
 523        default=0,
 524        metavar="SECONDS",
 525        help="Seconds to listen for follow-up answers (default: 0)",
 526    )
 527    p_multi.add_argument(
 528        "--inactivity-timeout",
 529        type=float,
 530        default=4.0,
 531        metavar="SECONDS",
 532        help="Seconds of inactivity before pivoting back to wake word (default: 4.0)",
 533    )
 534    p_multi.add_argument(
 535        "--continue-conversation",
 536        type=_parse_bool,
 537        default=True,
 538        metavar="BOOL",
 539        help="Resume most recent session for each coding agent (default: true)",
 540    )
 541    p_multi.add_argument(
 542        "--show-tool-events",
 543        type=_parse_bool,
 544        default=True,
 545        metavar="BOOL",
 546        help="Print live tool start/end events (default: true)",
 547    )
 548    p_multi.add_argument(
 549        "--speaker-backend",
 550        default="",
 551        choices=["chatterbox", "kokoro"],
 552        metavar="BACKEND",
 553        help="Explicit TTS backend to use (default: priority Chatterbox then Kokoro)",
 554    )
 555    p_multi.add_argument(
 556        "--use-speaker",
 557        type=_parse_bool,
 558        default=True,
 559        metavar="BOOL",
 560        help="Speak responses aloud via TTS (default: true)",
 561    )
 562    # ollama-specific flags (only used when 'ollama' is in --agents)
 563    p_multi.add_argument(
 564        "--ollama-model",
 565        default="llama3.2:latest",
 566        metavar="MODEL",
 567        help="Ollama model (default: llama3.2:latest). Only used when ollama is in --agents.",
 568    )
 569    p_multi.add_argument(
 570        "--ollama-host",
 571        default="http://localhost:11434",
 572        metavar="URL",
 573        help="Ollama instance URL (default: http://localhost:11434). Only used when ollama is in --agents.",
 574    )
 575    p_multi.add_argument(
 576        "--ollama-history-length",
 577        type=int,
 578        default=10,
 579        metavar="N",
 580        help="Ollama context history length (default: 10). Only used when ollama is in --agents.",
 581    )
 582    # opencode-specific flag
 583    p_multi.add_argument(
 584        "--opencode-model",
 585        default=None,
 586        metavar="MODEL",
 587        help="OpenCode model in provider/model format. Only used when opencode_cli is in --agents.",
 588    )
 589    # claude_code_sdk-specific flag
 590    p_multi.add_argument(
 591        "--setting-sources",
 592        nargs="+",
 593        metavar="SOURCE",
 594        default=["user", "project", "local"],
 595        help="Claude Code SDK setting sources (default: user project local). Only used when claude_code_sdk is in --agents.",
 596    )
 597
 598    # ------------------------------------------------------------------ #
 599    # profile_my_voice — Record a custom voice profile                   #
 600    # ------------------------------------------------------------------ #
 601    p_profile = subparsers.add_parser(
 602        "profile_my_voice",
 603        help="Record a 10-second voice sample to create a custom profile",
 604    )
 605    p_profile.add_argument(
 606        "--name",
 607        required=True,
 608        metavar="NAME",
 609        help="The name to save this voice profile as (e.g. 'my_voice')",
 610    )
 611    p_profile.add_argument(
 612        "--device-index",
 613        type=int,
 614        default=-1,
 615        metavar="N",
 616        help="Microphone device index; -1 uses system default (default: -1)",
 617    )
 618    p_profile.add_argument(
 619        "--alternate-output-file",
 620        default=None,
 621        metavar="PATH",
 622        help="An alternate file path to save the voice profile to (e.g. './my_voice.wav')",
 623    )
 624
 625    # ------------------------------------------------------------------ #
 626    # users — manage user profiles                                       #
 627    # ------------------------------------------------------------------ #
 628    p_users = subparsers.add_parser(
 629        "users",
 630        help="Manage user profiles and global settings",
 631        description=(
 632            "Launch an interactive menu to manage user profiles and global "
 633            "preferences. Profiles store personal info (name, age, extra context) "
 634            "used to tailor agent responses. You can also set the default user "
 635            "and terminal theme here."
 636        ),
 637    )
 638
 639    # ------------------------------------------------------------------ #
 640    # Dispatch                                                             #
 641    # ------------------------------------------------------------------ #
 642    args = parser.parse_args()
 643
 644    # Normalise any alias back to the canonical agent name so the dispatch
 645    # block below only needs to handle one name per agent.
 646    args.agent = _AGENT_ALIASES.get(args.agent, args.agent)
 647
 648    # Apply color theme as early as possible so all subsequent output uses it.
 649    if args.theme != "dark":
 650        from spych.cli_tools import set_theme
 651
 652        set_theme(args.theme)
 653
 654    # ------------------------------------------------------------------ #
 655    # Single-agent dispatch                                                #
 656    # ------------------------------------------------------------------ #
 657
 658    # Default wake words per agent — mirrors the factory function defaults.
 659    _DEFAULT_WAKE_WORDS: dict[str, list[str]] = {
 660        "ollama": ["llama", "ollama", "lama"],
 661        "claude_code_cli": ["claude", "clod", "cloud", "clawed"],
 662        "claude_code_sdk": ["claude", "clod", "cloud", "clawed"],
 663        "codex_cli": ["codex"],
 664        "gemini_cli": ["gemini"],
 665        "opencode_cli": ["opencode", "open code"],
 666    }
 667
 668    _AGENT_RESPONDERS: dict[str, str] = {
 669        "ollama": "Ollama",
 670        "claude_code_cli": "Claude Code CLI",
 671        "claude_code_sdk": "Claude Code SDK",
 672        "codex_cli": "Codex CLI",
 673        "gemini_cli": "Gemini CLI",
 674        "opencode_cli": "OpenCode CLI",
 675    }
 676
 677    def _start_dashboard(agent_name: str, responder_name: str, kwargs: dict):
 678        """Create a dashboard and inject it into kwargs; start is deferred until healthchecks pass."""
 679        from spych.dashboard import AgentDashboard
 680        from spych.utils import get_user, get_default_user
 681
 682        user_name = kwargs.get("user") or get_default_user()
 683        profile_name = "User"
 684        if user_name and user_name.lower() != "none":
 685            profile = get_user(user_name)
 686            if profile:
 687                profile_name = profile.get("name", "User") or "User"
 688
 689        wake_words = kwargs.get(
 690            "wake_words", _DEFAULT_WAKE_WORDS.get(args.agent, [])
 691        )
 692
 693        display_responder = _AGENT_RESPONDERS.get(args.agent, responder_name)
 694        kwargs["display_name"] = display_responder
 695
 696        dashboard = AgentDashboard(
 697            agent_name=kwargs.get("name", agent_name),
 698            wake_words=wake_words,
 699            responder_name=display_responder,
 700            response_style=kwargs.get("response_style", ""),
 701            use_speaker=kwargs.get("use_speaker", True),
 702            speaker_voice=kwargs.get("speaker_voice", "af_heart"),
 703            user_name=profile_name,
 704        )
 705        print("  ◌ Running healthchecks...")
 706        kwargs["dashboard"] = dashboard
 707        return dashboard
 708
 709    if args.agent == "ollama":
 710        from spych.agents import ollama
 711
 712        kwargs = _build_shared_kwargs(args)
 713        kwargs["model"] = args.model
 714        kwargs["history_length"] = args.history_length
 715        kwargs["host"] = args.host
 716        dashboard = (
 717            _start_dashboard("Ollama", "OllamaResponder", kwargs)
 718            if not args.verbose
 719            else None
 720        )
 721        try:
 722            ollama(**kwargs)
 723        finally:
 724            if dashboard is not None:
 725                dashboard.stop()
 726
 727    elif args.agent == "claude_code_cli":
 728        from spych.agents import claude_code_cli
 729
 730        kwargs = _build_agent_kwargs(args)
 731        dashboard = (
 732            _start_dashboard("Claude", "LocalClaudeCodeCLIResponder", kwargs)
 733            if not args.verbose
 734            else None
 735        )
 736        try:
 737            claude_code_cli(**kwargs)
 738        finally:
 739            if dashboard is not None:
 740                dashboard.stop()
 741
 742    elif args.agent == "claude_code_sdk":
 743        from spych.agents import claude_code_sdk
 744
 745        kwargs = _build_agent_kwargs(args)
 746        kwargs["setting_sources"] = args.setting_sources
 747        dashboard = (
 748            _start_dashboard("Claude", "LocalClaudeCodeSDKResponder", kwargs)
 749            if not args.verbose
 750            else None
 751        )
 752        try:
 753            claude_code_sdk(**kwargs)
 754        finally:
 755            if dashboard is not None:
 756                dashboard.stop()
 757
 758    elif args.agent == "codex_cli":
 759        from spych.agents import codex_cli
 760
 761        kwargs = _build_agent_kwargs(args)
 762        dashboard = (
 763            _start_dashboard("Codex", "LocalCodexCLIResponder", kwargs)
 764            if not args.verbose
 765            else None
 766        )
 767        try:
 768            codex_cli(**kwargs)
 769        finally:
 770            if dashboard is not None:
 771                dashboard.stop()
 772
 773    elif args.agent == "gemini_cli":
 774        from spych.agents import gemini_cli
 775
 776        kwargs = _build_agent_kwargs(args)
 777        dashboard = (
 778            _start_dashboard("Gemini", "LocalGeminiCLIResponder", kwargs)
 779            if not args.verbose
 780            else None
 781        )
 782        try:
 783            gemini_cli(**kwargs)
 784        finally:
 785            if dashboard is not None:
 786                dashboard.stop()
 787
 788    elif args.agent == "opencode_cli":
 789        from spych.agents import opencode_cli
 790
 791        kwargs = _build_agent_kwargs(args)
 792        if args.model is not None:
 793            kwargs["model"] = args.model
 794        dashboard = (
 795            _start_dashboard("OpenCode", "LocalOpenCodeCLIResponder", kwargs)
 796            if not args.verbose
 797            else None
 798        )
 799        try:
 800            opencode_cli(**kwargs)
 801        finally:
 802            if dashboard is not None:
 803                dashboard.stop()
 804
 805    elif args.agent == "live":
 806        from spych.live import SpychLive
 807
 808        SpychLive(
 809            output_format=args.output_format,
 810            output_path=args.output_path,
 811            show_timestamps=not args.no_timestamps,
 812            stop_key=args.stop_key,
 813            terminate_words=args.terminate_words,
 814            device_index=args.device_index,
 815            whisper_model=args.whisper_model,
 816            whisper_device=args.whisper_device,
 817            whisper_compute_type=args.whisper_compute_type,
 818            no_speech_threshold=args.no_speech_threshold,
 819            speech_threshold=args.speech_threshold,
 820            silence_threshold=args.silence_threshold,
 821            silence_frames_threshold=args.silence_frames,
 822            speech_pad_frames=args.speech_pad_frames,
 823            max_speech_duration_s=args.max_speech_duration,
 824            context_words=args.context_words,
 825        ).start()
 826
 827    elif args.agent == "profile_my_voice":
 828        from spych.voice_manager import profile_my_voice
 829
 830        profile_my_voice(
 831            name=args.name,
 832            device_index=args.device_index,
 833            alternate_output_file=args.alternate_output_file,
 834        )
 835
 836    elif args.agent == "users":
 837        from spych.utils import (
 838            get_all_users,
 839            get_user,
 840            set_user,
 841            set_default_user,
 842            get_default_user,
 843            set_setting,
 844            get_setting,
 845        )
 846        from spych.cli_tools import set_theme
 847
 848        def users_menu():
 849            while True:
 850                print("\n  " + "=" * 20)
 851                print("  SPYCH USER MANAGEMENT")
 852                print("  " + "=" * 20)
 853
 854                users = get_all_users()
 855                default_user = get_default_user()
 856                current_theme = get_setting("theme", "dark")
 857
 858                print(f"\n  Default User: {default_user or 'None'}")
 859                print(f"  Current Theme: {current_theme}")
 860                print("\n  Users:")
 861                if not users:
 862                    print("    (No users found)")
 863                for u in users:
 864                    print(
 865                        f"    - {u}{' (default)' if u == default_user else ''}"
 866                    )
 867
 868                print("\n  Options:")
 869                print("    1. Create new user")
 870                print("    2. Edit user")
 871                print("    3. Delete user")
 872                print("    4. Set default user")
 873                print("    5. Set theme")
 874                print("    6. Exit")
 875
 876                choice = input("\n  Choice: ").strip()
 877
 878                if choice == "1":
 879                    name = input("  User name: ").strip()
 880                    if name:
 881                        data = {
 882                            "name": input("  Full name: ").strip(),
 883                            "age": input("  Age: ").strip(),
 884                            "gender": input("  Gender: ").strip(),
 885                            "extra": input("  Extra info: ").strip(),
 886                        }
 887                        set_user(name, data)
 888                        print(f"  User '{name}' created.")
 889
 890                elif choice == "2":
 891                    name = input("  User name to edit: ").strip()
 892                    user = get_user(name)
 893                    if user:
 894                        print(f"  Editing {name} (leave blank to keep current)")
 895                        user["name"] = input(
 896                            f"    Full name [{user.get('name', '')}]: "
 897                        ).strip() or user.get("name", "")
 898                        user["age"] = input(
 899                            f"    Age [{user.get('age', '')}]: "
 900                        ).strip() or user.get("age", "")
 901                        user["gender"] = input(
 902                            f"    Gender [{user.get('gender', '')}]: "
 903                        ).strip() or user.get("gender", "")
 904                        user["extra"] = input(
 905                            f"    Extra info [{user.get('extra', '')}]: "
 906                        ).strip() or user.get("extra", "")
 907                        set_user(name, user)
 908                        print(f"  User '{name}' updated.")
 909                    else:
 910                        print("  User not found.")
 911
 912                elif choice == "3":
 913                    name = input("  User name to delete: ").strip()
 914                    path = os.path.join(get_cache_dir("users"), f"{name}.json")
 915                    if os.path.exists(path):
 916                        os.remove(path)
 917                        if get_default_user() == name:
 918                            set_default_user(None)
 919                        print(f"  User '{name}' deleted.")
 920                    else:
 921                        print("  User not found.")
 922
 923                elif choice == "4":
 924                    name = input("  Default user name (or 'none'): ").strip()
 925                    if name.lower() == "none":
 926                        set_default_user(None)
 927                        print("  Default user cleared.")
 928                    elif name in get_all_users():
 929                        set_default_user(name)
 930                        print(f"  Default user set to '{name}'.")
 931                    else:
 932                        print("  User not found.")
 933
 934                elif choice == "5":
 935                    theme = (
 936                        input("  Theme (dark, light, solarized, mono): ")
 937                        .strip()
 938                        .lower()
 939                    )
 940                    if theme in ["dark", "light", "solarized", "mono"]:
 941                        set_setting("theme", theme)
 942                        set_theme(theme)
 943                        print(f"  Theme set to '{theme}'.")
 944                    else:
 945                        print("  Invalid theme.")
 946
 947                elif choice == "6":
 948                    break
 949
 950        users_menu()
 951
 952    # ------------------------------------------------------------------ #
 953    # Multi-agent dispatch                                                 #
 954    # ------------------------------------------------------------------ #
 955    elif args.agent == "multi":
 956        from spych.core import Spych
 957        from spych.orchestrator import SpychOrchestrator
 958
 959        # A single Spych transcription object shared by all responders.
 960        spych_object = Spych(whisper_model="base.en")
 961
 962        # Build dashboard before responders so it can be injected.
 963        multi_dashboard = None
 964        if not args.verbose:
 965            from spych.dashboard import AgentDashboard
 966            from spych.utils import get_user, get_default_user
 967
 968            user_name = args.user or get_default_user()
 969            profile_name = "User"
 970            if user_name and user_name.lower() != "none":
 971                profile = get_user(user_name)
 972                if profile:
 973                    profile_name = profile.get("name", "User") or "User"
 974
 975            first_agent = _AGENT_ALIASES.get(args.agents[0], args.agents[0])
 976            _multi_name_map = {
 977                "claude_code_cli": "Claude",
 978                "claude_code_sdk": "Claude",
 979                "codex_cli": "Codex",
 980                "gemini_cli": "Gemini",
 981                "opencode_cli": "OpenCode",
 982                "ollama": "Ollama",
 983            }
 984            multi_dashboard = AgentDashboard(
 985                agent_name=_multi_name_map.get(first_agent, first_agent),
 986                wake_words=_DEFAULT_WAKE_WORDS.get(first_agent, []),
 987                responder_name=_AGENT_RESPONDERS.get(first_agent, ""),
 988                use_speaker=args.use_speaker,
 989                user_name=profile_name,
 990            )
 991            print("  ◌ Running healthchecks...")
 992
 993        entries = []
 994
 995        for agent_name in [_AGENT_ALIASES.get(a, a) for a in args.agents]:
 996            if agent_name == "claude_code_cli":
 997                from spych.agents.claude import LocalClaudeCodeCLIResponder
 998
 999                entries.append(
1000                    {
1001                        "responder": LocalClaudeCodeCLIResponder(
1002                            spych_object=spych_object,
1003                            continue_conversation=args.continue_conversation,
1004                            listen_duration=args.listen_duration,
1005                            follow_up_listen_duration=args.follow_up_listen_duration,
1006                            inactivity_timeout=args.inactivity_timeout,
1007                            speaker_backend=args.speaker_backend,
1008                            use_speaker=args.use_speaker,
1009                            show_tool_events=args.show_tool_events,
1010                            dashboard=multi_dashboard,
1011                            user=args.user,
1012                            display_name=_AGENT_RESPONDERS.get(
1013                                "claude_code_cli"
1014                            ),
1015                        ),
1016                        "wake_words": ["claude", "clod", "cloud", "clawed"],
1017                        "terminate_words": args.terminate_words,
1018                    }
1019                )
1020
1021            elif agent_name == "claude_code_sdk":
1022                from spych.agents.claude import LocalClaudeCodeSDKResponder
1023
1024                entries.append(
1025                    {
1026                        "responder": LocalClaudeCodeSDKResponder(
1027                            spych_object=spych_object,
1028                            continue_conversation=args.continue_conversation,
1029                            listen_duration=args.listen_duration,
1030                            follow_up_listen_duration=args.follow_up_listen_duration,
1031                            inactivity_timeout=args.inactivity_timeout,
1032                            speaker_backend=args.speaker_backend,
1033                            use_speaker=args.use_speaker,
1034                            setting_sources=args.setting_sources,
1035                            show_tool_events=args.show_tool_events,
1036                            dashboard=multi_dashboard,
1037                            user=args.user,
1038                            display_name=_AGENT_RESPONDERS.get(
1039                                "claude_code_sdk"
1040                            ),
1041                        ),
1042                        "wake_words": ["claude", "clod", "cloud", "clawed"],
1043                        "terminate_words": args.terminate_words,
1044                    }
1045                )
1046
1047            elif agent_name == "codex_cli":
1048                from spych.agents.codex import LocalCodexCLIResponder
1049
1050                entries.append(
1051                    {
1052                        "responder": LocalCodexCLIResponder(
1053                            spych_object=spych_object,
1054                            continue_conversation=args.continue_conversation,
1055                            listen_duration=args.listen_duration,
1056                            follow_up_listen_duration=args.follow_up_listen_duration,
1057                            inactivity_timeout=args.inactivity_timeout,
1058                            speaker_backend=args.speaker_backend,
1059                            use_speaker=args.use_speaker,
1060                            show_tool_events=args.show_tool_events,
1061                            dashboard=multi_dashboard,
1062                            user=args.user,
1063                            display_name=_AGENT_RESPONDERS.get("codex_cli"),
1064                        ),
1065                        "wake_words": ["codex"],
1066                        "terminate_words": args.terminate_words,
1067                    }
1068                )
1069
1070            elif agent_name == "gemini_cli":
1071                from spych.agents.gemini import LocalGeminiCLIResponder
1072
1073                entries.append(
1074                    {
1075                        "responder": LocalGeminiCLIResponder(
1076                            spych_object=spych_object,
1077                            continue_conversation=args.continue_conversation,
1078                            listen_duration=args.listen_duration,
1079                            follow_up_listen_duration=args.follow_up_listen_duration,
1080                            inactivity_timeout=args.inactivity_timeout,
1081                            speaker_backend=args.speaker_backend,
1082                            use_speaker=args.use_speaker,
1083                            show_tool_events=args.show_tool_events,
1084                            dashboard=multi_dashboard,
1085                            user=args.user,
1086                            display_name=_AGENT_RESPONDERS.get("gemini_cli"),
1087                        ),
1088                        "wake_words": ["gemini"],
1089                        "terminate_words": args.terminate_words,
1090                    }
1091                )
1092
1093            elif agent_name == "opencode_cli":
1094                from spych.agents.opencode import LocalOpenCodeCLIResponder
1095
1096                entries.append(
1097                    {
1098                        "responder": LocalOpenCodeCLIResponder(
1099                            spych_object=spych_object,
1100                            continue_conversation=args.continue_conversation,
1101                            listen_duration=args.listen_duration,
1102                            follow_up_listen_duration=args.follow_up_listen_duration,
1103                            inactivity_timeout=args.inactivity_timeout,
1104                            speaker_backend=args.speaker_backend,
1105                            use_speaker=args.use_speaker,
1106                            show_tool_events=args.show_tool_events,
1107                            model=args.opencode_model,
1108                            dashboard=multi_dashboard,
1109                            user=args.user,
1110                            display_name=_AGENT_RESPONDERS.get("opencode_cli"),
1111                        ),
1112                        "wake_words": ["opencode", "open code"],
1113                        "terminate_words": args.terminate_words,
1114                    }
1115                )
1116
1117            elif agent_name == "ollama":
1118                from spych.agents.ollama import OllamaResponder
1119
1120                entries.append(
1121                    {
1122                        "responder": OllamaResponder(
1123                            spych_object=spych_object,
1124                            model=args.ollama_model,
1125                            history_length=args.ollama_history_length,
1126                            host=args.ollama_host,
1127                            listen_duration=args.listen_duration,
1128                            follow_up_listen_duration=args.follow_up_listen_duration,
1129                            inactivity_timeout=args.inactivity_timeout,
1130                            speaker_backend=args.speaker_backend,
1131                            use_speaker=args.use_speaker,
1132                            dashboard=multi_dashboard,
1133                            user=args.user,
1134                            display_name=_AGENT_RESPONDERS.get("ollama"),
1135                        ),
1136                        "wake_words": ["llama", "ollama", "lama"],
1137                        "terminate_words": args.terminate_words,
1138                    }
1139                )
1140
1141        try:
1142            SpychOrchestrator(entries=entries).start()
1143        finally:
1144            if multi_dashboard is not None:
1145                multi_dashboard.stop()
1146
1147    else:
1148        parser.print_help()
1149        sys.exit(1)
1150
1151
1152if __name__ == "__main__":
1153    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="cpu",
 408        choices=["cpu", "cuda"],
 409        metavar="DEVICE",
 410        help="Device for whisper inference: cpu or cuda (default: cpu)",
 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_multi = subparsers.add_parser(
 471        "multi",
 472        help="Run multiple agents simultaneously under different wake words",
 473        formatter_class=argparse.RawDescriptionHelpFormatter,
 474        description=(
 475            "Run several agents at once. Each agent uses its own default wake "
 476            "words unless overridden.\n\n"
 477            "Example:\n"
 478            "  spych multi --agents claude_code_cli gemini_cli\n"
 479            "  spych multi --agents claude_code_cli ollama --ollama-model llama3.2:latest\n"
 480            "  spych multi --agents claude_code_sdk codex_cli --listen-duration 8"
 481        ),
 482    )
 483    p_multi.add_argument(
 484        "--agents",
 485        nargs="+",
 486        required=True,
 487        metavar="AGENT",
 488        choices=[
 489            "claude_code_cli",
 490            "claude",
 491            "claude_code_sdk",
 492            "claude_sdk",
 493            "codex_cli",
 494            "codex",
 495            "gemini_cli",
 496            "gemini",
 497            "opencode_cli",
 498            "opencode",
 499            "ollama",
 500        ],
 501        help=(
 502            "Agents to run. Choices: claude (claude_code_cli), "
 503            "claude_sdk (claude_code_sdk), codex (codex_cli), "
 504            "gemini (gemini_cli), opencode (opencode_cli), ollama"
 505        ),
 506    )
 507    p_multi.add_argument(
 508        "--terminate-words",
 509        nargs="+",
 510        metavar="WORD",
 511        default=["terminate"],
 512        help="Words that stop all agents (default: terminate)",
 513    )
 514    p_multi.add_argument(
 515        "--listen-duration",
 516        type=float,
 517        default=5,
 518        metavar="SECONDS",
 519        help="Seconds to listen after a wake word (default: 5)",
 520    )
 521    p_multi.add_argument(
 522        "--follow-up-listen-duration",
 523        type=float,
 524        default=0,
 525        metavar="SECONDS",
 526        help="Seconds to listen for follow-up answers (default: 0)",
 527    )
 528    p_multi.add_argument(
 529        "--inactivity-timeout",
 530        type=float,
 531        default=4.0,
 532        metavar="SECONDS",
 533        help="Seconds of inactivity before pivoting back to wake word (default: 4.0)",
 534    )
 535    p_multi.add_argument(
 536        "--continue-conversation",
 537        type=_parse_bool,
 538        default=True,
 539        metavar="BOOL",
 540        help="Resume most recent session for each coding agent (default: true)",
 541    )
 542    p_multi.add_argument(
 543        "--show-tool-events",
 544        type=_parse_bool,
 545        default=True,
 546        metavar="BOOL",
 547        help="Print live tool start/end events (default: true)",
 548    )
 549    p_multi.add_argument(
 550        "--speaker-backend",
 551        default="",
 552        choices=["chatterbox", "kokoro"],
 553        metavar="BACKEND",
 554        help="Explicit TTS backend to use (default: priority Chatterbox then Kokoro)",
 555    )
 556    p_multi.add_argument(
 557        "--use-speaker",
 558        type=_parse_bool,
 559        default=True,
 560        metavar="BOOL",
 561        help="Speak responses aloud via TTS (default: true)",
 562    )
 563    # ollama-specific flags (only used when 'ollama' is in --agents)
 564    p_multi.add_argument(
 565        "--ollama-model",
 566        default="llama3.2:latest",
 567        metavar="MODEL",
 568        help="Ollama model (default: llama3.2:latest). Only used when ollama is in --agents.",
 569    )
 570    p_multi.add_argument(
 571        "--ollama-host",
 572        default="http://localhost:11434",
 573        metavar="URL",
 574        help="Ollama instance URL (default: http://localhost:11434). Only used when ollama is in --agents.",
 575    )
 576    p_multi.add_argument(
 577        "--ollama-history-length",
 578        type=int,
 579        default=10,
 580        metavar="N",
 581        help="Ollama context history length (default: 10). Only used when ollama is in --agents.",
 582    )
 583    # opencode-specific flag
 584    p_multi.add_argument(
 585        "--opencode-model",
 586        default=None,
 587        metavar="MODEL",
 588        help="OpenCode model in provider/model format. Only used when opencode_cli is in --agents.",
 589    )
 590    # claude_code_sdk-specific flag
 591    p_multi.add_argument(
 592        "--setting-sources",
 593        nargs="+",
 594        metavar="SOURCE",
 595        default=["user", "project", "local"],
 596        help="Claude Code SDK setting sources (default: user project local). Only used when claude_code_sdk is in --agents.",
 597    )
 598
 599    # ------------------------------------------------------------------ #
 600    # profile_my_voice — Record a custom voice profile                   #
 601    # ------------------------------------------------------------------ #
 602    p_profile = subparsers.add_parser(
 603        "profile_my_voice",
 604        help="Record a 10-second voice sample to create a custom profile",
 605    )
 606    p_profile.add_argument(
 607        "--name",
 608        required=True,
 609        metavar="NAME",
 610        help="The name to save this voice profile as (e.g. 'my_voice')",
 611    )
 612    p_profile.add_argument(
 613        "--device-index",
 614        type=int,
 615        default=-1,
 616        metavar="N",
 617        help="Microphone device index; -1 uses system default (default: -1)",
 618    )
 619    p_profile.add_argument(
 620        "--alternate-output-file",
 621        default=None,
 622        metavar="PATH",
 623        help="An alternate file path to save the voice profile to (e.g. './my_voice.wav')",
 624    )
 625
 626    # ------------------------------------------------------------------ #
 627    # users — manage user profiles                                       #
 628    # ------------------------------------------------------------------ #
 629    p_users = subparsers.add_parser(
 630        "users",
 631        help="Manage user profiles and global settings",
 632        description=(
 633            "Launch an interactive menu to manage user profiles and global "
 634            "preferences. Profiles store personal info (name, age, extra context) "
 635            "used to tailor agent responses. You can also set the default user "
 636            "and terminal theme here."
 637        ),
 638    )
 639
 640    # ------------------------------------------------------------------ #
 641    # Dispatch                                                             #
 642    # ------------------------------------------------------------------ #
 643    args = parser.parse_args()
 644
 645    # Normalise any alias back to the canonical agent name so the dispatch
 646    # block below only needs to handle one name per agent.
 647    args.agent = _AGENT_ALIASES.get(args.agent, args.agent)
 648
 649    # Apply color theme as early as possible so all subsequent output uses it.
 650    if args.theme != "dark":
 651        from spych.cli_tools import set_theme
 652
 653        set_theme(args.theme)
 654
 655    # ------------------------------------------------------------------ #
 656    # Single-agent dispatch                                                #
 657    # ------------------------------------------------------------------ #
 658
 659    # Default wake words per agent — mirrors the factory function defaults.
 660    _DEFAULT_WAKE_WORDS: dict[str, list[str]] = {
 661        "ollama": ["llama", "ollama", "lama"],
 662        "claude_code_cli": ["claude", "clod", "cloud", "clawed"],
 663        "claude_code_sdk": ["claude", "clod", "cloud", "clawed"],
 664        "codex_cli": ["codex"],
 665        "gemini_cli": ["gemini"],
 666        "opencode_cli": ["opencode", "open code"],
 667    }
 668
 669    _AGENT_RESPONDERS: dict[str, str] = {
 670        "ollama": "Ollama",
 671        "claude_code_cli": "Claude Code CLI",
 672        "claude_code_sdk": "Claude Code SDK",
 673        "codex_cli": "Codex CLI",
 674        "gemini_cli": "Gemini CLI",
 675        "opencode_cli": "OpenCode CLI",
 676    }
 677
 678    def _start_dashboard(agent_name: str, responder_name: str, kwargs: dict):
 679        """Create a dashboard and inject it into kwargs; start is deferred until healthchecks pass."""
 680        from spych.dashboard import AgentDashboard
 681        from spych.utils import get_user, get_default_user
 682
 683        user_name = kwargs.get("user") or get_default_user()
 684        profile_name = "User"
 685        if user_name and user_name.lower() != "none":
 686            profile = get_user(user_name)
 687            if profile:
 688                profile_name = profile.get("name", "User") or "User"
 689
 690        wake_words = kwargs.get(
 691            "wake_words", _DEFAULT_WAKE_WORDS.get(args.agent, [])
 692        )
 693
 694        display_responder = _AGENT_RESPONDERS.get(args.agent, responder_name)
 695        kwargs["display_name"] = display_responder
 696
 697        dashboard = AgentDashboard(
 698            agent_name=kwargs.get("name", agent_name),
 699            wake_words=wake_words,
 700            responder_name=display_responder,
 701            response_style=kwargs.get("response_style", ""),
 702            use_speaker=kwargs.get("use_speaker", True),
 703            speaker_voice=kwargs.get("speaker_voice", "af_heart"),
 704            user_name=profile_name,
 705        )
 706        print("  ◌ Running healthchecks...")
 707        kwargs["dashboard"] = dashboard
 708        return dashboard
 709
 710    if args.agent == "ollama":
 711        from spych.agents import ollama
 712
 713        kwargs = _build_shared_kwargs(args)
 714        kwargs["model"] = args.model
 715        kwargs["history_length"] = args.history_length
 716        kwargs["host"] = args.host
 717        dashboard = (
 718            _start_dashboard("Ollama", "OllamaResponder", kwargs)
 719            if not args.verbose
 720            else None
 721        )
 722        try:
 723            ollama(**kwargs)
 724        finally:
 725            if dashboard is not None:
 726                dashboard.stop()
 727
 728    elif args.agent == "claude_code_cli":
 729        from spych.agents import claude_code_cli
 730
 731        kwargs = _build_agent_kwargs(args)
 732        dashboard = (
 733            _start_dashboard("Claude", "LocalClaudeCodeCLIResponder", kwargs)
 734            if not args.verbose
 735            else None
 736        )
 737        try:
 738            claude_code_cli(**kwargs)
 739        finally:
 740            if dashboard is not None:
 741                dashboard.stop()
 742
 743    elif args.agent == "claude_code_sdk":
 744        from spych.agents import claude_code_sdk
 745
 746        kwargs = _build_agent_kwargs(args)
 747        kwargs["setting_sources"] = args.setting_sources
 748        dashboard = (
 749            _start_dashboard("Claude", "LocalClaudeCodeSDKResponder", kwargs)
 750            if not args.verbose
 751            else None
 752        )
 753        try:
 754            claude_code_sdk(**kwargs)
 755        finally:
 756            if dashboard is not None:
 757                dashboard.stop()
 758
 759    elif args.agent == "codex_cli":
 760        from spych.agents import codex_cli
 761
 762        kwargs = _build_agent_kwargs(args)
 763        dashboard = (
 764            _start_dashboard("Codex", "LocalCodexCLIResponder", kwargs)
 765            if not args.verbose
 766            else None
 767        )
 768        try:
 769            codex_cli(**kwargs)
 770        finally:
 771            if dashboard is not None:
 772                dashboard.stop()
 773
 774    elif args.agent == "gemini_cli":
 775        from spych.agents import gemini_cli
 776
 777        kwargs = _build_agent_kwargs(args)
 778        dashboard = (
 779            _start_dashboard("Gemini", "LocalGeminiCLIResponder", kwargs)
 780            if not args.verbose
 781            else None
 782        )
 783        try:
 784            gemini_cli(**kwargs)
 785        finally:
 786            if dashboard is not None:
 787                dashboard.stop()
 788
 789    elif args.agent == "opencode_cli":
 790        from spych.agents import opencode_cli
 791
 792        kwargs = _build_agent_kwargs(args)
 793        if args.model is not None:
 794            kwargs["model"] = args.model
 795        dashboard = (
 796            _start_dashboard("OpenCode", "LocalOpenCodeCLIResponder", kwargs)
 797            if not args.verbose
 798            else None
 799        )
 800        try:
 801            opencode_cli(**kwargs)
 802        finally:
 803            if dashboard is not None:
 804                dashboard.stop()
 805
 806    elif args.agent == "live":
 807        from spych.live import SpychLive
 808
 809        SpychLive(
 810            output_format=args.output_format,
 811            output_path=args.output_path,
 812            show_timestamps=not args.no_timestamps,
 813            stop_key=args.stop_key,
 814            terminate_words=args.terminate_words,
 815            device_index=args.device_index,
 816            whisper_model=args.whisper_model,
 817            whisper_device=args.whisper_device,
 818            whisper_compute_type=args.whisper_compute_type,
 819            no_speech_threshold=args.no_speech_threshold,
 820            speech_threshold=args.speech_threshold,
 821            silence_threshold=args.silence_threshold,
 822            silence_frames_threshold=args.silence_frames,
 823            speech_pad_frames=args.speech_pad_frames,
 824            max_speech_duration_s=args.max_speech_duration,
 825            context_words=args.context_words,
 826        ).start()
 827
 828    elif args.agent == "profile_my_voice":
 829        from spych.voice_manager import profile_my_voice
 830
 831        profile_my_voice(
 832            name=args.name,
 833            device_index=args.device_index,
 834            alternate_output_file=args.alternate_output_file,
 835        )
 836
 837    elif args.agent == "users":
 838        from spych.utils import (
 839            get_all_users,
 840            get_user,
 841            set_user,
 842            set_default_user,
 843            get_default_user,
 844            set_setting,
 845            get_setting,
 846        )
 847        from spych.cli_tools import set_theme
 848
 849        def users_menu():
 850            while True:
 851                print("\n  " + "=" * 20)
 852                print("  SPYCH USER MANAGEMENT")
 853                print("  " + "=" * 20)
 854
 855                users = get_all_users()
 856                default_user = get_default_user()
 857                current_theme = get_setting("theme", "dark")
 858
 859                print(f"\n  Default User: {default_user or 'None'}")
 860                print(f"  Current Theme: {current_theme}")
 861                print("\n  Users:")
 862                if not users:
 863                    print("    (No users found)")
 864                for u in users:
 865                    print(
 866                        f"    - {u}{' (default)' if u == default_user else ''}"
 867                    )
 868
 869                print("\n  Options:")
 870                print("    1. Create new user")
 871                print("    2. Edit user")
 872                print("    3. Delete user")
 873                print("    4. Set default user")
 874                print("    5. Set theme")
 875                print("    6. Exit")
 876
 877                choice = input("\n  Choice: ").strip()
 878
 879                if choice == "1":
 880                    name = input("  User name: ").strip()
 881                    if name:
 882                        data = {
 883                            "name": input("  Full name: ").strip(),
 884                            "age": input("  Age: ").strip(),
 885                            "gender": input("  Gender: ").strip(),
 886                            "extra": input("  Extra info: ").strip(),
 887                        }
 888                        set_user(name, data)
 889                        print(f"  User '{name}' created.")
 890
 891                elif choice == "2":
 892                    name = input("  User name to edit: ").strip()
 893                    user = get_user(name)
 894                    if user:
 895                        print(f"  Editing {name} (leave blank to keep current)")
 896                        user["name"] = input(
 897                            f"    Full name [{user.get('name', '')}]: "
 898                        ).strip() or user.get("name", "")
 899                        user["age"] = input(
 900                            f"    Age [{user.get('age', '')}]: "
 901                        ).strip() or user.get("age", "")
 902                        user["gender"] = input(
 903                            f"    Gender [{user.get('gender', '')}]: "
 904                        ).strip() or user.get("gender", "")
 905                        user["extra"] = input(
 906                            f"    Extra info [{user.get('extra', '')}]: "
 907                        ).strip() or user.get("extra", "")
 908                        set_user(name, user)
 909                        print(f"  User '{name}' updated.")
 910                    else:
 911                        print("  User not found.")
 912
 913                elif choice == "3":
 914                    name = input("  User name to delete: ").strip()
 915                    path = os.path.join(get_cache_dir("users"), f"{name}.json")
 916                    if os.path.exists(path):
 917                        os.remove(path)
 918                        if get_default_user() == name:
 919                            set_default_user(None)
 920                        print(f"  User '{name}' deleted.")
 921                    else:
 922                        print("  User not found.")
 923
 924                elif choice == "4":
 925                    name = input("  Default user name (or 'none'): ").strip()
 926                    if name.lower() == "none":
 927                        set_default_user(None)
 928                        print("  Default user cleared.")
 929                    elif name in get_all_users():
 930                        set_default_user(name)
 931                        print(f"  Default user set to '{name}'.")
 932                    else:
 933                        print("  User not found.")
 934
 935                elif choice == "5":
 936                    theme = (
 937                        input("  Theme (dark, light, solarized, mono): ")
 938                        .strip()
 939                        .lower()
 940                    )
 941                    if theme in ["dark", "light", "solarized", "mono"]:
 942                        set_setting("theme", theme)
 943                        set_theme(theme)
 944                        print(f"  Theme set to '{theme}'.")
 945                    else:
 946                        print("  Invalid theme.")
 947
 948                elif choice == "6":
 949                    break
 950
 951        users_menu()
 952
 953    # ------------------------------------------------------------------ #
 954    # Multi-agent dispatch                                                 #
 955    # ------------------------------------------------------------------ #
 956    elif args.agent == "multi":
 957        from spych.core import Spych
 958        from spych.orchestrator import SpychOrchestrator
 959
 960        # A single Spych transcription object shared by all responders.
 961        spych_object = Spych(whisper_model="base.en")
 962
 963        # Build dashboard before responders so it can be injected.
 964        multi_dashboard = None
 965        if not args.verbose:
 966            from spych.dashboard import AgentDashboard
 967            from spych.utils import get_user, get_default_user
 968
 969            user_name = args.user or get_default_user()
 970            profile_name = "User"
 971            if user_name and user_name.lower() != "none":
 972                profile = get_user(user_name)
 973                if profile:
 974                    profile_name = profile.get("name", "User") or "User"
 975
 976            first_agent = _AGENT_ALIASES.get(args.agents[0], args.agents[0])
 977            _multi_name_map = {
 978                "claude_code_cli": "Claude",
 979                "claude_code_sdk": "Claude",
 980                "codex_cli": "Codex",
 981                "gemini_cli": "Gemini",
 982                "opencode_cli": "OpenCode",
 983                "ollama": "Ollama",
 984            }
 985            multi_dashboard = AgentDashboard(
 986                agent_name=_multi_name_map.get(first_agent, first_agent),
 987                wake_words=_DEFAULT_WAKE_WORDS.get(first_agent, []),
 988                responder_name=_AGENT_RESPONDERS.get(first_agent, ""),
 989                use_speaker=args.use_speaker,
 990                user_name=profile_name,
 991            )
 992            print("  ◌ Running healthchecks...")
 993
 994        entries = []
 995
 996        for agent_name in [_AGENT_ALIASES.get(a, a) for a in args.agents]:
 997            if agent_name == "claude_code_cli":
 998                from spych.agents.claude import LocalClaudeCodeCLIResponder
 999
1000                entries.append(
1001                    {
1002                        "responder": LocalClaudeCodeCLIResponder(
1003                            spych_object=spych_object,
1004                            continue_conversation=args.continue_conversation,
1005                            listen_duration=args.listen_duration,
1006                            follow_up_listen_duration=args.follow_up_listen_duration,
1007                            inactivity_timeout=args.inactivity_timeout,
1008                            speaker_backend=args.speaker_backend,
1009                            use_speaker=args.use_speaker,
1010                            show_tool_events=args.show_tool_events,
1011                            dashboard=multi_dashboard,
1012                            user=args.user,
1013                            display_name=_AGENT_RESPONDERS.get(
1014                                "claude_code_cli"
1015                            ),
1016                        ),
1017                        "wake_words": ["claude", "clod", "cloud", "clawed"],
1018                        "terminate_words": args.terminate_words,
1019                    }
1020                )
1021
1022            elif agent_name == "claude_code_sdk":
1023                from spych.agents.claude import LocalClaudeCodeSDKResponder
1024
1025                entries.append(
1026                    {
1027                        "responder": LocalClaudeCodeSDKResponder(
1028                            spych_object=spych_object,
1029                            continue_conversation=args.continue_conversation,
1030                            listen_duration=args.listen_duration,
1031                            follow_up_listen_duration=args.follow_up_listen_duration,
1032                            inactivity_timeout=args.inactivity_timeout,
1033                            speaker_backend=args.speaker_backend,
1034                            use_speaker=args.use_speaker,
1035                            setting_sources=args.setting_sources,
1036                            show_tool_events=args.show_tool_events,
1037                            dashboard=multi_dashboard,
1038                            user=args.user,
1039                            display_name=_AGENT_RESPONDERS.get(
1040                                "claude_code_sdk"
1041                            ),
1042                        ),
1043                        "wake_words": ["claude", "clod", "cloud", "clawed"],
1044                        "terminate_words": args.terminate_words,
1045                    }
1046                )
1047
1048            elif agent_name == "codex_cli":
1049                from spych.agents.codex import LocalCodexCLIResponder
1050
1051                entries.append(
1052                    {
1053                        "responder": LocalCodexCLIResponder(
1054                            spych_object=spych_object,
1055                            continue_conversation=args.continue_conversation,
1056                            listen_duration=args.listen_duration,
1057                            follow_up_listen_duration=args.follow_up_listen_duration,
1058                            inactivity_timeout=args.inactivity_timeout,
1059                            speaker_backend=args.speaker_backend,
1060                            use_speaker=args.use_speaker,
1061                            show_tool_events=args.show_tool_events,
1062                            dashboard=multi_dashboard,
1063                            user=args.user,
1064                            display_name=_AGENT_RESPONDERS.get("codex_cli"),
1065                        ),
1066                        "wake_words": ["codex"],
1067                        "terminate_words": args.terminate_words,
1068                    }
1069                )
1070
1071            elif agent_name == "gemini_cli":
1072                from spych.agents.gemini import LocalGeminiCLIResponder
1073
1074                entries.append(
1075                    {
1076                        "responder": LocalGeminiCLIResponder(
1077                            spych_object=spych_object,
1078                            continue_conversation=args.continue_conversation,
1079                            listen_duration=args.listen_duration,
1080                            follow_up_listen_duration=args.follow_up_listen_duration,
1081                            inactivity_timeout=args.inactivity_timeout,
1082                            speaker_backend=args.speaker_backend,
1083                            use_speaker=args.use_speaker,
1084                            show_tool_events=args.show_tool_events,
1085                            dashboard=multi_dashboard,
1086                            user=args.user,
1087                            display_name=_AGENT_RESPONDERS.get("gemini_cli"),
1088                        ),
1089                        "wake_words": ["gemini"],
1090                        "terminate_words": args.terminate_words,
1091                    }
1092                )
1093
1094            elif agent_name == "opencode_cli":
1095                from spych.agents.opencode import LocalOpenCodeCLIResponder
1096
1097                entries.append(
1098                    {
1099                        "responder": LocalOpenCodeCLIResponder(
1100                            spych_object=spych_object,
1101                            continue_conversation=args.continue_conversation,
1102                            listen_duration=args.listen_duration,
1103                            follow_up_listen_duration=args.follow_up_listen_duration,
1104                            inactivity_timeout=args.inactivity_timeout,
1105                            speaker_backend=args.speaker_backend,
1106                            use_speaker=args.use_speaker,
1107                            show_tool_events=args.show_tool_events,
1108                            model=args.opencode_model,
1109                            dashboard=multi_dashboard,
1110                            user=args.user,
1111                            display_name=_AGENT_RESPONDERS.get("opencode_cli"),
1112                        ),
1113                        "wake_words": ["opencode", "open code"],
1114                        "terminate_words": args.terminate_words,
1115                    }
1116                )
1117
1118            elif agent_name == "ollama":
1119                from spych.agents.ollama import OllamaResponder
1120
1121                entries.append(
1122                    {
1123                        "responder": OllamaResponder(
1124                            spych_object=spych_object,
1125                            model=args.ollama_model,
1126                            history_length=args.ollama_history_length,
1127                            host=args.ollama_host,
1128                            listen_duration=args.listen_duration,
1129                            follow_up_listen_duration=args.follow_up_listen_duration,
1130                            inactivity_timeout=args.inactivity_timeout,
1131                            speaker_backend=args.speaker_backend,
1132                            use_speaker=args.use_speaker,
1133                            dashboard=multi_dashboard,
1134                            user=args.user,
1135                            display_name=_AGENT_RESPONDERS.get("ollama"),
1136                        ),
1137                        "wake_words": ["llama", "ollama", "lama"],
1138                        "terminate_words": args.terminate_words,
1139                    }
1140                )
1141
1142        try:
1143            SpychOrchestrator(entries=entries).start()
1144        finally:
1145            if multi_dashboard is not None:
1146                multi_dashboard.stop()
1147
1148    else:
1149        parser.print_help()
1150        sys.exit(1)