cdx-manager 0.3.3 → 0.4.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 +183 -13
- package/bin/cdx +5 -2
- package/changelogs/CHANGELOGS_0_3_4.md +31 -0
- package/changelogs/CHANGELOGS_0_4_0.md +36 -0
- package/checksums/release-archives.json +9 -0
- package/install.ps1 +102 -0
- package/install.sh +51 -0
- package/package.json +3 -1
- package/pyproject.toml +1 -1
- package/src/backup_bundle.py +134 -0
- package/src/cli.py +111 -13
- package/src/cli_commands.py +360 -38
- package/src/health.py +13 -1
- package/src/provider_runtime.py +10 -1
- 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
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,36 +19,62 @@ 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
|
|
|
39
|
+
def _json_success(action, message, warnings=None, **extra):
|
|
40
|
+
payload = {
|
|
41
|
+
"schema_version": API_SCHEMA_VERSION,
|
|
42
|
+
"ok": True,
|
|
43
|
+
"action": action,
|
|
44
|
+
"message": message,
|
|
45
|
+
"warnings": warnings or [],
|
|
46
|
+
}
|
|
47
|
+
payload.update(extra)
|
|
48
|
+
return payload
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _write_json(ctx, payload):
|
|
52
|
+
ctx["out"](f"{json.dumps(payload, indent=2)}\n")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _parse_json_flag(args):
|
|
56
|
+
json_flag = "--json" in args
|
|
57
|
+
cleaned = [arg for arg in args if arg != "--json"]
|
|
58
|
+
return json_flag, cleaned
|
|
59
|
+
|
|
60
|
+
|
|
35
61
|
def _parse_add_args(args):
|
|
36
62
|
if len(args) == 1:
|
|
37
63
|
return {"provider": "codex", "name": args[0]}
|
|
38
64
|
if len(args) == 2:
|
|
39
65
|
return {"provider": args[0], "name": args[1]}
|
|
40
|
-
raise CdxError("Usage: cdx add [provider] <name>")
|
|
66
|
+
raise CdxError("Usage: cdx add [provider] <name> [--json]")
|
|
41
67
|
|
|
42
68
|
|
|
43
69
|
def _parse_copy_args(args):
|
|
44
70
|
if len(args) != 2:
|
|
45
|
-
raise CdxError("Usage: cdx cp <source> <dest>")
|
|
71
|
+
raise CdxError("Usage: cdx cp <source> <dest> [--json]")
|
|
46
72
|
return {"source": args[0], "dest": args[1]}
|
|
47
73
|
|
|
48
74
|
|
|
49
75
|
def _parse_rename_args(args):
|
|
50
76
|
if len(args) != 2:
|
|
51
|
-
raise CdxError("Usage: cdx ren <source> <dest>")
|
|
77
|
+
raise CdxError("Usage: cdx ren <source> <dest> [--json]")
|
|
52
78
|
return {"source": args[0], "dest": args[1]}
|
|
53
79
|
|
|
54
80
|
|
|
@@ -57,10 +83,146 @@ def _parse_remove_args(args):
|
|
|
57
83
|
names = [a for a in args if a != "--force"]
|
|
58
84
|
unknown = [a for a in args if a.startswith("-") and a != "--force"]
|
|
59
85
|
if unknown or len(names) != 1 or len(args) > 2:
|
|
60
|
-
raise CdxError("Usage: cdx rmv <name> [--force]")
|
|
86
|
+
raise CdxError("Usage: cdx rmv <name> [--force] [--json]")
|
|
61
87
|
return {"name": names[0], "force": force}
|
|
62
88
|
|
|
63
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
|
+
|
|
64
226
|
def _confirm_removal(name):
|
|
65
227
|
answer = input(f"Remove session {name}? [y/N] ")
|
|
66
228
|
return answer.strip().lower() in ("y", "yes")
|
|
@@ -78,10 +240,10 @@ def _resolve_confirmation(confirm_fn, name):
|
|
|
78
240
|
|
|
79
241
|
|
|
80
242
|
def handle_add(rest, ctx):
|
|
81
|
-
|
|
243
|
+
json_flag, args = _parse_json_flag(rest)
|
|
244
|
+
parsed = _parse_add_args(args)
|
|
82
245
|
session = ctx["service"]["create_session"](parsed["name"], parsed["provider"])
|
|
83
246
|
message = f"Created session {parsed['name']} ({parsed['provider']})"
|
|
84
|
-
ctx["out"](f"{_success(message, ctx['use_color'])}\n")
|
|
85
247
|
_ensure_session_authentication(
|
|
86
248
|
session,
|
|
87
249
|
ctx["service"],
|
|
@@ -100,28 +262,50 @@ def handle_add(rest, ctx):
|
|
|
100
262
|
"lastAuthenticatedAt": now,
|
|
101
263
|
"lastLoggedOutAt": auth.get("lastLoggedOutAt"),
|
|
102
264
|
})
|
|
265
|
+
if json_flag:
|
|
266
|
+
_write_json(ctx, _json_success(
|
|
267
|
+
"add",
|
|
268
|
+
message,
|
|
269
|
+
session=ctx["service"]["get_session"](parsed["name"]),
|
|
270
|
+
))
|
|
271
|
+
return 0
|
|
272
|
+
ctx["out"](f"{_success(message, ctx['use_color'])}\n")
|
|
103
273
|
return 0
|
|
104
274
|
|
|
105
275
|
|
|
106
276
|
def handle_copy(rest, ctx):
|
|
107
|
-
|
|
277
|
+
json_flag, args = _parse_json_flag(rest)
|
|
278
|
+
parsed = _parse_copy_args(args)
|
|
108
279
|
result = ctx["service"]["copy_session"](parsed["source"], parsed["dest"])
|
|
109
280
|
overwritten = " (overwritten)" if result["overwritten"] else ""
|
|
110
281
|
message = f"Copied session {parsed['source']} to {parsed['dest']}{overwritten}"
|
|
282
|
+
if json_flag:
|
|
283
|
+
_write_json(ctx, _json_success(
|
|
284
|
+
"copy",
|
|
285
|
+
message,
|
|
286
|
+
session=result["session"],
|
|
287
|
+
overwritten=result["overwritten"],
|
|
288
|
+
))
|
|
289
|
+
return 0
|
|
111
290
|
ctx["out"](f"{_success(message, ctx['use_color'])}\n")
|
|
112
291
|
return 0
|
|
113
292
|
|
|
114
293
|
|
|
115
294
|
def handle_rename(rest, ctx):
|
|
116
|
-
|
|
117
|
-
|
|
295
|
+
json_flag, args = _parse_json_flag(rest)
|
|
296
|
+
parsed = _parse_rename_args(args)
|
|
297
|
+
session = ctx["service"]["rename_session"](parsed["source"], parsed["dest"])
|
|
118
298
|
message = f"Renamed session {parsed['source']} to {parsed['dest']}"
|
|
299
|
+
if json_flag:
|
|
300
|
+
_write_json(ctx, _json_success("rename", message, session=session))
|
|
301
|
+
return 0
|
|
119
302
|
ctx["out"](f"{_success(message, ctx['use_color'])}\n")
|
|
120
303
|
return 0
|
|
121
304
|
|
|
122
305
|
|
|
123
306
|
def handle_remove(rest, ctx):
|
|
124
|
-
|
|
307
|
+
json_flag, args = _parse_json_flag(rest)
|
|
308
|
+
parsed = _parse_remove_args(args)
|
|
125
309
|
if not parsed["force"]:
|
|
126
310
|
confirm_fn = ctx["options"].get("confirmRemove")
|
|
127
311
|
if confirm_fn:
|
|
@@ -131,30 +315,47 @@ def handle_remove(rest, ctx):
|
|
|
131
315
|
else:
|
|
132
316
|
confirmed = _confirm_removal(parsed["name"])
|
|
133
317
|
if not confirmed:
|
|
318
|
+
if json_flag:
|
|
319
|
+
_write_json(ctx, _json_success("remove", "Cancelled.", cancelled=True, session=None))
|
|
320
|
+
return 0
|
|
134
321
|
ctx["out"](f"{_warn('Cancelled.', ctx['use_color'])}\n")
|
|
135
322
|
return 0
|
|
136
|
-
ctx["service"]["remove_session"](parsed["name"])
|
|
323
|
+
removed = ctx["service"]["remove_session"](parsed["name"])
|
|
137
324
|
message = f"Removed session {parsed['name']}"
|
|
325
|
+
if json_flag:
|
|
326
|
+
_write_json(ctx, _json_success("remove", message, session=removed, cancelled=False))
|
|
327
|
+
return 0
|
|
138
328
|
ctx["out"](f"{_success(message, ctx['use_color'])}\n")
|
|
139
329
|
return 0
|
|
140
330
|
|
|
141
331
|
|
|
142
332
|
def handle_clean(rest, ctx):
|
|
333
|
+
json_flag, args = _parse_json_flag(rest)
|
|
143
334
|
service = ctx["service"]
|
|
144
|
-
if len(
|
|
335
|
+
if len(args) == 0:
|
|
145
336
|
targets = service["list_sessions"]()
|
|
146
|
-
elif len(
|
|
147
|
-
session = service["get_session"](
|
|
337
|
+
elif len(args) == 1:
|
|
338
|
+
session = service["get_session"](args[0])
|
|
148
339
|
if not session:
|
|
149
|
-
raise CdxError(f"Unknown session: {
|
|
340
|
+
raise CdxError(f"Unknown session: {args[0]}")
|
|
150
341
|
targets = [session]
|
|
151
342
|
else:
|
|
152
|
-
raise CdxError("Usage: cdx clean [name]")
|
|
343
|
+
raise CdxError("Usage: cdx clean [name] [--json]")
|
|
153
344
|
|
|
345
|
+
cleaned_sessions = []
|
|
154
346
|
for session in targets:
|
|
155
347
|
log_paths = _list_launch_transcript_paths(session)
|
|
156
348
|
if not log_paths:
|
|
157
349
|
message = f"{session['name']}: no log found"
|
|
350
|
+
cleaned_sessions.append({
|
|
351
|
+
"session_name": session["name"],
|
|
352
|
+
"cleared": False,
|
|
353
|
+
"files_cleared": 0,
|
|
354
|
+
"freed_kb": 0,
|
|
355
|
+
"message": message,
|
|
356
|
+
})
|
|
357
|
+
if json_flag:
|
|
358
|
+
continue
|
|
158
359
|
ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
|
|
159
360
|
continue
|
|
160
361
|
total_size = 0
|
|
@@ -171,10 +372,30 @@ def handle_clean(rest, ctx):
|
|
|
171
372
|
f"Cleared {session['name']} logs ({cleared} file"
|
|
172
373
|
f"{'' if cleared == 1 else 's'}, {round(total_size / 1024)} KB freed)"
|
|
173
374
|
)
|
|
375
|
+
cleaned_sessions.append({
|
|
376
|
+
"session_name": session["name"],
|
|
377
|
+
"cleared": True,
|
|
378
|
+
"files_cleared": cleared,
|
|
379
|
+
"freed_kb": round(total_size / 1024),
|
|
380
|
+
"message": message,
|
|
381
|
+
})
|
|
382
|
+
if json_flag:
|
|
383
|
+
continue
|
|
174
384
|
ctx["out"](f"{_success(message, ctx['use_color'])}\n")
|
|
175
385
|
else:
|
|
176
386
|
message = f"{session['name']}: no log found"
|
|
387
|
+
cleaned_sessions.append({
|
|
388
|
+
"session_name": session["name"],
|
|
389
|
+
"cleared": False,
|
|
390
|
+
"files_cleared": 0,
|
|
391
|
+
"freed_kb": 0,
|
|
392
|
+
"message": message,
|
|
393
|
+
})
|
|
394
|
+
if json_flag:
|
|
395
|
+
continue
|
|
177
396
|
ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
|
|
397
|
+
if json_flag:
|
|
398
|
+
_write_json(ctx, _json_success("clean", "Cleaned session logs", sessions=cleaned_sessions))
|
|
178
399
|
return 0
|
|
179
400
|
|
|
180
401
|
|
|
@@ -189,7 +410,7 @@ def handle_doctor(rest, ctx):
|
|
|
189
410
|
env=ctx.get("env"),
|
|
190
411
|
)
|
|
191
412
|
if json_flag:
|
|
192
|
-
ctx
|
|
413
|
+
_write_json(ctx, _json_success("doctor", "Collected health report", report=report))
|
|
193
414
|
else:
|
|
194
415
|
ctx["out"](f"{format_health_report(report, use_color=ctx['use_color'])}\n")
|
|
195
416
|
return 0
|
|
@@ -211,7 +432,7 @@ def handle_repair(rest, ctx):
|
|
|
211
432
|
force=force,
|
|
212
433
|
)
|
|
213
434
|
if json_flag:
|
|
214
|
-
ctx
|
|
435
|
+
_write_json(ctx, _json_success("repair", "Collected repair report", report=report))
|
|
215
436
|
else:
|
|
216
437
|
ctx["out"](f"{format_repair_report(report, use_color=ctx['use_color'])}\n")
|
|
217
438
|
if dry_run:
|
|
@@ -238,7 +459,7 @@ def handle_notify(rest, ctx):
|
|
|
238
459
|
now_fn=ctx["options"].get("now"),
|
|
239
460
|
)
|
|
240
461
|
if parsed["json"]:
|
|
241
|
-
ctx
|
|
462
|
+
_write_json(ctx, _json_success("notify", "Resolved notification event", event=event))
|
|
242
463
|
else:
|
|
243
464
|
ctx["out"](f"{format_notify_event(event)}\n")
|
|
244
465
|
return 0
|
|
@@ -269,12 +490,19 @@ def handle_status(rest, ctx):
|
|
|
269
490
|
}
|
|
270
491
|
for item in refresh_result.get("errors", [])
|
|
271
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
|
+
]
|
|
272
501
|
|
|
273
502
|
rows = ctx["service"]["get_status_rows"]()
|
|
274
503
|
if len(args) == 0:
|
|
275
504
|
if json_flag:
|
|
276
|
-
ctx
|
|
277
|
-
_write_refresh_warnings(refresh_errors, ctx, stream="err")
|
|
505
|
+
_write_json(ctx, _json_success("status", "Collected session status rows", warnings=warnings, rows=rows))
|
|
278
506
|
return 0
|
|
279
507
|
ctx["out"](f"{_format_status_rows(rows, use_color=ctx['use_color'], small=small_flag)}\n")
|
|
280
508
|
_write_refresh_warnings(refresh_errors, ctx)
|
|
@@ -284,8 +512,7 @@ def handle_status(rest, ctx):
|
|
|
284
512
|
if not row:
|
|
285
513
|
raise CdxError(f"Unknown session: {args[0]}")
|
|
286
514
|
if json_flag:
|
|
287
|
-
ctx
|
|
288
|
-
_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))
|
|
289
516
|
return 0
|
|
290
517
|
ctx["out"](f"{_format_status_detail(row, use_color=ctx['use_color'])}\n")
|
|
291
518
|
_write_refresh_warnings(refresh_errors, ctx)
|
|
@@ -300,14 +527,83 @@ def _write_refresh_warnings(refresh_errors, ctx, stream="out"):
|
|
|
300
527
|
write(f"{_warn(f'Warning: Claude refresh failed for {session}: {error}', ctx['use_color'])}\n")
|
|
301
528
|
|
|
302
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
|
+
|
|
303
598
|
def handle_login(rest, ctx):
|
|
304
|
-
|
|
305
|
-
|
|
599
|
+
json_flag, args = _parse_json_flag(rest)
|
|
600
|
+
if len(args) != 1:
|
|
601
|
+
raise CdxError("Usage: cdx login <name> [--json]")
|
|
306
602
|
if not ctx["stdin_is_tty"]:
|
|
307
603
|
raise CdxError("Login requires an interactive terminal.")
|
|
308
|
-
session = ctx["service"]["get_session"](
|
|
604
|
+
session = ctx["service"]["get_session"](args[0])
|
|
309
605
|
if not session:
|
|
310
|
-
raise CdxError(f"Unknown session: {
|
|
606
|
+
raise CdxError(f"Unknown session: {args[0]}")
|
|
311
607
|
_run_interactive_provider_command(
|
|
312
608
|
session, "logout", spawn=ctx.get("spawn"), env_override=ctx.get("env"),
|
|
313
609
|
signal_emitter=ctx.get("signal_emitter")
|
|
@@ -317,36 +613,53 @@ def handle_login(rest, ctx):
|
|
|
317
613
|
signal_emitter=ctx.get("signal_emitter")
|
|
318
614
|
)
|
|
319
615
|
now = _local_now_iso()
|
|
320
|
-
ctx["service"]["update_auth_state"](
|
|
616
|
+
ctx["service"]["update_auth_state"](args[0], lambda auth: {
|
|
321
617
|
**auth, "status": "authenticated",
|
|
322
618
|
"lastCheckedAt": now, "lastAuthenticatedAt": now,
|
|
323
619
|
})
|
|
324
620
|
message = f"Reauthenticated session {session['name']} ({session['provider']})"
|
|
621
|
+
if json_flag:
|
|
622
|
+
_write_json(ctx, _json_success("login", message, session=ctx["service"]["get_session"](session["name"])))
|
|
623
|
+
return 0
|
|
325
624
|
ctx["out"](f"{_success(message, ctx['use_color'])}\n")
|
|
326
625
|
return 0
|
|
327
626
|
|
|
328
627
|
|
|
329
628
|
def handle_logout(rest, ctx):
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
629
|
+
json_flag, args = _parse_json_flag(rest)
|
|
630
|
+
if len(args) != 1:
|
|
631
|
+
raise CdxError("Usage: cdx logout <name> [--json]")
|
|
632
|
+
session = ctx["service"]["get_session"](args[0])
|
|
333
633
|
if not session:
|
|
334
|
-
raise CdxError(f"Unknown session: {
|
|
634
|
+
raise CdxError(f"Unknown session: {args[0]}")
|
|
335
635
|
_run_interactive_provider_command(
|
|
336
636
|
session, "logout", spawn=ctx.get("spawn"), env_override=ctx.get("env"),
|
|
337
637
|
signal_emitter=ctx.get("signal_emitter")
|
|
338
638
|
)
|
|
339
639
|
now = _local_now_iso()
|
|
340
|
-
ctx["service"]["update_auth_state"](
|
|
640
|
+
ctx["service"]["update_auth_state"](args[0], lambda auth: {
|
|
341
641
|
**auth, "status": "logged_out",
|
|
342
642
|
"lastCheckedAt": now, "lastLoggedOutAt": now,
|
|
343
643
|
})
|
|
344
644
|
message = f"Logged out session {session['name']} ({session['provider']})"
|
|
645
|
+
if json_flag:
|
|
646
|
+
_write_json(ctx, _json_success("logout", message, session=ctx["service"]["get_session"](session["name"])))
|
|
647
|
+
return 0
|
|
345
648
|
ctx["out"](f"{_success(message, ctx['use_color'])}\n")
|
|
346
649
|
return 0
|
|
347
650
|
|
|
348
651
|
|
|
349
652
|
def handle_launch(command, ctx):
|
|
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
|
+
})
|
|
350
663
|
session = ctx["service"]["launch_session"](command)
|
|
351
664
|
_ensure_session_authentication(
|
|
352
665
|
session,
|
|
@@ -359,11 +672,20 @@ def handle_launch(command, ctx):
|
|
|
359
672
|
signal_emitter=ctx.get("signal_emitter"),
|
|
360
673
|
)
|
|
361
674
|
message = f"Launching {session['provider']} session {session['name']}"
|
|
362
|
-
|
|
675
|
+
if not json_flag:
|
|
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")
|
|
363
682
|
if session["provider"] == "codex":
|
|
364
|
-
|
|
683
|
+
if not json_flag:
|
|
684
|
+
ctx["out"](f"{_dim('Tip: run /status once the Codex session opens.', ctx['use_color'])}\n")
|
|
365
685
|
_run_interactive_provider_command(
|
|
366
686
|
session, "launch", spawn=ctx.get("spawn"), env_override=ctx.get("env"),
|
|
367
687
|
signal_emitter=ctx.get("signal_emitter")
|
|
368
688
|
)
|
|
689
|
+
if json_flag:
|
|
690
|
+
_write_json(ctx, _json_success("launch", message, warnings=warnings, session=ctx["service"]["get_session"](session["name"])))
|
|
369
691
|
return 0
|
package/src/health.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import os
|
|
3
3
|
import shutil
|
|
4
|
+
import sys
|
|
4
5
|
import tempfile
|
|
5
6
|
from urllib.parse import quote, unquote
|
|
6
7
|
|
|
@@ -43,7 +44,7 @@ def collect_health_report(service, base_dir, env=None):
|
|
|
43
44
|
issues.append(_issue(
|
|
44
45
|
"OK" if script_path else "WARN",
|
|
45
46
|
"script_cli",
|
|
46
|
-
|
|
47
|
+
_script_cli_message(script_bin, bool(script_path)),
|
|
47
48
|
script_path,
|
|
48
49
|
))
|
|
49
50
|
|
|
@@ -74,6 +75,17 @@ def _check_cdx_home(base_dir):
|
|
|
74
75
|
return _issue("FAIL", "cdx_home_writable", "CDX_HOME is not writable", f"{base_dir}: {error}")
|
|
75
76
|
|
|
76
77
|
|
|
78
|
+
def _script_cli_message(script_bin, is_available):
|
|
79
|
+
if is_available:
|
|
80
|
+
return f"{script_bin} CLI found"
|
|
81
|
+
if sys.platform == "win32":
|
|
82
|
+
return (
|
|
83
|
+
f"{script_bin} CLI not found; Codex will launch without transcript capture "
|
|
84
|
+
f"(expected on many Windows setups)"
|
|
85
|
+
)
|
|
86
|
+
return f"{script_bin} CLI not found; Codex will launch without transcript fallback"
|
|
87
|
+
|
|
88
|
+
|
|
77
89
|
def _collect_profile_issues(base_dir, session_names):
|
|
78
90
|
profile_dir = _profiles_dir(base_dir)
|
|
79
91
|
if not os.path.isdir(profile_dir):
|
package/src/provider_runtime.py
CHANGED
|
@@ -187,6 +187,15 @@ def _signal_exit_code(sig):
|
|
|
187
187
|
return mapping.get(sig, 1)
|
|
188
188
|
|
|
189
189
|
|
|
190
|
+
def _signal_name(sig):
|
|
191
|
+
if hasattr(sig, "name"):
|
|
192
|
+
return sig.name
|
|
193
|
+
try:
|
|
194
|
+
return signal.Signals(sig).name
|
|
195
|
+
except (TypeError, ValueError):
|
|
196
|
+
return str(sig)
|
|
197
|
+
|
|
198
|
+
|
|
190
199
|
def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
|
|
191
200
|
env_override=None, signal_emitter=None):
|
|
192
201
|
spawn = spawn or subprocess.Popen
|
|
@@ -260,7 +269,7 @@ def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
|
|
|
260
269
|
|
|
261
270
|
if forwarded_signal[0] is not None:
|
|
262
271
|
raise CdxError(
|
|
263
|
-
f"{spec['label']} interrupted by {forwarded_signal[0]
|
|
272
|
+
f"{spec['label']} interrupted by {_signal_name(forwarded_signal[0])} for session {session['name']}",
|
|
264
273
|
_signal_exit_code(forwarded_signal[0]),
|
|
265
274
|
)
|
|
266
275
|
if child.returncode != 0:
|