cdx-manager 0.21.4 → 0.21.6

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.
@@ -74,6 +74,17 @@ def _format_credits(value, empty="n/a"):
74
74
  return str(value)
75
75
 
76
76
 
77
+ def _auth_type_label(row):
78
+ """`setup-token`, or with its remaining days once renewal is due."""
79
+ from .claude_credentials import SETUP_TOKEN_WARN_DAYS
80
+
81
+ label = row.get("auth_type") or "-"
82
+ days = row.get("auth_expires_in_days")
83
+ if days is None or days > SETUP_TOKEN_WARN_DAYS:
84
+ return label, False
85
+ return (f"{label} expired" if days < 0 else f"{label} {days}d"), True
86
+
87
+
77
88
  def _format_status_rows(rows, use_color=False, small=False):
78
89
  active_rows = [r for r in rows if r.get("enabled", True) is not False]
79
90
  def positive(value):
@@ -90,18 +101,19 @@ def _format_status_rows(rows, use_color=False, small=False):
90
101
  credits_visible = any(r.get("credits") is not None for r in active_rows)
91
102
  has_provider = len({r["provider"] for r in rows}) > 1 and not small
92
103
  has_label = any(r.get("label") for r in rows) and not small
104
+ type_visible = any(r.get("auth_type") for r in active_rows) and not small
93
105
  if small:
94
106
  headers = ["SESSION", "STATUS", "OK", "5H", "WEEK"] + [name for name, visible in zip(["RESETS", "RESET 5H", "RESET WEEK"], reset_visible) if visible]
95
107
  elif has_provider:
96
108
  headers = ["SESSION", "PROV."]
97
109
  if has_label:
98
110
  headers.append("LABEL")
99
- headers += ["STATUS", "AUTH", "OK", "5H", "WEEK"] + (["BLOCK"] if block_visible else []) + (["CR"] if credits_visible else []) + [name for name, visible in zip(["RESETS", "RESET 5H", "RESET WEEK"], reset_visible) if visible] + ["UPDATED"]
111
+ headers += ["STATUS", "AUTH"] + (["TYPE"] if type_visible else []) + ["OK", "5H", "WEEK"] + (["BLOCK"] if block_visible else []) + (["CR"] if credits_visible else []) + [name for name, visible in zip(["RESETS", "RESET 5H", "RESET WEEK"], reset_visible) if visible] + ["UPDATED"]
100
112
  else:
101
113
  headers = ["SESSION"]
102
114
  if has_label:
103
115
  headers.append("LABEL")
104
- headers += ["STATUS", "AUTH", "OK", "5H", "WEEK"] + (["BLOCK"] if block_visible else []) + (["CR"] if credits_visible else []) + [name for name, visible in zip(["RESETS", "RESET 5H", "RESET WEEK"], reset_visible) if visible] + ["UPDATED"]
116
+ headers += ["STATUS", "AUTH"] + (["TYPE"] if type_visible else []) + ["OK", "5H", "WEEK"] + (["BLOCK"] if block_visible else []) + (["CR"] if credits_visible else []) + [name for name, visible in zip(["RESETS", "RESET 5H", "RESET WEEK"], reset_visible) if visible] + ["UPDATED"]
105
117
  if not rows:
106
118
  if small:
107
119
  return "SESSION STATUS OK 5H WEEK RESETS RESET 5H RESET WEEK\nNo saved sessions yet."
@@ -130,6 +142,9 @@ def _format_status_rows(rows, use_color=False, small=False):
130
142
  auth = _format_auth_status(r)
131
143
  auth_color = "32" if auth == "logged" else "31" if auth == "logged out" else "2"
132
144
  base.append(_style(auth, auth_color, use_color))
145
+ if type_visible:
146
+ label, warn = _auth_type_label(r)
147
+ base.append(_style(label, "33" if warn else "2", use_color))
133
148
  if r.get("enabled", True) is False:
134
149
  usage_columns = [_style("-", "2", use_color)] * 6
135
150
  else:
