cdx-manager 0.9.6 → 0.9.8

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.9.6-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.9.8-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
4
4
 
5
5
  **Run multiple Codex, Claude, Antigravity, and Ollama sessions from one terminal. Switch between accounts instantly.**
6
6
 
@@ -76,7 +76,8 @@ One command to launch any session. Zero auth juggling.
76
76
  - On Windows, transcript capture is optional. If no compatible `script` wrapper is installed, Codex still launches normally without transcript capture.
77
77
  - Auth probe: synchronous subprocess call to `codex login status` or `claude auth status` before any interactive launch.
78
78
  - Signal forwarding: `SIGINT`, `SIGTERM`, and `SIGHUP` are forwarded to the child process and produce clean exit codes.
79
- - Test stack: Python built-in `unittest` runner with no test framework dependency.
79
+ - Test stack: `pytest`, `pytest-cov`, and `ruff` via the documented `dev` dependency extra.
80
+ - Python floor decision: `cdx-manager` keeps Python 3.9 support for the 0.9.x line to avoid dropping existing users before a planned compatibility break; classifiers remain aligned with `requires-python = ">=3.9"`.
80
81
 
81
82
  ---
82
83
 
@@ -149,6 +150,12 @@ cd cdx-manager
149
150
  make install
150
151
  ```
151
152
 
153
+ For local development, install the Python toolchain as well:
154
+
155
+ ```bash
156
+ python3 -m pip install -e ".[dev]"
157
+ ```
158
+
152
159
  From source on Windows:
153
160
 
154
161
  ```powershell
@@ -512,9 +519,10 @@ Notes:
512
519
 
513
520
  ## Available Scripts
514
521
 
515
- - `npm test`: run the Python test suite
516
- - `npm run test:py`: run the Python unit tests through the portable launcher
517
- - `npm run lint`: check project guidance, the Node launcher, and byte-compile the Python sources, scripts, and tests
522
+ - `npm test`: run the Python test suite with `pytest`
523
+ - `npm run test:py`: run `pytest` through the portable launcher
524
+ - `npm run test:coverage`: run `pytest` with terminal coverage reporting for `src/`
525
+ - `npm run lint`: check project guidance, the Node launcher, `ruff`, and byte-compile the Python sources, scripts, and tests
518
526
  - `npm run release:validate`: verify version alignment and required GitHub release archive checksum metadata before publication
519
527
  - `npm run link`: link `cdx` globally for local development (`npm link`)
520
528
  - `npm run unlink`: remove the global link
