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 +21 -2
- package/changelogs/CHANGELOGS_0_4_4.md +33 -0
- package/changelogs/CHANGELOGS_0_5_0.md +45 -0
- package/package.json +3 -1
- package/pyproject.toml +1 -1
- package/src/claude_refresh.py +1 -0
- package/src/cli.py +31 -2
- package/src/cli_commands.py +288 -0
- package/src/cli_render.py +7 -0
- package/src/context_store.py +127 -0
- package/src/session_service.py +41 -2
- package/src/status_view.py +15 -7
- package/src/update_check.py +13 -3
- package/src/update_manager.py +208 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# CDX Manager
|
|
2
2
|
|
|
3
|
-
[](LICENSE) ](LICENSE)  
|
|
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.
|
|
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.
|
|
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
package/src/claude_refresh.py
CHANGED
|
@@ -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.
|
|
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
|
|
package/src/cli_commands.py
CHANGED
|
@@ -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
|
+
}
|
package/src/session_service.py
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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,
|
package/src/status_view.py
CHANGED
|
@@ -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
|
-
|
|
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)}",
|
package/src/update_check.py
CHANGED
|
@@ -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
|
-
|
|
87
|
-
|
|
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()}"
|