cdx-manager 0.4.3 → 0.5.0

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.5.0-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
 
@@ -36,6 +36,8 @@ One command to launch any session. Zero auth juggling.
36
36
  - **Instant launch.** `cdx work` opens your "work" session. `cdx personal` opens another. No config files to edit mid-flow.
37
37
  - **Auth guardrails.** `cdx` checks authentication before launching. If a session is not logged in, it tells you exactly what to run — no silent failures.
38
38
  - **Usage at a glance.** `cdx status` shows token usage, 5-hour window quota, weekly quota, and last-updated timestamps for every session in one aligned table.
39
+ - **Session control.** Disable a session without deleting it when an account is temporarily out of credits; disabled sessions remain visible and sort last.
40
+ - **Shared handoff context.** Keep a per-workspace Markdown context and install it into another assistant session before switching providers or accounts.
39
41
  - **Passive status resolution.** If a session has no recorded status, `cdx` reads it directly from the provider's session logs and JSONL history — no manual sync required.
40
42
  - **Session transcript capture.** Every launch is recorded to a local log file via `script`, giving you a full terminal transcript for each session.
41
43
  - **Clean removal.** `cdx rmv` wipes a session and its entire auth directory. No orphaned files, no stale credentials.
@@ -52,6 +54,7 @@ One command to launch any session. Zero auth juggling.
52
54
  - Persistence:
53
55
  - Session registry at `~/.cdx/sessions.json` (versioned JSON store).
54
56
  - Per-session state at `~/.cdx/state/<name>.json`.
57
+ - Per-workspace shared context at `~/.cdx/contexts/<workspace-hash>/context.md`.
55
58
  - Auth and provider data under `~/.cdx/profiles/<name>/`.
56
59
  - All paths are URL-encoded to support arbitrary session names.
57
60
  - Status resolution pipeline:
@@ -122,7 +125,7 @@ For a specific version:
122
125
 
123
126
  ```bash
124
127
  curl -fsSL https://raw.githubusercontent.com/AlexAgo83/cdx-manager/main/install.sh -o install.sh
125
- CDX_VERSION=v0.4.3 sh install.sh
128
+ CDX_VERSION=v0.5.0 sh install.sh
126
129
  ```
127
130
 
128
131
  From source:
@@ -143,6 +146,12 @@ npm install -g .
143
146
 
144
147
  `cdx` is now available globally. Changes to the source take effect immediately — no reinstall needed.
145
148
 
149
+ To update an installed copy later:
150
+
151
+ ```bash
152
+ cdx update
153
+ ```
154
+
146
155
  To uninstall:
147
156
 
148
157
  ```bash
@@ -236,12 +245,17 @@ cdx status
236
245
  | `cdx ren <source> <dest> [--json]` | Rename a session and move its auth data |
237
246
  | `cdx login <name> [--json]` | Re-authenticate a session (logout + login) |
238
247
  | `cdx logout <name> [--json]` | Log out of a session |
248
+ | `cdx disable <name> [--json]` | Disable a session without deleting it; disabled sessions stay visible and cannot launch |
249
+ | `cdx enable <name> [--json]` | Re-enable a disabled session |
250
+ | `cdx context show\|path\|init\|edit\|clear\|set [text...] [--json]` | Manage the shared Markdown context for the current workspace |
251
+ | `cdx handoff <name> [--json]` | Install the current workspace context into a target session and launch it unless `--json` is used |
239
252
  | `cdx rmv <name> [--force] [--json]` | Remove a session and its auth data (prompts for confirmation unless `--force`) |
240
253
  | `cdx clean [name] [--json]` | Clear launch transcript logs for one session or all sessions |
241
254
  | `cdx export <file> [--include-auth] [--sessions a,b] [--passphrase-env VAR] [--force] [--json]` | Export sessions to a portable bundle; `--include-auth` encrypts auth data with a passphrase |
242
255
  | `cdx import <file> [--sessions a,b] [--passphrase-env VAR] [--force] [--json]` | Import sessions from a bundle into the current `CDX_HOME` |
243
256
  | `cdx doctor [--json]` | Inspect CLI dependencies, CDX_HOME permissions, missing state, orphan profiles, and pending quarantines |
244
257
  | `cdx repair [--dry-run] [--force] [--json]` | Plan or apply safe repairs for missing state files, quarantines, and orphan profiles |
258
+ | `cdx update [--check] [--yes] [--json] [--version TAG]` | Update cdx-manager using the installer that matches how it was installed |
245
259
  | `cdx notify <name> --at-reset [--poll seconds] [--once] [--json]` | Wait for a session reset time and send a desktop notification when due |
246
260
  | `cdx notify --next-ready [--poll seconds] [--once] [--json]` | Wait until the recommended session is usable or needs a refresh after reset |
247
261
  | `cdx status [--json] [--refresh]` | Show token usage table for all sessions; JSON returns a versioned payload with structured warnings |
@@ -270,8 +284,13 @@ Commands with machine-readable output:
270
284
  - `cdx import ... --json`
271
285
  - `cdx login ... --json`
272
286
  - `cdx logout ... --json`
287
+ - `cdx disable ... --json`
288
+ - `cdx enable ... --json`
289
+ - `cdx context ... --json`
290
+ - `cdx handoff ... --json`
273
291
  - `cdx doctor --json`
274
292
  - `cdx repair --json`
293
+ - `cdx update --json`
275
294
  - `cdx notify ... --json`
276
295
 
277
296
  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`