@@ -0,0 +1,25 @@
1
+ # CDX Manager 0.9.7
2
+
3
+ ## Highlights
4
+
5
+ - Hardened profile cleanup so read-only provider cache files do not block session removal or repair cleanup.
6
+
7
+ ## Changes
8
+
9
+ ### Read-only profile cleanup
10
+
11
+ `cdx rmv`, session overwrite rollback cleanup, and repair quarantine cleanup now make profile trees user-writable before deleting them.
12
+
13
+ This fixes failures seen when provider-managed caches contain read-only files, including nested Go module cache paths created under session-specific provider homes.
14
+
15
+ ## Validation
16
+
17
+ - `npm run release:validate`
18
+ - `npm run lint`
19
+ - `npm test`
20
+ - `logics-manager lint --require-status`
21
+ - `logics-manager audit`
22
+ - `git diff --check`
23
+ - `npm --cache /private/tmp/cdx-npm-cache pack --dry-run`
24
+ - `python -m build`
25
+ - `python -m twine check dist/*`
@@ -0,0 +1,43 @@
1
+ # CDX Manager 0.9.8
2
+
3
+ ## Highlights
4
+
5
+ - Hardened encrypted auth bundles so session names are no longer exposed in cleartext bundle metadata.
6
+ - Replaced deprecated tree removal handling with Python 3.12+ `onexc` support and a fallback for older supported Python versions.
7
+ - Added `pytest` coverage reporting and a `ruff` lint gate to local and CI validation.
8
+
9
+ ## Changes
10
+
11
+ ### Auth bundle privacy
12
+
13
+ Encrypted bundles now keep session names inside the encrypted payload instead of duplicating them in the cleartext wrapper.
14
+
15
+ Existing encrypted bundles that still contain cleartext `session_names` remain import-compatible.
16
+
17
+ ### Runtime hardening
18
+
19
+ Profile tree deletion now uses `shutil.rmtree(..., onexc=...)` where available, avoiding the deprecated `onerror` path on Python 3.12+ while retaining compatibility with Python 3.9-3.11.
20
+
21
+ Auth bundle tests now skip cleanly when `cryptography` is unavailable in the active interpreter.
22
+
23
+ ### CLI maintainability
24
+
25
+ Top-level command routing now uses an explicit dispatch table, with update notice suppression controlled by command metadata instead of an inline negative list.
26
+
27
+ ### Tooling and CI
28
+
29
+ CI now installs the `dev` extra, runs `ruff check`, and executes the same `pytest` suite used locally with coverage reporting for `src/`.
30
+
31
+ The Python floor remains `>=3.9` for the 0.9.x line to avoid a compatibility break before a planned major support change.
32
+
33
+ ## Validation
34
+
35
+ - `npm run release:validate`
36
+ - `npm run lint`
37
+ - `npm test`
38
+ - `logics-manager lint --require-status`
39
+ - `logics-manager audit`
40
+ - `git diff --check`
41
+ - `npm --cache /private/tmp/cdx-npm-cache pack --dry-run`
42
+ - `python -m build`
43
+ - `python -m twine check dist/*`
@@ -88,6 +88,18 @@
88
88
  "v0.9.5": {
89
89
  "github_tarball_sha256": "29c512600ed366e06bcaa8ae12f105cc198f85c6887d46c9032d677c5335203b",
90
90
  "github_zip_sha256": "a7be652ba39b929eae6e90c48410ce8b7c9c122625da55bfcd50edb755d6d609"
91
+ },
92
+ "v0.9.6": {
93
+ "github_tarball_sha256": "7706ba127f247dfab50404ca175921c7874624e835c920799b3b122a45f7dc4b",
94
+ "github_zip_sha256": "be11246f65eae006512247067bff22863a6ebe8d5738b056fe17f654fd5d9b5c"
95
+ },
96
+ "v0.9.7": {
97
+ "github_tarball_sha256": "8ca47e94843c9bf2592eb6a771aa33f209cf0f5bf04ed3e93cb000cea8a73bf6",
98
+ "github_zip_sha256": "1fe3ac56efe3de42a794614edaffab2f8ae8b5da8e51cdbda0f625665993f1f9"
99
+ },
100
+ "v0.9.8": {
101
+ "github_tarball_sha256": "8f05d052b7890d74de7ac510c20376430515ad2616e139941cb9bb8edc4fb680",
102
+ "github_zip_sha256": "3bc3834ecf84784c77cf603fcd4f6f439e42005612f82fabab588886519179eb"
91
103
  }
92
104
  }
93
105
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.9.6",
3
+ "version": "0.9.8",
4
4
  "description": "Terminal session manager for Codex and Claude accounts.",
5
5
  "license": "MIT",
6
6
  "author": "Alexandre Agostini",
@@ -42,9 +42,11 @@
42
42
  "cdx": "bin/cdx.js"
43
43
  },
