cdx-manager 0.5.3 → 0.5.5

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.
@@ -7,6 +7,7 @@ from datetime import datetime
7
7
 
8
8
  from .claude_refresh import _refresh_claude_sessions
9
9
  from .cli_render import _dim, _info, _success, _warn
10
+ from .config import PROVIDER_CODEX
10
11
  from .context_store import (
11
12
  clear_context,
12
13
  edit_context,
@@ -68,6 +69,76 @@ def _write_json(ctx, payload):
68
69
  ctx["out"](f"{json.dumps(payload, indent=2)}\n")
69
70
 
70
71
 
72
+ def _format_bytes(value):
73
+ if value is None:
74
+ return "n/a"
75
+ try:
76
+ amount = float(value)
77
+ except (TypeError, ValueError):
78
+ return str(value)
79
+ units = ["B", "KB", "MB", "GB", "TB"]
80
+ unit = units[0]
81
+ for unit in units:
82
+ if amount < 1024 or unit == units[-1]:
83
+ break
84
+ amount /= 1024
85
+ if unit == "B":
86
+ return f"{int(amount)} B"
87
+ return f"{amount:.1f} {unit}"
88
+
89
+
90
+ def _format_export_report(result):
91
+ lines = [
92
+ f"Path: {result['path']}",
93
+ f"Sessions: {', '.join(result['session_names']) or '-'}",
94
+ f"Auth: {'included and encrypted' if result['include_auth'] else 'not included'}",
95
+ f"Bundle size: {_format_bytes(result.get('bundle_size_bytes'))}",
96
+ ]
97
+ if result.get("include_auth"):
98
+ lines.extend([
99
+ f"Auth files: {result.get('profile_file_count', 0)}",
100
+ f"Auth data: {_format_bytes(result.get('profile_bytes'))}",
101
+ ])
102
+ return "\n".join(lines)
103
+
104
+
105
+ def _make_export_progress(ctx):
106
+ def progress(event):
107
+ kind = event.get("event")
108
+ if kind == "export_started":
109
+ auth = " with auth" if event.get("include_auth") else ""
110
+ message = f"Exporting {event.get('session_count', 0)} session(s){auth}..."
111
+ ctx["out"](f"{_info(message, ctx['use_color'])}\n")
112
+ elif kind == "session_started":
113
+ message = f"Collecting {event.get('session_name')}..."
114
+ ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
115
+ elif kind == "profile_progress":
116
+ message = f" {event.get('session_name')}: {event.get('file_count', 0)} files, {_format_bytes(event.get('bytes'))}"
117
+ ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
118
+ elif kind == "encoding_started":
119
+ ctx["out"](f"{_dim('Encoding and encrypting bundle...', ctx['use_color'])}\n")
120
+ elif kind == "writing_started":
121
+ message = f"Writing {_format_bytes(event.get('bundle_size_bytes'))}..."
122
+ ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
123
+ return progress
124
+
125
+
126
+ def _make_status_progress(ctx):
127
+ def progress(event):
128
+ kind = event.get("event")
129
+ if kind == "status_started":
130
+ message = f"Resolving status for {event.get('session_count', 0)} session(s)..."
131
+ ctx["out"](f"{_info(message, ctx['use_color'])}\n")
132
+ elif kind == "session_started":
133
+ provider = event.get("provider") or "session"
134
+ message = f"Checking {event.get('session_name')} ({provider})..."
135
+ ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
136
+ elif kind == "status_finished":
137
+ message = f"Resolved {event.get('row_count', 0)} status row(s)."
138
+ ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
139
+ return progress
140
+
141
+
71
142
  def _latest_launch_transcript_path(session):
72
143
  paths = _list_launch_transcript_paths(session)
73
144
  if not paths:
@@ -112,7 +183,7 @@ def _build_handoff_context(source, target, transcript_path, transcript, truncate
112
183
 
113
184
 
114
185
  def _handoff_launch_prompt(session, install=None):
115
- if session.get("provider") == "codex":
186
+ if session.get("provider") == PROVIDER_CODEX:
116
187
  context_ref = "$CODEX_HOME/shared-context.md"
117
188
  else:
118
189
  context_ref = (install or {}).get("target_path") or os.path.join(
@@ -131,11 +202,54 @@ def _parse_json_flag(args):
131
202
  return json_flag, cleaned
132
203
 
133
204
 
205
+ def _parse_flag_args(args, schema, usage, positionals_key=None, max_positionals=0):
206
+ parsed = {spec["key"]: spec.get("default") for spec in schema.values()}
207
+ positionals = []
208
+ index = 0
209
+ while index < len(args):
210
+ arg = args[index]
211
+ if arg in schema:
212
+ spec = schema[arg]
213
+ if spec["type"] == "bool":
214
+ parsed[spec["key"]] = True
215
+ index += 1
216
+ continue
217
+ value, index = _read_option_value(args, index, usage)
218
+ parsed[spec["key"]] = spec.get("transform", lambda item: item)(value)
219
+ continue
220
+ if arg.startswith("--") and "=" in arg:
221
+ flag, value = arg.split("=", 1)
222
+ spec = schema.get(flag)
223
+ if not spec or spec["type"] == "bool":
224
+ raise CdxError(usage)
225
+ parsed[spec["key"]] = spec.get("transform", lambda item: item)(value)
226
+ index += 1
227
+ continue
228
+ if arg.startswith("-"):
229
+ raise CdxError(usage)
230
+ positionals.append(arg)
231
+ if len(positionals) > max_positionals:
232
+ raise CdxError(usage)
233
+ index += 1
234
+
235
+ if positionals_key is not None:
236
+ parsed[positionals_key] = positionals
237
+ return parsed
238
+
239
+
134
240
  def _parse_add_args(args):
135
- if len(args) == 1:
136
- return {"provider": "codex", "name": args[0]}
137
- if len(args) == 2:
138
- return {"provider": args[0], "name": args[1]}
241
+ parsed = _parse_flag_args(
242
+ args,
243
+ {},
244
+ "Usage: cdx add [provider] <name> [--json]",
245
+ positionals_key="values",
246
+ max_positionals=2,
247
+ )
248
+ values = parsed["values"]
249
+ if len(values) == 1:
250
+ return {"provider": PROVIDER_CODEX, "name": values[0]}
251
+ if len(values) == 2:
252
+ return {"provider": values[0], "name": values[1]}
139
253
  raise CdxError("Usage: cdx add [provider] <name> [--json]")
140
254
 
141
255
 
@@ -152,19 +266,21 @@ def _parse_rename_args(args):
152
266
 
153
267
 
154
268
  def _parse_remove_args(args):
155
- force = "--force" in args
156
- names = [a for a in args if a != "--force"]
157
- unknown = [a for a in args if a.startswith("-") and a != "--force"]
158
- if unknown or len(names) != 1 or len(args) > 2:
269
+ parsed = _parse_flag_args(args, {
270
+ "--force": {"key": "force", "type": "bool", "default": False},
271
+ }, "Usage: cdx rmv <name> [--force] [--json]", positionals_key="names", max_positionals=1)
272
+ if len(parsed["names"]) != 1:
159
273
  raise CdxError("Usage: cdx rmv <name> [--force] [--json]")
160
- return {"name": names[0], "force": force}
274
+ return {"name": parsed["names"][0], "force": parsed["force"]}
161
275
 
162
276
 
163
277
  def _parse_toggle_args(args, usage):
164
- json_flag, cleaned = _parse_json_flag(args)
165
- if len(cleaned) != 1:
278
+ parsed = _parse_flag_args(args, {
279
+ "--json": {"key": "json", "type": "bool", "default": False},
280
+ }, usage, positionals_key="names", max_positionals=1)
281
+ if len(parsed["names"]) != 1:
166
282
  raise CdxError(usage)
167
- return {"name": cleaned[0], "json": json_flag}
283
+ return {"name": parsed["names"][0], "json": parsed["json"]}
168
284
 
169
285
 
170
286
  def _read_option_value(args, index, usage):
@@ -183,37 +299,12 @@ def _parse_session_names(value):
183
299
 
184
300
 
185
301
  def _parse_update_args(args):
186
- parsed = {
187
- "check": False,
188
- "json": False,
189
- "yes": False,
190
- "version": None,
191
- }
192
- index = 0
193
- while index < len(args):
194
- arg = args[index]
195
- if arg == "--check":
196
- parsed["check"] = True
197
- index += 1
198
- continue
199
- if arg == "--json":
200
- parsed["json"] = True
201
- index += 1
202
- continue
203
- if arg == "--yes":
204
- parsed["yes"] = True
205
- index += 1
206
- continue
207
- if arg == "--version":
208
- value, index = _read_option_value(args, index, UPDATE_USAGE)
209
- parsed["version"] = value
210
- continue
211
- if arg.startswith("--version="):
212
- parsed["version"] = arg.split("=", 1)[1]
213
- index += 1
214
- continue
215
- raise CdxError(UPDATE_USAGE)
216
-
302
+ parsed = _parse_flag_args(args, {
303
+ "--check": {"key": "check", "type": "bool", "default": False},
304
+ "--json": {"key": "json", "type": "bool", "default": False},
305
+ "--yes": {"key": "yes", "type": "bool", "default": False},
306
+ "--version": {"key": "version", "type": "str", "default": None},
307
+ }, UPDATE_USAGE)
217
308
  if parsed["check"] and parsed["version"]:
218
309
  raise CdxError("Usage: cdx update --check cannot be combined with --version.")
219
310
  if parsed["version"] is not None and not parsed["version"].strip():
@@ -222,52 +313,19 @@ def _parse_update_args(args):
222
313
 
223
314
 
224
315
  def _parse_export_args(args):
225
- parsed = {
226
- "file_path": None,
227
- "include_auth": False,
228
- "force": False,
229
- "json": False,
230
- "session_names": None,
231
- "passphrase_env": None,
232
- }
233
- index = 0
234
- while index < len(args):
235
- arg = args[index]
236
- if arg == "--include-auth":
237
- parsed["include_auth"] = True
238
- index += 1
239
- continue
240
- if arg == "--force":
241
- parsed["force"] = True
242
- index += 1
243
- continue
244
- if arg == "--json":
245
- parsed["json"] = True
246
- index += 1
247
- continue
248
- if arg == "--sessions":
249
- value, index = _read_option_value(args, index, EXPORT_USAGE)
250
- parsed["session_names"] = _parse_session_names(value)
251
- continue
252
- if arg.startswith("--sessions="):
253
- parsed["session_names"] = _parse_session_names(arg.split("=", 1)[1])
254
- index += 1
255
- continue
256
- if arg == "--passphrase-env":
257
- value, index = _read_option_value(args, index, EXPORT_USAGE)
258
- parsed["passphrase_env"] = value
259
- continue
260
- if arg.startswith("--passphrase-env="):
261
- parsed["passphrase_env"] = arg.split("=", 1)[1]
262
- index += 1
263
- continue
264
- if arg.startswith("-"):
265
- raise CdxError(EXPORT_USAGE)
266
- if parsed["file_path"] is not None:
267
- raise CdxError(EXPORT_USAGE)
268
- parsed["file_path"] = arg
269
- index += 1
270
-
316
+ parsed = _parse_flag_args(args, {
317
+ "--include-auth": {"key": "include_auth", "type": "bool", "default": False},
318
+ "--force": {"key": "force", "type": "bool", "default": False},
319
+ "--json": {"key": "json", "type": "bool", "default": False},
320
+ "--sessions": {
321
+ "key": "session_names",
322
+ "type": "str",
323
+ "default": None,
324
+ "transform": _parse_session_names,
325
+ },
326
+ "--passphrase-env": {"key": "passphrase_env", "type": "str", "default": None},
327
+ }, EXPORT_USAGE, positionals_key="positionals", max_positionals=1)
328
+ parsed["file_path"] = parsed.pop("positionals")[0] if parsed["positionals"] else None
271
329
  if not parsed["file_path"]:
272
330
  raise CdxError(EXPORT_USAGE)
273
331
  if parsed["passphrase_env"] and not parsed["include_auth"]:
@@ -276,47 +334,18 @@ def _parse_export_args(args):
276
334
 
277
335
 
278
336
  def _parse_import_args(args):
279
- parsed = {
280
- "file_path": None,
281
- "force": False,
282
- "json": False,
283
- "session_names": None,
284
- "passphrase_env": None,
285
- }
286
- index = 0
287
- while index < len(args):
288
- arg = args[index]
289
- if arg == "--force":
290
- parsed["force"] = True
291
- index += 1
292
- continue
293
- if arg == "--json":
294
- parsed["json"] = True
295
- index += 1
296
- continue
297
- if arg == "--sessions":
298
- value, index = _read_option_value(args, index, IMPORT_USAGE)
299
- parsed["session_names"] = _parse_session_names(value)
300
- continue
301
- if arg.startswith("--sessions="):
302
- parsed["session_names"] = _parse_session_names(arg.split("=", 1)[1])
303
- index += 1
304
- continue
305
- if arg == "--passphrase-env":
306
- value, index = _read_option_value(args, index, IMPORT_USAGE)
307
- parsed["passphrase_env"] = value
308
- continue
309
- if arg.startswith("--passphrase-env="):
310
- parsed["passphrase_env"] = arg.split("=", 1)[1]
311
- index += 1
312
- continue
313
- if arg.startswith("-"):
314
- raise CdxError(IMPORT_USAGE)
315
- if parsed["file_path"] is not None:
316
- raise CdxError(IMPORT_USAGE)
317
- parsed["file_path"] = arg
318
- index += 1
319
-
337
+ parsed = _parse_flag_args(args, {
338
+ "--force": {"key": "force", "type": "bool", "default": False},
339
+ "--json": {"key": "json", "type": "bool", "default": False},
340
+ "--sessions": {
341
+ "key": "session_names",
342
+ "type": "str",
343
+ "default": None,
344
+ "transform": _parse_session_names,
345
+ },
346
+ "--passphrase-env": {"key": "passphrase_env", "type": "str", "default": None},
347
+ }, IMPORT_USAGE, positionals_key="positionals", max_positionals=1)
348
+ parsed["file_path"] = parsed.pop("positionals")[0] if parsed["positionals"] else None
320
349
  if not parsed["file_path"]:
321
350
  raise CdxError(IMPORT_USAGE)
322
351
  return parsed
@@ -558,21 +587,20 @@ def handle_doctor(rest, ctx):
558
587
 
559
588
 
560
589
  def handle_repair(rest, ctx):
561
- json_flag = "--json" in rest
562
- dry_run = "--dry-run" in rest or "--force" not in rest
563
- force = "--force" in rest
564
- allowed = {"--json", "--dry-run", "--force"}
565
- unknown = [arg for arg in rest if arg not in allowed]
566
- if unknown:
567
- raise CdxError(REPAIR_USAGE)
590
+ parsed = _parse_flag_args(rest, {
591
+ "--json": {"key": "json", "type": "bool", "default": False},
592
+ "--dry-run": {"key": "dry_run", "type": "bool", "default": False},
593
+ "--force": {"key": "force", "type": "bool", "default": False},
594
+ }, REPAIR_USAGE)
595
+ dry_run = parsed["dry_run"] or not parsed["force"]
568
596
  report = repair_health(
569
597
  ctx["service"],
570
598
  ctx["service"]["base_dir"],
571
599
  env=ctx.get("env"),
572
600
  dry_run=dry_run,
573
- force=force,
601
+ force=parsed["force"],
574
602
  )
575
- if json_flag:
603
+ if parsed["json"]:
576
604
  _write_json(ctx, _json_success("repair", "Collected repair report", report=report))
577
605
  else:
578
606
  ctx["out"](f"{format_repair_report(report, use_color=ctx['use_color'])}\n")
@@ -704,22 +732,23 @@ def handle_context(rest, ctx):
704
732
 
705
733
 
706
734
  def handle_status(rest, ctx):
707
- json_flag = "--json" in rest
708
- small_flag = "--small" in rest or "-s" in rest
709
- refresh_flag = "--refresh" in rest
710
- status_flags = {"--json", "--small", "-s", "--refresh"}
711
- args = [a for a in rest if a not in status_flags]
712
- unknown_flags = [a for a in args if a.startswith("-")]
713
- if unknown_flags or (json_flag and small_flag):
735
+ parsed = _parse_flag_args(rest, {
736
+ "--json": {"key": "json", "type": "bool", "default": False},
737
+ "--small": {"key": "small", "type": "bool", "default": False},
738
+ "-s": {"key": "small", "type": "bool", "default": False},
739
+ "--refresh": {"key": "refresh", "type": "bool", "default": False},
740
+ }, STATUS_USAGE, positionals_key="args", max_positionals=1)
741
+ if parsed["json"] and parsed["small"]:
714
742
  raise CdxError(STATUS_USAGE)
715
- if len(args) > 1 or (len(args) == 1 and small_flag):
743
+ args = parsed["args"]
744
+ if len(args) == 1 and parsed["small"]:
716
745
  raise CdxError(STATUS_USAGE)
717
746
 
718
747
  refresh_result = _refresh_claude_sessions(
719
748
  ctx["service"],
720
749
  ctx.get("refresh_fn"),
721
750
  target_names=args if len(args) == 1 else None,
722
- force=refresh_flag,
751
+ force=parsed["refresh"],
723
752
  )
724
753
  refresh_errors = [
725
754
  {
@@ -737,19 +766,20 @@ def handle_status(rest, ctx):
737
766
  for item in refresh_errors
738
767
  ]
739
768
 
740
- rows = ctx["service"]["get_status_rows"]()
769
+ status_progress = None if parsed["json"] else _make_status_progress(ctx)
770
+ rows = ctx["service"]["get_status_rows"](progress_callback=status_progress)
741
771
  if len(args) == 0:
742
- if json_flag:
772
+ if parsed["json"]:
743
773
  _write_json(ctx, _json_success("status", "Collected session status rows", warnings=warnings, rows=rows))
744
774
  return 0
745
- ctx["out"](f"{_format_status_rows(rows, use_color=ctx['use_color'], small=small_flag)}\n")
775
+ ctx["out"](f"{_format_status_rows(rows, use_color=ctx['use_color'], small=parsed['small'])}\n")
746
776
  _write_refresh_warnings(refresh_errors, ctx)
747
777
  return 0
748
778
 
749
779
  row = next((r for r in rows if r["session_name"] == args[0]), None)
750
780
  if not row:
751
781
  raise CdxError(f"Unknown session: {args[0]}")
752
- if json_flag:
782
+ if parsed["json"]:
753
783
  _write_json(ctx, _json_success("status", f"Collected status for {args[0]}", warnings=warnings, session=row))
754
784
  return 0
755
785
  ctx["out"](f"{_format_status_detail(row, use_color=ctx['use_color'])}\n")
@@ -781,6 +811,7 @@ def handle_export(rest, ctx):
781
811
  session_names=parsed["session_names"],
782
812
  passphrase=passphrase,
783
813
  force=parsed["force"],
814
+ progress_callback=None if parsed["json"] else _make_export_progress(ctx),
784
815
  )
785
816
  session_count = len(result["session_names"])
786
817
  auth_suffix = " with auth" if result["include_auth"] else ""
@@ -794,6 +825,7 @@ def handle_export(rest, ctx):
794
825
  _write_json(ctx, payload)
795
826
  return 0
796
827
  ctx["out"](f"{_success(message, ctx['use_color'])}\n")
828
+ ctx["out"](f"{_format_export_report(result)}\n")
797
829
  return 0
798
830
 
799
831
 
@@ -1002,9 +1034,6 @@ def handle_launch(command, ctx, initial_prompt=None):
1002
1034
  if update_notice.get("url"):
1003
1035
  text = f"{text} {update_notice['url']}"
1004
1036
  ctx["out"](f"{_warn(text, ctx['use_color'])}\n")
1005
- if session["provider"] == "codex":
1006
- if not json_flag:
1007
- ctx["out"](f"{_dim('Tip: cdx status reads Codex rate limits from the local app-server when available.', ctx['use_color'])}\n")
1008
1037
  _run_interactive_provider_command(
1009
1038
  session, "launch", spawn=ctx.get("spawn"), env_override=ctx.get("env"),
1010
1039
  signal_emitter=ctx.get("signal_emitter"), initial_prompt=initial_prompt,
@@ -106,10 +106,10 @@ def _write_json_line(process, payload):
106
106
  process.stdin.flush()
107
107
 
108
108
 
109
- def fetch_codex_rate_limits(session, timeout=5, popen_factory=None):
109
+ def fetch_codex_rate_limit_diagnostic(session, timeout=5, popen_factory=None):
110
110
  auth_home = session.get("authHome")
111
111
  if not auth_home:
112
- return None
112
+ return {"ok": False, "reason": "missing_auth_home", "status": None}
113
113
 
114
114
  env = os.environ.copy()
115
115
  env["CODEX_HOME"] = auth_home
@@ -139,7 +139,12 @@ def fetch_codex_rate_limits(session, timeout=5, popen_factory=None):
139
139
  })
140
140
  initialized = _read_response(output, 1, timeout)
141
141
  if not initialized or initialized.get("error"):
142
- return None
142
+ return {
143
+ "ok": False,
144
+ "reason": "initialize_failed",
145
+ "status": None,
146
+ "response": initialized,
147
+ }
143
148
 
144
149
  _write_json_line(process, {
145
150
  "jsonrpc": "2.0",
@@ -149,13 +154,28 @@ def fetch_codex_rate_limits(session, timeout=5, popen_factory=None):
149
154
  })
150
155
  response = _read_response(output, 2, timeout)
151
156
  if not response or response.get("error"):
152
- return None
157
+ return {
158
+ "ok": False,
159
+ "reason": "rate_limits_read_failed",
160
+ "status": None,
161
+ "response": response,
162
+ }
153
163
  result = response.get("result") or {}
154
164
  by_limit = result.get("rateLimitsByLimitId") or {}
155
165
  snapshot = by_limit.get("codex") or result.get("rateLimits")
156
- return normalize_codex_rate_limit_snapshot(snapshot)
157
- except (OSError, ValueError, BrokenPipeError):
158
- return None
166
+ status = normalize_codex_rate_limit_snapshot(snapshot)
167
+ if not status:
168
+ return {
169
+ "ok": False,
170
+ "reason": "missing_rate_limits",
171
+ "status": None,
172
+ "response": response,
173
+ }
174
+ return {"ok": True, "reason": None, "status": status, "response": response}
175
+ except FileNotFoundError as error:
176
+ return {"ok": False, "reason": "codex_cli_not_found", "status": None, "error": str(error)}
177
+ except (OSError, ValueError, BrokenPipeError) as error:
178
+ return {"ok": False, "reason": "probe_failed", "status": None, "error": str(error)}
159
179
  finally:
160
180
  if process is not None:
161
181
  try:
@@ -166,3 +186,12 @@ def fetch_codex_rate_limits(session, timeout=5, popen_factory=None):
166
186
  process.kill()
167
187
  except OSError:
168
188
  pass
189
+
190
+
191
+ def fetch_codex_rate_limits(session, timeout=5, popen_factory=None):
192
+ diagnostic = fetch_codex_rate_limit_diagnostic(
193
+ session,
194
+ timeout=timeout,
195
+ popen_factory=popen_factory,
196
+ )
197
+ return diagnostic.get("status") if diagnostic.get("ok") else None
package/src/config.py CHANGED
@@ -1,6 +1,10 @@
1
1
  import os
2
2
  from pathlib import Path
3
3
 
4
+ PROVIDER_CODEX = "codex"
5
+ PROVIDER_CLAUDE = "claude"
6
+ PROVIDERS = (PROVIDER_CODEX, PROVIDER_CLAUDE)
7
+
4
8
 
5
9
  def get_cdx_home(env=None):
6
10
  if env is None:
package/src/notify.py CHANGED
@@ -29,6 +29,13 @@ def parse_notify_args(args):
29
29
  raise CdxError("--poll must be a number of seconds") from error
30
30
  i += 2
31
31
  continue
32
+ if arg.startswith("--poll="):
33
+ try:
34
+ poll = max(1, int(arg.split("=", 1)[1]))
35
+ except ValueError as error:
36
+ raise CdxError("--poll must be a number of seconds") from error
37
+ i += 1
38
+ continue
32
39
  if arg.startswith("-"):
33
40
  raise CdxError("Usage: cdx notify <name> --at-reset [--poll seconds] [--once] | cdx notify --next-ready [--poll seconds] [--once]")
34
41
  cleaned.append(arg)
@@ -121,7 +128,24 @@ def send_desktop_notification(title, message, spawn_sync=None, env=None):
121
128
  _send_windows_notification(title, message, spawn_sync, env)
122
129
  elif shutil_which("osascript", env):
123
130
  script = f'display notification "{_escape_applescript(message)}" with title "{_escape_applescript(title)}"'
124
- spawn_sync(["osascript", "-e", script], env=env, capture_output=True, text=True)
131
+ _run_notification_command(
132
+ ["osascript", "-e", script],
133
+ spawn_sync,
134
+ env,
135
+ )
136
+ elif shutil_which("notify-send", env):
137
+ _run_notification_command(
138
+ ["notify-send", str(title), str(message)],
139
+ spawn_sync,
140
+ env,
141
+ )
142
+
143
+
144
+ def _run_notification_command(argv, spawn_sync, env):
145
+ try:
146
+ spawn_sync(argv, env=env, capture_output=True, text=True, timeout=5)
147
+ except (FileNotFoundError, OSError):
148
+ pass
125
149
 
126
150
 
127
151
  def _send_windows_notification(title, message, spawn_sync, env):