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