cdx-manager 0.4.0 → 0.4.2

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
@@ -1,6 +1,6 @@
1
1
  # CDX Manager
2
2
 
3
- [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.4.0-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
3
+ [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.4.2-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
4
4
 
5
5
  **Run multiple Codex and Claude sessions from one terminal. Switch between accounts instantly.**
6
6
 
@@ -48,6 +48,7 @@ One command to launch any session. Zero auth juggling.
48
48
  - Environment isolation per session:
49
49
  - Codex sessions override `CODEX_HOME` to a dedicated profile directory.
50
50
  - Claude sessions override `HOME` to a dedicated profile directory.
51
+ - New Codex sessions seed their auth home from your existing global `~/.codex/auth.json` when available, so an already logged-in Codex CLI can be reused without giving up per-session isolation afterward.
51
52
  - Persistence:
52
53
  - Session registry at `~/.cdx/sessions.json` (versioned JSON store).
53
54
  - Per-session state at `~/.cdx/state/<name>.json`.
@@ -69,10 +70,12 @@ One command to launch any session. Zero auth juggling.
69
70
 
70
71
  ### Prerequisites
71
72
 
73
+ - Node.js 18+ with npm
72
74
  - Python 3.9+
73
- - npm
74
75
  - `codex` and/or `claude` CLI installed and available in your PATH
75
76
 
77
+ On Windows, the npm launcher looks for Python in this order: `py -3`, `python`, then `python3`. Make sure at least one of those commands resolves to Python 3.
78
+
76
79
  ### Install
77
80
 
78
81
  From npm:
@@ -119,7 +122,7 @@ For a specific version:
119
122
 
120
123
  ```bash
121
124
  curl -fsSL https://raw.githubusercontent.com/AlexAgo83/cdx-manager/main/install.sh -o install.sh
122
- CDX_VERSION=v0.4.0 sh install.sh
125
+ CDX_VERSION=v0.4.2 sh install.sh
123
126
  ```
124
127
 
125
128
  From source:
@@ -336,8 +339,8 @@ Notes:
336
339
  ## Available Scripts
337
340
 
338
341
  - `npm test`: run the Python test suite
339
- - `npm run test:py`: run the Python unit tests directly
340
- - `npm run lint`: byte-compile the Python sources and tests
342
+ - `npm run test:py`: run the Python unit tests through the portable launcher
343
+ - `npm run lint`: check the Node launcher and byte-compile the Python sources and tests
341
344
  - `npm run link`: link `cdx` globally for local development (`npm link`)
342
345
  - `npm run unlink`: remove the global link
343
346
 
@@ -350,6 +353,7 @@ Notes:
350
353
  - `pipx install cdx-manager`
351
354
  - `uv tool install cdx-manager`
352
355
  - `install.ps1`
356
+ - The npm launcher resolves Python via `py -3`, `python`, then `python3`, so a global npm install works even when `python3.exe` is missing.
353
357
  - `install.sh` is Unix-only.
354
358
  - `make install` and `make uninstall` are Unix-oriented convenience commands, not the default Windows path.
355
359
  - `cdx` isolates Claude sessions on Windows by setting `HOME`, `USERPROFILE`, `HOMEDRIVE`, and `HOMEPATH`.
@@ -365,7 +369,9 @@ Notes:
365
369
 
366
370
  ```text
367
371
  bin/
368
- cdx # Entry point shebang + main() call
372
+ cdx.js # Node launcher used by npm
373
+ python-runner.js # Shared Python resolver and process wrapper
374
+ cdx # Python entrypoint invoked by the launcher
369
375
 
370
376
  src/
371
377
  cli.py # Top-level command router
@@ -419,6 +425,7 @@ Session names are URL-encoded when used as directory or file names. CLI command
419
425
  ## Troubleshooting
420
426
 
421
427
  - **`cdx <name>` fails with "not authenticated"** — run `cdx login <name>` first.
428
+ - **`cdx` says no compatible Python 3 interpreter was found** — install Python 3 and make `py -3`, `python`, or `python3` available on PATH.
422
429
  - **`cdx add` succeeds but the session does not appear** — check that `CDX_HOME` is consistent between calls; a mismatch creates two separate registries.
423
430
  - **Status shows `n/a` for all fields** — the session has not been launched yet, or the provider has not written any status output to its history files. Launch the session and run `/status` inside it at least once.
424
431
  - **`cdx rmv` says "Removal requires confirmation in an interactive terminal"** — pass `--force` to bypass the prompt in non-interactive environments (scripts, CI).
package/bin/cdx.js ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+
3
+ const path = require("node:path");
4
+ const { runPython } = require("./python-runner");
5
+
6
+ const scriptPath = path.join(__dirname, "cdx");
7
+ const exitCode = runPython([scriptPath, ...process.argv.slice(2)], { expandGlobs: false });
8
+
9
+ process.exit(exitCode);
@@ -0,0 +1,165 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require("node:fs");
4
+ const os = require("node:os");
5
+ const path = require("node:path");
6
+ const { spawnSync } = require("node:child_process");
7
+
8
+ const PYTHON_VERSION_CHECK = "import sys; sys.exit(0 if sys.version_info[0] == 3 else 1)";
9
+ const PYTHON_CACHE_PREFIX = path.join(os.tmpdir(), "cdx-manager-pycache");
10
+ const SIGNAL_EXIT_CODES = {
11
+ SIGHUP: 129,
12
+ SIGINT: 130,
13
+ SIGTERM: 143,
14
+ };
15
+
16
+ const WINDOWS_CANDIDATES = [
17
+ { command: "py", args: ["-3"], label: "py -3" },
18
+ { command: "python", args: [], label: "python" },
19
+ { command: "python3", args: [], label: "python3" },
20
+ ];
21
+
22
+ const UNIX_CANDIDATES = [
23
+ { command: "python3", args: [], label: "python3" },
24
+ { command: "python", args: [], label: "python" },
25
+ ];
26
+
27
+ function getCandidates(platform = process.platform) {
28
+ return platform === "win32" ? WINDOWS_CANDIDATES : UNIX_CANDIDATES;
29
+ }
30
+
31
+ function probeCandidate(candidate) {
32
+ const result = spawnSync(
33
+ candidate.command,
34
+ [...candidate.args, "-c", PYTHON_VERSION_CHECK],
35
+ { stdio: "ignore", windowsHide: true }
36
+ );
37
+ return result.status === 0;
38
+ }
39
+
40
+ function findPython(platform = process.platform) {
41
+ for (const candidate of getCandidates(platform)) {
42
+ if (probeCandidate(candidate)) {
43
+ return candidate;
44
+ }
45
+ }
46
+ return null;
47
+ }
48
+
49
+ function hasGlobCharacters(value) {
50
+ return value.includes("*") || value.includes("?") || value.includes("[");
51
+ }
52
+
53
+ function escapeRegex(value) {
54
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
55
+ }
56
+
57
+ function segmentToRegExp(segment) {
58
+ const pattern = escapeRegex(segment).replace(/\\\*/g, ".*").replace(/\\\?/g, ".");
59
+ return new RegExp(`^${pattern}$`);
60
+ }
61
+
62
+ function expandGlob(pattern) {
63
+ const pathLike = pattern.includes("/") || pattern.includes("\\") || path.isAbsolute(pattern);
64
+ if (!hasGlobCharacters(pattern) || !pathLike) {
65
+ return [pattern];
66
+ }
67
+
68
+ const absolute = path.isAbsolute(pattern);
69
+ const root = absolute ? path.parse(pattern).root : process.cwd();
70
+ const relativePattern = absolute ? path.relative(root, pattern) : pattern;
71
+ const segments = relativePattern.split(/[\\/]+/).filter(Boolean);
72
+ const matches = [];
73
+
74
+ const walk = (currentPath, index) => {
75
+ if (index >= segments.length) {
76
+ if (fs.existsSync(currentPath)) {
77
+ matches.push(currentPath);
78
+ }
79
+ return;
80
+ }
81
+
82
+ const segment = segments[index];
83
+ const nextIndex = index + 1;
84
+
85
+ if (!hasGlobCharacters(segment)) {
86
+ walk(path.join(currentPath, segment), nextIndex);
87
+ return;
88
+ }
89
+
90
+ if (!fs.existsSync(currentPath) || !fs.statSync(currentPath).isDirectory()) {
91
+ return;
92
+ }
93
+
94
+ const matcher = segmentToRegExp(segment);
95
+ for (const entry of fs.readdirSync(currentPath, { withFileTypes: true })) {
96
+ if (matcher.test(entry.name)) {
97
+ walk(path.join(currentPath, entry.name), nextIndex);
98
+ }
99
+ }
100
+ };
101
+
102
+ walk(root, 0);
103
+ return matches.length > 0 ? matches : [pattern];
104
+ }
105
+
106
+ function expandArgs(args) {
107
+ const expanded = [];
108
+ for (const arg of args) {
109
+ expanded.push(...expandGlob(arg));
110
+ }
111
+ return expanded;
112
+ }
113
+
114
+ function prepareEnv(env = process.env) {
115
+ const nextEnv = { ...env };
116
+ if (!nextEnv.PYTHONPYCACHEPREFIX) {
117
+ fs.mkdirSync(PYTHON_CACHE_PREFIX, { recursive: true });
118
+ nextEnv.PYTHONPYCACHEPREFIX = PYTHON_CACHE_PREFIX;
119
+ }
120
+ return nextEnv;
121
+ }
122
+
123
+ function runPython(args, options = {}) {
124
+ const platform = options.platform || process.platform;
125
+ const candidate = findPython(platform);
126
+
127
+ if (!candidate) {
128
+ const tried = getCandidates(platform).map((item) => item.label).join(", ");
129
+ console.error(`cdx: no compatible Python 3 interpreter found. Tried: ${tried}.`);
130
+ console.error("Install Python 3 and make one of those commands available on PATH.");
131
+ return 127;
132
+ }
133
+
134
+ const finalArgs = options.expandGlobs === false ? args.slice() : expandArgs(args);
135
+ const result = spawnSync(
136
+ candidate.command,
137
+ [...candidate.args, ...finalArgs],
138
+ {
139
+ env: prepareEnv(options.env),
140
+ stdio: "inherit",
141
+ windowsHide: true,
142
+ }
143
+ );
144
+
145
+ if (result.error) {
146
+ console.error(`cdx: failed to launch ${candidate.label}: ${result.error.message}`);
147
+ return 127;
148
+ }
149
+
150
+ if (result.signal) {
151
+ return SIGNAL_EXIT_CODES[result.signal] || 128;
152
+ }
153
+
154
+ return typeof result.status === "number" ? result.status : 1;
155
+ }
156
+
157
+ if (require.main === module) {
158
+ process.exit(runPython(process.argv.slice(2)));
159
+ }
160
+
161
+ module.exports = {
162
+ expandArgs,
163
+ findPython,
164
+ runPython,
165
+ };
@@ -0,0 +1,28 @@
1
+ # CHANGELOGS_0_4_1
2
+
3
+ Release date: 2026-04-19
4
+
5
+ ## CDX Manager 0.4.1
6
+
7
+ CDX Manager 0.4.1 fixes the Windows npm entry point so `cdx` no longer depends on `python3.exe` being present on PATH. It now ships a Node launcher that resolves a usable Python 3 interpreter cross-platform before invoking the existing Python CLI entry point.
8
+
9
+ ### Windows npm launcher
10
+
11
+ - Replaced the npm-facing `bin.cdx` target with a Node launcher at `bin/cdx.js`.
12
+ - Added Python discovery that tries `py -3`, then `python`, then `python3` on Windows.
13
+ - Kept the existing Python script under `bin/cdx` as the primary CLI entry point.
14
+ - Added a clear error message when no compatible Python 3 interpreter is available.
15
+
16
+ ### Documentation and packaging
17
+
18
+ - Updated the README with Windows Python prerequisites and the new launcher behavior.
19
+ - Added a shared portable Node wrapper for the npm test and lint scripts.
20
+ - Bumped the package versions for the npm and PyPI release workflows.
21
+
22
+ ### Validation
23
+
24
+ ```bash
25
+ npm run lint
26
+ npm test
27
+ node bin/cdx.js --version
28
+ ```
@@ -0,0 +1,32 @@
1
+ # CHANGELOGS_0_4_2
2
+
3
+ Release date: 2026-04-19
4
+
5
+ ## CDX Manager 0.4.2
6
+
7
+ CDX Manager 0.4.2 fixes Codex session bootstrap on Windows so `cdx add` can reuse an existing logged-in Codex CLI without requiring a second manual login flow. It also hardens executable resolution for the Codex probe so Windows shell shims and direct process spawning behave consistently.
8
+
9
+ ### Codex auth bootstrap
10
+
11
+ - Seeded new Codex session auth homes from the global `~/.codex/auth.json` when available.
12
+ - Short-circuited the Codex auth probe when a session already has an auth file in its isolated home.
13
+ - Kept the per-session auth directory model intact after bootstrap so session isolation still applies.
14
+
15
+ ### Windows command resolution
16
+
17
+ - Resolved `codex` through the active `PATH` before invoking the login-status probe.
18
+ - Applied the same command-resolution path to interactive provider launches when the default process spawner is used.
19
+ - Added regression coverage for Codex auth bootstrap and resolved-command spawning on Windows.
20
+
21
+ ### Documentation
22
+
23
+ - Documented the Codex auth bootstrap behavior in the README.
24
+
25
+ ### Validation
26
+
27
+ ```bash
28
+ npm run lint
29
+ npm test
30
+ node bin/cdx.js rmv main --force
31
+ node bin/cdx.js add main
32
+ ```
@@ -4,6 +4,10 @@
4
4
  "v0.3.4": {
5
5
  "github_tarball_sha256": "8e8111d6ec41b819fc0249800f175b5741cd11b1439c7e88a3feec770774b12d",
6
6
  "github_zip_sha256": "e0bd79f731d86b83787e99b8f2220c8c5fbaa3d7507ebc52823aa5b8d11f0666"
7
+ },
8
+ "v0.4.0": {
9
+ "github_tarball_sha256": "47a99ff2663f4fee33339098d2cfe7c27fbd0f74a16a1f6711f3003287409ae8",
10
+ "github_zip_sha256": "ae7eff748e569c621ef203abb6b395fbf893370471e962252e8c781e7751e5c8"
7
11
  }
8
12
  }
9
13
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Terminal session manager for Codex and Claude accounts.",
5
5
  "license": "MIT",
6
6
  "author": "Alexandre Agostini",
@@ -35,12 +35,12 @@
35
35
  "LICENSE"
36
36
  ],
37
37
  "bin": {
38
- "cdx": "bin/cdx"
38
+ "cdx": "bin/cdx.js"
39
39
  },
40
40
  "scripts": {
41
41
  "test": "npm run test:py",
42
- "test:py": "PYTHONPYCACHEPREFIX=/tmp/pycache python3 -m unittest discover -s test -p 'test_*_py.py'",
43
- "lint": "PYTHONPYCACHEPREFIX=/tmp/pycache python3 -m py_compile bin/cdx src/*.py test/test_*_py.py",
42
+ "test:py": "node bin/python-runner.js -m unittest discover -s test -p test_*_py.py",
43
+ "lint": "node --check bin/cdx.js && node --check bin/python-runner.js && node bin/python-runner.js -m py_compile bin/cdx src/*.py test/test_*_py.py",
44
44
  "prepublishOnly": "npm run lint && npm test",
45
45
  "link": "npm link",
46
46
  "unlink": "npm unlink -g cdx-manager"
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.4.0"
7
+ version = "0.4.2"
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
@@ -44,7 +44,7 @@ from .status_view import (
44
44
  )
45
45
  from .update_check import check_for_update
46
46
 
47
- VERSION = "0.4.0"
47
+ VERSION = "0.4.2"
48
48
 
49
49
 
50
50
  # ---------------------------------------------------------------------------
@@ -2,6 +2,7 @@ import json
2
2
  import os
3
3
  import signal
4
4
  import shlex
5
+ import shutil
5
6
  import subprocess
6
7
  import sys
7
8
  from datetime import datetime, timezone
@@ -157,26 +158,49 @@ def _build_auth_action_spec(session, action, cwd=None, env_override=None):
157
158
  "options": {"cwd": cwd, "env": env}, "label": f"codex {action}"}
158
159
 
159
160
 
161
+ def _format_probe_failure(session, spec, error):
162
+ command = spec["command"]
163
+ if isinstance(error, FileNotFoundError):
164
+ return CdxError(
165
+ f"Failed to check login status for {session['name']}: {command} CLI not found on PATH. "
166
+ f"Install {command} and retry cdx add {session['name']}.",
167
+ 127,
168
+ )
169
+ message = getattr(error, "message", None) or str(error)
170
+ return CdxError(f"Failed to check login status for {session['name']}: {message}")
171
+
172
+
173
+ def _resolve_command(command, env=None):
174
+ env = env or os.environ
175
+ return shutil.which(command, path=env.get("PATH")) or command
176
+
177
+
160
178
  def _probe_provider_auth(session, spawn_sync=None, env_override=None):
161
179
  spawn_sync = spawn_sync or subprocess.run
162
180
  spec = _build_login_status_spec(session, env_override)
163
- if spawn_sync is subprocess.run:
164
- result = subprocess.run(
165
- [spec["command"]] + spec["args"],
166
- env=spec["env"],
167
- capture_output=True, text=True,
168
- )
169
- output = (result.stdout or "") + (result.stderr or "")
170
- else:
171
- result = spawn_sync(spec["command"], spec["args"], spec)
172
- error = result.get("error") if isinstance(result, dict) else getattr(result, "error", None)
173
- if error:
174
- raise CdxError(
175
- f"Failed to check login status for {session['name']}: {getattr(error, 'message', str(error))}"
181
+ if session.get("provider") == "codex":
182
+ auth_path = os.path.join(_get_auth_home(session), "auth.json")
183
+ if os.path.isfile(auth_path):
184
+ return True
185
+ try:
186
+ if spawn_sync is subprocess.run:
187
+ command = _resolve_command(spec["command"], spec["env"])
188
+ result = subprocess.run(
189
+ [command] + spec["args"],
190
+ env=spec["env"],
191
+ capture_output=True, text=True,
176
192
  )
177
- stdout = result.get("stdout") if isinstance(result, dict) else getattr(result, "stdout", "")
178
- stderr = result.get("stderr") if isinstance(result, dict) else getattr(result, "stderr", "")
179
- output = (stdout or "") + (stderr or "")
193
+ output = (result.stdout or "") + (result.stderr or "")
194
+ else:
195
+ result = spawn_sync(spec["command"], spec["args"], spec)
196
+ error = result.get("error") if isinstance(result, dict) else getattr(result, "error", None)
197
+ if error:
198
+ raise _format_probe_failure(session, spec, error)
199
+ stdout = result.get("stdout") if isinstance(result, dict) else getattr(result, "stdout", "")
200
+ stderr = result.get("stderr") if isinstance(result, dict) else getattr(result, "stderr", "")
201
+ output = (stdout or "") + (stderr or "")
202
+ except FileNotFoundError as error:
203
+ raise _format_probe_failure(session, spec, error)
180
204
  return spec["parser"](output)
181
205
 
182
206
 
@@ -205,8 +229,11 @@ def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
205
229
  else _build_auth_action_spec(session, action, cwd=cwd, env_override=env_override)
206
230
  )
207
231
  def start_child(current_spec):
232
+ command = current_spec["command"]
233
+ if spawn is subprocess.Popen:
234
+ command = _resolve_command(command, current_spec.get("options", {}).get("env"))
208
235
  return spawn(
209
- [current_spec["command"]] + current_spec["args"],
236
+ [command] + current_spec["args"],
210
237
  **{k: v for k, v in current_spec.get("options", {}).items() if k != "stdio"},
211
238
  )
212
239
 
@@ -55,6 +55,21 @@ def _ensure_private_dir(path):
55
55
  pass
56
56
 
57
57
 
58
+ def _get_global_codex_home(env=None):
59
+ env = env or os.environ
60
+ return env.get("CODEX_HOME") or os.path.join(os.path.expanduser("~"), ".codex")
61
+
62
+
63
+ def _seed_codex_auth_from_global(auth_home, env=None):
64
+ source_home = _get_global_codex_home(env)
65
+ source_auth = os.path.join(source_home, "auth.json")
66
+ dest_auth = os.path.join(auth_home, "auth.json")
67
+ if source_home == auth_home or os.path.exists(dest_auth) or not os.path.isfile(source_auth):
68
+ return False
69
+ shutil.copy2(source_auth, dest_auth)
70
+ return True
71
+
72
+
58
73
  def _local_now_iso():
59
74
  return datetime.now().astimezone().isoformat()
60
75
 
@@ -303,6 +318,8 @@ def create_session_service(options=None):
303
318
  _ensure_private_dir(os.path.join(base_dir, "profiles"))
304
319
  _ensure_private_dir(session_root)
305
320
  _ensure_private_dir(auth_home)
321
+ if normalized_provider == "codex":
322
+ _seed_codex_auth_from_global(auth_home, env=env)
306
323
  now = _local_now_iso()
307
324
  session = {
308
325
  "name": name,