cdx-manager 0.2.1 → 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.2.1-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
 
@@ -69,6 +69,38 @@ One command to launch any session. Zero auth juggling.
69
69
 
70
70
  ### Install
71
71
 
72
+ From npm:
73
+
74
+ ```bash
75
+ npm install -g cdx-manager
76
+ ```
77
+
78
+ With pipx:
79
+
80
+ ```bash
81
+ pipx install cdx-manager
82
+ ```
83
+
84
+ With uv:
85
+
86
+ ```bash
87
+ uv tool install cdx-manager
88
+ ```
89
+
90
+ With the standalone GitHub installer:
91
+
92
+ ```bash
93
+ curl -fsSL https://raw.githubusercontent.com/AlexAgo83/cdx-manager/main/install.sh | sh
94
+ ```
95
+
96
+ For a specific version:
97
+
98
+ ```bash
99
+ curl -fsSL https://raw.githubusercontent.com/AlexAgo83/cdx-manager/main/install.sh | CDX_VERSION=v0.3.3 sh
100
+ ```
101
+
102
+ From source:
103
+
72
104
  ```bash
73
105
  git clone <repo>
74
106
  cd cdx-manager
@@ -83,18 +115,12 @@ To uninstall:
83
115
  make uninstall
84
116
  ```
85
117
 
86
- Alternatively, for a non-symlinked global install:
118
+ Alternatively, for a non-symlinked global source install:
87
119
 
88
120
  ```bash
89
121
  npm install -g .
90
122
  ```
91
123
 
92
- Once published to npm:
93
-
94
- ```bash
95
- npm install -g cdx-manager
96
- ```
97
-
98
124
  ### Environment
99
125
 
100
126
  By default, `cdx` stores all data under `~/.cdx/`. Override with:
@@ -0,0 +1,32 @@
1
+ # CHANGELOGS_0_3_0
2
+
3
+ Release date: 2026-04-16
4
+
5
+ ## CDX Manager 0.3.0
6
+
7
+ CDX Manager 0.3.0 adds Python-native and standalone installation paths in addition to npm.
8
+
9
+ ### Packaging
10
+
11
+ - Added `pyproject.toml` so the CLI can be installed with `pipx`, `pip`, or `uv tool`.
12
+ - Added the Python console entrypoint `cdx = "src.cli:cli_entry"`.
13
+ - Added `install.sh` for GitHub Release based installs into `~/.local/share/cdx-manager` with a symlink in `~/.local/bin`.
14
+ - Included `install.sh` and `pyproject.toml` in the npm package file list.
15
+ - Documented npm, pipx, uv, curl installer, and source installation paths.
16
+ - Added GitHub Actions automation for PyPI publication when a GitHub Release is published.
17
+
18
+ ### Validation
19
+
20
+ ```bash
21
+ npm run lint
22
+ npm test
23
+ python3 -m venv /tmp/cdx-pyinstall
24
+ /tmp/cdx-pyinstall/bin/pip install --no-build-isolation .
25
+ /tmp/cdx-pyinstall/bin/cdx --version
26
+ npm --cache /tmp/cdx-npm-cache publish --dry-run
27
+ ```
28
+
29
+ ### Notes
30
+
31
+ - PyPI publication is now technically possible, but still requires PyPI credentials and an explicit publish step.
32
+ - The standalone installer defaults to the latest GitHub Release and supports `CDX_VERSION=vX.Y.Z` for pinned installs.
@@ -0,0 +1,27 @@
1
+ # CHANGELOGS_0_3_1
2
+
3
+ Release date: 2026-04-16
4
+
5
+ ## CDX Manager 0.3.1
6
+
7
+ CDX Manager 0.3.1 is a release-channel synchronization update.
8
+
9
+ ### Packaging
10
+
11
+ - Uses npm Trusted Publishing through GitHub Actions OIDC instead of long-lived npm tokens.
12
+ - Keeps npm, PyPI, GitHub Releases, pipx, uv, and the standalone installer aligned on the same release version.
13
+ - Retains the Python-native packaging and standalone install support introduced in 0.3.0.
14
+
15
+ ### Validation
16
+
17
+ ```bash
18
+ npm run lint
19
+ npm test
20
+ npm --cache /tmp/cdx-npm-cache publish --dry-run
21
+ python -m build
22
+ python -m twine check dist/*
23
+ ```
24
+
25
+ ### Notes
26
+
27
+ - This release exists because the npm Trusted Publishing workflow was added after the `v0.3.0` tag. A fresh release is required for GitHub Actions to run the updated workflow definition for npm publishing.
@@ -0,0 +1,21 @@
1
+ # CHANGELOGS_0_3_2
2
+
3
+ Release date: 2026-04-16
4
+
5
+ ## CDX Manager 0.3.2
6
+
7
+ CDX Manager 0.3.2 updates the npm release workflow to use the npm CLI version required for Trusted Publishing.
8
+
9
+ ### Packaging
10
+
11
+ - Runs the npm publish workflow on Node 24.
12
+ - Installs npm 11 before publishing so GitHub Actions OIDC trusted publishing is supported.
13
+ - Keeps npm, PyPI, GitHub Releases, pipx, uv, and the standalone installer aligned on the same release version.
14
+
15
+ ### Validation
16
+
17
+ ```bash
18
+ npm run lint
19
+ npm test
20
+ npm --cache /tmp/cdx-npm-cache publish --dry-run
21
+ ```
@@ -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/install.sh ADDED
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env sh
2
+ set -eu
3
+
4
+ REPO="AlexAgo83/cdx-manager"
5
+ VERSION="${CDX_VERSION:-}"
6
+ PREFIX="${PREFIX:-$HOME/.local}"
7
+ BIN_DIR="${BIN_DIR:-$PREFIX/bin}"
8
+ INSTALL_ROOT="${CDX_INSTALL_ROOT:-$PREFIX/share/cdx-manager}"
9
+
10
+ need() {
11
+ if ! command -v "$1" >/dev/null 2>&1; then
12
+ echo "cdx install: missing required command: $1" >&2
13
+ exit 1
14
+ fi
15
+ }
16
+
17
+ need curl
18
+ need tar
19
+ need python3
20
+
21
+ if [ -z "$VERSION" ]; then
22
+ VERSION="$(
23
+ curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" |
24
+ python3 -c 'import json, sys; print(json.load(sys.stdin)["tag_name"])'
25
+ )"
26
+ fi
27
+
28
+ case "$VERSION" in
29
+ v*) TAG="$VERSION" ;;
30
+ *) TAG="v$VERSION" ;;
31
+ esac
32
+
33
+ TMP_DIR="$(mktemp -d)"
34
+ cleanup() {
35
+ rm -rf "$TMP_DIR"
36
+ }
37
+ trap cleanup EXIT INT TERM
38
+
39
+ ARCHIVE_URL="https://github.com/$REPO/archive/refs/tags/$TAG.tar.gz"
40
+ curl -fsSL "$ARCHIVE_URL" -o "$TMP_DIR/cdx-manager.tar.gz"
41
+ tar -xzf "$TMP_DIR/cdx-manager.tar.gz" -C "$TMP_DIR"
42
+
43
+ SRC_DIR="$(find "$TMP_DIR" -mindepth 1 -maxdepth 1 -type d | head -n 1)"
44
+ TARGET_DIR="$INSTALL_ROOT/${TAG#v}"
45
+
46
+ mkdir -p "$INSTALL_ROOT" "$BIN_DIR"
47
+ rm -rf "$TARGET_DIR"
48
+ mkdir -p "$TARGET_DIR"
49
+
50
+ cp -R "$SRC_DIR"/. "$TARGET_DIR"/
51
+ chmod +x "$TARGET_DIR/bin/cdx"
52
+ ln -sfn "$TARGET_DIR/bin/cdx" "$BIN_DIR/cdx"
53
+
54
+ echo "Installed cdx $TAG to $TARGET_DIR"
55
+ echo "Linked $BIN_DIR/cdx"
56
+ case ":$PATH:" in
57
+ *":$BIN_DIR:"*) ;;
58
+ *)
59
+ echo "Add $BIN_DIR to PATH to run cdx from anywhere." >&2
60
+ ;;
61
+ esac
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.2.1",
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",
@@ -27,6 +27,8 @@
27
27
  "bin",