@@ -0,0 +1,45 @@
1
+ # Changelog (`0.4.4 -> 0.5.0`)
2
+
3
+ Release date: 2026-05-20
4
+
5
+ ## Major Highlights
6
+
7
+ - Added session disable/enable controls so an assistant can be kept in the registry while temporarily removed from launch rotation.
8
+ - Added shared workspace context commands to make provider/account handoff explicit when moving between Codex and Claude sessions.
9
+ - Prepared the package metadata for the 0.5.0 release across npm, PyPI, CLI version output, and README install examples.
10
+
11
+ ## Session Disable Toggle
12
+
13
+ - Added `cdx disable <name> [--json]` and `cdx enable <name> [--json]`.
14
+ - Disabled sessions remain visible in the main list and status views with a `disabled` status.
15
+ - Disabled sessions sort after enabled sessions and are excluded from status priority recommendations.
16
+ - Launching a disabled session now fails with a clear `Session is disabled: <name>` error.
17
+ - Claude live status refresh skips disabled sessions.
18
+
19
+ ## Shared Context Handoff
20
+
21
+ - Added per-workspace shared context storage at `~/.cdx/contexts/<workspace-hash>/context.md`.
22
+ - Added `cdx context show`, `path`, `init`, `edit`, `clear`, and `set`.
23
+ - Added `cdx handoff <name>` to install the current workspace context into the target session as `shared-context.md` before launching it.
24
+ - Added `cdx handoff <name> --json` for non-interactive integrations that only need to install the context and inspect the resulting paths.
25
+ - Reserved `context` and `handoff` as session names to avoid command collisions.
26
+
27
+ ## Packaging
28
+
29
+ - Bumped `package.json`, `package-lock.json`, `pyproject.toml`, and `src/cli.py` to `0.5.0`.
30
+ - Updated the README version badge, pinned installer example, command table, JSON command list, and data layout notes.
31
+ - Removed the accidental npm self-dependency from `package.json` and regenerated the lockfile.
32
+
33
+ ## Validation and Regression Coverage
34
+
35
+ - Added CLI coverage for context storage, workspace isolation, and handoff installation.
36
+ - Added session-service coverage for disabled-session ordering, launch blocking, and new reserved command names.
37
+ - Kept the full Python test suite green.
38
+
39
+ ## Validation and Regression Evidence
40
+
41
+ - `python -m pytest`
42
+ - `npm run lint`
43
+ - `npm test`
44
+ - `node bin/cdx.js --version`
45
+ - `npm pack --dry-run`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
4
4
  "description": "Terminal session manager for Codex and Claude accounts.",
5
5
  "license": "MIT",
6
6
  "author": "Alexandre Agostini",
@@ -28,6 +28,8 @@
28
28
  "checksums",
29
29
  "changelogs",
30
30
  "src",
31
+ "!src/__pycache__",
32
+ "!src/**/*.pyc",
31
33
  "install.sh",
32
34
  "install.ps1",
33
35
  "pyproject.toml",
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.5.0"
8
8
  description = "Terminal session manager for Codex and Claude accounts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -36,6 +36,7 @@ def _refresh_claude_sessions(service, refresh_fn=None, target_names=None, force=
36
36
  claude_sessions = [
37
37
  s for s in sessions
38
38
  if s["provider"] == "claude"
39
+ and s.get("enabled", True) is not False
39
40
  and (not target_names or s["name"] in target_names)
40
41
  and (force or _is_stale(s, ttl_seconds=ttl_seconds))
41
42
  ]