44
44
  "scripts": {
45
+ "status": "node bin/cdx.js status",
45
46
  "test": "npm run test:py",
46
- "test:py": "node bin/python-runner.js -m unittest discover -s test -p test_*_py.py",
47
- "lint": "node --check bin/cdx.js && node --check bin/python-runner.js && node bin/python-runner.js scripts/verify_project_docs.py && node bin/python-runner.js -m py_compile bin/cdx src/*.py scripts/*.py test/test_*_py.py",
47
+ "test:py": "node bin/python-runner.js -m pytest",
48
+ "test:coverage": "node bin/python-runner.js -m pytest --cov=src --cov-report=term-missing",
49
+ "lint": "node --check bin/cdx.js && node --check bin/python-runner.js && node bin/python-runner.js scripts/verify_project_docs.py && node bin/python-runner.js -m ruff check . && node bin/python-runner.js -m py_compile bin/cdx src/*.py scripts/*.py test/test_*_py.py",
48
50
  "release:validate": "node bin/python-runner.js scripts/verify_release_checksums.py",
49
51
  "prepublishOnly": "npm run release:validate && npm run lint && npm test",
50
52
  "link": "npm link",
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.9.6"
7
+ version = "0.9.8"
8
8
  description = "Terminal session manager for Codex and Claude accounts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -28,6 +28,13 @@ classifiers = [
28
28
  ]
29
29
  dependencies = ["cryptography>=42"]
30
30
 
31
+ [project.optional-dependencies]
32
+ dev = [
33
+ "pytest>=8",
34
+ "pytest-cov>=5",
35
+ "ruff>=0.6",
36
+ ]
37
+
31
38
  [project.urls]
32
39
  Homepage = "https://github.com/AlexAgo83/cdx-manager"
33
40
  Repository = "https://github.com/AlexAgo83/cdx-manager.git"
@@ -42,3 +49,22 @@ packages = ["cdx_manager"]
42
49
 
43
50
  [tool.setuptools.package-dir]
44
51
  cdx_manager = "src"
52
+
53
+ [tool.pytest.ini_options]
54
+ testpaths = ["test"]
55
+ python_files = ["test_*_py.py"]
56
+
57
+ [tool.coverage.run]
58
+ branch = true
59
+ source = ["src"]
60
+
61
+ [tool.coverage.report]
62
+ show_missing = true
63
+ skip_empty = true
64
+
65
+ [tool.ruff]
66
+ target-version = "py39"
67
+ line-length = 140
68
+
69
+ [tool.ruff.lint]
70
+ select = ["E9", "F63", "F7", "F82"]
@@ -105,7 +105,6 @@ def encode_bundle(payload, include_auth=False, passphrase=None):
105
105
  "created_at": _now_iso(),
106
106
  "include_auth": bool(include_auth),
107
107
  "encrypted": bool(include_auth),
108
- "session_names": [item["name"] for item in payload.get("sessions", [])],
109
108
  }
110
109
  if include_auth:
111
110
  salt = os.urandom(_SALT_BYTES)
@@ -120,6 +119,7 @@ def encode_bundle(payload, include_auth=False, passphrase=None):
120
119
  "payload": _b64_encode(ciphertext),
121
120
  })
122
121
  else:
122
+ wrapper["session_names"] = [item["name"] for item in payload.get("sessions", [])]
123
123
  wrapper["payload"] = _b64_encode(payload_bytes)
124
124
  return json.dumps(wrapper, indent=2).encode("utf-8")
125
125
 
package/src/cli.py CHANGED
@@ -66,7 +66,70 @@ from .status_view import (
66
66
  )
67
67
  from .update_check import check_for_update, check_logics_manager_for_update
68
68
 