@@ -365,6 +380,7 @@ def _format_status_detail(row, use_color=False):
365
380
  f"{_style('Provider:', '1', use_color)} {row.get('provider') or 'n/a'}",
366
381
  f"{_style('Status:', '1', use_color)} {_style(row.get('status') or ('enabled' if row.get('enabled', True) else 'disabled'), '2' if row.get('enabled', True) is False else '32', use_color)}",
367
382
  f"{_style('Auth:', '1', use_color)} {_style(auth, auth_color, use_color)}",
383
+ *([f"{_style('Auth type:', '1', use_color)} {_auth_type_label(row)[0]}"] if row.get("auth_type") else []),
368
384
  f"{_style('Available:', '1', use_color)} {_style_pct(row.get('available_pct'), use_color)}",
369
385
  f"{_style('5h left:', '1', use_color)} {_style_pct(row.get('remaining_5h_pct'), use_color)}",
370
386
  f"{_style('Week left:', '1', use_color)} {_style_pct(row.get('remaining_week_pct'), use_color)}",
@@ -24,6 +24,8 @@ import json
24
24
  import os
25
25
  import time
26
26
 
27
+ from .errors import CdxError
28
+
27
29
  SCHEMA_VERSION = 1
28
30
  # The tray heartbeats once per poll: 30s native, 60s across WSL. This window has
29
31
  # to clear the slowest of those with room for a late tick, or a live tray reads
