softvoice 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
softvoice/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """softvoice: on-device voice in and out for Claude Code and Codex."""
2
+
3
+ __version__ = "0.1.0"
softvoice/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from softvoice.cli import main
2
+
3
+ main()
softvoice/autostart.py ADDED
@@ -0,0 +1,92 @@
1
+ """`softvoice autostart`: run the app as a macOS login item.
2
+
3
+ A LaunchAgent in ~/Library/LaunchAgents starts `softvoice app` when you log in, with no terminal
4
+ involved, and starts it again if it ever crashes (not after you quit it on purpose). Permissions
5
+ are then attributed to the Python running softvoice rather than to a terminal, so macOS asks for
6
+ them once more the first time.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import plistlib
13
+ import re
14
+ import subprocess
15
+ from pathlib import Path
16
+
17
+ from softvoice.config import HOME_DIR, ensure_home
18
+ from softvoice.install import executable
19
+
20
+ LABEL = "com.softvoice.app"
21
+ PLIST_PATH = Path.home() / "Library" / "LaunchAgents" / f"{LABEL}.plist"
22
+ APP_LOG = HOME_DIR / "app.log"
23
+
24
+
25
+ def program() -> list[str]:
26
+ exe = executable()
27
+ if exe:
28
+ return [exe]
29
+ import sys
30
+
31
+ return [sys.executable, "-m", "softvoice"]
32
+
33
+
34
+ def plist(extra_args: list[str] | tuple[str, ...] = ()) -> dict:
35
+ return {
36
+ "Label": LABEL,
37
+ "ProgramArguments": [*program(), "app", *extra_args],
38
+ "RunAtLoad": True,
39
+ "KeepAlive": {"SuccessfulExit": False}, # back after a crash; not after Quit
40
+ "ProcessType": "Interactive",
41
+ "LimitLoadToSessionType": "Aqua",
42
+ "StandardOutPath": str(APP_LOG),
43
+ "StandardErrorPath": str(APP_LOG),
44
+ "EnvironmentVariables": {
45
+ "PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin",
46
+ "PYTHONUNBUFFERED": "1",
47
+ "SOFTVOICE_LAUNCHD": "1", # lets the app know a crash-exit brings it back
48
+ },
49
+ }
50
+
51
+
52
+ def _domain() -> str:
53
+ return f"gui/{os.getuid()}"
54
+
55
+
56
+ def _launchctl(*args: str) -> subprocess.CompletedProcess:
57
+ return subprocess.run(["launchctl", *args], capture_output=True, text=True)
58
+
59
+
60
+ def enable(extra_args: list[str] | tuple[str, ...] = ()) -> Path:
61
+ """Write the LaunchAgent and load it, which also starts the app now."""
62
+ ensure_home()
63
+ PLIST_PATH.parent.mkdir(parents=True, exist_ok=True)
64
+ with PLIST_PATH.open("wb") as fh:
65
+ plistlib.dump(plist(extra_args), fh)
66
+ _launchctl("bootout", f"{_domain()}/{LABEL}") # a previous version, if any; fine when there is none
67
+ result = _launchctl("bootstrap", _domain(), str(PLIST_PATH))
68
+ if result.returncode != 0:
69
+ raise RuntimeError(f"launchctl bootstrap failed: {result.stderr.strip() or result.stdout.strip()}")
70
+ return PLIST_PATH
71
+
72
+
73
+ def disable() -> bool:
74
+ """Unload (which stops the app) and remove the LaunchAgent. False when there was none."""
75
+ existed = PLIST_PATH.exists()
76
+ _launchctl("bootout", f"{_domain()}/{LABEL}")
77
+ if existed:
78
+ PLIST_PATH.unlink()
79
+ return existed
80
+
81
+
82
+ def status() -> dict:
83
+ """{'installed': bool, 'loaded': bool, 'pid': int | None, 'state': str | None}"""
84
+ info = {"installed": PLIST_PATH.exists(), "loaded": False, "pid": None, "state": None}
85
+ result = _launchctl("print", f"{_domain()}/{LABEL}")
86
+ if result.returncode == 0:
87
+ info["loaded"] = True
88
+ pid = re.search(r"^\s*pid = (\d+)", result.stdout, re.M)
89
+ state = re.search(r"^\s*state = (\w+)", result.stdout, re.M)
90
+ info["pid"] = int(pid.group(1)) if pid else None
91
+ info["state"] = state.group(1) if state else None
92
+ return info
softvoice/clean.py ADDED
@@ -0,0 +1,152 @@
1
+ """Turn an agent's markdown reply into text that sounds natural when read aloud.
2
+
3
+ Code blocks, tables, URLs and long paths are dropped or shortened: you look at the
4
+ screen for those. Anything that looks like a secret is replaced before it reaches a
5
+ speaker.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from dataclasses import dataclass
12
+
13
+ _FENCE = re.compile(r"```.*?```|~~~.*?~~~", re.DOTALL)
14
+ _HTML = re.compile(r"<[^>\n]{1,200}>")
15
+ _IMAGE = re.compile(r"!\[([^\]]*)\]\([^)]*\)")
16
+ _LINK = re.compile(r"\[([^\]]+)\]\([^)]*\)")
17
+ _URL = re.compile(r"https?://[^\s)>\]]+")
18
+ _INLINE_CODE = re.compile(r"`([^`\n]*)`")
19
+ _HEADING = re.compile(r"^\s{0,3}#{1,6}\s*(.*?)\s*#*\s*$", re.MULTILINE)
20
+ _BULLET = re.compile(r"^\s*(?:[-*+•]|\d{1,3}[.)])\s+", re.MULTILINE)
21
+ _QUOTE = re.compile(r"^\s*>\s?", re.MULTILINE)
22
+ _RULE = re.compile(r"^\s*([-*_])(?:\s*\1){2,}\s*$", re.MULTILINE)
23
+ _TABLE_ROW = re.compile(r"^\s*\|.*\|\s*$", re.MULTILINE)
24
+ _EMPHASIS = re.compile(r"(\*\*|__|\*|_|~~)(?=\S)(.+?)(?<=\S)\1")
25
+ _CHECKBOX = re.compile(r"\[( |x|X)\]\s*")
26
+ # Something with at least two path separators, or a dotted filename with a line number.
27
+ _PATH = re.compile(r"(?<![\w./-])(?:~|\.{1,2})?[\w.@-]*(?:/[\w.@-]+){2,}(?::\d+(?::\d+)?)?")
28
+ _FILE_LINE = re.compile(r"\b([\w-]+\.[a-z]{1,5}):(\d+)(?::\d+)?\b")
29
+ _MULTISPACE = re.compile(r"[ \t]+")
30
+ _MULTINEWLINE = re.compile(r"\n{2,}")
31
+ _SENTENCE_END = re.compile(r"(?<=[.!?])\s+")
32
+
33
+ _SECRETS = [
34
+ re.compile(p)
35
+ for p in (
36
+ r"\bsk-(?:ant-)?[A-Za-z0-9_-]{16,}\b", # OpenAI / Anthropic style keys
37
+ r"\bgh[pousr]_[A-Za-z0-9]{20,}\b", # GitHub tokens
38
+ r"\bAKIA[0-9A-Z]{16}\b", # AWS access keys
39
+ r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b", # Slack tokens
40
+ r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b", # JWTs
41
+ r"\b[A-Fa-f0-9]{32,}\b", # long hex: hashes, API keys
42
+ r"(?<![\w/+])[A-Za-z0-9+/]{40,}={0,2}(?![\w/+])", # long base64 blobs
43
+ r"(?i)\b(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*\S+",
44
+ )
45
+ ]
46
+
47
+ _SYMBOLS = [
48
+ (re.compile(r"\s*->\s*"), " to "),
49
+ (re.compile(r"\s*=>\s*"), " gives "),
50
+ (re.compile(r"\s*&&\s*"), " and "),
51
+ (re.compile(r"\s*\|\|\s*"), " or "),
52
+ (re.compile(r"(?<=\w)/(?=\w)"), " or "), # "input/output" -> "input or output"
53
+ ]
54
+
55
+
56
+ @dataclass
57
+ class Cleaned:
58
+ text: str
59
+ truncated: bool
60
+ dropped_code_blocks: int
61
+
62
+
63
+ def clean(markdown: str, max_chars: int = 700, truncation_suffix: str = "More on screen.") -> Cleaned:
64
+ """Return speakable text for a markdown reply. Empty text means: nothing worth saying."""
65
+ text = markdown.replace("\r\n", "\n").replace("\r", "\n")
66
+
67
+ dropped = len(_FENCE.findall(text))
68
+ text = _FENCE.sub("\n", text)
69
+ # An unterminated fence: drop from the fence to the end.
70
+ if "```" in text:
71
+ text = text.split("```", 1)[0]
72
+ dropped += 1
73
+
74
+ text = _TABLE_ROW.sub("", text)
75
+ text = _HTML.sub("", text)
76
+ text = _IMAGE.sub(lambda m: m.group(1), text)
77
+ text = _LINK.sub(lambda m: m.group(1), text)
78
+ text = _URL.sub("link", text)
79
+ text = _INLINE_CODE.sub(_inline_code, text)
80
+ text = _HEADING.sub(lambda m: _sentence(m.group(1)), text)
81
+ text = _RULE.sub("", text)
82
+ text = _QUOTE.sub("", text)
83
+ text = _CHECKBOX.sub("", text)
84
+ text = _BULLET.sub("", text)
85
+ for _ in range(2): # nested emphasis like ***x*** or **_x_**
86
+ text = _EMPHASIS.sub(lambda m: m.group(2), text)
87
+ text = _FILE_LINE.sub(lambda m: f"{m.group(1)} line {m.group(2)}", text)
88
+ text = _PATH.sub(_path_basename, text)
89
+
90
+ for pattern in _SECRETS:
91
+ text = pattern.sub("redacted", text)
92
+ for pattern, replacement in _SYMBOLS:
93
+ text = pattern.sub(replacement, text)
94
+
95
+ # Every line becomes a sentence so the voice pauses between bullets.
96
+ lines = [_sentence(line.strip()) for line in text.split("\n")]
97
+ text = " ".join(line for line in lines if line)
98
+ text = _MULTISPACE.sub(" ", text).strip()
99
+ text = re.sub(r"\s+([.,;:!?])", r"\1", text)
100
+ text = re.sub(r"([.!?])\1+", r"\1", text)
101
+
102
+ truncated = False
103
+ if max_chars and len(text) > max_chars:
104
+ text = _cut_at_sentence(text, max_chars)
105
+ truncated = True
106
+ if truncation_suffix:
107
+ text = f"{text} {truncation_suffix}"
108
+
109
+ # Nothing but code or symbols: say nothing.
110
+ if not re.search(r"[A-Za-z]{2,}", text):
111
+ text = ""
112
+ return Cleaned(text=text, truncated=truncated, dropped_code_blocks=dropped)
113
+
114
+
115
+ def _inline_code(match: re.Match) -> str:
116
+ code = match.group(1).strip()
117
+ if not code or len(code) > 40:
118
+ return ""
119
+ return code
120
+
121
+
122
+ def _path_basename(match: re.Match) -> str:
123
+ path = match.group(0)
124
+ line = ""
125
+ if ":" in path:
126
+ path, _, rest = path.partition(":")
127
+ line = f" line {rest.split(':')[0]}"
128
+ name = path.rstrip("/").rsplit("/", 1)[-1]
129
+ return f"{name}{line}" if name else ""
130
+
131
+
132
+ def _sentence(line: str) -> str:
133
+ line = line.strip()
134
+ if not line:
135
+ return ""
136
+ if line[-1] not in ".!?:;,":
137
+ line += "."
138
+ elif line[-1] in ":;,":
139
+ line = line[:-1] + "."
140
+ return line
141
+
142
+
143
+ def _cut_at_sentence(text: str, max_chars: int) -> str:
144
+ head = text[:max_chars]
145
+ parts = _SENTENCE_END.split(head)
146
+ if len(parts) > 1:
147
+ kept = " ".join(parts[:-1]).strip()
148
+ if len(kept) >= max_chars // 3:
149
+ return kept
150
+ # No sentence boundary in range: cut at a word.
151
+ cut = head.rsplit(" ", 1)[0].rstrip(",;:")
152
+ return cut + "."
softvoice/cli.py ADDED
@@ -0,0 +1,391 @@
1
+ """Command-line entry point. Heavy imports stay inside the commands that need them."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import sys
9
+ import time
10
+ from pathlib import Path
11
+
12
+ from softvoice import __version__
13
+ from softvoice.config import CONFIG_PATH, LOG_PATH, Config, write_default_config
14
+
15
+
16
+ def main(argv: list[str] | None = None) -> int:
17
+ parser = argparse.ArgumentParser(prog="softvoice", description="On-device voice in and out for Claude Code and Codex.")
18
+ parser.add_argument("--version", action="version", version=f"softvoice {__version__}")
19
+ sub = parser.add_subparsers(dest="cmd", required=True)
20
+
21
+ p = sub.add_parser("serve", help="run the speaker daemon (normally started for you)")
22
+ p.add_argument("--foreground", "-f", action="store_true", help="log to the terminal as well")
23
+
24
+ p = sub.add_parser("say", help="speak text through the daemon")
25
+ p.add_argument("text", nargs="*", help="text to speak; reads stdin when omitted")
26
+ p.add_argument("--raw", action="store_true", help="skip markdown cleaning")
27
+ p.add_argument("--out", metavar="WAV", help="write a WAV file instead of speaking (loads the model in-process)")
28
+
29
+ sub.add_parser("stop", help="stop speaking and clear the queue")
30
+ sub.add_parser("mute", help="stay silent until unmuted")
31
+ sub.add_parser("unmute", help="speak again")
32
+ sub.add_parser("status", help="show daemon state")
33
+ sub.add_parser("restart", help="stop the daemon; the next hook starts a fresh one")
34
+ sub.add_parser("quit", help="stop the daemon")
35
+ p = sub.add_parser("voice", help="switch the voice for this daemon run")
36
+ p.add_argument("name", help="a predefined voice name or a WAV path to clone")
37
+ sub.add_parser("voices", help="list predefined voices")
38
+ p = sub.add_parser("log", help="show the daemon log")
39
+ p.add_argument("-n", type=int, default=40)
40
+
41
+ sub.add_parser("hook", help="agent hook entry: reads the hook JSON on stdin")
42
+ p = sub.add_parser("notify", help="Codex legacy notify entry: JSON as an argument")
43
+ p.add_argument("payload")
44
+
45
+ sub.add_parser("setup", help="download the models and ask macOS for the permissions, so the first run is instant")
46
+ p = sub.add_parser("install", help="write hooks into Claude Code and Codex settings")
47
+ p.add_argument("--claude-only", action="store_true")
48
+ p.add_argument("--codex-only", action="store_true")
49
+ sub.add_parser("uninstall", help="remove softvoice hooks")
50
+ sub.add_parser("config", help="print the config file path and contents")
51
+
52
+ p = sub.add_parser("listen", help="push-to-talk: hold the hotkey, speak, release to paste")
53
+ p.add_argument("--hotkey", help="the talk key: alt_r, fn, f5, or a combination such as alt+space")
54
+ p.add_argument("--auto-submit", action="store_true", help="press Enter after pasting")
55
+ p.add_argument("--no-paste", action="store_true", help="print transcripts instead of pasting")
56
+ p.add_argument("--hands-free", action="store_true", help="after each spoken reply, open the mic and send what you say")
57
+ p = sub.add_parser("app", help="menu bar app: a mic icon that shows whose turn it is, with Talk, Stop, Mute, Hands-free")
58
+ p.add_argument("--hotkey", help="the talk key: alt_r, fn, f5, or a combination such as alt+space")
59
+ p.add_argument("--auto-submit", action="store_true", help="press Enter after push-to-talk pastes")
60
+ p.add_argument("--no-paste", action="store_true", help="print transcripts instead of pasting")
61
+ hf = p.add_mutually_exclusive_group()
62
+ hf.add_argument("--hands-free", dest="hands_free", action="store_true", default=None)
63
+ hf.add_argument("--no-hands-free", dest="hands_free", action="store_false")
64
+ p = sub.add_parser("autostart", help="run the app as a login item: starts at login, no terminal, back after a crash")
65
+ p.add_argument("action", nargs="?", choices=["on", "off", "status"], default="status")
66
+ p.add_argument("--hands-free", action="store_true", help="start hands-free (or set hands_free = true in the config)")
67
+ p = sub.add_parser("transcribe", help="transcribe an audio file with Parakeet")
68
+ p.add_argument("file")
69
+
70
+ args = parser.parse_args(argv)
71
+ return COMMANDS[args.cmd](args)
72
+
73
+
74
+ # -- speaking -----------------------------------------------------------------------
75
+
76
+ def cmd_serve(args) -> int:
77
+ from softvoice.daemon import serve
78
+
79
+ return serve(foreground=args.foreground)
80
+
81
+
82
+ def cmd_say(args) -> int:
83
+ text = " ".join(args.text) if args.text else sys.stdin.read()
84
+ if not text.strip():
85
+ print("nothing to say", file=sys.stderr)
86
+ return 1
87
+ if args.out:
88
+ import soundfile as sf
89
+
90
+ from softvoice.clean import clean
91
+ from softvoice.tts import Speaker
92
+
93
+ cfg = Config.load()
94
+ spoken = text if args.raw else clean(text, cfg.max_chars, cfg.truncation_suffix).text
95
+ speaker = Speaker(cfg)
96
+ t0 = time.monotonic()
97
+ audio = speaker.synthesize(spoken)
98
+ sf.write(args.out, audio, speaker.sample_rate)
99
+ print(f"wrote {args.out}: {len(audio) / speaker.sample_rate:.1f}s audio in {time.monotonic() - t0:.1f}s")
100
+ return 0
101
+ return _send({"cmd": "speak", "text": text, "raw": args.raw, "meta": {"event": "say"}})
102
+
103
+
104
+ def cmd_simple(cmd: str, **fields):
105
+ def run(args) -> int:
106
+ return _send({"cmd": cmd, **fields}, autostart=cmd not in ("stop", "quit", "mute", "unmute"))
107
+
108
+ return run
109
+
110
+
111
+ def cmd_status(args) -> int:
112
+ from softvoice.client import alive, send
113
+
114
+ if not alive():
115
+ print("daemon: not running")
116
+ return 1
117
+ print(json.dumps(send({"cmd": "status"}, autostart=False), indent=2))
118
+ return 0
119
+
120
+
121
+ def cmd_restart(args) -> int:
122
+ from softvoice.client import alive, send
123
+
124
+ from softvoice.config import PID_PATH
125
+
126
+ old_pid = None
127
+ try:
128
+ old_pid = int(PID_PATH.read_text().strip() or 0) or None
129
+ except (OSError, ValueError):
130
+ pass
131
+ if alive():
132
+ send({"cmd": "quit"}, autostart=False)
133
+ for _ in range(100): # wait for the old process itself, not just its socket
134
+ if not alive() and not (old_pid and _pid_running(old_pid)):
135
+ break
136
+ time.sleep(0.1)
137
+ from softvoice.client import ensure_daemon
138
+
139
+ ok = ensure_daemon()
140
+ print("daemon restarted" if ok else "daemon did not come up; see: softvoice log")
141
+ return 0 if ok else 1
142
+
143
+
144
+ def _pid_running(pid: int) -> bool:
145
+ try:
146
+ os.kill(pid, 0)
147
+ return True
148
+ except ProcessLookupError:
149
+ return False
150
+ except PermissionError:
151
+ return True
152
+
153
+
154
+ def cmd_voice(args) -> int:
155
+ return _send({"cmd": "voice", "voice": args.name})
156
+
157
+
158
+ def cmd_voices(args) -> int:
159
+ from softvoice.tts import PREDEFINED_VOICES
160
+
161
+ print("\n".join(PREDEFINED_VOICES))
162
+ print("\nOr pass a path to a short WAV of a voice to clone. Set `voice` in", CONFIG_PATH)
163
+ return 0
164
+
165
+
166
+ def cmd_log(args) -> int:
167
+ if not LOG_PATH.exists():
168
+ print("no log yet")
169
+ return 0
170
+ lines = LOG_PATH.read_text(errors="replace").splitlines()
171
+ print("\n".join(lines[-args.n:]))
172
+ return 0
173
+
174
+
175
+ def _send(req: dict, autostart: bool = True) -> int:
176
+ from softvoice.client import DaemonUnavailable, send
177
+
178
+ try:
179
+ resp = send(req, autostart=autostart)
180
+ except DaemonUnavailable as exc:
181
+ print(exc, file=sys.stderr)
182
+ return 1
183
+ if not resp.get("ok"):
184
+ print(resp.get("error", "failed"), file=sys.stderr)
185
+ return 1
186
+ extras = {k: v for k, v in resp.items() if k != "ok"}
187
+ if extras:
188
+ print(json.dumps(extras))
189
+ return 0
190
+
191
+
192
+ # -- hooks --------------------------------------------------------------------------
193
+
194
+ def _hook_payload(raw: str) -> int:
195
+ """Shared by `hook` (stdin) and `notify` (argv). Never fails loudly: agents must not notice us."""
196
+ if os.environ.get("SOFTVOICE_DISABLED"):
197
+ return 0
198
+ try:
199
+ payload = json.loads(raw) if raw.strip() else {}
200
+ cfg = Config.load()
201
+ from softvoice.client import send
202
+ from softvoice.hooks import interpret
203
+
204
+ action = interpret(payload, cfg.speak_notifications, cfg.interrupt_on_prompt,
205
+ whole_turn=cfg.speak_whole_turn, interim_min_chars=cfg.interim_min_chars)
206
+ if action.kind == "speak":
207
+ send({"cmd": "speak", "text": action.text, "meta": action.meta})
208
+ elif action.kind == "stop":
209
+ send({"cmd": "stop", "reason": "prompt"}, autostart=False)
210
+ except Exception as exc: # log and stay silent: stdout must stay clean for Codex
211
+ try:
212
+ from softvoice.config import HOME_DIR, ensure_home
213
+
214
+ ensure_home()
215
+ with open(HOME_DIR / "hook.log", "a") as fh:
216
+ fh.write(f"{time.strftime('%F %T')} {type(exc).__name__}: {exc}\n")
217
+ except OSError:
218
+ pass
219
+ return 0
220
+
221
+
222
+ def cmd_hook(args) -> int:
223
+ return _hook_payload(sys.stdin.read())
224
+
225
+
226
+ def cmd_notify(args) -> int:
227
+ return _hook_payload(args.payload)
228
+
229
+
230
+ def cmd_setup(args) -> int:
231
+ """Everything that would otherwise happen, slowly, the first time you use it."""
232
+ from huggingface_hub import hf_hub_download
233
+
234
+ from softvoice import permissions
235
+
236
+ cfg = Config.load()
237
+ print(f"config: {write_default_config()}")
238
+
239
+ t0 = time.monotonic()
240
+ print(f"Pocket TTS ({cfg.language}, voice {cfg.voice}): about 230 MB the first time", flush=True)
241
+ from softvoice.tts import Speaker
242
+
243
+ Speaker(cfg)
244
+ print(f" ready in {time.monotonic() - t0:.0f}s")
245
+
246
+ t0 = time.monotonic()
247
+ print(f"Parakeet ({cfg.stt_model}): 2.5 GB the first time", flush=True)
248
+ for name in ("config.json", "model.safetensors"): # the same two files parakeet-mlx loads
249
+ hf_hub_download(cfg.stt_model, name)
250
+ print(f" ready in {time.monotonic() - t0:.0f}s")
251
+
252
+ print("Permissions (granted to the app this terminal belongs to):")
253
+ try:
254
+ import sounddevice as sd
255
+
256
+ with sd.InputStream(channels=1, samplerate=16000): # makes macOS ask for the microphone now
257
+ time.sleep(0.3)
258
+ print("Microphone: opened")
259
+ except Exception as exc:
260
+ print(f"Microphone: could not open ({exc})")
261
+ permissions.check_and_request(need_paste=True, need_hotkey=True)
262
+ print("Next: `softvoice install` adds the hooks, then `softvoice app --hands-free` starts the pill.")
263
+ return 0
264
+
265
+
266
+ def cmd_install(args) -> int:
267
+ from softvoice import install as inst
268
+
269
+ write_default_config()
270
+ command = inst.hook_command()
271
+ print(f"hook command: {command}")
272
+ if not args.codex_only:
273
+ path, backup = inst.install_claude(command)
274
+ print(f"Claude Code: hooks written to {path}" + (f" (backup {backup.name})" if backup else ""))
275
+ print(" Restart Claude Code sessions to pick them up.")
276
+ if not args.claude_only:
277
+ result = inst.install_codex(command)
278
+ if result:
279
+ path, backup = result
280
+ print(f"Codex: hooks written to {path}" + (f" (backup {backup.name})" if backup else ""))
281
+ print(" Run /hooks inside Codex once to trust them.")
282
+ else:
283
+ print("Codex: ~/.codex not found, skipped. When you install Codex, run `softvoice install --codex-only`.")
284
+ print(f"config: {CONFIG_PATH}")
285
+ return 0
286
+
287
+
288
+ def cmd_uninstall(args) -> int:
289
+ from softvoice import install as inst
290
+
291
+ for label, fn in (("Claude Code", inst.uninstall_claude), ("Codex", inst.uninstall_codex)):
292
+ path = fn()
293
+ print(f"{label}: {'removed from ' + str(path) if path else 'nothing to remove'}")
294
+ return 0
295
+
296
+
297
+ def cmd_config(args) -> int:
298
+ path = write_default_config()
299
+ print(path)
300
+ print(path.read_text())
301
+ return 0
302
+
303
+
304
+ # -- listening ----------------------------------------------------------------------
305
+
306
+ def cmd_listen(args) -> int:
307
+ from softvoice.ptt import listen
308
+
309
+ cfg = Config.load()
310
+ return listen(cfg, hotkey=args.hotkey or cfg.hotkey, auto_submit=args.auto_submit or cfg.auto_submit,
311
+ paste=not args.no_paste, hands_free=args.hands_free or cfg.hands_free)
312
+
313
+
314
+ def cmd_app(args) -> int:
315
+ from softvoice.menubar import run_app
316
+
317
+ cfg = Config.load()
318
+ hands_free = cfg.hands_free if args.hands_free is None else args.hands_free
319
+ return run_app(cfg, hotkey=args.hotkey or cfg.hotkey, auto_submit=args.auto_submit or cfg.auto_submit,
320
+ paste=not args.no_paste, hands_free=hands_free)
321
+
322
+
323
+ def cmd_autostart(args) -> int:
324
+ from softvoice import autostart, instance
325
+
326
+ if args.action == "on":
327
+ other = instance.running_pid()
328
+ if other:
329
+ print(f"stopping the running copy (pid {other}) so the login item starts it fresh ...", flush=True)
330
+ if not instance.quit(other):
331
+ print("it did not stop; quit it yourself and run this again")
332
+ return 1
333
+ path = autostart.enable(["--hands-free"] if args.hands_free else [])
334
+ time.sleep(1.5)
335
+ st = autostart.status()
336
+ print(f"login item: {path}")
337
+ running = f" and is running now (pid {st['pid']})" if st.get("pid") else ""
338
+ print(f"softvoice app starts at every login{running}. Output: {autostart.APP_LOG}")
339
+ print("If macOS asks for the microphone, Accessibility or Input Monitoring again, that is because the app")
340
+ print("now runs under Python instead of your terminal. Grant them once and they stick.")
341
+ return 0
342
+ if args.action == "off":
343
+ if autostart.disable():
344
+ print("login item removed and the app stopped. `softvoice app` starts it by hand.")
345
+ else:
346
+ print("no login item was installed.")
347
+ return 0
348
+ st = autostart.status()
349
+ if not st["installed"]:
350
+ print("not installed. `softvoice autostart on` makes the app start at login.")
351
+ else:
352
+ where = f"running (pid {st['pid']})" if st.get("pid") else ("loaded, not running" if st["loaded"] else "installed, not loaded")
353
+ print(f"login item {autostart.PLIST_PATH}: {where}")
354
+ return 0
355
+
356
+
357
+ def cmd_transcribe(args) -> int:
358
+ from softvoice.stt import Transcriber
359
+
360
+ cfg = Config.load()
361
+ t = Transcriber(cfg.stt_model)
362
+ t0 = time.monotonic()
363
+ text = t.transcribe_file(Path(args.file))
364
+ print(text)
365
+ print(f"[{time.monotonic() - t0:.2f}s]", file=sys.stderr)
366
+ return 0
367
+
368
+
369
+ COMMANDS = {
370
+ "serve": cmd_serve,
371
+ "say": cmd_say,
372
+ "stop": cmd_simple("stop", reason="cli"),
373
+ "mute": cmd_simple("mute"),
374
+ "unmute": cmd_simple("unmute"),
375
+ "status": cmd_status,
376
+ "restart": cmd_restart,
377
+ "quit": cmd_simple("quit"),
378
+ "voice": cmd_voice,
379
+ "voices": cmd_voices,
380
+ "log": cmd_log,
381
+ "hook": cmd_hook,
382
+ "notify": cmd_notify,
383
+ "setup": cmd_setup,
384
+ "install": cmd_install,
385
+ "uninstall": cmd_uninstall,
386
+ "config": cmd_config,
387
+ "listen": cmd_listen,
388
+ "app": cmd_app,
389
+ "autostart": cmd_autostart,
390
+ "transcribe": cmd_transcribe,
391
+ }