28
28
  "changelogs",
29
29
  "src",
30
+ "install.sh",
31
+ "pyproject.toml",
30
32
  "README.md",
31
33
  "LICENSE"
32
34
  ],
package/pyproject.toml ADDED
@@ -0,0 +1,44 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "cdx-manager"
7
+ version = "0.3.3"
8
+ description = "Terminal session manager for Codex and Claude accounts."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ authors = [
13
+ { name = "Alexandre Agostini" }
14
+ ]
15
+ keywords = ["codex", "claude", "cli", "terminal", "session-manager", "ai-tools"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Environment :: Console",
19
+ "Intended Audience :: Developers",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.9",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Topic :: Software Development",
27
+ "Topic :: Terminals",
28
+ ]
29
+ dependencies = []
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/AlexAgo83/cdx-manager"
33
+ Repository = "https://github.com/AlexAgo83/cdx-manager.git"
34
+ Issues = "https://github.com/AlexAgo83/cdx-manager/issues"
35
+ Changelog = "https://github.com/AlexAgo83/cdx-manager/tree/main/changelogs"
36
+
37
+ [project.scripts]
38
+ cdx = "cdx_manager.cli:cli_entry"
39
+
40
+ [tool.setuptools]
41
+ packages = ["cdx_manager"]
42
+
43
+ [tool.setuptools.package-dir]
44
+ cdx_manager = "src"
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.2.1"
42
+ VERSION = "0.3.3"
43
43
 
44
44
 
45
45
  # ---------------------------------------------------------------------------
@@ -180,9 +180,41 @@ def main(argv, options=None):
180
180
  raise CdxError(f"Unknown command: {command}. Use cdx --help.")
181
181
 
182
182
 
183
- if __name__ == "__main__":
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
+
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:
187
215
  sys.stderr.write(f"{format_error(error)}\n")
188
216
  raise SystemExit(error.exit_code)
217
+
218
+
219
+ if __name__ == "__main__":
220
+ cli_entry()
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):