69
- VERSION = "0.9.6"
69
+ VERSION = "0.9.8"
70
+
71
+
72
+ _COMMAND_HANDLERS = {
73
+ "add": handle_add,
74
+ "cp": handle_copy,
75
+ "clean": handle_clean,
76
+ "config": handle_config,
77
+ "configs": handle_configs,
78
+ "context": handle_context,
79
+ "doctor": handle_doctor,
80
+ "disable": handle_disable,
81
+ "enable": handle_enable,
82
+ "export": handle_export,
83
+ "handoff": handle_handoff,
84
+ "history": handle_history,
85
+ "import": handle_import,
86
+ "last": handle_last,
87
+ "login": handle_login,
88
+ "logout": handle_logout,
89
+ "next": handle_next,
90
+ "notify": handle_notify,
91
+ "repair": handle_repair,
92
+ "rename": handle_rename,
93
+ "resume": handle_resume,
94
+ "can-resume": handle_can_resume,
95
+ "rmv": handle_remove,
96
+ "run": handle_run,
97
+ "run-report": handle_run_report,
98
+ "run-status": handle_run_status,
99
+ "runs": handle_runs,
100
+ "select": handle_select,
101
+ "set": handle_set,
102
+ "stats": handle_stats,
103
+ "status": handle_status,
104
+ "unset": handle_unset,
105
+ "update": handle_update,
106
+ "view": handle_view,
107
+ }
108
+
109
+ _COMMAND_ALIASES = {
110
+ "ren": "rename",
111
+ "mv": "rename",
112
+ }
113
+
114
+ _SUPPRESS_UPDATE_NOTICE_COMMANDS = {
115
+ *set(_COMMAND_HANDLERS),
116
+ "fast",
117
+ "help",
118
+ "model",
119
+ "perm",
120
+ "power",
121
+ "ready",
122
+ "rename",
123
+ "version",
124
+ } - {"status"}
125
+
126
+
127
+ def _resolve_command(command):
128
+ return _COMMAND_ALIASES.get(command, command)
129
+
130
+
131
+ def _should_check_update_notices(command):
132
+ return _resolve_command(command) not in _SUPPRESS_UPDATE_NOTICE_COMMANDS
70
133
 
71
134
 
72
135
  # ---------------------------------------------------------------------------
@@ -271,6 +334,7 @@ def main(argv, options=None):
271
334
  return 0
272
335
 
273
336
  command, *rest = argv
337
+ resolved_command = _resolve_command(command)
274
338
  ctx = {
275
339
  "env": env,
276
340
  "options": options,
@@ -286,130 +350,30 @@ def main(argv, options=None):
286
350
  "stdin_is_tty": stdin_is_tty,
287
351
  "version": VERSION,
288
352
  "cwd": options.get("cwd") or os.getcwd(),
289
- "update_notices": _get_update_notices(service, env, options) if command not in (
290
- "add", "cp", "ren", "rename", "mv", "rmv", "clean", "doctor", "repair", "view", "update", "ready", "notify", "next", "context", "config", "configs", "set", "unset", "power", "perm", "fast", "model", "history", "stats", "resume", "can-resume", "handoff", "login", "logout", "disable", "enable", "export", "import", "select", "run", "help", "version"
291
- ) else None,
353
+ "update_notices": _get_update_notices(service, env, options) if _should_check_update_notices(command) else None,
292
354
  "use_color": use_color,
293
355
  }
294
356
 
295
- if command == "add":
296
- return handle_add(rest, ctx)
297
-
298
- if command == "cp":
299
- return handle_copy(rest, ctx)
300
-
301
- if command in ("ren", "rename", "mv"):
302
- return handle_rename(rest, ctx)
303
-
304
- if command == "rmv":
305
- return handle_remove(rest, ctx)
306
-
307
- if command == "disable":
308
- return handle_disable(rest, ctx)
309
-
310
- if command == "enable":
311
- return handle_enable(rest, ctx)
312
-
313
- if command == "clean":
314
- return handle_clean(rest, ctx)
315
-
316
- if command == "export":
317
- return handle_export(rest, ctx)
318
-
319
- if command == "import":
320
- return handle_import(rest, ctx)
321
-
322
- if command == "doctor":
323
- return handle_doctor(rest, ctx)
324
-
325
- if command == "repair":
326
- return handle_repair(rest, ctx)
327
-
328
- if command == "view":
329
- return handle_view(rest, ctx)
330
-
331
- if command == "update":
332
- return handle_update(rest, ctx)
333
-
334
- if command == "ready":
357
+ if resolved_command == "ready":
335
358
  if any(arg not in ("--refresh", "--json") for arg in rest):
336
359
  raise CdxError("Usage: cdx ready [--refresh] [--json]")
337
360
  return handle_notify(["--next-ready", "--schedule", *rest], ctx)
338
361
 
