cdx-manager 0.4.3 → 0.4.4

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.3-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.4-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
 
@@ -122,7 +122,7 @@ For a specific version:
122
122
 
123
123
  ```bash
124
124
  curl -fsSL https://raw.githubusercontent.com/AlexAgo83/cdx-manager/main/install.sh -o install.sh
125
- CDX_VERSION=v0.4.3 sh install.sh
125
+ CDX_VERSION=v0.4.4 sh install.sh
126
126
  ```
127
127
 
128
128
  From source:
@@ -143,6 +143,12 @@ npm install -g .
143
143
 
144
144
  `cdx` is now available globally. Changes to the source take effect immediately — no reinstall needed.
145
145
 
146
+ To update an installed copy later:
147
+
148
+ ```bash
149
+ cdx update
150
+ ```
151
+
146
152
  To uninstall:
147
153
 
148
154
  ```bash
@@ -242,6 +248,7 @@ cdx status
242
248
  | `cdx import <file> [--sessions a,b] [--passphrase-env VAR] [--force] [--json]` | Import sessions from a bundle into the current `CDX_HOME` |
243
249
  | `cdx doctor [--json]` | Inspect CLI dependencies, CDX_HOME permissions, missing state, orphan profiles, and pending quarantines |
244
250
  | `cdx repair [--dry-run] [--force] [--json]` | Plan or apply safe repairs for missing state files, quarantines, and orphan profiles |
251
+ | `cdx update [--check] [--yes] [--json] [--version TAG]` | Update cdx-manager using the installer that matches how it was installed |
245
252
  | `cdx notify <name> --at-reset [--poll seconds] [--once] [--json]` | Wait for a session reset time and send a desktop notification when due |
246
253
  | `cdx notify --next-ready [--poll seconds] [--once] [--json]` | Wait until the recommended session is usable or needs a refresh after reset |
247
254
  | `cdx status [--json] [--refresh]` | Show token usage table for all sessions; JSON returns a versioned payload with structured warnings |
@@ -272,6 +279,7 @@ Commands with machine-readable output:
272
279
  - `cdx logout ... --json`
273
280
  - `cdx doctor --json`
274
281
  - `cdx repair --json`
282
+ - `cdx update --json`
275
283
  - `cdx notify ... --json`
276
284
 
277
285
  Success payloads follow a shared envelope:
@@ -0,0 +1,33 @@
1
+ # Changelog (`0.4.3 -> 0.4.4`)
2
+
3
+ Release date: 2026-04-20
4
+
5
+ ## Major Highlights
6
+
7
+ - Generated from the release work on `cdx update`, the first built-in self-update path for `cdx-manager`.
8
+ - Added a version-aware update command that can check for a newer release, confirm interactively, and delegate to the right installer for the current installation type.
9
+ - Kept the existing update warning behavior intact so the CLI still surfaces newer releases on startup.
10
+ - Reserved `update` as a session name to avoid collisions with the new command.
11
+
12
+ ## `cdx update`
13
+
14
+ - Added `cdx update --check` for a quick release check without applying changes.
15
+ - Added `cdx update --yes` for non-interactive environments.
16
+ - Added `cdx update --version TAG` so maintainers can target a specific release.
17
+ - Routed standalone installs through `install.sh` / `install.ps1`.
18
+ - Routed npm installs through `npm install -g cdx-manager@...`.
19
+ - Routed Python environment installs through `python -m pip install --upgrade ...`.
20
+ - Routed source checkouts through `git pull --ff-only` or an explicit tag checkout when a version is requested.
21
+ - Refused source updates when the checkout contains uncommitted changes.
22
+
23
+ ## Validation and Regression Coverage
24
+
25
+ - Added CLI coverage for update checks, update execution, and version-aware help text.
26
+ - Added session-service coverage for the new reserved command name.
27
+ - Added unit coverage for installation detection and source-checkout safety in the update planner.
28
+ - Kept the existing CLI and session-service test suites green.
29
+
30
+ ## Validation and Regression Evidence
31
+
32
+ - `python3 -m unittest test.test_cli_py test.test_session_service_py test.test_update_manager_py`
33
+ - `npm run lint`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
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.4.3"
7
+ version = "0.4.4"
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
@@ -21,6 +21,7 @@ from .cli_commands import (
21
21
  handle_repair,
22
22
  handle_rename,
23
23
  handle_status,
24
+ handle_update,
24
25
  )
25
26
  from .cli_render import (
26
27
  _format_sessions,
@@ -44,7 +45,7 @@ from .status_view import (
44
45
  )
45
46
  from .update_check import check_for_update
46
47
 
47
- VERSION = "0.4.3"
48
+ VERSION = "0.4.4"
48
49
 
49
50
 
50
51
  # ---------------------------------------------------------------------------
@@ -72,6 +73,7 @@ def _print_help(use_color=False):
72
73
  f" {_style('cdx import <file> [--sessions a,b] [--passphrase-env VAR] [--force] [--json]', '36', use_color)}",
73
74
  f" {_style('cdx doctor [--json]', '36', use_color)}",
74
75
  f" {_style('cdx repair [--dry-run] [--force] [--json]', '36', use_color)}",
76
+ f" {_style('cdx update [--check] [--yes] [--json] [--version TAG]', '36', use_color)}",
75
77
  f" {_style('cdx notify <name> --at-reset [--json]', '36', use_color)}",
76
78
  f" {_style('cdx notify --next-ready [--json]', '36', use_color)}",
77
79
  f" {_style('cdx <name> [--json]', '36', use_color)}",
@@ -205,8 +207,9 @@ def main(argv, options=None):
205
207
  "spawn": spawn,
206
208
  "spawn_sync": spawn_sync,
207
209
  "stdin_is_tty": stdin_is_tty,
210
+ "version": VERSION,
208
211
  "update_notice": _get_update_notice(service, env, options) if command not in (
209
- "add", "cp", "ren", "rename", "mv", "rmv", "clean", "doctor", "repair", "notify", "status", "login", "logout", "export", "import", "help", "version"
212
+ "add", "cp", "ren", "rename", "mv", "rmv", "clean", "doctor", "repair", "update", "notify", "status", "login", "logout", "export", "import", "help", "version"
210
213
  ) else None,
211
214
  "use_color": use_color,
212
215
  }
@@ -238,6 +241,9 @@ def main(argv, options=None):
238
241
  if command == "repair":
239
242
  return handle_repair(rest, ctx)
240
243
 
244
+ if command == "update":
245
+ return handle_update(rest, ctx)
246
+
241
247
  if command == "notify":
242
248
  return handle_notify(rest, ctx)
243
249
 
@@ -22,11 +22,14 @@ from .provider_runtime import (
22
22
  from .repair import format_repair_report, repair_health
23
23
  from .backup_bundle import read_bundle_meta
24
24
  from .status_view import _format_status_detail, _format_status_rows
25
+ from .update_check import fetch_latest_release, is_newer_version
26
+ from .update_manager import build_update_plan, format_update_failure, run_update_plan
25
27
 
26
28
 
27
29
  STATUS_USAGE = "Usage: cdx status [--json] [--refresh] | cdx status --small|-s [--refresh] | cdx status <name> [--json] [--refresh]"
28
30
  DOCTOR_USAGE = "Usage: cdx doctor [--json]"
29
31
  REPAIR_USAGE = "Usage: cdx repair [--dry-run] [--force] [--json]"
32
+ UPDATE_USAGE = "Usage: cdx update [--check] [--yes] [--json] [--version TAG]"
30
33
  EXPORT_USAGE = "Usage: cdx export <file> [--include-auth] [--force] [--json] [--sessions name1,name2] [--passphrase-env VAR]"
31
34
  IMPORT_USAGE = "Usage: cdx import <file> [--force] [--json] [--sessions name1,name2] [--passphrase-env VAR]"
32
35
  API_SCHEMA_VERSION = 1
@@ -102,6 +105,45 @@ def _parse_session_names(value):
102
105
  return names
103
106
 
104
107
 
108
+ def _parse_update_args(args):
109
+ parsed = {
110
+ "check": False,
111
+ "json": False,
112
+ "yes": False,
113
+ "version": None,
114
+ }
115
+ index = 0
116
+ while index < len(args):
117
+ arg = args[index]
118
+ if arg == "--check":
119
+ parsed["check"] = True
120
+ index += 1
121
+ continue
122
+ if arg == "--json":
123
+ parsed["json"] = True
124
+ index += 1
125
+ continue
126
+ if arg == "--yes":
127
+ parsed["yes"] = True
128
+ index += 1
129
+ continue
130
+ if arg == "--version":
131
+ value, index = _read_option_value(args, index, UPDATE_USAGE)
132
+ parsed["version"] = value
133
+ continue
134
+ if arg.startswith("--version="):
135
+ parsed["version"] = arg.split("=", 1)[1]
136
+ index += 1
137
+ continue
138
+ raise CdxError(UPDATE_USAGE)
139
+
140
+ if parsed["check"] and parsed["version"]:
141
+ raise CdxError("Usage: cdx update --check cannot be combined with --version.")
142
+ if parsed["version"] is not None and not parsed["version"].strip():
143
+ raise CdxError("Usage: cdx update [--check] [--yes] [--json] [--version TAG]")
144
+ return parsed
145
+
146
+
105
147
  def _parse_export_args(args):
106
148
  parsed = {
107
149
  "file_path": None,
@@ -649,6 +691,91 @@ def handle_logout(rest, ctx):
649
691
  return 0
650
692
 
651
693
 
694
+ def handle_update(rest, ctx):
695
+ parsed = _parse_update_args(rest)
696
+ json_flag = parsed["json"]
697
+ current_version = str(ctx.get("version") or "").strip()
698
+ release_fetcher = ctx["options"].get("fetchLatestRelease") or fetch_latest_release
699
+ target_version = None
700
+ release_url = None
701
+ update_available = False
702
+
703
+ if parsed["version"] is not None:
704
+ target_version = str(parsed["version"]).strip().lstrip("v")
705
+ else:
706
+ latest = release_fetcher()
707
+ if not latest:
708
+ raise CdxError("Unable to check for the latest cdx-manager release. Check your network and try again.")
709
+ target_version = str(latest.get("latest_version") or "").strip()
710
+ release_url = latest.get("url")
711
+ if not target_version:
712
+ raise CdxError("Unable to determine the latest cdx-manager release.")
713
+ update_available = is_newer_version(current_version, target_version)
714
+ if parsed["check"] or not update_available:
715
+ message = (
716
+ f"Update available: cdx-manager {target_version} (current {current_version})"
717
+ if update_available
718
+ else f"cdx-manager {current_version} is already up to date."
719
+ )
720
+ if json_flag:
721
+ _write_json(ctx, _json_success(
722
+ "update",
723
+ message,
724
+ checked=True,
725
+ update_available=update_available,
726
+ current_version=current_version,
727
+ target_version=target_version,
728
+ release_url=release_url,
729
+ warnings=[{
730
+ "code": "update_available",
731
+ "message": message,
732
+ "latest_version": target_version,
733
+ "url": release_url,
734
+ }] if update_available else [],
735
+ ))
736
+ return 0
737
+ ctx["out"](f"{_warn(message, ctx['use_color']) if update_available else _success(message, ctx['use_color'])}\n")
738
+ return 0
739
+
740
+ if not parsed["yes"]:
741
+ if not ctx["stdin_is_tty"]:
742
+ raise CdxError("Update requires an interactive terminal or --yes in non-interactive mode.")
743
+ answer = input(f"Update cdx-manager to {target_version}? [y/N] ")
744
+ if answer.strip().lower() not in ("y", "yes"):
745
+ message = "Cancelled."
746
+ if json_flag:
747
+ _write_json(ctx, _json_success("update", message, cancelled=True, current_version=current_version, target_version=target_version))
748
+ return 0
749
+ ctx["out"](f"{_warn(message, ctx['use_color'])}\n")
750
+ return 0
751
+
752
+ plan = build_update_plan(
753
+ target_version=target_version,
754
+ package_root=ctx["options"].get("packageRoot"),
755
+ prefix=ctx["options"].get("prefix"),
756
+ base_prefix=ctx["options"].get("basePrefix"),
757
+ )
758
+ results = run_update_plan(plan, runner=ctx["options"].get("runUpdate"), env=ctx.get("env"))
759
+ failed = any((result.get("returncode") not in (0, None)) for result in results)
760
+ if failed:
761
+ raise CdxError(format_update_failure(results))
762
+
763
+ message = f"Updated cdx-manager to {target_version}"
764
+ if json_flag:
765
+ _write_json(ctx, _json_success(
766
+ "update",
767
+ message,
768
+ updated=True,
769
+ current_version=current_version,
770
+ target_version=target_version,
771
+ mode=plan["mode"],
772
+ steps=results,
773
+ ))
774
+ return 0
775
+ ctx["out"](f"{_success(message, ctx['use_color'])}\n")
776
+ return 0
777
+
778
+
652
779
  def handle_launch(command, ctx):
653
780
  json_flag = "--json" in ctx["options"].get("raw_args", [])
654
781
  update_notice = ctx.get("update_notice")
@@ -33,6 +33,7 @@ RESERVED_SESSION_NAMES = {
33
33
  "rename",
34
34
  "rmv",
35
35
  "status",
36
+ "update",
36
37
  "version",
37
38
  "--help",
38
39
  "-h",
@@ -28,6 +28,10 @@ def _is_newer_version(current_version, latest_version):
28
28
  return latest > current
29
29
 
30
30
 
31
+ def is_newer_version(current_version, latest_version):
32
+ return _is_newer_version(current_version, latest_version)
33
+
34
+
31
35
  def _cache_path(base_dir):
32
36
  return os.path.join(base_dir, "state", "update-check.json")
33
37
 
@@ -63,6 +67,13 @@ def _fetch_latest_release():
63
67
  }
64
68
 
65
69
 
70
+ def fetch_latest_release():
71
+ try:
72
+ return _fetch_latest_release()
73
+ except (urllib.error.URLError, TimeoutError, ValueError, OSError):
74
+ return None
75
+
76
+
66
77
  def check_for_update(base_dir, current_version, env=None, now_fn=None):
67
78
  env = env or os.environ
68
79
  now_fn = now_fn or (lambda: datetime.now(timezone.utc).timestamp())
@@ -83,9 +94,8 @@ def check_for_update(base_dir, current_version, env=None, now_fn=None):
83
94
  }
84
95
  return None
85
96
 
86
- try:
87
- latest = _fetch_latest_release()
88
- except (urllib.error.URLError, TimeoutError, ValueError, OSError):
97
+ latest = fetch_latest_release()
98
+ if not latest:
89
99
  return None
90
100
 
91
101
  payload = {
@@ -0,0 +1,208 @@
1
+ import os
2
+ import subprocess
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ from .errors import CdxError
7
+
8
+
9
+ def _package_root(path=None):
10
+ if path is not None:
11
+ return Path(path).resolve()
12
+ return Path(__file__).resolve().parents[1]
13
+
14
+
15
+ def _normalize_version(value):
16
+ if value is None:
17
+ return None
18
+ text = str(value).strip()
19
+ if not text:
20
+ return None
21
+ return text.lstrip("v")
22
+
23
+
24
+ def _is_standalone_install(package_root):
25
+ return package_root.parent.name == "versions"
26
+
27
+
28
+ def _is_source_checkout(package_root):
29
+ return (package_root / ".git").exists()
30
+
31
+
32
+ def _is_git_dirty(package_root):
33
+ try:
34
+ result = subprocess.run(
35
+ ["git", "-C", str(package_root), "status", "--porcelain"],
36
+ capture_output=True,
37
+ text=True,
38
+ check=False,
39
+ )
40
+ except FileNotFoundError as error:
41
+ raise CdxError("git is required to update a source checkout.") from error
42
+ return bool((result.stdout or "").strip())
43
+
44
+
45
+ def _is_python_env(prefix=None, base_prefix=None):
46
+ prefix = prefix or sys.prefix
47
+ base_prefix = base_prefix or sys.base_prefix
48
+ return prefix != base_prefix
49
+
50
+
51
+ def detect_installation(package_root=None, prefix=None, base_prefix=None):
52
+ root = _package_root(package_root)
53
+ if _is_standalone_install(root):
54
+ return {"mode": "standalone", "package_root": str(root)}
55
+ if _is_source_checkout(root):
56
+ return {"mode": "source", "package_root": str(root)}
57
+ if _is_python_env(prefix=prefix, base_prefix=base_prefix):
58
+ return {"mode": "python", "package_root": str(root)}
59
+ if (root / "package.json").exists():
60
+ return {"mode": "npm", "package_root": str(root)}
61
+ return {"mode": "unknown", "package_root": str(root)}
62
+
63
+
64
+ def _join_command(*parts):
65
+ return [str(part) for part in parts if part is not None]
66
+
67
+
68
+ def _build_standalone_step(package_root, target_version):
69
+ package_root = _package_root(package_root)
70
+ env = {}
71
+ if target_version:
72
+ env["CDX_VERSION"] = target_version
73
+ if sys.platform == "win32":
74
+ return {
75
+ "label": "standalone installer",
76
+ "command": _join_command("powershell", "-ExecutionPolicy", "Bypass", "-File", package_root / "install.ps1"),
77
+ "cwd": str(package_root),
78
+ "env": env,
79
+ }
80
+ return {
81
+ "label": "standalone installer",
82
+ "command": _join_command("sh", package_root / "install.sh"),
83
+ "cwd": str(package_root),
84
+ "env": env,
85
+ }
86
+
87
+
88
+ def _build_source_steps(package_root, target_version):
89
+ package_root = _package_root(package_root)
90
+ if _is_git_dirty(package_root):
91
+ raise CdxError(
92
+ "Your source checkout has uncommitted changes. "
93
+ "Commit or stash them before running cdx update."
94
+ )
95
+ if target_version:
96
+ return [
97
+ {
98
+ "label": "fetch tags",
99
+ "command": _join_command("git", "-C", package_root, "fetch", "--tags", "--force"),
100
+ "cwd": str(package_root),
101
+ "env": {},
102
+ },
103
+ {
104
+ "label": f"checkout v{target_version}",
105
+ "command": _join_command("git", "-C", package_root, "checkout", f"v{target_version}"),
106
+ "cwd": str(package_root),
107
+ "env": {},
108
+ },
109
+ ]
110
+ return [
111
+ {
112
+ "label": "git pull --ff-only",
113
+ "command": _join_command("git", "-C", package_root, "pull", "--ff-only"),
114
+ "cwd": str(package_root),
115
+ "env": {},
116
+ }
117
+ ]
118
+
119
+
120
+ def _build_python_step(target_version):
121
+ command = [sys.executable, "-m", "pip", "install", "--upgrade"]
122
+ if target_version:
123
+ command.append(f"cdx-manager=={target_version}")
124
+ else:
125
+ command.append("cdx-manager")
126
+ return {
127
+ "label": "python package upgrade",
128
+ "command": command,
129
+ "cwd": None,
130
+ "env": {},
131
+ }
132
+
133
+
134
+ def _build_npm_step(target_version):
135
+ spec = f"cdx-manager@{target_version}" if target_version else "cdx-manager@latest"
136
+ return {
137
+ "label": "npm global upgrade",
138
+ "command": ["npm", "install", "-g", spec],
139
+ "cwd": None,
140
+ "env": {},
141
+ }
142
+
143
+
144
+ def build_update_plan(target_version=None, package_root=None, env=None, prefix=None, base_prefix=None):
145
+ root = _package_root(package_root)
146
+ version = _normalize_version(target_version)
147
+ detection = detect_installation(root, prefix=prefix, base_prefix=base_prefix)
148
+ mode = detection["mode"]
149
+ if mode == "standalone":
150
+ steps = [_build_standalone_step(root, version)]
151
+ elif mode == "source":
152
+ steps = _build_source_steps(root, version)
153
+ elif mode == "python":
154
+ steps = [_build_python_step(version)]
155
+ elif mode == "npm":
156
+ steps = [_build_npm_step(version)]
157
+ else:
158
+ raise CdxError(
159
+ "Unable to determine how cdx-manager was installed. "
160
+ "Set CDX_UPDATE_METHOD or update it manually."
161
+ )
162
+ return {
163
+ "mode": mode,
164
+ "package_root": str(root),
165
+ "target_version": version,
166
+ "steps": steps,
167
+ }
168
+
169
+
170
+ def _result_code(result):
171
+ if isinstance(result, dict):
172
+ return result.get("returncode") if result.get("returncode") is not None else result.get("status")
173
+ return getattr(result, "returncode", getattr(result, "status", None))
174
+
175
+
176
+ def _result_text(result, attr):
177
+ if isinstance(result, dict):
178
+ return result.get(attr)
179
+ return getattr(result, attr, "")
180
+
181
+
182
+ def run_update_plan(plan, runner=None, env=None):
183
+ runner = runner or subprocess.run
184
+ results = []
185
+ for step in plan["steps"]:
186
+ step_env = {**(env or os.environ), **(step.get("env") or {})}
187
+ kwargs = {"cwd": step.get("cwd"), "env": step_env, "check": False}
188
+ result = runner(step["command"], **kwargs)
189
+ code = _result_code(result)
190
+ results.append({
191
+ "label": step["label"],
192
+ "command": step["command"],
193
+ "cwd": step.get("cwd"),
194
+ "returncode": code,
195
+ "stdout": _result_text(result, "stdout"),
196
+ "stderr": _result_text(result, "stderr"),
197
+ })
198
+ if code not in (0, None):
199
+ break
200
+ return results
201
+
202
+
203
+ def format_update_failure(results):
204
+ if not results:
205
+ return "Update failed."
206
+ last = results[-1]
207
+ message = last.get("stderr") or last.get("stdout") or "Update failed."
208
+ return f"{last['label']} failed: {str(message).strip()}"