@@ -130,12 +132,13 @@ def publish(base_dir, title, message, kind="complete", now=None, event_id=None,
130
132
  path = events_path(base_dir)
131
133
  os.makedirs(os.path.dirname(path), exist_ok=True)
132
134
  line = json.dumps(event, ensure_ascii=False) + "\n"
133
- # O_APPEND makes this atomic against other hooks firing at the same
134
- # instant, which a read-modify-write of a JSON array would not be.
135
- with open(path, "a", encoding="utf-8") as handle:
135
+ # O_APPEND keeps concurrent hooks apart; the lock keeps this append
136
+ # out of acknowledge()'s read-and-replace, which would otherwise
137
+ # overwrite it and lose the notification.
138
+ with _spool_lock(path), open(path, "a", encoding="utf-8") as handle:
136
139
  handle.write(line)
137
140
  return True
138
- except OSError:
141
+ except (OSError, CdxError):
139
142
  return False
140
143
 
141
144
 
@@ -171,6 +174,11 @@ def read_events(base_dir, include_acknowledged=False):
171
174
  return events[-MAX_SPOOLED_EVENTS:]
172
175
 
173
176
 
177
+ # A hook must never stall an agent's turn on this lock; giving up only means
178
+ # the alert is delivered directly instead.
179
+ SPOOL_LOCK_TIMEOUT_SECONDS = 2
180
+
181
+
174
182
  def acknowledge(base_dir, ids, now=None):
175
183
  """Mark events consumed, idempotently, and compact when it is worth it.
176
184
 
@@ -183,6 +191,17 @@ def acknowledge(base_dir, ids, now=None):
183
191
  if not wanted:
184
192
  return {"acknowledged": 0, "compacted": False}
185
193
 
194
+ with _spool_lock(events_path(base_dir)):
195
+ return _acknowledge_locked(base_dir, wanted, now)
196
+
197
+
198
+ def _spool_lock(path):
199
+ from .run_registry import _registry_lock
200
+
201
+ return _registry_lock(path, timeout_seconds=SPOOL_LOCK_TIMEOUT_SECONDS, label="tray events")
202
+
203
+
204
+ def _acknowledge_locked(base_dir, wanted, now):
186
205
  try:
187
206
  with open(events_path(base_dir), encoding="utf-8") as handle:
188
207
  lines = handle.readlines()
@@ -6,9 +6,10 @@ per-user temporary directory, then `cdx-tray/companion.pid`.
6
6
  """
7
7
  import os
8
8
  import platform
9
- import subprocess
10
9
  import tempfile
11
10
 
11
+ from .session_helpers import _process_is_running
12
+
12
13
 
13
14
  def lock_path(env=None):
14
15
  env = os.environ if env is None else env
@@ -20,20 +21,8 @@ def lock_path(env=None):
20
21
 
21
22
 
22
23
  def _is_alive(pid):
23
- if platform.system() == "Windows":
24
- result = subprocess.run(
25
- ["tasklist", "/fi", f"PID eq {pid}", "/nh"],
26
- capture_output=True, text=True, check=False,
27
- )
28
- return str(pid) in result.stdout
29
- try:
30
- os.kill(pid, 0)
31
- except (ProcessLookupError, ValueError):
32
- return False
33
- except PermissionError:
34
- # Alive, owned by someone else. Still a reason not to start a second.
35
- return True
36
- return True
24
+ # Alive includes owned by someone else: still a reason not to start a second.
25
+ return _process_is_running(pid)
37
26
 
38
27
 
39
28
  def companion_instance(env=None):
@@ -88,7 +88,7 @@ def _status(env=None, runner=None, executable=None, directories=None):
88
88
  try:
89
89
  completed = (runner or subprocess.run)(
90
90
  [executable, "status", "--format", "json"], capture_output=True,
91
- text=True, timeout=ADAPTER_TIMEOUT_SECONDS, check=False, **({"cwd": root} if root else {}),
91
+ text=True, encoding="utf-8", errors="replace", timeout=ADAPTER_TIMEOUT_SECONDS, check=False, **({"cwd": root} if root else {}),
92
92
  )
93
93
  payload = json.loads(completed.stdout) if getattr(completed, "returncode", 1) == 0 else None
94
94
  except (OSError, subprocess.SubprocessError, TypeError, ValueError):
package/src/update_all.py CHANGED
@@ -33,7 +33,7 @@ def _which(command, env):
33
33
  def _run(command, env, runner=None, timeout=15):
34
34
  runner = runner or subprocess.run
35
35
  try:
36
- result = runner(command, env=env, capture_output=True, text=True, check=False, timeout=timeout)
36
+ result = runner(command, env=env, capture_output=True, text=True, encoding="utf-8", errors="replace", check=False, timeout=timeout)
37
37
  except (OSError, subprocess.TimeoutExpired) as error:
38
38
  return {"returncode": 1, "stdout": "", "stderr": str(error)}
39
39
  if isinstance(result, dict):
@@ -143,7 +143,7 @@ def _installed_logics_manager_version(env=None, runner=None):
143
143
  result = runner(
144
144
  [executable, "--version"],
145
145
  capture_output=True,
146
- text=True,
146
+ text=True, encoding="utf-8", errors="replace",
147
147
  timeout=2,
148
148
  env=env,
149
149
  )
@@ -43,7 +43,7 @@ def _is_git_dirty(package_root):
43
43
  result = subprocess.run(
44
44
  ["git", "-C", str(package_root), "status", "--porcelain"],
45
45
  capture_output=True,
46
- text=True,
46
+ text=True, encoding="utf-8", errors="replace",
47
47
  check=False,
48
48
  )
49
49
  except FileNotFoundError as error:
@@ -216,7 +216,11 @@ def _version_check_env(env):
216
216
  source = env or os.environ
217
217
  allowed = {
218
218
  key: source[key]
219
- for key in ("PATH", "PATHEXT", "SystemRoot", "COMSPEC", "WINDIR")
219
+ # The home variables let the child find its home directory: without
220
+ # USERPROFILE, Windows Python's Path.home() raises and `cdx -v` prints
221
+ # a traceback instead of the version.
222
+ for key in ("PATH", "PATHEXT", "SystemRoot", "COMSPEC", "WINDIR",
223
+ "HOME", "USERPROFILE", "HOMEDRIVE", "HOMEPATH", "CDX_HOME")
220
224
  if source.get(key)
221
225
  }
222
226
  return allowed
@@ -244,7 +248,7 @@ def verify_updated_command(target_version, runner=None, env=None):
244
248
  env=_version_check_env(env),
245
249
  check=False,
246
250
  capture_output=True,
247
- text=True,
251
+ text=True, encoding="utf-8", errors="replace",
248
252
  )
249
253
  except OSError as error:
250
254
  return {