cdx-manager 0.3.4 → 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 +58 -7
- package/changelogs/CHANGELOGS_0_4_0.md +36 -0
- package/checksums/release-archives.json +9 -0
- package/install.ps1 +21 -1
- package/install.sh +51 -0
- package/package.json +2 -1
- 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
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
|
package/src/session_service.py
CHANGED
|
@@ -2,10 +2,12 @@ import os
|
|
|
2
2
|
import shutil
|
|
3
3
|
import json
|
|
4
4
|
import base64
|
|
5
|
+
import sys
|
|
5
6
|
import tempfile
|
|
6
7
|
from datetime import datetime, timezone
|
|
7
8
|
from urllib.parse import quote
|
|
8
9
|
|
|
10
|
+
from .backup_bundle import decode_bundle, encode_bundle
|
|
9
11
|
from .config import get_cdx_home
|
|
10
12
|
from .errors import CdxError
|
|
11
13
|
from .session_store import create_session_store
|
|
@@ -13,12 +15,15 @@ from .status_source import find_latest_status_artifact
|
|
|
13
15
|
|
|
14
16
|
DEFAULT_PROVIDER = "codex"
|
|
15
17
|
ALLOWED_PROVIDERS = {"codex", "claude"}
|
|
18
|
+
MAX_SESSION_NAME_LENGTH = 64
|
|
16
19
|
RESERVED_SESSION_NAMES = {
|
|
17
20
|
"add",
|
|
18
21
|
"clean",
|
|
19
22
|
"cp",
|
|
20
23
|
"doctor",
|
|
24
|
+
"export",
|
|
21
25
|
"help",
|
|
26
|
+
"import",
|
|
22
27
|
"login",
|
|
23
28
|
"logout",
|
|
24
29
|
"mv",
|
|
@@ -40,10 +45,27 @@ def _encode(name):
|
|
|
40
45
|
return quote(name, safe="")
|
|
41
46
|
|
|
42
47
|
|
|
48
|
+
def _ensure_private_dir(path):
|
|
49
|
+
os.makedirs(path, exist_ok=True)
|
|
50
|
+
if sys.platform == "win32":
|
|
51
|
+
return
|
|
52
|
+
try:
|
|
53
|
+
os.chmod(path, 0o700)
|
|
54
|
+
except OSError:
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
|
|
43
58
|
def _local_now_iso():
|
|
44
59
|
return datetime.now().astimezone().isoformat()
|
|
45
60
|
|
|
46
61
|
|
|
62
|
+
def _safe_relpath(path):
|
|
63
|
+
normalized = str(path or "").replace("\\", "/").strip("/")
|
|
64
|
+
if not normalized or normalized.startswith("../") or "/../" in f"/{normalized}/":
|
|
65
|
+
raise CdxError("Bundle contains an unsafe file path.")
|
|
66
|
+
return normalized
|
|
67
|
+
|
|
68
|
+
|
|
47
69
|
def _to_local_iso(value):
|
|
48
70
|
if not value:
|
|
49
71
|
return value
|
|
@@ -222,15 +244,65 @@ def create_session_service(options=None):
|
|
|
222
244
|
def _validate_new_session_name(name):
|
|
223
245
|
if not name:
|
|
224
246
|
raise CdxError("Session name is required")
|
|
247
|
+
if str(name) != str(name).strip():
|
|
248
|
+
raise CdxError("Session name cannot start or end with whitespace")
|
|
249
|
+
if len(str(name)) > MAX_SESSION_NAME_LENGTH:
|
|
250
|
+
raise CdxError(f"Session name is too long (max {MAX_SESSION_NAME_LENGTH} characters)")
|
|
251
|
+
if any(ord(ch) < 32 or ord(ch) == 127 for ch in str(name)):
|
|
252
|
+
raise CdxError("Session name cannot contain control characters")
|
|
225
253
|
if name in RESERVED_SESSION_NAMES:
|
|
226
254
|
raise CdxError(f"Session name is reserved: {name}")
|
|
227
255
|
|
|
256
|
+
def _build_export_session_record(session):
|
|
257
|
+
return {
|
|
258
|
+
"name": session["name"],
|
|
259
|
+
"provider": session["provider"],
|
|
260
|
+
"createdAt": session.get("createdAt"),
|
|
261
|
+
"updatedAt": session.get("updatedAt"),
|
|
262
|
+
"lastLaunchedAt": session.get("lastLaunchedAt"),
|
|
263
|
+
"lastStatusAt": session.get("lastStatusAt"),
|
|
264
|
+
"lastStatus": session.get("lastStatus"),
|
|
265
|
+
"auth": session.get("auth"),
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
def _collect_profile_files(session_root):
|
|
269
|
+
excluded_dirs = {"log", "tmp", "cache", "__pycache__", "shell_snapshots"}
|
|
270
|
+
files = []
|
|
271
|
+
if not os.path.isdir(session_root):
|
|
272
|
+
return files
|
|
273
|
+
for dirpath, dirnames, filenames in os.walk(session_root):
|
|
274
|
+
dirnames[:] = [name for name in dirnames if name not in excluded_dirs]
|
|
275
|
+
for filename in filenames:
|
|
276
|
+
full_path = os.path.join(dirpath, filename)
|
|
277
|
+
if not os.path.isfile(full_path):
|
|
278
|
+
continue
|
|
279
|
+
rel_path = os.path.relpath(full_path, session_root)
|
|
280
|
+
with open(full_path, "rb") as handle:
|
|
281
|
+
content = base64.b64encode(handle.read()).decode("ascii")
|
|
282
|
+
files.append({"path": rel_path.replace(os.sep, "/"), "data_b64": content})
|
|
283
|
+
return files
|
|
284
|
+
|
|
285
|
+
def _resolve_session_subset(session_names):
|
|
286
|
+
if not session_names:
|
|
287
|
+
return list_sessions()
|
|
288
|
+
by_name = {session["name"]: session for session in list_sessions()}
|
|
289
|
+
selected = []
|
|
290
|
+
for name in session_names:
|
|
291
|
+
session = by_name.get(name)
|
|
292
|
+
if not session:
|
|
293
|
+
raise CdxError(f"Unknown session: {name}")
|
|
294
|
+
selected.append(session)
|
|
295
|
+
return selected
|
|
296
|
+
|
|
228
297
|
def create_session(name, provider=DEFAULT_PROVIDER):
|
|
229
298
|
_validate_new_session_name(name)
|
|
230
299
|
normalized_provider = _normalize_provider(provider)
|
|
231
300
|
session_root = _get_session_root(name)
|
|
232
301
|
auth_home = _get_session_auth_home(name, normalized_provider)
|
|
233
|
-
|
|
302
|
+
_ensure_private_dir(base_dir)
|
|
303
|
+
_ensure_private_dir(os.path.join(base_dir, "profiles"))
|
|
304
|
+
_ensure_private_dir(session_root)
|
|
305
|
+
_ensure_private_dir(auth_home)
|
|
234
306
|
now = _local_now_iso()
|
|
235
307
|
session = {
|
|
236
308
|
"name": name,
|
|
@@ -514,7 +586,6 @@ def create_session_service(options=None):
|
|
|
514
586
|
rows.append({
|
|
515
587
|
"session_name": s["name"],
|
|
516
588
|
"provider": s["provider"],
|
|
517
|
-
"auth_home": s.get("authHome") or _get_session_auth_home(s["name"], s["provider"]),
|
|
518
589
|
"remaining_5h_pct": status.get("remaining_5h_pct") if status else None,
|
|
519
590
|
"remaining_week_pct": status.get("remaining_week_pct") if status else None,
|
|
520
591
|
"credits": status.get("credits") if status else None,
|
|
@@ -543,6 +614,116 @@ def create_session_service(options=None):
|
|
|
543
614
|
def get_session_root(name):
|
|
544
615
|
return _get_session_root(name)
|
|
545
616
|
|
|
617
|
+
def export_bundle(file_path, include_auth=False, session_names=None, passphrase=None, force=False):
|
|
618
|
+
if not file_path:
|
|
619
|
+
raise CdxError("Export path is required.")
|
|
620
|
+
if os.path.exists(file_path) and not force:
|
|
621
|
+
raise CdxError(f"Export path already exists: {file_path}")
|
|
622
|
+
|
|
623
|
+
sessions = _resolve_session_subset(session_names)
|
|
624
|
+
payload = {
|
|
625
|
+
"schema_version": 1,
|
|
626
|
+
"created_at": _local_now_iso(),
|
|
627
|
+
"include_auth": bool(include_auth),
|
|
628
|
+
"sessions": [],
|
|
629
|
+
"states": {},
|
|
630
|
+
"profiles": {},
|
|
631
|
+
}
|
|
632
|
+
for session in sessions:
|
|
633
|
+
payload["sessions"].append(_build_export_session_record(session))
|
|
634
|
+
state = store["read_session_state"](session["name"])
|
|
635
|
+
if state is not None:
|
|
636
|
+
payload["states"][session["name"]] = state
|
|
637
|
+
if include_auth:
|
|
638
|
+
session_root = session.get("sessionRoot") or _get_session_root(session["name"])
|
|
639
|
+
payload["profiles"][session["name"]] = _collect_profile_files(session_root)
|
|
640
|
+
|
|
641
|
+
bundle_bytes = encode_bundle(payload, include_auth=include_auth, passphrase=passphrase)
|
|
642
|
+
_ensure_private_dir(os.path.dirname(os.path.abspath(file_path)) or ".")
|
|
643
|
+
with open(file_path, "wb") as handle:
|
|
644
|
+
handle.write(bundle_bytes)
|
|
645
|
+
if sys.platform != "win32":
|
|
646
|
+
try:
|
|
647
|
+
os.chmod(file_path, 0o600)
|
|
648
|
+
except OSError:
|
|
649
|
+
pass
|
|
650
|
+
return {
|
|
651
|
+
"path": file_path,
|
|
652
|
+
"include_auth": include_auth,
|
|
653
|
+
"session_names": [session["name"] for session in sessions],
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
def import_bundle(file_path, passphrase=None, session_names=None, force=False):
|
|
657
|
+
if not file_path or not os.path.isfile(file_path):
|
|
658
|
+
raise CdxError(f"Bundle file not found: {file_path}")
|
|
659
|
+
with open(file_path, "rb") as handle:
|
|
660
|
+
decoded = decode_bundle(handle.read(), passphrase=passphrase)
|
|
661
|
+
payload = decoded["payload"]
|
|
662
|
+
imported_sessions = payload.get("sessions") or []
|
|
663
|
+
if payload.get("schema_version") != 1:
|
|
664
|
+
raise CdxError("Unsupported bundle payload schema version.")
|
|
665
|
+
|
|
666
|
+
selected_names = set(session_names or [])
|
|
667
|
+
if selected_names:
|
|
668
|
+
imported_sessions = [item for item in imported_sessions if item["name"] in selected_names]
|
|
669
|
+
missing_names = sorted(selected_names - {item["name"] for item in imported_sessions})
|
|
670
|
+
if missing_names:
|
|
671
|
+
raise CdxError(f"Bundle does not contain requested sessions: {', '.join(missing_names)}")
|
|
672
|
+
names = [item["name"] for item in imported_sessions]
|
|
673
|
+
|
|
674
|
+
existing = {session["name"] for session in list_sessions()}
|
|
675
|
+
conflicts = [name for name in names if name in existing]
|
|
676
|
+
if conflicts and not force:
|
|
677
|
+
raise CdxError(f"Import would overwrite existing sessions: {', '.join(conflicts)}")
|
|
678
|
+
|
|
679
|
+
for session_payload in imported_sessions:
|
|
680
|
+
name = session_payload["name"]
|
|
681
|
+
_validate_new_session_name(name)
|
|
682
|
+
provider = _normalize_provider(session_payload["provider"])
|
|
683
|
+
if name in existing:
|
|
684
|
+
remove_session(name)
|
|
685
|
+
|
|
686
|
+
session_root = _get_session_root(name)
|
|
687
|
+
auth_home = _get_session_auth_home(name, provider)
|
|
688
|
+
_ensure_private_dir(base_dir)
|
|
689
|
+
_ensure_private_dir(os.path.join(base_dir, "profiles"))
|
|
690
|
+
_ensure_private_dir(session_root)
|
|
691
|
+
_ensure_private_dir(auth_home)
|
|
692
|
+
|
|
693
|
+
session_record = {
|
|
694
|
+
**session_payload,
|
|
695
|
+
"provider": provider,
|
|
696
|
+
"sessionRoot": session_root,
|
|
697
|
+
"authHome": auth_home,
|
|
698
|
+
}
|
|
699
|
+
store["replace_session"](name, session_record)
|
|
700
|
+
|
|
701
|
+
state = (payload.get("states") or {}).get(name)
|
|
702
|
+
if state is not None:
|
|
703
|
+
store["write_session_state"](name, state)
|
|
704
|
+
|
|
705
|
+
for item in (payload.get("profiles") or {}).get(name, []):
|
|
706
|
+
rel_path = _safe_relpath(item.get("path"))
|
|
707
|
+
try:
|
|
708
|
+
content = base64.b64decode(item.get("data_b64", "").encode("ascii"))
|
|
709
|
+
except (AttributeError, ValueError, UnicodeEncodeError) as error:
|
|
710
|
+
raise CdxError(f"Bundle contains invalid file data for session {name}: {rel_path}") from error
|
|
711
|
+
dest_path = os.path.join(session_root, rel_path)
|
|
712
|
+
_ensure_private_dir(os.path.dirname(dest_path))
|
|
713
|
+
with open(dest_path, "wb") as handle:
|
|
714
|
+
handle.write(content)
|
|
715
|
+
if sys.platform != "win32":
|
|
716
|
+
try:
|
|
717
|
+
os.chmod(dest_path, 0o600)
|
|
718
|
+
except OSError:
|
|
719
|
+
pass
|
|
720
|
+
|
|
721
|
+
return {
|
|
722
|
+
"path": file_path,
|
|
723
|
+
"session_names": names,
|
|
724
|
+
"include_auth": bool(decoded["meta"].get("include_auth")),
|
|
725
|
+
}
|
|
726
|
+
|
|
546
727
|
return {
|
|
547
728
|
"create_session": create_session,
|
|
548
729
|
"remove_session": remove_session,
|
|
@@ -558,6 +739,8 @@ def create_session_service(options=None):
|
|
|
558
739
|
"format_list_rows": format_list_rows,
|
|
559
740
|
"get_session_auth_home": get_session_auth_home,
|
|
560
741
|
"get_session_root": get_session_root,
|
|
742
|
+
"export_bundle": export_bundle,
|
|
743
|
+
"import_bundle": import_bundle,
|
|
561
744
|
"base_dir": base_dir,
|
|
562
745
|
"normalize_provider": _normalize_provider,
|
|
563
746
|
}
|
package/src/session_store.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import os
|
|
3
|
+
import sys
|
|
3
4
|
import tempfile
|
|
4
5
|
from contextlib import contextmanager
|
|
5
6
|
from pathlib import Path
|
|
@@ -9,6 +10,16 @@ from .errors import CdxError
|
|
|
9
10
|
|
|
10
11
|
def _ensure_dir(path):
|
|
11
12
|
Path(path).mkdir(parents=True, exist_ok=True)
|
|
13
|
+
_restrict_dir_permissions(path)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _restrict_dir_permissions(path):
|
|
17
|
+
if sys.platform == "win32":
|
|
18
|
+
return
|
|
19
|
+
try:
|
|
20
|
+
os.chmod(path, 0o700)
|
|
21
|
+
except OSError:
|
|
22
|
+
pass
|
|
12
23
|
|
|
13
24
|
|
|
14
25
|
def _read_json(file_path, fallback):
|
package/src/status_source.py
CHANGED
|
@@ -119,7 +119,9 @@ def _extract_status_blocks_from_text(text, provider=None, source_ref=None, times
|
|
|
119
119
|
r"^To continue this session\b", r"^╰",
|
|
120
120
|
]],
|
|
121
121
|
context_pattern=re.compile(
|
|
122
|
-
r"^\s
|
|
122
|
+
r"^\s*$"
|
|
123
|
+
r"|^\s*[│|](?:\s|[│|])*$"
|
|
124
|
+
r"|^\s*(?:[│|]\s*)?(?:╭|Visit\b|information\b|Model:|Directory:|Permissions:|Agents\.md:|Account:|Collaboration mode:|Session:)",
|
|
123
125
|
re.I,
|
|
124
126
|
),
|
|
125
127
|
context_stop_patterns=[
|