ctl.py 3.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108
  1. """One-shot control client for hotkeys (`stt-ctl`).
  2. Connects to the assistant control socket, sends a single command, prints the
  3. replies it receives, and exits. Intended to be bound to global hotkeys.
  4. """
  5. import argparse
  6. import json
  7. import socket
  8. import sys
  9. from .assistant.control import default_control_path
  10. from .config import load_config
  11. COMMAND_MAP = {
  12. "command-start": "command_start",
  13. "command-execute": "command_execute",
  14. "command-cancel": "command_cancel",
  15. "command-edit": "command_edit",
  16. "chat-toggle": "chat_toggle",
  17. "clear": "clear",
  18. "status": "status",
  19. "speaker-list": "speaker_list",
  20. "speaker-name": "speaker_name",
  21. }
  22. READ_TIMEOUT_S = 1.0
  23. READ_CHUNK = 65536
  24. def build_message(command: str, text: str | None = None, rest: list[str] | None = None) -> dict:
  25. """Map a CLI verb to a control-socket message."""
  26. if command not in COMMAND_MAP:
  27. raise ValueError(f"unknown command: {command}")
  28. message = {"type": COMMAND_MAP[command]}
  29. if text is not None:
  30. message["text"] = text
  31. if command == "speaker-name":
  32. args = list(rest or [])
  33. message["speaker_id"] = args[0] if len(args) > 0 else ""
  34. message["name"] = args[1] if len(args) > 1 else ""
  35. return message
  36. def main() -> int:
  37. """Entry point for `stt-ctl`."""
  38. parser = argparse.ArgumentParser(description="Send one command to the running assistant")
  39. parser.add_argument("command", choices=sorted(COMMAND_MAP))
  40. parser.add_argument("rest", nargs="*", help="Extra args (speaker-name: <id> <name>)")
  41. parser.add_argument("--text", default=None, help="Text for command-edit")
  42. parser.add_argument("--config", default=None, help="Path to config.toml")
  43. parser.add_argument("--socket", default=None, help="Override the control socket path")
  44. args = parser.parse_args()
  45. path = args.socket
  46. if path is None:
  47. cfg = load_config(args.config)
  48. path = cfg.assistant.control_socket or default_control_path()
  49. try:
  50. message = build_message(args.command, args.text, args.rest)
  51. except ValueError as e:
  52. print(f"error: {e}", file=sys.stderr)
  53. return 2
  54. sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
  55. try:
  56. sock.connect(path)
  57. except OSError as e:
  58. print(f"error: cannot connect to {path}: {e}", file=sys.stderr)
  59. return 1
  60. sock.sendall((json.dumps(message) + "\n").encode("utf-8"))
  61. sock.settimeout(READ_TIMEOUT_S)
  62. buffer = bytearray()
  63. try:
  64. while True:
  65. try:
  66. chunk = sock.recv(READ_CHUNK)
  67. except socket.timeout:
  68. break
  69. if not chunk:
  70. break
  71. buffer.extend(chunk)
  72. while True:
  73. newline = buffer.find(b"\n")
  74. if newline == -1:
  75. break
  76. line = bytes(buffer[:newline])
  77. del buffer[: newline + 1]
  78. if not line.strip():
  79. continue
  80. try:
  81. reply = json.loads(line.decode("utf-8"))
  82. except (UnicodeDecodeError, json.JSONDecodeError):
  83. continue
  84. print(json.dumps(reply, ensure_ascii=False))
  85. finally:
  86. try:
  87. sock.shutdown(socket.SHUT_RDWR)
  88. except OSError:
  89. pass
  90. sock.close()
  91. return 0
  92. if __name__ == "__main__":
  93. sys.exit(main())