cdx-manager 0.3.2 → 0.3.3

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.
package/README.md CHANGED
@@ -6,7 +6,7 @@ If you use AI coding tools at scale ; multiple accounts, multiple providers : yo
6
6
 
7
7
  One command to launch any session. Zero auth juggling.
8
8
 
9
- [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.3.2-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
9
+ [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.3.3-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
10
10
 
11
11
  ---
12
12
 
@@ -96,7 +96,7 @@ curl -fsSL https://raw.githubusercontent.com/AlexAgo83/cdx-manager/main/install.
96
96
  For a specific version:
97
97
 
98
98
  ```bash
99
- curl -fsSL https://raw.githubusercontent.com/AlexAgo83/cdx-manager/main/install.sh | CDX_VERSION=v0.3.2 sh
99
+ curl -fsSL https://raw.githubusercontent.com/AlexAgo83/cdx-manager/main/install.sh | CDX_VERSION=v0.3.3 sh
100
100
  ```
101
101
 
102
102
  From source:
@@ -0,0 +1,27 @@
1
+ # CHANGELOGS_0_3_3
2
+
3
+ Release date: 2026-04-16
4
+
5
+ ## CDX Manager 0.3.3
6
+
7
+ CDX Manager 0.3.3 adds Windows compatibility across the full codebase.
8
+
9
+ ### Windows support
10
+
11
+ - **Session store locking**: replaced `fcntl.flock` (Unix-only) with `msvcrt.locking` on Windows, with `seek(0)` to ensure consistent byte-range locking.
12
+ - **Signal handling**: guarded `signal.SIGHUP` references behind `hasattr` checks — `SIGHUP` does not exist on Windows.
13
+ - **Profile isolation**: added `_home_env_overrides()` helper that sets `USERPROFILE`, `HOMEDRIVE`, and `HOMEPATH` in addition to `HOME` when launching the `claude` CLI on Windows, so Node.js `os.homedir()` resolves to the correct session profile.
14
+ - **Desktop notifications**: `cdx notify` now sends a notification via PowerShell `System.Windows.Forms.MessageBox` on Windows (falls back silently if PowerShell is unavailable).
15
+ - **ANSI colors**: `cli_entry` enables VT processing via `ctypes.windll.kernel32.SetConsoleMode` on Windows so color output works in terminals that support it.
16
+ - **Console encoding**: `cli_entry` reconfigures `stdout`/`stderr` to UTF-8 on Windows to prevent `UnicodeEncodeError` on non-ASCII session names.
17
+
18
+ ### Maintenance
19
+
20
+ - Expanded `.gitignore` with standard Python build artifacts (`__pycache__/`, `*.egg-info/`, `dist/`, `build/`), virtual environments, coverage output, and OS-specific files (`.DS_Store`, `Thumbs.db`, `desktop.ini`).
21
+
22
+ ### Validation
23
+
24
+ ```bash
25
+ npm run lint
26
+ npm test
27
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "Terminal session manager for Codex and Claude accounts.",
5
5
  "license": "MIT",
6
6
  "author": "Alexandre Agostini",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cdx-manager"
7
- version = "0.3.2"
7
+ version = "0.3.3"
8
8
  description = "Terminal session manager for Codex and Claude accounts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
package/src/cli.py CHANGED
@@ -39,7 +39,7 @@ from .status_view import (
39
39
  _format_status_rows,
40
40
  )
41
41
 
42
- VERSION = "0.3.2"
42
+ VERSION = "0.3.3"
43
43
 
44
44
 
45
45
  # ---------------------------------------------------------------------------
@@ -180,7 +180,35 @@ def main(argv, options=None):
180
180
  raise CdxError(f"Unknown command: {command}. Use cdx --help.")
181
181
 
182
182
 
183
+ def _enable_windows_ansi():
184
+ if sys.platform != "win32":
185
+ return
186
+ try:
187
+ import ctypes
188
+ kernel32 = ctypes.windll.kernel32
189
+ for handle_id in (-10, -11, -12): # stdin, stdout, stderr
190
+ handle = kernel32.GetStdHandle(handle_id)
191
+ mode = ctypes.c_ulong()
192
+ if kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
193
+ kernel32.SetConsoleMode(handle, mode.value | 0x0004)
194
+ except Exception:
195
+ pass
196
+
197
+
198
+ def _configure_windows_encoding():
199
+ if sys.platform != "win32":
200
+ return
201
+ for stream in (sys.stdout, sys.stderr):
202
+ try:
203
+ if hasattr(stream, "reconfigure"):
204
+ stream.reconfigure(encoding="utf-8", errors="replace")
205
+ except Exception:
206
+ pass
207
+
208
+
183
209
  def cli_entry():
210
+ _enable_windows_ansi()
211
+ _configure_windows_encoding()
184
212
  try:
185
213
  raise SystemExit(main(sys.argv[1:]))
186
214
  except CdxError as error:
package/src/notify.py CHANGED
@@ -114,13 +114,38 @@ def _event(ready, title, message, session_name, target_timestamp=None):
114
114
 
115
115
 
116
116
  def send_desktop_notification(title, message, spawn_sync=None, env=None):
117
+ import sys
117
118
  spawn_sync = spawn_sync or subprocess.run
118
119
  env = env or os.environ
119
- if shutil_which("osascript", env):
120
+ if sys.platform == "win32":
121
+ _send_windows_notification(title, message, spawn_sync, env)
122
+ elif shutil_which("osascript", env):
120
123
  script = f'display notification "{_escape_applescript(message)}" with title "{_escape_applescript(title)}"'
121
124
  spawn_sync(["osascript", "-e", script], env=env, capture_output=True, text=True)
122
125
 
123
126
 
127
+ def _send_windows_notification(title, message, spawn_sync, env):
128
+ title_escaped = _escape_powershell(title)
129
+ message_escaped = _escape_powershell(message)
130
+ script = (
131
+ "Add-Type -AssemblyName System.Windows.Forms; "
132
+ f"[System.Windows.Forms.MessageBox]::Show('{message_escaped}', '{title_escaped}')"
133
+ )
134
+ try:
135
+ spawn_sync(
136
+ ["powershell", "-NoProfile", "-NonInteractive", "-Command", script],
137
+ env=env,
138
+ capture_output=True,
139
+ text=True,
140
+ )
141
+ except (FileNotFoundError, OSError):
142
+ pass
143
+
144
+
145
+ def _escape_powershell(value):
146
+ return str(value).replace("'", "''")
147
+
148
+
124
149
  def shutil_which(command, env):
125
150
  import shutil
126
151
  return shutil.which(command, path=env.get("PATH"))
@@ -3,6 +3,7 @@ import os
3
3
  import signal
4
4
  import shlex
5
5
  import subprocess
6
+ import sys
6
7
  from datetime import datetime, timezone
7
8
 
8
9
  from .errors import CdxError
@@ -11,6 +12,21 @@ from .errors import CdxError
11
12
  LOG_ROTATE_BYTES = 10 * 1024 * 1024 # 10 MB
12
13
 
13
14
 
15
+ def _home_env_overrides(auth_home):
16
+ """Return env vars that point the claude CLI to the given home directory.
17
+
18
+ On Unix, only HOME is needed. On Windows, Node.js resolves the home
19
+ directory via USERPROFILE (and falls back to HOMEDRIVE+HOMEPATH), so we
20
+ set all three to ensure profile isolation works regardless of the platform.
21
+ """
22
+ overrides = {"HOME": auth_home}
23
+ if sys.platform == "win32":
24
+ overrides["USERPROFILE"] = auth_home
25
+ overrides["HOMEDRIVE"] = os.path.splitdrive(auth_home)[0] or "C:"
26
+ overrides["HOMEPATH"] = os.path.splitdrive(auth_home)[1] or auth_home
27
+ return overrides
28
+
29
+
14
30
  def _get_auth_home(session):
15
31
  return session.get("authHome") or session.get("sessionRoot") or session.get("codexHome", "")
16
32
 
@@ -91,7 +107,7 @@ def _build_launch_spec(session, cwd=None, env_override=None):
91
107
  "args": ["--name", session["name"]],
92
108
  "options": {
93
109
  "cwd": cwd,
94
- "env": {**env, "HOME": _get_auth_home(session)},
110
+ "env": {**env, **_home_env_overrides(_get_auth_home(session))},
95
111
  },
96
112
  "label": "claude",
97
113
  }
@@ -108,7 +124,7 @@ def _build_launch_spec(session, cwd=None, env_override=None):
108
124
  def _build_login_status_spec(session, env_override=None):
109
125
  env = {**os.environ, **(env_override or {})}
110
126
  if session["provider"] == "claude":
111
- env["HOME"] = _get_auth_home(session)
127
+ env.update(_home_env_overrides(_get_auth_home(session)))
112
128
 
113
129
  def parser(output):
114
130
  try:
@@ -133,7 +149,7 @@ def _build_auth_action_spec(session, action, cwd=None, env_override=None):
133
149
  cwd = cwd or os.getcwd()
134
150
  env = {**os.environ, **(env_override or {})}
135
151
  if session["provider"] == "claude":
136
- env["HOME"] = _get_auth_home(session)
152
+ env.update(_home_env_overrides(_get_auth_home(session)))
137
153
  return {"command": "claude", "args": ["auth", action],
138
154
  "options": {"cwd": cwd, "env": env}, "label": f"claude auth {action}"}
139
155
  env["CODEX_HOME"] = _get_auth_home(session)
@@ -165,7 +181,10 @@ def _probe_provider_auth(session, spawn_sync=None, env_override=None):
165
181
 
166
182
 
167
183
  def _signal_exit_code(sig):
168
- return {signal.SIGHUP: 129, signal.SIGINT: 130, signal.SIGTERM: 143}.get(sig, 1)
184
+ mapping = {signal.SIGINT: 130, signal.SIGTERM: 143}
185
+ if hasattr(signal, "SIGHUP"):
186
+ mapping[signal.SIGHUP] = 129
187
+ return mapping.get(sig, 1)
169
188
 
170
189
 
171
190
  def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
@@ -210,7 +229,10 @@ def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
210
229
  handlers.append((sig, handler))
211
230
  signal_emitter.on(sig, handler)
212
231
  else:
213
- for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
232
+ _forward_sigs = [signal.SIGINT, signal.SIGTERM]
233
+ if hasattr(signal, "SIGHUP"):
234
+ _forward_sigs.append(signal.SIGHUP)
235
+ for sig in _forward_sigs:
214
236
  try:
215
237
  original_handlers[sig] = signal.signal(sig, forward)
216
238
  except (OSError, ValueError):
@@ -54,23 +54,40 @@ def _fsync_directory(directory):
54
54
 
55
55
  @contextmanager
56
56
  def _file_lock(lock_path):
57
+ import sys
57
58
  _ensure_dir(os.path.dirname(lock_path))
58
59
  with open(lock_path, "a", encoding="utf-8") as lock:
59
- try:
60
- import fcntl
61
- except ImportError as error:
62
- raise CdxError("Session store locking requires fcntl on this platform") from error
63
- try:
64
- fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
65
- except OSError as error:
66
- raise CdxError(f"Failed to lock session store: {error}") from error
67
- try:
68
- yield
69
- finally:
60
+ if sys.platform == "win32":
61
+ import msvcrt
62
+ try:
63
+ lock.seek(0)
64
+ msvcrt.locking(lock.fileno(), msvcrt.LK_LOCK, 1)
65
+ except OSError as error:
66
+ raise CdxError(f"Failed to lock session store: {error}") from error
67
+ try:
68
+ yield
69
+ finally:
70
+ try:
71
+ lock.seek(0)
72
+ msvcrt.locking(lock.fileno(), msvcrt.LK_UNLCK, 1)
73
+ except OSError as error:
74
+ raise CdxError(f"Failed to unlock session store: {error}") from error
75
+ else:
70
76
  try:
71
- fcntl.flock(lock.fileno(), fcntl.LOCK_UN)
77
+ import fcntl
78
+ except ImportError as error:
79
+ raise CdxError("Session store locking requires fcntl on this platform") from error
80
+ try:
81
+ fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
72
82
  except OSError as error:
73
- raise CdxError(f"Failed to unlock session store: {error}") from error
83
+ raise CdxError(f"Failed to lock session store: {error}") from error
84
+ try:
85
+ yield
86
+ finally:
87
+ try:
88
+ fcntl.flock(lock.fileno(), fcntl.LOCK_UN)
89
+ except OSError as error:
90
+ raise CdxError(f"Failed to unlock session store: {error}") from error
74
91
 
75
92
 
76
93
  def create_session_store(base_dir):