Repository navigation
Expand file tree
/
Copy pathcli.py
More file actions
432 lines (351 loc) · 15.1 KB
/
Copy pathcli.py
File metadata and controls
432 lines (351 loc) · 15.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
"""CLI entrypoint.
Usage:
python -m openflow # run daemon
python -m openflow run # run daemon
python -m openflow dict list
python -m openflow dict add NAME --hints "h1,h2" [--lang en|hi|both] [--context TEXT]
python -m openflow dict remove NAME
python -m openflow snippets list
python -m openflow snippets add "my email" "me@example.com"
python -m openflow snippets remove "my email"
python -m openflow history [--limit 20]
python -m openflow config path
python -m openflow key set groq|anthropic # fast cleanup LLM key → Keychain
python -m openflow hub [page] # open the main window (home, history, …)
python -m openflow supervise # (LaunchAgent guard) restart OpenFlow if it died or hung
"""
from __future__ import annotations
import argparse
import os
import sys
from config import CONFIG_DIR, CONFIG_PATH, DICT_PATH, HISTORY_PATH
# Held for the daemon's whole life. The LaunchAgent runs the daemon from
# source while the Dock / Finder launch OpenFlow.app; without this a Dock
# click started a second daemon (second menu bar icon, second set of hotkey
# listeners) whose sockets all failed with "already running".
DAEMON_LOCK = CONFIG_DIR / "daemon.lock"
def acquire_daemon_lock(path, wait_s: float = 0.0) -> int | None:
"""Exclusive non-blocking flock on `path`; the fd, or None when another
process holds it (still, after retrying for `wait_s`). The OS drops the
lock if the process dies."""
import errno
import fcntl
import time
from pathlib import Path
Path(path).parent.mkdir(parents=True, exist_ok=True)
deadline = time.monotonic() + wait_s
while True:
fd = os.open(str(path), os.O_CREAT | os.O_RDWR, 0o600)
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
return fd
except OSError as exc:
os.close(fd)
if exc.errno not in (errno.EWOULDBLOCK, errno.EAGAIN, errno.EACCES):
raise
if time.monotonic() >= deadline:
return None
time.sleep(0.1)
def release_daemon_lock(fd: int) -> None:
os.close(fd) # closing drops the flock
# A supervisor's probe may hold the lock for an instant (supervisor.lock_held).
LOCK_RETRY_S = 0.3
# After killing a hung daemon, how long to wait for its lock to free up.
TAKEOVER_WAIT_S = 3.0
def _cmd_run(args: argparse.Namespace) -> int:
import supervisor
fd = acquire_daemon_lock(DAEMON_LOCK, wait_s=LOCK_RETRY_S)
if fd is None:
hung = supervisor.hung_daemon(daemon=lambda: supervisor.running_daemon(DAEMON_LOCK))
if hung is None:
# A daemon is already running: this launch is the user opening
# the app (Dock, Finder, Spotlight), so show the main window.
print("[cli] OpenFlow is already running; opening the main window.", flush=True)
return _open_hub("home")
# It holds the lock but doesn't answer: the window couldn't reach
# it either. Replace it.
print(f"[cli] OpenFlow (pid {hung.pid}) isn't responding; restarting it.", flush=True)
supervisor.terminate(hung.pid)
fd = acquire_daemon_lock(DAEMON_LOCK, wait_s=TAKEOVER_WAIT_S)
if fd is None:
print("[cli] the old OpenFlow is still holding on; opening the main window.",
flush=True)
return _open_hub("home")
try:
supervisor.write_lock_info(fd)
except OSError as e:
print(f"[cli] could not record the daemon pid: {e}", flush=True)
# Started on purpose (login, Dock, a supervisor): supervision is back on.
supervisor.clear_user_quit()
_refresh_login_guard()
try:
from daemon import main
main()
finally:
release_daemon_lock(fd)
return 0
def _refresh_login_guard() -> None:
"""Bring the LaunchAgent guard up to date for users who turned on
Open at login before it existed (login_item.refresh_guard)."""
try:
import login_item
login_item.refresh_guard()
except Exception as e:
print(f"[cli] login guard refresh failed: {e}", flush=True)
def _cmd_supervise(args: argparse.Namespace) -> int:
"""LaunchAgent guard: one check, then exit (launchd runs it again)."""
import login_item
import supervisor
if not login_item.guard_installed():
return 0 # Open at login was turned off; launchd drops us at logout
outcome = supervisor.supervise_once()
if outcome != "running":
print(f"[supervise] {outcome}", flush=True)
return 0
def _cmd_dict_list(args: argparse.Namespace) -> int:
from dictionary import Dictionary
d = Dictionary.load()
if not d.terms:
print("(empty)")
return 0
for t in d.terms:
hints = ", ".join(t.phonetic_hints) if t.phonetic_hints else "-"
ctx = f" [{t.context}]" if t.context else ""
print(f"{t.canonical} ({t.language}){ctx} hints: {hints}")
return 0
def _cmd_dict_add(args: argparse.Namespace) -> int:
from dictionary import Dictionary
d = Dictionary.load()
hints = [h.strip() for h in (args.hints or "").split(",") if h.strip()]
d.add(args.name, hints=hints, language=args.lang, context=args.context)
d.save()
print(f"added: {args.name}")
return 0
def _cmd_dict_remove(args: argparse.Namespace) -> int:
from dictionary import Dictionary
d = Dictionary.load()
ok = d.remove(args.name)
d.save()
print("removed" if ok else "not found")
return 0 if ok else 1
def _cmd_snippets_list(args: argparse.Namespace) -> int:
from snippets import Snippets
s = Snippets.load()
if not s.items:
print("(empty)")
return 0
for item in s.items:
print(f"{item.trigger!r} -> {item.expansion!r}")
return 0
def _cmd_snippets_add(args: argparse.Namespace) -> int:
from snippets import Snippets
s = Snippets.load()
# Shells pass "\n" literally; let a signature span lines.
expansion = args.expansion.replace("\\n", "\n")
try:
s.add(args.trigger, expansion)
except ValueError as e:
print(f"error: {e}")
return 1
s.save()
print(f"added: {args.trigger}")
return 0
def _cmd_snippets_remove(args: argparse.Namespace) -> int:
from snippets import Snippets
s = Snippets.load()
ok = s.remove(args.trigger)
s.save()
print("removed" if ok else "not found")
return 0 if ok else 1
def _cmd_dict_edit(args: argparse.Namespace) -> int:
"""Old `dict edit` GUI: now the hub's Dictionary page."""
return _open_hub("dictionary")
def _cmd_edit_overlay(args: argparse.Namespace) -> int:
"""Launch the edit-mode overlay (subprocess target for daemon)."""
from ui.edit_overlay import main as overlay_main
sys.argv = [sys.argv[0]]
return overlay_main()
def _cmd_settings(args: argparse.Namespace) -> int:
"""Old settings window: now the hub's Settings page."""
return _open_hub("settings")
def _cmd_history_viewer(args: argparse.Namespace) -> int:
"""Old history viewer: now the hub's History page."""
return _open_hub("history")
def _open_hub(page: str) -> int:
from ui.hub import app as hub_app
return hub_app.main(page)
def _cmd_hub(args: argparse.Namespace) -> int:
"""Open the main window on a page (single instance: hands off and exits
if one is already open)."""
return _open_hub(args.page)
def _cmd_flow_widget(args: argparse.Namespace) -> int:
"""Launch the on-screen flow widget (subprocess target for the daemon)."""
from ui.flow_widget import main as widget_main
return widget_main()
def _cmd_onboarding(args: argparse.Namespace) -> int:
"""Open the first-run window (permissions, Sarvam key, try it)."""
from ui.first_run import main as first_run_main
return first_run_main()
def _cmd_history(args: argparse.Namespace) -> int:
from history import History
h = History()
rows = h.recent(limit=args.limit)
for r in rows:
took = f" ({r.t_total:.2f}s)" if r.t_total is not None else ""
print(f"[{r.tone}/{r.lang}]{took} {r.final}")
return 0
def _cmd_key_set(args: argparse.Namespace) -> int:
"""Store a fast cleanup provider's key in the Keychain. With [cleanup]
provider = "auto" (the default) the daemon switches to it on restart."""
import getpass
import keyring
import keyring.errors
from llm import FAST_PROVIDERS
from sarvam import KEYRING_SERVICE
key = (getpass.getpass(f"{args.provider} API key: ") or "").strip()
if not key:
print("No key entered; nothing changed.")
return 1
keyring.set_password(KEYRING_SERVICE, FAST_PROVIDERS[args.provider]["keyring_user"], key)
print(f"Saved. Restart OpenFlow to use {args.provider} for cleanup.")
return 0
def _cmd_key_clear(args: argparse.Namespace) -> int:
import keyring
import keyring.errors
from llm import FAST_PROVIDERS
from sarvam import KEYRING_SERVICE
try:
keyring.delete_password(KEYRING_SERVICE, FAST_PROVIDERS[args.provider]["keyring_user"])
except keyring.errors.PasswordDeleteError:
print(f"No {args.provider} key stored.")
return 0
print("Removed. Restart OpenFlow to go back to Sarvam for cleanup.")
return 0
def _cmd_config(args: argparse.Namespace) -> int:
print(f"config: {CONFIG_PATH}")
print(f"dict: {DICT_PATH}")
print(f"hist: {HISTORY_PATH}")
return 0
def _cmd_doctor(args: argparse.Namespace) -> int:
"""Diagnostic — reports Accessibility trust, mic, pynput state, key parsing."""
print("== OpenFlow doctor ==")
print(f"sys.executable: {sys.executable}")
print(f"sys.frozen: {getattr(sys, 'frozen', False)}")
import doctor
# Accessibility; microphone — try to open
print(doctor.check_accessibility()["line"])
print(doctor.check_input_monitoring()["line"])
print(doctor.check_microphone()["line"])
print(doctor.check_sound_output(probe=True)["line"])
# pynput listener — start and watch for 5 seconds
print("\nListening for any key events for 5s. Press right Option a few times…")
try:
from pynput import keyboard
events: list[str] = []
def _p(k):
try:
events.append(f"press {k}")
except Exception:
pass
def _r(k):
try:
events.append(f"release {k}")
except Exception:
pass
li = keyboard.Listener(on_press=_p, on_release=_r)
li.start()
import time as _t
_t.sleep(5.0)
li.stop()
print(f"Captured {len(events)} events:")
for ev in events[:30]:
print(f" {ev}")
if not events:
print(" (none) — pynput is not receiving key events.")
print(" Most common cause: this binary lacks Accessibility permission.")
print(" Check System Settings → Privacy & Security → Accessibility — the")
print(" entry must point at /Applications/OpenFlow.app/Contents/MacOS/openflow")
print(" (or simply OpenFlow.app) and be toggled ON.")
except Exception as e:
print(f"pynput listener failed: {type(e).__name__}: {e}")
# Config sanity
print()
for c in doctor.check_files():
print(c["line"])
print(doctor.check_sarvam_key()["line"])
for c in doctor.check_fallback_keys():
print(c["line"])
return 0
def _cmd_logs(args: argparse.Namespace) -> int:
from openflow_logger import tail_log, _MAIN_LOG, _ERROR_LOG
level = "errors" if args.errors else "all"
if args.path:
print(_ERROR_LOG if args.errors else _MAIN_LOG)
return 0
sys.stdout.write(tail_log(level=level, n=args.tail))
return 0
def build_parser() -> argparse.ArgumentParser:
ap = argparse.ArgumentParser(prog="openflow")
sub = ap.add_subparsers(dest="cmd")
sub.add_parser("run", help="run the daemon (default)").set_defaults(func=_cmd_run)
sub.add_parser("supervise", help="(internal) LaunchAgent guard: restart OpenFlow "
"if it died or stopped answering").set_defaults(func=_cmd_supervise)
d = sub.add_parser("dict", help="manage custom dictionary")
dsub = d.add_subparsers(dest="dcmd", required=True)
dsub.add_parser("list").set_defaults(func=_cmd_dict_list)
da = dsub.add_parser("add")
da.add_argument("name")
da.add_argument("--hints", default="")
da.add_argument("--lang", default="both", choices=["en", "hi", "both"])
da.add_argument("--context", default=None)
da.set_defaults(func=_cmd_dict_add)
dr = dsub.add_parser("remove")
dr.add_argument("name")
dr.set_defaults(func=_cmd_dict_remove)
dsub.add_parser("edit", help="open the Dictionary page").set_defaults(func=_cmd_dict_edit)
sn = sub.add_parser("snippets", help="manage snippets (spoken trigger -> text)")
snsub = sn.add_subparsers(dest="scmd", required=True)
snsub.add_parser("list").set_defaults(func=_cmd_snippets_list)
sa = snsub.add_parser("add")
sa.add_argument("trigger")
sa.add_argument("expansion", help=r"text to paste; \n for a new line")
sa.set_defaults(func=_cmd_snippets_add)
sr = snsub.add_parser("remove")
sr.add_argument("trigger")
sr.set_defaults(func=_cmd_snippets_remove)
eo = sub.add_parser("edit-overlay", help="(internal) launch edit-mode overlay")
eo.set_defaults(func=_cmd_edit_overlay)
sub.add_parser("settings", help="open the Settings page").set_defaults(func=_cmd_settings)
sub.add_parser("history-viewer", help="open the History page").set_defaults(func=_cmd_history_viewer)
hb = sub.add_parser("hub", help="open the OpenFlow window")
hb.add_argument("page", nargs="?", default="home",
help="home, insights, history, dictionary, tones, settings or help")
hb.set_defaults(func=_cmd_hub)
sub.add_parser("flow-widget", help="(internal) launch the on-screen flow widget").set_defaults(func=_cmd_flow_widget)
sub.add_parser("onboarding", help="open the first-run setup window").set_defaults(func=_cmd_onboarding)
h = sub.add_parser("history")
h.add_argument("--limit", type=int, default=20)
h.set_defaults(func=_cmd_history)
k = sub.add_parser("key", help="store a fast cleanup LLM key (groq, anthropic)")
ksub = k.add_subparsers(dest="kcmd", required=True)
for name, fn in (("set", _cmd_key_set), ("clear", _cmd_key_clear)):
kp = ksub.add_parser(name)
kp.add_argument("provider", choices=["groq", "anthropic"])
kp.set_defaults(func=fn)
c = sub.add_parser("config")
c.set_defaults(func=_cmd_config)
sub.add_parser("doctor", help="diagnose permissions + key events").set_defaults(func=_cmd_doctor)
lg = sub.add_parser("logs", help="show recent log lines")
lg.add_argument("--tail", type=int, default=50, help="number of trailing lines")
lg.add_argument("--errors", action="store_true", help="only ERROR-level entries")
lg.add_argument("--path", action="store_true", help="print log file path instead of contents")
lg.set_defaults(func=_cmd_logs)
return ap
def main() -> int:
ap = build_parser()
args = ap.parse_args()
if not getattr(args, "cmd", None):
return _cmd_run(args)
return args.func(args)
if __name__ == "__main__":
sys.exit(main())