package/src/cli.py CHANGED
@@ -9,10 +9,14 @@ from .cli_commands import (
9
9
  STATUS_USAGE,
10
10
  handle_add,
11
11
  handle_clean,
12
+ handle_context,
12
13
  handle_copy,
13
14
  handle_doctor,
15
+ handle_disable,
16
+ handle_enable,
14
17
  handle_export,
15
18
  handle_import,
19
+ handle_handoff,
16
20
  handle_launch,
17
21
  handle_login,
18
22
  handle_logout,
@@ -21,6 +25,7 @@ from .cli_commands import (
21
25
  handle_repair,
22
26
  handle_rename,
23
27
  handle_status,
28
+ handle_update,
24
29
  )
25
30
  from .cli_render import (
26
31
  _format_sessions,
@@ -44,7 +49,7 @@ from .status_view import (
44
49
  )
45
50
  from .update_check import check_for_update
46
51
 
47
- VERSION = "0.4.3"
52
+ VERSION = "0.5.0"
48
53
 
49
54
 
50
55
  # ---------------------------------------------------------------------------
@@ -61,17 +66,22 @@ def _print_help(use_color=False):
61
66
  f" {_style('cdx status [--json] [--refresh]', '36', use_color)}",
62
67
  f" {_style('cdx status --small|-s [--refresh]', '36', use_color)}",
63
68
  f" {_style('cdx status <name> [--json] [--refresh]', '36', use_color)}",
69
+ f" {_style('cdx context show|path|init|edit|clear|set [text...] [--json]', '36', use_color)}",
70
+ f" {_style('cdx handoff <name> [--json]', '36', use_color)}",
64
71
  f" {_style('cdx add [provider] <name> [--json]', '36', use_color)}",
65
72
  f" {_style('cdx cp <source> <dest> [--json]', '36', use_color)}",
66
73
  f" {_style('cdx ren <source> <dest> [--json]', '36', use_color)}",
67
74
  f" {_style('cdx login <name> [--json]', '36', use_color)}",
68
75
  f" {_style('cdx logout <name> [--json]', '36', use_color)}",
76
+ f" {_style('cdx disable <name> [--json]', '36', use_color)}",
77
+ f" {_style('cdx enable <name> [--json]', '36', use_color)}",
69
78
  f" {_style('cdx rmv <name> [--force] [--json]', '36', use_color)}",
70
79
  f" {_style('cdx clean [name] [--json]', '36', use_color)}",
71
80
  f" {_style('cdx export <file> [--include-auth] [--sessions a,b] [--passphrase-env VAR] [--force] [--json]', '36', use_color)}",
72
81
  f" {_style('cdx import <file> [--sessions a,b] [--passphrase-env VAR] [--force] [--json]', '36', use_color)}",
73
82
  f" {_style('cdx doctor [--json]', '36', use_color)}",
74
83
  f" {_style('cdx repair [--dry-run] [--force] [--json]', '36', use_color)}",
84
+ f" {_style('cdx update [--check] [--yes] [--json] [--version TAG]', '36', use_color)}",
75
85
  f" {_style('cdx notify <name> --at-reset [--json]', '36', use_color)}",
76
86
  f" {_style('cdx notify --next-ready [--json]', '36', use_color)}",
77
87
  f" {_style('cdx <name> [--json]', '36', use_color)}",
@@ -99,6 +109,8 @@ def format_json_error(error):
99
109
  code = "unknown_command"
100
110
  elif message.startswith("Session already exists:"):
101
111
  code = "session_exists"
112
+ elif message.startswith("Session is disabled:"):
113
+ code = "session_disabled"
102
114
  elif "requires an interactive terminal" in message or "requires confirmation" in message:
103
115
  code = "interactive_terminal_required"
104
116
  return json.dumps({
@@ -205,8 +217,10 @@ def main(argv, options=None):
205
217
  "spawn": spawn,
206
218
  "spawn_sync": spawn_sync,
207
219
  "stdin_is_tty": stdin_is_tty,
220
+ "version": VERSION,
221
+ "cwd": options.get("cwd") or os.getcwd(),
208
222
  "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"
223
+ "add", "cp", "ren", "rename", "mv", "rmv", "clean", "doctor", "repair", "update", "notify", "status", "context", "handoff", "login", "logout", "disable", "enable", "export", "import", "help", "version"
210
224
  ) else None,
211
225
  "use_color": use_color,
212
226
  }
@@ -223,6 +237,12 @@ def main(argv, options=None):
223
237
  if command == "rmv":
224
238
  return handle_remove(rest, ctx)
225
239
 
240
+ if command == "disable":
241
+ return handle_disable(rest, ctx)
242
+
243
+ if command == "enable":
244
+ return handle_enable(rest, ctx)
245
+
226
246
  if command == "clean":
227
247
  return handle_clean(rest, ctx)
228
248
 
@@ -238,9 +258,18 @@ def main(argv, options=None):
238
258
  if command == "repair":
239
259
  return handle_repair(rest, ctx)
240
260
 
261
+ if command == "update":
262
+ return handle_update(rest, ctx)
263
+
241
264
  if command == "notify":
242
265
  return handle_notify(rest, ctx)
243
266
 
267
+ if command == "context":
268
+ return handle_context(rest, ctx)
269
+
270
+ if command == "handoff":
271
+ return handle_handoff(rest, ctx)
272
+
244
273
  if command == "status":
245
274
  return handle_status(rest, ctx)
246
275
 
@@ -2,10 +2,20 @@ import asyncio
2
2
  import getpass
3
3
  import json
4
4
  import os
5
+ import sys
5
6
  from datetime import datetime
6
7
 
7
8
  from .claude_refresh import _refresh_claude_sessions
8
9
  from .cli_render import _dim, _info, _success, _warn
10
+ from .context_store import (
11
+ clear_context,
12
+ edit_context,
13
+ get_context_path,
14
+ init_context,
15
+ install_context_for_session,
16
+ read_context,
17
+ write_context,
18
+ )
9
19
  from .errors import CdxError
10
20
  from .health import collect_health_report, format_health_report
11
21
  from .notify import (
@@ -22,13 +32,18 @@ from .provider_runtime import (
22
32
  from .repair import format_repair_report, repair_health
23
33
  from .backup_bundle import read_bundle_meta
24
34
  from .status_view import _format_status_detail, _format_status_rows
35
+ from .update_check import fetch_latest_release, is_newer_version
36
+ from .update_manager import build_update_plan, format_update_failure, run_update_plan
25
37
 
26
38
 
27
39
  STATUS_USAGE = "Usage: cdx status [--json] [--refresh] | cdx status --small|-s [--refresh] | cdx status <name> [--json] [--refresh]"
28
40
  DOCTOR_USAGE = "Usage: cdx doctor [--json]"
29
41
  REPAIR_USAGE = "Usage: cdx repair [--dry-run] [--force] [--json]"
42
+ UPDATE_USAGE = "Usage: cdx update [--check] [--yes] [--json] [--version TAG]"
30
43
  EXPORT_USAGE = "Usage: cdx export <file> [--include-auth] [--force] [--json] [--sessions name1,name2] [--passphrase-env VAR]"
31
44
  IMPORT_USAGE = "Usage: cdx import <file> [--force] [--json] [--sessions name1,name2] [--passphrase-env VAR]"
45
+ CONTEXT_USAGE = "Usage: cdx context show|path|init|edit|clear|set [text...] [--json]"
46
+ HANDOFF_USAGE = "Usage: cdx handoff <name> [--json]"
32
47
  API_SCHEMA_VERSION = 1
33
48
 
34
49
 
@@ -87,6 +102,13 @@ def _parse_remove_args(args):
87
102
  return {"name": names[0], "force": force}
88
103
 
89
104
 
105
+ def _parse_toggle_args(args, usage):
106
+ json_flag, cleaned = _parse_json_flag(args)
107
+ if len(cleaned) != 1:
108
+ raise CdxError(usage)
109
+ return {"name": cleaned[0], "json": json_flag}
110
+
111
+
90
112
  def _read_option_value(args, index, usage):
91
113
  if index + 1 >= len(args):
92
114
  raise CdxError(usage)
@@ -102,6 +124,45 @@ def _parse_session_names(value):
102
124
  return names
103
125
 
104
126
 
127
+ def _parse_update_args(args):
128
+ parsed = {
129
+ "check": False,
130
+ "json": False,
131
+ "yes": False,
132
+ "version": None,
133
+ }
134
+ index = 0
135
+ while index < len(args):
136
+ arg = args[index]
137
+ if arg == "--check":
138
+ parsed["check"] = True
139
+ index += 1
140
+ continue
141
+ if arg == "--json":
142
+ parsed["json"] = True
143
+ index += 1
144
+ continue
145
+ if arg == "--yes":
146
+ parsed["yes"] = True
147
+ index += 1
148
+ continue
149
+ if arg == "--version":
150
+ value, index = _read_option_value(args, index, UPDATE_USAGE)
151
+ parsed["version"] = value
152
+ continue
153
+ if arg.startswith("--version="):
154
+ parsed["version"] = arg.split("=", 1)[1]
155
+ index += 1
156
+ continue
157
+ raise CdxError(UPDATE_USAGE)
158
+
159
+ if parsed["check"] and parsed["version"]:
160
+ raise CdxError("Usage: cdx update --check cannot be combined with --version.")
161
+ if parsed["version"] is not None and not parsed["version"].strip():
162
+ raise CdxError("Usage: cdx update [--check] [--yes] [--json] [--version TAG]")
163
+ return parsed
164
+
165
+
105
166
  def _parse_export_args(args):
106
167
  parsed = {
107
168
  "file_path": None,
@@ -329,6 +390,28 @@ def handle_remove(rest, ctx):
329
390
  return 0
330
391
 
331
392
 
393
+ def handle_disable(rest, ctx):
394
+ parsed = _parse_toggle_args(rest, "Usage: cdx disable <name> [--json]")
395
+ session = ctx["service"]["set_session_enabled"](parsed["name"], False)
396
+ message = f"Disabled session {parsed['name']}"
397
+ if parsed["json"]:
398
+ _write_json(ctx, _json_success("disable", message, session=session))
399
+ return 0
400
+ ctx["out"](f"{_success(message, ctx['use_color'])}\n")
401
+ return 0
402
+
403
+
404
+ def handle_enable(rest, ctx):
405
+ parsed = _parse_toggle_args(rest, "Usage: cdx enable <name> [--json]")
406
+ session = ctx["service"]["set_session_enabled"](parsed["name"], True)
407
+ message = f"Enabled session {parsed['name']}"
408
+ if parsed["json"]:
409
+ _write_json(ctx, _json_success("enable", message, session=session))
410
+ return 0
411
+ ctx["out"](f"{_success(message, ctx['use_color'])}\n")
412
+ return 0
413
+
414
+
332
415
  def handle_clean(rest, ctx):
333
416
  json_flag, args = _parse_json_flag(rest)
334
417
  service = ctx["service"]
@@ -465,6 +548,103 @@ def handle_notify(rest, ctx):
465
548
  return 0
466
549
 
467
550
 
551
+ def handle_context(rest, ctx):
552
+ json_flag, args = _parse_json_flag(rest)
553
+ if not args:
554
+ args = ["show"]
555
+ action = args[0]
556
+ base_dir = ctx["service"]["base_dir"]
557
+ cwd = ctx.get("cwd")
558
+
559
+ if action == "show":
560
+ if len(args) != 1:
561
+ raise CdxError(CONTEXT_USAGE)
562
+ content = read_context(base_dir, cwd)
563
+ payload = {
564
+ "path": get_context_path(base_dir, cwd),
565
+ "exists": bool(content.strip()),
566
+ "content": content,
567
+ }
568
+ if json_flag:
569
+ _write_json(ctx, _json_success("context.show", "Loaded shared context", context=payload))
570
+ return 0
571
+ if content.strip():
572
+ ctx["out"](content if content.endswith("\n") else f"{content}\n")
573
+ else:
574
+ ctx["out"](f"{_dim('No shared context for this workspace. Run: cdx context init or cdx context set <text>', ctx['use_color'])}\n")
575
+ return 0
576
+
577
+ if action == "path":
578
+ if len(args) != 1:
579
+ raise CdxError(CONTEXT_USAGE)
580
+ path = get_context_path(base_dir, cwd)
581
+ if json_flag:
582
+ _write_json(ctx, _json_success("context.path", "Resolved shared context path", path=path))
583
+ return 0
584
+ ctx["out"](f"{path}\n")
585
+ return 0
586
+
587
+ if action == "init":
588
+ if len(args) != 1:
589
+ raise CdxError(CONTEXT_USAGE)
590
+ result = init_context(base_dir, cwd)
591
+ message = "Created shared context" if result.get("created") else "Shared context already exists"
592
+ if json_flag:
593
+ _write_json(ctx, _json_success("context.init", message, context=result))
594
+ return 0
595
+ text = f"{message}: {result['path']}"
596
+ ctx["out"](f"{_success(text, ctx['use_color'])}\n")
597
+ return 0
598
+
599
+ if action == "edit":
600
+ if len(args) != 1:
601
+ raise CdxError(CONTEXT_USAGE)
602
+ result = edit_context(
603
+ base_dir,
604
+ cwd,
605
+ env=ctx.get("env"),
606
+ spawn_sync=ctx.get("spawn_sync"),
607
+ )
608
+ if json_flag:
609
+ _write_json(ctx, _json_success("context.edit", "Edited shared context", context=result))
610
+ return 0
611
+ text = f"Edited shared context: {result['path']}"
612
+ ctx["out"](f"{_success(text, ctx['use_color'])}\n")
613
+ return 0
614
+
615
+ if action == "clear":
616
+ if len(args) != 1:
617
+ raise CdxError(CONTEXT_USAGE)
618
+ result = clear_context(base_dir, cwd)
619
+ message = "Cleared shared context" if result["removed"] else "No shared context to clear"
620
+ if json_flag:
621
+ _write_json(ctx, _json_success("context.clear", message, context=result))
622
+ return 0
623
+ ctx["out"](f"{_success(message, ctx['use_color'])}\n")
624
+ return 0
625
+
626
+ if action == "set":
627
+ content_args = args[1:]
628
+ if content_args:
629
+ content = " ".join(content_args)
630
+ else:
631
+ stdin = ctx["options"].get("stdin_data")
632
+ if stdin is None and not ctx.get("stdin_is_tty"):
633
+ stdin = sys.stdin.read()
634
+ if stdin is None:
635
+ raise CdxError("Usage: cdx context set <text> [--json]")
636
+ content = stdin
637
+ result = write_context(base_dir, content, cwd)
638
+ if json_flag:
639
+ _write_json(ctx, _json_success("context.set", "Saved shared context", context=result))
640
+ return 0
641
+ text = f"Saved shared context: {result['path']}"
642
+ ctx["out"](f"{_success(text, ctx['use_color'])}\n")
643
+ return 0
644
+
645
+ raise CdxError(CONTEXT_USAGE)
646
+
647
+
468
648
  def handle_status(rest, ctx):
469
649
  json_flag = "--json" in rest
470
650
  small_flag = "--small" in rest or "-s" in rest
@@ -649,6 +829,91 @@ def handle_logout(rest, ctx):
649
829
  return 0
650
830
 
651
831
 
832
+ def handle_update(rest, ctx):
833
+ parsed = _parse_update_args(rest)
834
+ json_flag = parsed["json"]
835
+ current_version = str(ctx.get("version") or "").strip()
836
+ release_fetcher = ctx["options"].get("fetchLatestRelease") or fetch_latest_release
837
+ target_version = None
838
+ release_url = None
839
+ update_available = False
840
+
841
+ if parsed["version"] is not None:
842
+ target_version = str(parsed["version"]).strip().lstrip("v")
843
+ else:
844
+ latest = release_fetcher()
845
+ if not latest:
846
+ raise CdxError("Unable to check for the latest cdx-manager release. Check your network and try again.")
847
+ target_version = str(latest.get("latest_version") or "").strip()
848
+ release_url = latest.get("url")
849
+ if not target_version:
850
+ raise CdxError("Unable to determine the latest cdx-manager release.")
851
+ update_available = is_newer_version(current_version, target_version)
852
+ if parsed["check"] or not update_available:
853
+ message = (
854
+ f"Update available: cdx-manager {target_version} (current {current_version})"
855
+ if update_available
856
+ else f"cdx-manager {current_version} is already up to date."
857
+ )
858
+ if json_flag:
859
+ _write_json(ctx, _json_success(
860
+ "update",
861
+ message,
862
+ checked=True,
863
+ update_available=update_available,
864
+ current_version=current_version,
865
+ target_version=target_version,
866
+ release_url=release_url,
867
+ warnings=[{
868
+ "code": "update_available",
869
+ "message": message,
870
+ "latest_version": target_version,
871
+ "url": release_url,
872
+ }] if update_available else [],
873
+ ))
874
+ return 0
875
+ ctx["out"](f"{_warn(message, ctx['use_color']) if update_available else _success(message, ctx['use_color'])}\n")
876
+ return 0
877
+
878
+ if not parsed["yes"]:
879
+ if not ctx["stdin_is_tty"]:
880
+ raise CdxError("Update requires an interactive terminal or --yes in non-interactive mode.")
881
+ answer = input(f"Update cdx-manager to {target_version}? [y/N] ")
882
+ if answer.strip().lower() not in ("y", "yes"):
883
+ message = "Cancelled."
884
+ if json_flag:
885
+ _write_json(ctx, _json_success("update", message, cancelled=True, current_version=current_version, target_version=target_version))
886
+ return 0
887
+ ctx["out"](f"{_warn(message, ctx['use_color'])}\n")
888
+ return 0
889
+
890
+ plan = build_update_plan(
891
+ target_version=target_version,
892
+ package_root=ctx["options"].get("packageRoot"),
893
+ prefix=ctx["options"].get("prefix"),
894
+ base_prefix=ctx["options"].get("basePrefix"),
895
+ )
896
+ results = run_update_plan(plan, runner=ctx["options"].get("runUpdate"), env=ctx.get("env"))
897
+ failed = any((result.get("returncode") not in (0, None)) for result in results)
898
+ if failed:
899
+ raise CdxError(format_update_failure(results))
900
+
901
+ message = f"Updated cdx-manager to {target_version}"
902
+ if json_flag:
903
+ _write_json(ctx, _json_success(
904
+ "update",
905
+ message,
906
+ updated=True,
907
+ current_version=current_version,
908
+ target_version=target_version,
909
+ mode=plan["mode"],
910
+ steps=results,
911
+ ))
912
+ return 0
913
+ ctx["out"](f"{_success(message, ctx['use_color'])}\n")
914
+ return 0
915
+
916
+
652
917
  def handle_launch(command, ctx):
653
918
  json_flag = "--json" in ctx["options"].get("raw_args", [])
654
919
  update_notice = ctx.get("update_notice")
@@ -689,3 +954,26 @@ def handle_launch(command, ctx):
689
954
  if json_flag:
690
955
  _write_json(ctx, _json_success("launch", message, warnings=warnings, session=ctx["service"]["get_session"](session["name"])))
691
956
  return 0
957
+
958
+
959
+ def handle_handoff(rest, ctx):
960
+ json_flag, args = _parse_json_flag(rest)
961
+ if len(args) != 1:
962
+ raise CdxError(HANDOFF_USAGE)
963
+ name = args[0]
964
+ session = ctx["service"]["get_session"](name)
965
+ if not session:
966
+ raise CdxError(f"Unknown session: {name}")
967
+ install = install_context_for_session(ctx["service"]["base_dir"], session, ctx.get("cwd"))
968
+ if json_flag:
969
+ _write_json(ctx, _json_success(
970
+ "handoff",
971
+ f"Installed shared context for {name}",
972
+ context=install,
973
+ session=session,
974
+ ))
975
+ return 0
976
+ text = f"Shared context installed for {name}: {install['target_path']}"
977
+ ctx["out"](f"{_info(text, ctx['use_color'])}\n")
978
+ ctx["out"](f"{_dim('Ask the assistant to read shared-context.md if it does not pick it up automatically.', ctx['use_color'])}\n")
979
+ return handle_launch(name, ctx)
package/src/cli_render.py CHANGED
@@ -108,6 +108,7 @@ def _format_sessions(service, use_color=False):
108
108
  headers = ["SESSION"]
109
109
  if has_provider:
110
110
  headers.append("PROVIDER")
111
+ headers.append("STATUS")
111
112
  headers.append("UPDATED")
112
113
  headers = [_style(header, "1", use_color) for header in headers]
113
114
  table_rows = []
@@ -115,6 +116,8 @@ def _format_sessions(service, use_color=False):
115
116
  parts = [r["name"]]
116
117
  if has_provider:
117
118
  parts.append(r.get("provider") or "n/a")
119
+ status = r.get("enabled_status") or ("enabled" if r.get("enabled", True) else "disabled")
120
+ parts.append(_style(status, "2" if status == "disabled" else "32", use_color))
118
121
  parts.append(_dim(_format_relative_age(r.get("updated_at")), use_color))
119
122
  table_rows.append(parts)
120
123
  lines = [_style("Known sessions:", "1", use_color), _pad_table([headers] + table_rows), ""]
@@ -124,6 +127,10 @@ def _format_sessions(service, use_color=False):
124
127
  f" {_style('cdx <name>', '36', use_color)}",
125
128
  f" {_style('cdx login <name>', '36', use_color)}",
126
129
  f" {_style('cdx logout <name>', '36', use_color)}",
130
+ f" {_style('cdx context show', '36', use_color)}",
131
+ f" {_style('cdx handoff <name>', '36', use_color)}",
132
+ f" {_style('cdx disable <name>', '36', use_color)}",
133
+ f" {_style('cdx enable <name>', '36', use_color)}",
127
134
  f" {_style('cdx ren <source> <dest>', '36', use_color)}",
128
135
  f" {_style('cdx rmv <name>', '36', use_color)}",
129
136
  f" {_style('cdx status', '36', use_color)}",
@@ -0,0 +1,127 @@
1
+ import hashlib
2
+ import os
3
+ import shlex
4
+ import subprocess
5
+ from datetime import datetime
6
+
7
+ from .errors import CdxError
8
+ from .session_store import _ensure_dir
9
+
10
+
11
+ DEFAULT_CONTEXT_TEMPLATE = """# Shared Context
12
+
13
+ ## Goal
14
+
15
+ ## Current State
16
+
17
+ ## Decisions
18
+
19
+ ## Files Changed
20
+
21
+ ## Open Questions
22
+
23
+ ## Next Steps
24
+ """
25
+
26
+
27
+ def _local_now_iso():
28
+ return datetime.now().astimezone().isoformat()
29
+
30
+
31
+ def _workspace_key(cwd):
32
+ normalized = os.path.realpath(cwd or os.getcwd())
33
+ digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:16]
34
+ return digest
35
+
36
+
37
+ def _context_dir(base_dir, cwd):
38
+ return os.path.join(base_dir, "contexts", _workspace_key(cwd))
39
+
40
+
41
+ def get_context_path(base_dir, cwd=None):
42
+ return os.path.join(_context_dir(base_dir, cwd), "context.md")
43
+
44
+
45
+ def get_context_meta_path(base_dir, cwd=None):
46
+ return os.path.join(_context_dir(base_dir, cwd), "meta.json")
47
+
48
+
49
+ def read_context(base_dir, cwd=None):
50
+ path = get_context_path(base_dir, cwd)
51
+ try:
52
+ with open(path, "r", encoding="utf-8") as handle:
53
+ return handle.read()
54
+ except FileNotFoundError:
55
+ return ""
56
+
57
+
58
+ def write_context(base_dir, content, cwd=None):
59
+ path = get_context_path(base_dir, cwd)
60
+ _ensure_dir(os.path.dirname(path))
61
+ with open(path, "w", encoding="utf-8") as handle:
62
+ handle.write(content.rstrip() + "\n")
63
+ return {
64
+ "path": path,
65
+ "updated_at": _local_now_iso(),
66
+ "bytes": os.path.getsize(path),
67
+ }
68
+
69
+
70
+ def init_context(base_dir, cwd=None):
71
+ current = read_context(base_dir, cwd)
72
+ if current.strip():
73
+ return {
74
+ "path": get_context_path(base_dir, cwd),
75
+ "created": False,
76
+ "bytes": len(current.encode("utf-8")),
77
+ }
78
+ result = write_context(base_dir, DEFAULT_CONTEXT_TEMPLATE, cwd)
79
+ return {**result, "created": True}
80
+
81
+
82
+ def clear_context(base_dir, cwd=None):
83
+ path = get_context_path(base_dir, cwd)
84
+ try:
85
+ os.remove(path)
86
+ removed = True
87
+ except FileNotFoundError:
88
+ removed = False
89
+ return {"path": path, "removed": removed}
90
+
91
+
92
+ def edit_context(base_dir, cwd=None, editor=None, env=None, spawn_sync=None):
93
+ result = init_context(base_dir, cwd)
94
+ path = result["path"]
95
+ env = env or os.environ
96
+ editor = editor or env.get("VISUAL") or env.get("EDITOR")
97
+ if not editor:
98
+ raise CdxError("Context edit requires VISUAL or EDITOR.")
99
+ spawn_sync = spawn_sync or subprocess.run
100
+ argv = shlex.split(editor) + [path]
101
+ if spawn_sync is subprocess.run:
102
+ completed = subprocess.run(argv)
103
+ returncode = completed.returncode
104
+ else:
105
+ completed = spawn_sync(argv[0], argv[1:], {"env": env})
106
+ returncode = completed.get("returncode", 0) if isinstance(completed, dict) else getattr(completed, "returncode", 0)
107
+ if returncode not in (0, None):
108
+ raise CdxError(f"Context editor exited with code {returncode}")
109
+ return {"path": path, "edited": True}
110
+
111
+
112
+ def install_context_for_session(base_dir, session, cwd=None):
113
+ content = read_context(base_dir, cwd)
114
+ if not content.strip():
115
+ raise CdxError("No shared context for this workspace. Run: cdx context init or cdx context set <text>")
116
+ auth_home = session.get("authHome") or session.get("sessionRoot")
117
+ if not auth_home:
118
+ raise CdxError(f"Session auth home missing for {session['name']}")
119
+ target_path = os.path.join(auth_home, "shared-context.md")
120
+ _ensure_dir(os.path.dirname(target_path))
121
+ with open(target_path, "w", encoding="utf-8") as handle:
122
+ handle.write(content.rstrip() + "\n")
123
+ return {
124
+ "source_path": get_context_path(base_dir, cwd),
125
+ "target_path": target_path,
126
+ "bytes": os.path.getsize(target_path),
127
+ }
@@ -19,10 +19,14 @@ MAX_SESSION_NAME_LENGTH = 64
19
19
  RESERVED_SESSION_NAMES = {
20
20
  "add",
21
21
  "clean",
22
+ "context",
22
23
  "cp",
24
+ "disable",
23
25
  "doctor",
26
+ "enable",
24
27
  "export",
25
28
  "help",
29
+ "handoff",
26
30
  "import",
27
31
  "login",
28
32
  "logout",
@@ -33,6 +37,7 @@ RESERVED_SESSION_NAMES = {
33
37
  "rename",
34
38
  "rmv",
35
39
  "status",
40
+ "update",
36
41
  "version",
37
42
  "--help",
38
43
  "-h",
@@ -272,6 +277,7 @@ def create_session_service(options=None):
272
277
  return {
273
278
  "name": session["name"],
274
279
  "provider": session["provider"],
280
+ "enabled": session.get("enabled", True) is not False,
275
281
  "createdAt": session.get("createdAt"),
276
282
  "updatedAt": session.get("updatedAt"),
277
283
  "lastLaunchedAt": session.get("lastLaunchedAt"),
@@ -324,6 +330,7 @@ def create_session_service(options=None):
324
330
  session = {
325
331
  "name": name,
326
332
  "provider": normalized_provider,
333
+ "enabled": True,
327
334
  "sessionRoot": session_root,
328
335
  "authHome": auth_home,
329
336
  "createdAt": now,
@@ -398,6 +405,7 @@ def create_session_service(options=None):
398
405
  replacement = {
399
406
  "name": dest_name,
400
407
  "provider": source["provider"],
408
+ "enabled": True,
401
409
  "sessionRoot": dest_root,
402
410
  "authHome": dest_auth_home,
403
411
  "createdAt": now,
@@ -484,6 +492,8 @@ def create_session_service(options=None):
484
492
  session = store["get_session"](name)
485
493
  if not session:
486
494
  raise CdxError(f"Unknown session: {name}")
495
+ if session.get("enabled", True) is False:
496
+ raise CdxError(f"Session is disabled: {name}")
487
497
  state = store["read_session_state"](name)
488
498
  if not state:
489
499
  raise CdxError(f"Session state missing for {name}. Reconnect required.")
@@ -514,6 +524,17 @@ def create_session_service(options=None):
514
524
  def get_session(name):
515
525
  return store["get_session"](name)
516
526
 
527
+ def set_session_enabled(name, enabled):
528
+ session = store["get_session"](name)
529
+ if not session:
530
+ raise CdxError(f"Unknown session: {name}")
531
+ now = _local_now_iso()
532
+ return store["update_session"](name, lambda s: {
533
+ **s,
534
+ "enabled": bool(enabled),
535
+ "updatedAt": now,
536
+ })
537
+
517
538
  def record_status(name, payload):
518
539
  normalized = _normalize_status_payload(payload)
519
540
  updated = store["update_session"](name, lambda s: {
@@ -604,10 +625,15 @@ def create_session_service(options=None):
604
625
 
605
626
  def sort_key(s):
606
627
  at = s.get("lastStatusAt") or ""
607
- return ("" if at else "\xff", at, s["name"])
628
+ disabled_rank = 1 if s.get("enabled", True) is False else 0
629
+ return (disabled_rank, "" if at else "\xff", at, s["name"])
608
630
 
609
631
  resolved.sort(key=sort_key)
610
- resolved.reverse()
632
+ enabled = [s for s in resolved if s.get("enabled", True) is not False]
633
+ disabled = [s for s in resolved if s.get("enabled", True) is False]
634
+ enabled.reverse()
635
+ disabled.sort(key=lambda s: s["name"])
636
+ resolved = enabled + disabled
611
637
 
612
638
  rows = []
613
639
  for s in resolved:
@@ -615,6 +641,8 @@ def create_session_service(options=None):
615
641
  rows.append({
616
642
  "session_name": s["name"],
617
643
  "provider": s["provider"],
644
+ "enabled": s.get("enabled", True) is not False,
645
+ "status": "disabled" if s.get("enabled", True) is False else "enabled",
618
646
  "remaining_5h_pct": status.get("remaining_5h_pct") if status else None,
619
647
  "remaining_week_pct": status.get("remaining_week_pct") if status else None,
620
648
  "credits": status.get("credits") if status else None,
@@ -630,9 +658,18 @@ def create_session_service(options=None):
630
658
  sessions = list_sessions()
631
659
  providers = {s["provider"] for s in sessions}
632
660
  has_multiple = len(providers) > 1
661
+ sessions = sorted(
662
+ sessions,
663
+ key=lambda s: (
664
+ 1 if s.get("enabled", True) is False else 0,
665
+ s.get("name", ""),
666
+ ),
667
+ )
633
668
  return [{
634
669
  "name": s["name"],
635
670
  "provider": s["provider"] if has_multiple else None,
671
+ "enabled": s.get("enabled", True) is not False,
672
+ "enabled_status": "disabled" if s.get("enabled", True) is False else "enabled",
636
673
  "status": s.get("lastStatus"),
637
674
  "updated_at": _to_local_iso(s.get("updatedAt")),
638
675
  } for s in sessions]
@@ -722,6 +759,7 @@ def create_session_service(options=None):
722
759
  session_record = {
723
760
  **session_payload,
724
761
  "provider": provider,
762
+ "enabled": session_payload.get("enabled", True) is not False,
725
763
  "sessionRoot": session_root,
726
764
  "authHome": auth_home,
727
765
  }
@@ -762,6 +800,7 @@ def create_session_service(options=None):
762
800
  "ensure_session_state": ensure_session_state,
763
801
  "list_sessions": list_sessions,
764
802
  "get_session": get_session,
803
+ "set_session_enabled": set_session_enabled,
765
804
  "record_status": record_status,
766
805
  "update_auth_state": update_auth_state,
767
806
  "get_status_rows": get_status_rows,
@@ -55,22 +55,29 @@ def _style_reset_time(value, use_color=False):
55
55
  def _format_status_rows(rows, use_color=False, small=False):
56
56
  has_provider = len({r["provider"] for r in rows}) > 1 and not small
57
57
  if small:
58
- headers = ["SESSION", "OK", "5H", "WEEK", "RESET 5H", "RESET WEEK"]
58
+ headers = ["SESSION", "STATUS", "OK", "5H", "WEEK", "RESET 5H", "RESET WEEK"]
59
59
  elif has_provider:
60
- headers = ["SESSION", "PROV.", "OK", "5H", "WEEK", "BLOCK", "CR", "RESET 5H", "RESET WEEK", "UPDATED"]
60
+ headers = ["SESSION", "PROV.", "STATUS", "OK", "5H", "WEEK", "BLOCK", "CR", "RESET 5H", "RESET WEEK", "UPDATED"]
61
61
  else:
62
- headers = ["SESSION", "OK", "5H", "WEEK", "BLOCK", "CR", "RESET 5H", "RESET WEEK", "UPDATED"]
62
+ headers = ["SESSION", "STATUS", "OK", "5H", "WEEK", "BLOCK", "CR", "RESET 5H", "RESET WEEK", "UPDATED"]
63
63
  if not rows:
64
64
  if small:
65
- return "SESSION OK 5H WEEK RESET 5H RESET WEEK\nNo saved sessions yet."
66
- return "SESSION OK 5H WEEK BLOCK CR RESET 5H RESET WEEK UPDATED\nNo saved sessions yet."
65
+ return "SESSION STATUS OK 5H WEEK RESET 5H RESET WEEK\nNo saved sessions yet."
66
+ return "SESSION STATUS OK 5H WEEK BLOCK CR RESET 5H RESET WEEK UPDATED\nNo saved sessions yet."
67
67
  headers = [_style(header, "1", use_color) for header in headers]
68
- priority = _recommend_priority_sessions(rows)
68
+ active_rows = [r for r in rows if r.get("enabled", True) is not False]
69
+ disabled_rows = sorted(
70
+ [r for r in rows if r.get("enabled", True) is False],
71
+ key=lambda r: r.get("session_name") or "",
72
+ )
73
+ priority = _recommend_priority_sessions(active_rows)
69
74
  table_rows = []
70
- for r in priority:
75
+ for r in priority + disabled_rows:
71
76
  base = [r["session_name"]]
72
77
  if has_provider:
73
78
  base.append(r.get("provider") or "n/a")
79
+ status = r.get("status") or ("enabled" if r.get("enabled", True) else "disabled")
80
+ base.append(_style(status, "2" if status == "disabled" else "32", use_color))
74
81
  usage_columns = [
75
82
  _style_pct(r.get("available_pct"), use_color),
76
83
  _style_pct(r.get("remaining_5h_pct"), use_color),
@@ -258,6 +265,7 @@ def _format_status_detail(row, use_color=False):
258
265
  lines = [
259
266
  f"{_style('Session:', '1', use_color)} {row['session_name']}",
260
267
  f"{_style('Provider:', '1', use_color)} {row.get('provider') or 'n/a'}",
268
+ f"{_style('Status:', '1', use_color)} {_style(row.get('status') or ('enabled' if row.get('enabled', True) else 'disabled'), '2' if row.get('enabled', True) is False else '32', use_color)}",
261
269
  f"{_style('Available:', '1', use_color)} {_style_pct(row.get('available_pct'), use_color)}",
262
270
  f"{_style('5h left:', '1', use_color)} {_style_pct(row.get('remaining_5h_pct'), use_color)}",
263
271
  f"{_style('Week left:', '1', use_color)} {_style_pct(row.get('remaining_week_pct'), use_color)}",
@@ -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()}"