339
- if command == "notify":
340
- return handle_notify(rest, ctx)
341
-
342
- if command == "next":
343
- return handle_next(rest, ctx)
344
-
345
- if command == "context":
346
- return handle_context(rest, ctx)
347
-
348
- if command == "config":
349
- return handle_config(rest, ctx)
350
-
351
- if command == "configs":
352
- return handle_configs(rest, ctx)
353
-
354
- if command == "set":
355
- return handle_set(rest, ctx)
362
+ if resolved_command in ("power", "perm", "fast", "model"):
363
+ return handle_launch_setting_alias(resolved_command, rest, ctx)
356
364
 
357
- if command == "unset":
358
- return handle_unset(rest, ctx)
359
-
360
- if command in ("power", "perm", "fast", "model"):
361
- return handle_launch_setting_alias(command, rest, ctx)
362
-
363
- if command == "history":
364
- return handle_history(rest, ctx)
365
-
366
- if command == "stats":
367
- return handle_stats(rest, ctx)
368
-
369
- if command == "last":
370
- return handle_last(rest, ctx)
371
-
372
- if command == "resume":
373
- return handle_resume(rest, ctx)
374
-
375
- if command == "can-resume":
376
- return handle_can_resume(rest, ctx)
377
-
378
- if command == "handoff":
379
- return handle_handoff(rest, ctx)
380
-
381
- if command == "status":
382
- return handle_status(rest, ctx)
383
-
384
- if command == "select":
385
- return handle_select(rest, ctx)
386
-
387
- if command == "run":
388
- return handle_run(rest, ctx)
389
-
390
- if command == "runs":
391
- return handle_runs(rest, ctx)
392
-
393
- if command == "run-status":
394
- return handle_run_status(rest, ctx)
395
-
396
- if command == "run-report":
397
- return handle_run_report(rest, ctx)
398
-
399
- if command == "login":
400
- return handle_login(rest, ctx)
401
-
402
- if command == "logout":
403
- return handle_logout(rest, ctx)
404
-
405
- if command in ("help",):
365
+ if resolved_command == "help":
406
366
  out(f"{_print_help(use_color=use_color)}\n")
407
367
  return 0
408
368
 
409
- if command in ("version",):
369
+ if resolved_command == "version":
410
370
  out(f"{_print_version()}\n")
411
371
  return 0
412
372
 
373
+ handler = _COMMAND_HANDLERS.get(resolved_command)
374
+ if handler:
375
+ return handler(rest, ctx)
376
+
413
377
  if all(arg in ("--json", "-r", "--resume") for arg in rest):
414
378
  return handle_launch(command, ctx, resume=("-r" in rest or "--resume" in rest))
415
379
 
@@ -0,0 +1,60 @@
1
+ import inspect
2
+ import os
3
+ import shutil
4
+ import stat
5
+
6
+
7
+ def _make_user_writable(path):
8
+ if os.path.islink(path):
9
+ return
10
+ try:
11
+ current = os.stat(path).st_mode
12
+ except OSError:
13
+ return
14
+ mode = current | stat.S_IRUSR | stat.S_IWUSR
15
+ if stat.S_ISDIR(current):
16
+ mode |= stat.S_IXUSR
17
+ try:
18
+ os.chmod(path, mode)
19
+ except OSError:
20
+ pass
21
+
22
+
23
+ def _make_tree_user_writable(path):
24
+ if not os.path.exists(path):
25
+ return
26
+ for root, dirs, files in os.walk(path, topdown=False):
27
+ for name in files:
28
+ file_path = os.path.join(root, name)
29
+ if not os.path.islink(file_path):
30
+ _make_user_writable(file_path)
31
+ for name in dirs:
32
+ dir_path = os.path.join(root, name)
33
+ if not os.path.islink(dir_path):
34
+ _make_user_writable(dir_path)
35
+ _make_user_writable(root)
36
+
37
+
38
+ def remove_tree(path, ignore_errors=False):
39
+ def retry_after_chmod(func, failing_path):
40
+ parent = os.path.dirname(failing_path)
41
+ if parent:
42
+ _make_user_writable(parent)
43
+ _make_user_writable(failing_path)
44
+ try:
45
+ func(failing_path)
46
+ except OSError:
47
+ if not ignore_errors:
48
+ raise
49
+
50
+ def onerror(func, failing_path, _exc_info):
51
+ retry_after_chmod(func, failing_path)
52
+
53
+ def onexc(func, failing_path, _exc):
54
+ retry_after_chmod(func, failing_path)
55
+
56
+ _make_tree_user_writable(path)
57
+ if "onexc" in inspect.signature(shutil.rmtree).parameters:
58
+ shutil.rmtree(path, ignore_errors=ignore_errors, onexc=onexc)
59
+ else:
60
+ shutil.rmtree(path, ignore_errors=ignore_errors, onerror=onerror)
@@ -335,8 +335,6 @@ def _launch_config_args(session):
335
335
  args += ["--dangerously-skip-permissions"]
