cdx-manager 0.3.4 → 0.4.1
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 +68 -11
- package/bin/cdx.js +9 -0
- package/bin/python-runner.js +165 -0
- package/changelogs/CHANGELOGS_0_4_0.md +36 -0
- package/changelogs/CHANGELOGS_0_4_1.md +28 -0
- package/checksums/release-archives.json +13 -0
- package/install.ps1 +21 -1
- package/install.sh +51 -0
- package/package.json +5 -4
- package/pyproject.toml +1 -1
- package/src/backup_bundle.py +134 -0
- package/src/cli.py +54 -4
- package/src/cli_commands.py +242 -13
- package/src/session_service.py +185 -2
- package/src/session_store.py +11 -0
- package/src/status_source.py +3 -1
- package/src/update_check.py +107 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import hashlib
|
|
3
|
+
import hmac
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
|
|
8
|
+
from .errors import CdxError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
BUNDLE_SCHEMA_VERSION = 1
|
|
12
|
+
_SALT_BYTES = 16
|
|
13
|
+
_NONCE_BYTES = 16
|
|
14
|
+
_SCRYPT_N = 2 ** 14
|
|
15
|
+
_SCRYPT_R = 8
|
|
16
|
+
_SCRYPT_P = 1
|
|
17
|
+
_PBKDF2_ITERATIONS = 200000
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _now_iso():
|
|
21
|
+
return datetime.now(timezone.utc).astimezone().isoformat()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _b64_encode(data):
|
|
25
|
+
return base64.b64encode(data).decode("ascii")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _b64_decode(data):
|
|
29
|
+
try:
|
|
30
|
+
return base64.b64decode(data.encode("ascii"))
|
|
31
|
+
except (AttributeError, ValueError, UnicodeEncodeError) as error:
|
|
32
|
+
raise CdxError("Bundle contains invalid base64 data.") from error
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def read_bundle_meta(data):
|
|
36
|
+
try:
|
|
37
|
+
wrapper = json.loads(data.decode("utf-8"))
|
|
38
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
39
|
+
raise CdxError("Invalid bundle format.") from error
|
|
40
|
+
|
|
41
|
+
if wrapper.get("schema_version") != BUNDLE_SCHEMA_VERSION:
|
|
42
|
+
raise CdxError("Unsupported bundle schema version.")
|
|
43
|
+
return wrapper
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _derive_keys(passphrase, salt):
|
|
47
|
+
if not passphrase:
|
|
48
|
+
raise CdxError("A non-empty passphrase is required for bundles that include auth data.")
|
|
49
|
+
if isinstance(passphrase, str):
|
|
50
|
+
passphrase = passphrase.encode("utf-8")
|
|
51
|
+
if hasattr(hashlib, "scrypt"):
|
|
52
|
+
key_material = hashlib.scrypt(
|
|
53
|
+
passphrase,
|
|
54
|
+
salt=salt,
|
|
55
|
+
n=_SCRYPT_N,
|
|
56
|
+
r=_SCRYPT_R,
|
|
57
|
+
p=_SCRYPT_P,
|
|
58
|
+
dklen=64,
|
|
59
|
+
)
|
|
60
|
+
else:
|
|
61
|
+
key_material = hashlib.pbkdf2_hmac(
|
|
62
|
+
"sha256",
|
|
63
|
+
passphrase,
|
|
64
|
+
salt,
|
|
65
|
+
_PBKDF2_ITERATIONS,
|
|
66
|
+
dklen=64,
|
|
67
|
+
)
|
|
68
|
+
return key_material[:32], key_material[32:]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _xor_keystream(data, key, nonce):
|
|
72
|
+
output = bytearray()
|
|
73
|
+
counter = 0
|
|
74
|
+
while len(output) < len(data):
|
|
75
|
+
block = hashlib.sha256(key + nonce + counter.to_bytes(8, "big")).digest()
|
|
76
|
+
output.extend(block)
|
|
77
|
+
counter += 1
|
|
78
|
+
return bytes(a ^ b for a, b in zip(data, output[:len(data)]))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def encode_bundle(payload, include_auth=False, passphrase=None):
|
|
82
|
+
payload_bytes = json.dumps(payload, indent=2, sort_keys=True).encode("utf-8")
|
|
83
|
+
wrapper = {
|
|
84
|
+
"schema_version": BUNDLE_SCHEMA_VERSION,
|
|
85
|
+
"bundle_version": 1,
|
|
86
|
+
"created_at": _now_iso(),
|
|
87
|
+
"include_auth": bool(include_auth),
|
|
88
|
+
"encrypted": bool(include_auth),
|
|
89
|
+
"session_names": [item["name"] for item in payload.get("sessions", [])],
|
|
90
|
+
}
|
|
91
|
+
if include_auth:
|
|
92
|
+
salt = os.urandom(_SALT_BYTES)
|
|
93
|
+
nonce = os.urandom(_NONCE_BYTES)
|
|
94
|
+
enc_key, mac_key = _derive_keys(passphrase, salt)
|
|
95
|
+
ciphertext = _xor_keystream(payload_bytes, enc_key, nonce)
|
|
96
|
+
mac = hmac.new(mac_key, nonce + ciphertext, hashlib.sha256).digest()
|
|
97
|
+
wrapper.update({
|
|
98
|
+
"salt": _b64_encode(salt),
|
|
99
|
+
"nonce": _b64_encode(nonce),
|
|
100
|
+
"hmac_sha256": _b64_encode(mac),
|
|
101
|
+
"payload": _b64_encode(ciphertext),
|
|
102
|
+
})
|
|
103
|
+
else:
|
|
104
|
+
wrapper["payload"] = _b64_encode(payload_bytes)
|
|
105
|
+
return json.dumps(wrapper, indent=2).encode("utf-8")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def decode_bundle(data, passphrase=None):
|
|
109
|
+
wrapper = read_bundle_meta(data)
|
|
110
|
+
|
|
111
|
+
encrypted = bool(wrapper.get("encrypted"))
|
|
112
|
+
payload_b64 = wrapper.get("payload")
|
|
113
|
+
if not isinstance(payload_b64, str):
|
|
114
|
+
raise CdxError("Bundle payload is missing.")
|
|
115
|
+
|
|
116
|
+
if encrypted:
|
|
117
|
+
salt = _b64_decode(wrapper.get("salt", ""))
|
|
118
|
+
nonce = _b64_decode(wrapper.get("nonce", ""))
|
|
119
|
+
expected_mac = _b64_decode(wrapper.get("hmac_sha256", ""))
|
|
120
|
+
ciphertext = _b64_decode(payload_b64)
|
|
121
|
+
enc_key, mac_key = _derive_keys(passphrase, salt)
|
|
122
|
+
actual_mac = hmac.new(mac_key, nonce + ciphertext, hashlib.sha256).digest()
|
|
123
|
+
if not hmac.compare_digest(actual_mac, expected_mac):
|
|
124
|
+
raise CdxError("Invalid bundle passphrase or corrupted bundle.")
|
|
125
|
+
payload_bytes = _xor_keystream(ciphertext, enc_key, nonce)
|
|
126
|
+
else:
|
|
127
|
+
payload_bytes = _b64_decode(payload_b64)
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
payload = json.loads(payload_bytes.decode("utf-8"))
|
|
131
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
132
|
+
raise CdxError("Bundle payload is corrupt.") from error
|
|
133
|
+
|
|
134
|
+
return {"meta": wrapper, "payload": payload}
|
package/src/cli.py
CHANGED
|
@@ -5,11 +5,14 @@ import os
|
|
|
5
5
|
import sys
|
|
6
6
|
|
|
7
7
|
from .cli_commands import (
|
|
8
|
+
API_SCHEMA_VERSION,
|
|
8
9
|
STATUS_USAGE,
|
|
9
10
|
handle_add,
|
|
10
11
|
handle_clean,
|
|
11
12
|
handle_copy,
|
|
12
13
|
handle_doctor,
|
|
14
|
+
handle_export,
|
|
15
|
+
handle_import,
|
|
13
16
|
handle_launch,
|
|
14
17
|
handle_login,
|
|
15
18
|
handle_logout,
|
|
@@ -39,8 +42,9 @@ from .status_view import (
|
|
|
39
42
|
_format_status_detail,
|
|
40
43
|
_format_status_rows,
|
|
41
44
|
)
|
|
45
|
+
from .update_check import check_for_update
|
|
42
46
|
|
|
43
|
-
VERSION = "0.
|
|
47
|
+
VERSION = "0.4.1"
|
|
44
48
|
|
|
45
49
|
|
|
46
50
|
# ---------------------------------------------------------------------------
|
|
@@ -64,6 +68,8 @@ def _print_help(use_color=False):
|
|
|
64
68
|
f" {_style('cdx logout <name> [--json]', '36', use_color)}",
|
|
65
69
|
f" {_style('cdx rmv <name> [--force] [--json]', '36', use_color)}",
|
|
66
70
|
f" {_style('cdx clean [name] [--json]', '36', use_color)}",
|
|
71
|
+
f" {_style('cdx export <file> [--include-auth] [--sessions a,b] [--passphrase-env VAR] [--force] [--json]', '36', use_color)}",
|
|
72
|
+
f" {_style('cdx import <file> [--sessions a,b] [--passphrase-env VAR] [--force] [--json]', '36', use_color)}",
|
|
67
73
|
f" {_style('cdx doctor [--json]', '36', use_color)}",
|
|
68
74
|
f" {_style('cdx repair [--dry-run] [--force] [--json]', '36', use_color)}",
|
|
69
75
|
f" {_style('cdx notify <name> --at-reset [--json]', '36', use_color)}",
|
|
@@ -96,6 +102,7 @@ def format_json_error(error):
|
|
|
96
102
|
elif "requires an interactive terminal" in message or "requires confirmation" in message:
|
|
97
103
|
code = "interactive_terminal_required"
|
|
98
104
|
return json.dumps({
|
|
105
|
+
"schema_version": API_SCHEMA_VERSION,
|
|
99
106
|
"ok": False,
|
|
100
107
|
"error": {
|
|
101
108
|
"code": code,
|
|
@@ -105,6 +112,35 @@ def format_json_error(error):
|
|
|
105
112
|
}, indent=2)
|
|
106
113
|
|
|
107
114
|
|
|
115
|
+
def _get_update_notice(service, env, options):
|
|
116
|
+
checker = options.get("checkForUpdate") or check_for_update
|
|
117
|
+
return checker(
|
|
118
|
+
service["base_dir"],
|
|
119
|
+
VERSION,
|
|
120
|
+
env=env,
|
|
121
|
+
now_fn=options.get("now"),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _update_warning_payload(notice):
|
|
126
|
+
if not notice:
|
|
127
|
+
return []
|
|
128
|
+
message = f"Update available: cdx-manager {notice['latest_version']} (current {VERSION})"
|
|
129
|
+
return [{
|
|
130
|
+
"code": "update_available",
|
|
131
|
+
"message": message,
|
|
132
|
+
"latest_version": notice["latest_version"],
|
|
133
|
+
"url": notice.get("url"),
|
|
134
|
+
}]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _update_warning_text(notice):
|
|
138
|
+
if not notice:
|
|
139
|
+
return None
|
|
140
|
+
suffix = f" {notice['url']}" if notice.get("url") else ""
|
|
141
|
+
return f"Update available: cdx-manager {notice['latest_version']} (current {VERSION}).{suffix}"
|
|
142
|
+
|
|
143
|
+
|
|
108
144
|
# ---------------------------------------------------------------------------
|
|
109
145
|
# main()
|
|
110
146
|
# ---------------------------------------------------------------------------
|
|
@@ -145,11 +181,15 @@ def main(argv, options=None):
|
|
|
145
181
|
|
|
146
182
|
if argv == ["--json"]:
|
|
147
183
|
rows = service["format_list_rows"]()
|
|
148
|
-
|
|
184
|
+
notice = _get_update_notice(service, env, options)
|
|
185
|
+
out(f"{json.dumps(_list_json_payload(rows, notice=notice), indent=2)}\n")
|
|
149
186
|
return 0
|
|
150
187
|
|
|
151
188
|
if not argv:
|
|
189
|
+
notice = _get_update_notice(service, env, options)
|
|
152
190
|
out(f"{_format_sessions(service, use_color=use_color)}\n")
|
|
191
|
+
if notice:
|
|
192
|
+
out(f"{_style(_update_warning_text(notice), '33', use_color)}\n")
|
|
153
193
|
return 0
|
|
154
194
|
|
|
155
195
|
command, *rest = argv
|
|
@@ -165,6 +205,9 @@ def main(argv, options=None):
|
|
|
165
205
|
"spawn": spawn,
|
|
166
206
|
"spawn_sync": spawn_sync,
|
|
167
207
|
"stdin_is_tty": stdin_is_tty,
|
|
208
|
+
"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"
|
|
210
|
+
) else None,
|
|
168
211
|
"use_color": use_color,
|
|
169
212
|
}
|
|
170
213
|
|
|
@@ -183,6 +226,12 @@ def main(argv, options=None):
|
|
|
183
226
|
if command == "clean":
|
|
184
227
|
return handle_clean(rest, ctx)
|
|
185
228
|
|
|
229
|
+
if command == "export":
|
|
230
|
+
return handle_export(rest, ctx)
|
|
231
|
+
|
|
232
|
+
if command == "import":
|
|
233
|
+
return handle_import(rest, ctx)
|
|
234
|
+
|
|
186
235
|
if command == "doctor":
|
|
187
236
|
return handle_doctor(rest, ctx)
|
|
188
237
|
|
|
@@ -215,12 +264,13 @@ def main(argv, options=None):
|
|
|
215
264
|
raise CdxError(f"Unknown command: {command}. Use cdx --help.")
|
|
216
265
|
|
|
217
266
|
|
|
218
|
-
def _list_json_payload(rows):
|
|
267
|
+
def _list_json_payload(rows, notice=None):
|
|
219
268
|
return {
|
|
269
|
+
"schema_version": API_SCHEMA_VERSION,
|
|
220
270
|
"ok": True,
|
|
221
271
|
"action": "list",
|
|
222
272
|
"message": "Listed known sessions",
|
|
223
|
-
"warnings":
|
|
273
|
+
"warnings": _update_warning_payload(notice),
|
|
224
274
|
"sessions": rows,
|
|
225
275
|
}
|
|
226
276
|
|
package/src/cli_commands.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import getpass
|
|
2
3
|
import json
|
|
3
4
|
import os
|
|
4
5
|
from datetime import datetime
|
|
@@ -6,10 +7,9 @@ from datetime import datetime
|
|
|
6
7
|
from .claude_refresh import _refresh_claude_sessions
|
|
7
8
|
from .cli_render import _dim, _info, _success, _warn
|
|
8
9
|
from .errors import CdxError
|
|
9
|
-
from .health import collect_health_report, format_health_report
|
|
10
|
+
from .health import collect_health_report, format_health_report
|
|
10
11
|
from .notify import (
|
|
11
12
|
format_notify_event,
|
|
12
|
-
notify_json,
|
|
13
13
|
parse_notify_args,
|
|
14
14
|
send_desktop_notification,
|
|
15
15
|
wait_for_notification_event,
|
|
@@ -19,25 +19,30 @@ from .provider_runtime import (
|
|
|
19
19
|
_list_launch_transcript_paths,
|
|
20
20
|
_run_interactive_provider_command,
|
|
21
21
|
)
|
|
22
|
-
from .repair import format_repair_report, repair_health
|
|
22
|
+
from .repair import format_repair_report, repair_health
|
|
23
|
+
from .backup_bundle import read_bundle_meta
|
|
23
24
|
from .status_view import _format_status_detail, _format_status_rows
|
|
24
25
|
|
|
25
26
|
|
|
26
27
|
STATUS_USAGE = "Usage: cdx status [--json] [--refresh] | cdx status --small|-s [--refresh] | cdx status <name> [--json] [--refresh]"
|
|
27
28
|
DOCTOR_USAGE = "Usage: cdx doctor [--json]"
|
|
28
29
|
REPAIR_USAGE = "Usage: cdx repair [--dry-run] [--force] [--json]"
|
|
30
|
+
EXPORT_USAGE = "Usage: cdx export <file> [--include-auth] [--force] [--json] [--sessions name1,name2] [--passphrase-env VAR]"
|
|
31
|
+
IMPORT_USAGE = "Usage: cdx import <file> [--force] [--json] [--sessions name1,name2] [--passphrase-env VAR]"
|
|
32
|
+
API_SCHEMA_VERSION = 1
|
|
29
33
|
|
|
30
34
|
|
|
31
35
|
def _local_now_iso():
|
|
32
36
|
return datetime.now().astimezone().isoformat()
|
|
33
37
|
|
|
34
38
|
|
|
35
|
-
def _json_success(action, message, **extra):
|
|
39
|
+
def _json_success(action, message, warnings=None, **extra):
|
|
36
40
|
payload = {
|
|
41
|
+
"schema_version": API_SCHEMA_VERSION,
|
|
37
42
|
"ok": True,
|
|
38
43
|
"action": action,
|
|
39
44
|
"message": message,
|
|
40
|
-
"warnings": [],
|
|
45
|
+
"warnings": warnings or [],
|
|
41
46
|
}
|
|
42
47
|
payload.update(extra)
|
|
43
48
|
return payload
|
|
@@ -82,6 +87,142 @@ def _parse_remove_args(args):
|
|
|
82
87
|
return {"name": names[0], "force": force}
|
|
83
88
|
|
|
84
89
|
|
|
90
|
+
def _read_option_value(args, index, usage):
|
|
91
|
+
if index + 1 >= len(args):
|
|
92
|
+
raise CdxError(usage)
|
|
93
|
+
return args[index + 1], index + 2
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _parse_session_names(value):
|
|
97
|
+
if value is None:
|
|
98
|
+
return None
|
|
99
|
+
names = [item.strip() for item in value.split(",") if item.strip()]
|
|
100
|
+
if not names:
|
|
101
|
+
raise CdxError("At least one session name is required in --sessions.")
|
|
102
|
+
return names
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _parse_export_args(args):
|
|
106
|
+
parsed = {
|
|
107
|
+
"file_path": None,
|
|
108
|
+
"include_auth": False,
|
|
109
|
+
"force": False,
|
|
110
|
+
"json": False,
|
|
111
|
+
"session_names": None,
|
|
112
|
+
"passphrase_env": None,
|
|
113
|
+
}
|
|
114
|
+
index = 0
|
|
115
|
+
while index < len(args):
|
|
116
|
+
arg = args[index]
|
|
117
|
+
if arg == "--include-auth":
|
|
118
|
+
parsed["include_auth"] = True
|
|
119
|
+
index += 1
|
|
120
|
+
continue
|
|
121
|
+
if arg == "--force":
|
|
122
|
+
parsed["force"] = True
|
|
123
|
+
index += 1
|
|
124
|
+
continue
|
|
125
|
+
if arg == "--json":
|
|
126
|
+
parsed["json"] = True
|
|
127
|
+
index += 1
|
|
128
|
+
continue
|
|
129
|
+
if arg == "--sessions":
|
|
130
|
+
value, index = _read_option_value(args, index, EXPORT_USAGE)
|
|
131
|
+
parsed["session_names"] = _parse_session_names(value)
|
|
132
|
+
continue
|
|
133
|
+
if arg.startswith("--sessions="):
|
|
134
|
+
parsed["session_names"] = _parse_session_names(arg.split("=", 1)[1])
|
|
135
|
+
index += 1
|
|
136
|
+
continue
|
|
137
|
+
if arg == "--passphrase-env":
|
|
138
|
+
value, index = _read_option_value(args, index, EXPORT_USAGE)
|
|
139
|
+
parsed["passphrase_env"] = value
|
|
140
|
+
continue
|
|
141
|
+
if arg.startswith("--passphrase-env="):
|
|
142
|
+
parsed["passphrase_env"] = arg.split("=", 1)[1]
|
|
143
|
+
index += 1
|
|
144
|
+
continue
|
|
145
|
+
if arg.startswith("-"):
|
|
146
|
+
raise CdxError(EXPORT_USAGE)
|
|
147
|
+
if parsed["file_path"] is not None:
|
|
148
|
+
raise CdxError(EXPORT_USAGE)
|
|
149
|
+
parsed["file_path"] = arg
|
|
150
|
+
index += 1
|
|
151
|
+
|
|
152
|
+
if not parsed["file_path"]:
|
|
153
|
+
raise CdxError(EXPORT_USAGE)
|
|
154
|
+
if parsed["passphrase_env"] and not parsed["include_auth"]:
|
|
155
|
+
raise CdxError("--passphrase-env requires --include-auth for export.")
|
|
156
|
+
return parsed
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _parse_import_args(args):
|
|
160
|
+
parsed = {
|
|
161
|
+
"file_path": None,
|
|
162
|
+
"force": False,
|
|
163
|
+
"json": False,
|
|
164
|
+
"session_names": None,
|
|
165
|
+
"passphrase_env": None,
|
|
166
|
+
}
|
|
167
|
+
index = 0
|
|
168
|
+
while index < len(args):
|
|
169
|
+
arg = args[index]
|
|
170
|
+
if arg == "--force":
|
|
171
|
+
parsed["force"] = True
|
|
172
|
+
index += 1
|
|
173
|
+
continue
|
|
174
|
+
if arg == "--json":
|
|
175
|
+
parsed["json"] = True
|
|
176
|
+
index += 1
|
|
177
|
+
continue
|
|
178
|
+
if arg == "--sessions":
|
|
179
|
+
value, index = _read_option_value(args, index, IMPORT_USAGE)
|
|
180
|
+
parsed["session_names"] = _parse_session_names(value)
|
|
181
|
+
continue
|
|
182
|
+
if arg.startswith("--sessions="):
|
|
183
|
+
parsed["session_names"] = _parse_session_names(arg.split("=", 1)[1])
|
|
184
|
+
index += 1
|
|
185
|
+
continue
|
|
186
|
+
if arg == "--passphrase-env":
|
|
187
|
+
value, index = _read_option_value(args, index, IMPORT_USAGE)
|
|
188
|
+
parsed["passphrase_env"] = value
|
|
189
|
+
continue
|
|
190
|
+
if arg.startswith("--passphrase-env="):
|
|
191
|
+
parsed["passphrase_env"] = arg.split("=", 1)[1]
|
|
192
|
+
index += 1
|
|
193
|
+
continue
|
|
194
|
+
if arg.startswith("-"):
|
|
195
|
+
raise CdxError(IMPORT_USAGE)
|
|
196
|
+
if parsed["file_path"] is not None:
|
|
197
|
+
raise CdxError(IMPORT_USAGE)
|
|
198
|
+
parsed["file_path"] = arg
|
|
199
|
+
index += 1
|
|
200
|
+
|
|
201
|
+
if not parsed["file_path"]:
|
|
202
|
+
raise CdxError(IMPORT_USAGE)
|
|
203
|
+
return parsed
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _resolve_bundle_passphrase(ctx, env_var, prompt, confirm=False):
|
|
207
|
+
env = ctx.get("env", {})
|
|
208
|
+
if env_var:
|
|
209
|
+
passphrase = env.get(env_var)
|
|
210
|
+
if not passphrase:
|
|
211
|
+
raise CdxError(f"Environment variable {env_var} is empty or unset.")
|
|
212
|
+
return passphrase
|
|
213
|
+
if not ctx["stdin_is_tty"]:
|
|
214
|
+
raise CdxError("Encrypted bundle export/import requires an interactive terminal or --passphrase-env.")
|
|
215
|
+
getpass_fn = ctx["options"].get("getpass") or getpass.getpass
|
|
216
|
+
passphrase = getpass_fn(prompt)
|
|
217
|
+
if not passphrase:
|
|
218
|
+
raise CdxError("Bundle passphrase cannot be empty.")
|
|
219
|
+
if confirm:
|
|
220
|
+
confirmation = getpass_fn("Confirm bundle passphrase: ")
|
|
221
|
+
if passphrase != confirmation:
|
|
222
|
+
raise CdxError("Bundle passphrase confirmation does not match.")
|
|
223
|
+
return passphrase
|
|
224
|
+
|
|
225
|
+
|
|
85
226
|
def _confirm_removal(name):
|
|
86
227
|
answer = input(f"Remove session {name}? [y/N] ")
|
|
87
228
|
return answer.strip().lower() in ("y", "yes")
|
|
@@ -269,7 +410,7 @@ def handle_doctor(rest, ctx):
|
|
|
269
410
|
env=ctx.get("env"),
|
|
270
411
|
)
|
|
271
412
|
if json_flag:
|
|
272
|
-
ctx
|
|
413
|
+
_write_json(ctx, _json_success("doctor", "Collected health report", report=report))
|
|
273
414
|
else:
|
|
274
415
|
ctx["out"](f"{format_health_report(report, use_color=ctx['use_color'])}\n")
|
|
275
416
|
return 0
|
|
@@ -291,7 +432,7 @@ def handle_repair(rest, ctx):
|
|
|
291
432
|
force=force,
|
|
292
433
|
)
|
|
293
434
|
if json_flag:
|
|
294
|
-
ctx
|
|
435
|
+
_write_json(ctx, _json_success("repair", "Collected repair report", report=report))
|
|
295
436
|
else:
|
|
296
437
|
ctx["out"](f"{format_repair_report(report, use_color=ctx['use_color'])}\n")
|
|
297
438
|
if dry_run:
|
|
@@ -318,7 +459,7 @@ def handle_notify(rest, ctx):
|
|
|
318
459
|
now_fn=ctx["options"].get("now"),
|
|
319
460
|
)
|
|
320
461
|
if parsed["json"]:
|
|
321
|
-
ctx
|
|
462
|
+
_write_json(ctx, _json_success("notify", "Resolved notification event", event=event))
|
|
322
463
|
else:
|
|
323
464
|
ctx["out"](f"{format_notify_event(event)}\n")
|
|
324
465
|
return 0
|
|
@@ -349,12 +490,19 @@ def handle_status(rest, ctx):
|
|
|
349
490
|
}
|
|
350
491
|
for item in refresh_result.get("errors", [])
|
|
351
492
|
]
|
|
493
|
+
warnings = [
|
|
494
|
+
{
|
|
495
|
+
"code": "claude_refresh_failed",
|
|
496
|
+
"session": item.get("session") or "unknown",
|
|
497
|
+
"message": item.get("error") or "unknown error",
|
|
498
|
+
}
|
|
499
|
+
for item in refresh_errors
|
|
500
|
+
]
|
|
352
501
|
|
|
353
502
|
rows = ctx["service"]["get_status_rows"]()
|
|
354
503
|
if len(args) == 0:
|
|
355
504
|
if json_flag:
|
|
356
|
-
ctx
|
|
357
|
-
_write_refresh_warnings(refresh_errors, ctx, stream="err")
|
|
505
|
+
_write_json(ctx, _json_success("status", "Collected session status rows", warnings=warnings, rows=rows))
|
|
358
506
|
return 0
|
|
359
507
|
ctx["out"](f"{_format_status_rows(rows, use_color=ctx['use_color'], small=small_flag)}\n")
|
|
360
508
|
_write_refresh_warnings(refresh_errors, ctx)
|
|
@@ -364,8 +512,7 @@ def handle_status(rest, ctx):
|
|
|
364
512
|
if not row:
|
|
365
513
|
raise CdxError(f"Unknown session: {args[0]}")
|
|
366
514
|
if json_flag:
|
|
367
|
-
ctx
|
|
368
|
-
_write_refresh_warnings(refresh_errors, ctx, stream="err")
|
|
515
|
+
_write_json(ctx, _json_success("status", f"Collected status for {args[0]}", warnings=warnings, session=row))
|
|
369
516
|
return 0
|
|
370
517
|
ctx["out"](f"{_format_status_detail(row, use_color=ctx['use_color'])}\n")
|
|
371
518
|
_write_refresh_warnings(refresh_errors, ctx)
|
|
@@ -380,6 +527,74 @@ def _write_refresh_warnings(refresh_errors, ctx, stream="out"):
|
|
|
380
527
|
write(f"{_warn(f'Warning: Claude refresh failed for {session}: {error}', ctx['use_color'])}\n")
|
|
381
528
|
|
|
382
529
|
|
|
530
|
+
def handle_export(rest, ctx):
|
|
531
|
+
parsed = _parse_export_args(rest)
|
|
532
|
+
passphrase = None
|
|
533
|
+
if parsed["include_auth"]:
|
|
534
|
+
passphrase = _resolve_bundle_passphrase(
|
|
535
|
+
ctx,
|
|
536
|
+
parsed["passphrase_env"],
|
|
537
|
+
"Bundle passphrase: ",
|
|
538
|
+
confirm=True,
|
|
539
|
+
)
|
|
540
|
+
result = ctx["service"]["export_bundle"](
|
|
541
|
+
parsed["file_path"],
|
|
542
|
+
include_auth=parsed["include_auth"],
|
|
543
|
+
session_names=parsed["session_names"],
|
|
544
|
+
passphrase=passphrase,
|
|
545
|
+
force=parsed["force"],
|
|
546
|
+
)
|
|
547
|
+
session_count = len(result["session_names"])
|
|
548
|
+
auth_suffix = " with auth" if result["include_auth"] else ""
|
|
549
|
+
message = f"Exported {session_count} session{'s' if session_count != 1 else ''}{auth_suffix} to {result['path']}"
|
|
550
|
+
payload = _json_success(
|
|
551
|
+
"export",
|
|
552
|
+
message,
|
|
553
|
+
bundle=result,
|
|
554
|
+
)
|
|
555
|
+
if parsed["json"]:
|
|
556
|
+
_write_json(ctx, payload)
|
|
557
|
+
return 0
|
|
558
|
+
ctx["out"](f"{_success(message, ctx['use_color'])}\n")
|
|
559
|
+
return 0
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def handle_import(rest, ctx):
|
|
563
|
+
parsed = _parse_import_args(rest)
|
|
564
|
+
passphrase = None
|
|
565
|
+
try:
|
|
566
|
+
with open(parsed["file_path"], "rb") as handle:
|
|
567
|
+
meta = read_bundle_meta(handle.read())
|
|
568
|
+
except OSError as error:
|
|
569
|
+
raise CdxError(f"Bundle file not found: {parsed['file_path']}") from error
|
|
570
|
+
if meta.get("encrypted"):
|
|
571
|
+
passphrase = _resolve_bundle_passphrase(
|
|
572
|
+
ctx,
|
|
573
|
+
parsed["passphrase_env"],
|
|
574
|
+
"Bundle passphrase: ",
|
|
575
|
+
confirm=False,
|
|
576
|
+
)
|
|
577
|
+
result = ctx["service"]["import_bundle"](
|
|
578
|
+
parsed["file_path"],
|
|
579
|
+
passphrase=passphrase,
|
|
580
|
+
session_names=parsed["session_names"],
|
|
581
|
+
force=parsed["force"],
|
|
582
|
+
)
|
|
583
|
+
session_count = len(result["session_names"])
|
|
584
|
+
auth_suffix = " with auth" if result["include_auth"] else ""
|
|
585
|
+
message = f"Imported {session_count} session{'s' if session_count != 1 else ''}{auth_suffix} from {result['path']}"
|
|
586
|
+
payload = _json_success(
|
|
587
|
+
"import",
|
|
588
|
+
message,
|
|
589
|
+
bundle=result,
|
|
590
|
+
)
|
|
591
|
+
if parsed["json"]:
|
|
592
|
+
_write_json(ctx, payload)
|
|
593
|
+
return 0
|
|
594
|
+
ctx["out"](f"{_success(message, ctx['use_color'])}\n")
|
|
595
|
+
return 0
|
|
596
|
+
|
|
597
|
+
|
|
383
598
|
def handle_login(rest, ctx):
|
|
384
599
|
json_flag, args = _parse_json_flag(rest)
|
|
385
600
|
if len(args) != 1:
|
|
@@ -436,6 +651,15 @@ def handle_logout(rest, ctx):
|
|
|
436
651
|
|
|
437
652
|
def handle_launch(command, ctx):
|
|
438
653
|
json_flag = "--json" in ctx["options"].get("raw_args", [])
|
|
654
|
+
update_notice = ctx.get("update_notice")
|
|
655
|
+
warnings = []
|
|
656
|
+
if update_notice:
|
|
657
|
+
warnings.append({
|
|
658
|
+
"code": "update_available",
|
|
659
|
+
"message": f"Update available: cdx-manager {update_notice['latest_version']}",
|
|
660
|
+
"latest_version": update_notice["latest_version"],
|
|
661
|
+
"url": update_notice.get("url"),
|
|
662
|
+
})
|
|
439
663
|
session = ctx["service"]["launch_session"](command)
|
|
440
664
|
_ensure_session_authentication(
|
|
441
665
|
session,
|
|
@@ -450,6 +674,11 @@ def handle_launch(command, ctx):
|
|
|
450
674
|
message = f"Launching {session['provider']} session {session['name']}"
|
|
451
675
|
if not json_flag:
|
|
452
676
|
ctx["out"](f"{_info(message, ctx['use_color'])}\n")
|
|
677
|
+
if update_notice:
|
|
678
|
+
text = f"Update available: cdx-manager {update_notice['latest_version']} (current version installed may be older)."
|
|
679
|
+
if update_notice.get("url"):
|
|
680
|
+
text = f"{text} {update_notice['url']}"
|
|
681
|
+
ctx["out"](f"{_warn(text, ctx['use_color'])}\n")
|
|
453
682
|
if session["provider"] == "codex":
|
|
454
683
|
if not json_flag:
|
|
455
684
|
ctx["out"](f"{_dim('Tip: run /status once the Codex session opens.', ctx['use_color'])}\n")
|
|
@@ -458,5 +687,5 @@ def handle_launch(command, ctx):
|
|
|
458
687
|
signal_emitter=ctx.get("signal_emitter")
|
|
459
688
|
)
|
|
460
689
|
if json_flag:
|
|
461
|
-
_write_json(ctx, _json_success("launch", message, session=ctx["service"]["get_session"](session["name"])))
|
|
690
|
+
_write_json(ctx, _json_success("launch", message, warnings=warnings, session=ctx["service"]["get_session"](session["name"])))
|
|
462
691
|
return 0
|