336
336
  return args
337
337
  if provider == PROVIDER_OLLAMA:
338
- if power:
339
- args += ["--think", power if power in ("low", "medium", "high") else "high"]
340
338
  if permission == "full":
341
339
  args += ["--experimental-yolo"]
342
340
  return args
@@ -424,6 +422,8 @@ def _logics_enabled(session, env=None):
424
422
 
425
423
 
426
424
  def _with_launch_preferences(session, initial_prompt=None, env=None):
425
+ if session["provider"] == PROVIDER_OLLAMA:
426
+ return initial_prompt
427
427
  prompts = []
428
428
  if _rtk_enabled(session):
429
429
  prompts.append(RTK_PROMPT)
package/src/repair.py CHANGED
@@ -1,7 +1,7 @@
1
1
  import json
2
2
  import os
3
- import shutil
4
3
 
4
+ from .fs_utils import remove_tree
5
5
  from .health import collect_health_report
6
6
 
7
7
 
@@ -69,7 +69,7 @@ def _apply_recreate_state(service, name):
69
69
 
70
70
 
71
71
  def _apply_remove_path(path):
72
- shutil.rmtree(path)
72
+ remove_tree(path)
73
73
  return "applied"
74
74
 
75
75
 
@@ -13,6 +13,7 @@ from .backup_bundle import decode_bundle, encode_bundle
13
13
  from .config import PROVIDER_ANTIGRAVITY, PROVIDER_CLAUDE, PROVIDER_CODEX, PROVIDERS, get_cdx_home
14
14
  from .codex_usage import fetch_codex_rate_limits
15
15
  from .errors import CdxError
16
+ from .fs_utils import remove_tree
16
17
  from .session_store import create_session_store
17
18
  from .status_source import find_latest_status_artifact
18
19
 
@@ -641,7 +642,7 @@ def create_session_service(options=None):
641
642
  raise CdxError(f"Unknown session: {name}")
642
643
  if quarantine_root:
643
644
  try:
644
- shutil.rmtree(quarantine_root)
645
+ remove_tree(quarantine_root)
645
646
  except OSError as error:
646
647
  raise CdxError(
647
648
  f"Removed session {name}, but failed to delete archived profile {quarantine_root}: {error}"
@@ -701,14 +702,14 @@ def create_session_service(options=None):
701
702
  overwritten = bool(existing)
702
703
  except Exception:
703
704
  if moved_temp and os.path.exists(dest_root):
704
- shutil.rmtree(dest_root, ignore_errors=True)
705
+ remove_tree(dest_root, ignore_errors=True)
705
706
  if backup_root and os.path.exists(backup_root) and not os.path.exists(dest_root):
706
707
  os.rename(backup_root, dest_root)
707
708
  raise
708
709
  finally:
709
710
  if backup_root and os.path.exists(backup_root):
710
- shutil.rmtree(backup_root, ignore_errors=True)
711
- shutil.rmtree(temp_parent, ignore_errors=True)
711
+ remove_tree(backup_root, ignore_errors=True)
712
+ remove_tree(temp_parent, ignore_errors=True)
712
713
  if not result["ok"]:
713
714
  raise CdxError(f"Failed to create session: {dest_name}")
714
715
  return {"session": result["session"], "overwritten": overwritten}