cdx-manager 0.12.3 → 0.14.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 +135 -6
- package/changelogs/CHANGELOGS_0_12_3.md +5 -0
- package/changelogs/CHANGELOGS_0_12_4.md +18 -0
- package/changelogs/CHANGELOGS_0_14_0.md +146 -0
- package/package.json +3 -3
- package/pyproject.toml +1 -1
- package/src/cli.py +65 -5
- package/src/cli_args.py +274 -33
- package/src/cli_commands.py +251 -3049
- package/src/cli_helpers.py +177 -2
- package/src/commands/__init__.py +6 -0
- package/src/commands/auth.py +224 -0
- package/src/commands/backup.py +112 -0
- package/src/commands/context_memory.py +341 -0
- package/src/commands/launch.py +218 -0
- package/src/commands/lifecycle.py +154 -0
- package/src/commands/maintenance.py +723 -0
- package/src/commands/runs.py +562 -0
- package/src/commands/settings.py +107 -0
- package/src/commands/status.py +681 -0
- package/src/config.py +41 -0
- package/src/errors.py +17 -0
- package/src/health.py +151 -3
- package/src/notify.py +11 -5
- package/src/provider_runtime.py +46 -11
- package/src/run_command.py +112 -4
- package/src/run_registry.py +53 -2
- package/src/session_backup.py +343 -0
- package/src/session_helpers.py +179 -0
- package/src/session_ranking.py +259 -0
- package/src/session_service.py +574 -1402
- package/src/session_status.py +534 -0
- package/src/status_view.py +15 -34
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""Backup domain: exporting and importing session bundles.
|
|
2
|
+
|
|
3
|
+
Split out of session_service.py along the second of the two seams the req_027
|
|
4
|
+
coupling measurement supports. Moved verbatim; re-exported by session_service.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import base64
|
|
8
|
+
import binascii
|
|
9
|
+
import os
|
|
10
|
+
import shutil
|
|
11
|
+
import tempfile
|
|
12
|
+
|
|
13
|
+
from .backup_bundle import decode_bundle, encode_bundle
|
|
14
|
+
from .config import PROVIDER_CLAUDE
|
|
15
|
+
from .errors import CdxError
|
|
16
|
+
from .fs_utils import atomic_write, remove_tree
|
|
17
|
+
from .session_helpers import (
|
|
18
|
+
_encode,
|
|
19
|
+
_ensure_private_dir,
|
|
20
|
+
_get_session_auth_home,
|
|
21
|
+
_get_session_root,
|
|
22
|
+
_local_now_iso,
|
|
23
|
+
_normalize_provider,
|
|
24
|
+
_validate_new_session_name,
|
|
25
|
+
list_sessions,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
_FORCE_IMPORT_PRESERVED_PROFILE_PATHS = ("plugins",)
|
|
29
|
+
|
|
30
|
+
def _safe_relpath(path):
|
|
31
|
+
normalized = os.path.normpath(str(path or "").replace("\\", "/")).replace("\\", "/")
|
|
32
|
+
if (
|
|
33
|
+
not normalized
|
|
34
|
+
or normalized == "."
|
|
35
|
+
or normalized.startswith("/")
|
|
36
|
+
or (len(normalized) > 1 and normalized[1] == ":")
|
|
37
|
+
or normalized == ".."
|
|
38
|
+
or normalized.startswith("../")
|
|
39
|
+
):
|
|
40
|
+
raise CdxError("Bundle contains an unsafe file path.")
|
|
41
|
+
return normalized
|
|
42
|
+
|
|
43
|
+
def _build_export_session_record(session):
|
|
44
|
+
return {
|
|
45
|
+
"name": session["name"],
|
|
46
|
+
"provider": session["provider"],
|
|
47
|
+
"label": session.get("label"),
|
|
48
|
+
"enabled": session.get("enabled", True) is not False,
|
|
49
|
+
"createdAt": session.get("createdAt"),
|
|
50
|
+
"updatedAt": session.get("updatedAt"),
|
|
51
|
+
"lastLaunchedAt": session.get("lastLaunchedAt"),
|
|
52
|
+
"lastStatusAt": session.get("lastStatusAt"),
|
|
53
|
+
"lastStatus": session.get("lastStatus"),
|
|
54
|
+
"launch": session.get("launch"),
|
|
55
|
+
"auth": session.get("auth"),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
def _auth_bundle_paths(provider):
|
|
59
|
+
if provider == PROVIDER_CLAUDE:
|
|
60
|
+
return [
|
|
61
|
+
"claude-home/configs/default.json",
|
|
62
|
+
"claude-home/credentials/default.json",
|
|
63
|
+
"claude-home/.claude/.credentials.json",
|
|
64
|
+
"claude-home/.claude.json",
|
|
65
|
+
"claude-home/auth.json",
|
|
66
|
+
]
|
|
67
|
+
return [
|
|
68
|
+
"auth.json",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
def _collect_auth_files(session_root, provider, session_name=None, progress_callback=None):
|
|
72
|
+
files = []
|
|
73
|
+
total_bytes = 0
|
|
74
|
+
if not os.path.isdir(session_root):
|
|
75
|
+
return {"files": files, "file_count": 0, "bytes": 0}
|
|
76
|
+
for rel_path in _auth_bundle_paths(provider):
|
|
77
|
+
full_path = os.path.join(session_root, rel_path)
|
|
78
|
+
if not os.path.isfile(full_path):
|
|
79
|
+
continue
|
|
80
|
+
with open(full_path, "rb") as handle:
|
|
81
|
+
raw_content = handle.read()
|
|
82
|
+
total_bytes += len(raw_content)
|
|
83
|
+
content = base64.b64encode(raw_content).decode("ascii")
|
|
84
|
+
files.append({"path": rel_path.replace(os.sep, "/"), "data_b64": content})
|
|
85
|
+
if progress_callback:
|
|
86
|
+
progress_callback({
|
|
87
|
+
"event": "profile_progress",
|
|
88
|
+
"session_name": session_name,
|
|
89
|
+
"file_count": len(files),
|
|
90
|
+
"bytes": total_bytes,
|
|
91
|
+
})
|
|
92
|
+
return {"files": files, "file_count": len(files), "bytes": total_bytes}
|
|
93
|
+
|
|
94
|
+
def _validate_import_profile_files(payload, imported_sessions):
|
|
95
|
+
profiles = payload.get("profiles") or {}
|
|
96
|
+
if not isinstance(profiles, dict):
|
|
97
|
+
raise CdxError("Bundle contains invalid profile data.")
|
|
98
|
+
decoded_profiles = {}
|
|
99
|
+
for session_payload in imported_sessions:
|
|
100
|
+
name = session_payload["name"]
|
|
101
|
+
files = []
|
|
102
|
+
profile_items = profiles.get(name, [])
|
|
103
|
+
if not isinstance(profile_items, list):
|
|
104
|
+
raise CdxError(f"Bundle contains invalid profile data for session {name}.")
|
|
105
|
+
for item in profile_items:
|
|
106
|
+
if not isinstance(item, dict):
|
|
107
|
+
raise CdxError(f"Bundle contains invalid profile entry for session {name}.")
|
|
108
|
+
rel_path = _safe_relpath(item.get("path"))
|
|
109
|
+
data_b64 = item.get("data_b64")
|
|
110
|
+
if not isinstance(data_b64, str):
|
|
111
|
+
raise CdxError(f"Bundle contains invalid file data for session {name}: {rel_path}")
|
|
112
|
+
try:
|
|
113
|
+
content = base64.b64decode(data_b64.encode("ascii"), validate=True)
|
|
114
|
+
except (binascii.Error, UnicodeEncodeError) as error:
|
|
115
|
+
raise CdxError(f"Bundle contains invalid file data for session {name}: {rel_path}") from error
|
|
116
|
+
files.append({"path": rel_path, "content": content})
|
|
117
|
+
decoded_profiles[name] = files
|
|
118
|
+
return decoded_profiles
|
|
119
|
+
|
|
120
|
+
def _force_import_preserved_profile_paths(session_root):
|
|
121
|
+
return [rel_path for rel_path in _FORCE_IMPORT_PRESERVED_PROFILE_PATHS if os.path.exists(os.path.join(session_root, rel_path))]
|
|
122
|
+
|
|
123
|
+
def _restore_force_import_profile_paths(name, backup_root, session_root, rel_paths):
|
|
124
|
+
if not backup_root:
|
|
125
|
+
return
|
|
126
|
+
for rel_path in rel_paths:
|
|
127
|
+
source_path = os.path.join(backup_root, rel_path)
|
|
128
|
+
dest_path = os.path.join(session_root, rel_path)
|
|
129
|
+
if not os.path.exists(source_path) or os.path.exists(dest_path):
|
|
130
|
+
continue
|
|
131
|
+
try:
|
|
132
|
+
if os.path.isdir(source_path) and not os.path.islink(source_path):
|
|
133
|
+
shutil.copytree(source_path, dest_path)
|
|
134
|
+
else:
|
|
135
|
+
_ensure_private_dir(os.path.dirname(dest_path))
|
|
136
|
+
shutil.copy2(source_path, dest_path)
|
|
137
|
+
except Exception as error:
|
|
138
|
+
raise CdxError(f"Could not restore local plugin state for session {name}: {rel_path}") from error
|
|
139
|
+
|
|
140
|
+
def _resolve_session_subset(store, session_names):
|
|
141
|
+
if not session_names:
|
|
142
|
+
return list_sessions(store)
|
|
143
|
+
by_name = {session["name"]: session for session in list_sessions(store)}
|
|
144
|
+
selected = []
|
|
145
|
+
for name in session_names:
|
|
146
|
+
session = by_name.get(name)
|
|
147
|
+
if not session:
|
|
148
|
+
raise CdxError(f"Unknown session: {name}")
|
|
149
|
+
selected.append(session)
|
|
150
|
+
return selected
|
|
151
|
+
|
|
152
|
+
def _restore_import_backup(store, name, backup_root, session_root, old_record, old_state):
|
|
153
|
+
if os.path.exists(session_root):
|
|
154
|
+
remove_tree(session_root, ignore_errors=True)
|
|
155
|
+
if backup_root and os.path.exists(backup_root):
|
|
156
|
+
os.rename(backup_root, session_root)
|
|
157
|
+
if old_record:
|
|
158
|
+
store["replace_session"](name, old_record)
|
|
159
|
+
if old_state is not None:
|
|
160
|
+
store["write_session_state"](name, old_state)
|
|
161
|
+
else:
|
|
162
|
+
store["remove_session"](name)
|
|
163
|
+
|
|
164
|
+
def export_bundle(base_dir, store, file_path, include_auth=False, session_names=None, passphrase=None, force=False, progress_callback=None):
|
|
165
|
+
if not file_path:
|
|
166
|
+
raise CdxError("Export path is required.")
|
|
167
|
+
if os.path.exists(file_path) and not force:
|
|
168
|
+
raise CdxError(f"Export path already exists: {file_path}")
|
|
169
|
+
|
|
170
|
+
sessions = _resolve_session_subset(store, session_names)
|
|
171
|
+
payload = {
|
|
172
|
+
"schema_version": 1,
|
|
173
|
+
"created_at": _local_now_iso(),
|
|
174
|
+
"include_auth": bool(include_auth),
|
|
175
|
+
"sessions": [],
|
|
176
|
+
"states": {},
|
|
177
|
+
"profiles": {},
|
|
178
|
+
}
|
|
179
|
+
profile_file_count = 0
|
|
180
|
+
profile_bytes = 0
|
|
181
|
+
if progress_callback:
|
|
182
|
+
progress_callback({
|
|
183
|
+
"event": "export_started",
|
|
184
|
+
"include_auth": bool(include_auth),
|
|
185
|
+
"session_count": len(sessions),
|
|
186
|
+
"session_names": [session["name"] for session in sessions],
|
|
187
|
+
})
|
|
188
|
+
for session in sessions:
|
|
189
|
+
if progress_callback:
|
|
190
|
+
progress_callback({"event": "session_started", "session_name": session["name"]})
|
|
191
|
+
payload["sessions"].append(_build_export_session_record(session))
|
|
192
|
+
state = store["read_session_state"](session["name"])
|
|
193
|
+
if state is not None:
|
|
194
|
+
payload["states"][session["name"]] = state
|
|
195
|
+
if include_auth:
|
|
196
|
+
session_root = session.get("sessionRoot") or _get_session_root(base_dir, session["name"])
|
|
197
|
+
profile = _collect_auth_files(session_root, session["provider"], session["name"], progress_callback)
|
|
198
|
+
payload["profiles"][session["name"]] = profile["files"]
|
|
199
|
+
profile_file_count += profile["file_count"]
|
|
200
|
+
profile_bytes += profile["bytes"]
|
|
201
|
+
if progress_callback:
|
|
202
|
+
progress_callback({"event": "session_finished", "session_name": session["name"]})
|
|
203
|
+
|
|
204
|
+
if progress_callback:
|
|
205
|
+
progress_callback({"event": "encoding_started"})
|
|
206
|
+
bundle_bytes = encode_bundle(payload, include_auth=include_auth, passphrase=passphrase)
|
|
207
|
+
if progress_callback:
|
|
208
|
+
progress_callback({"event": "writing_started", "path": file_path, "bundle_size_bytes": len(bundle_bytes)})
|
|
209
|
+
# Atomic + 0o600 from the start: the export path may live in a
|
|
210
|
+
# world-traversable directory, and a crash must not clobber a
|
|
211
|
+
# previous good bundle with a truncated one.
|
|
212
|
+
atomic_write(os.path.abspath(file_path), bundle_bytes, mode=0o600)
|
|
213
|
+
return {
|
|
214
|
+
"path": file_path,
|
|
215
|
+
"include_auth": include_auth,
|
|
216
|
+
"session_names": [session["name"] for session in sessions],
|
|
217
|
+
"session_count": len(sessions),
|
|
218
|
+
"profile_file_count": profile_file_count if include_auth else None,
|
|
219
|
+
"profile_bytes": profile_bytes if include_auth else None,
|
|
220
|
+
"bundle_size_bytes": len(bundle_bytes),
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
def import_bundle(base_dir, store, file_path,
|
|
224
|
+
passphrase=None,
|
|
225
|
+
session_names=None,
|
|
226
|
+
force=False,
|
|
227
|
+
merge=False,
|
|
228
|
+
allow_authless_force=False,):
|
|
229
|
+
if not file_path or not os.path.isfile(file_path):
|
|
230
|
+
raise CdxError(f"Bundle file not found: {file_path}")
|
|
231
|
+
with open(file_path, "rb") as handle:
|
|
232
|
+
decoded = decode_bundle(handle.read(), passphrase=passphrase)
|
|
233
|
+
payload = decoded["payload"]
|
|
234
|
+
imported_sessions = payload.get("sessions") or []
|
|
235
|
+
if payload.get("schema_version") != 1:
|
|
236
|
+
raise CdxError("Unsupported bundle payload schema version.")
|
|
237
|
+
|
|
238
|
+
selected_names = set(session_names or [])
|
|
239
|
+
if selected_names:
|
|
240
|
+
imported_sessions = [item for item in imported_sessions if item["name"] in selected_names]
|
|
241
|
+
missing_names = sorted(selected_names - {item["name"] for item in imported_sessions})
|
|
242
|
+
if missing_names:
|
|
243
|
+
raise CdxError(f"Bundle does not contain requested sessions: {', '.join(missing_names)}")
|
|
244
|
+
names = [item["name"] for item in imported_sessions]
|
|
245
|
+
|
|
246
|
+
existing = {session["name"] for session in list_sessions(store)}
|
|
247
|
+
conflicts = [name for name in names if name in existing]
|
|
248
|
+
if conflicts and not force and not merge:
|
|
249
|
+
raise CdxError(f"Import would overwrite existing sessions: {', '.join(conflicts)}")
|
|
250
|
+
if conflicts and force and not decoded["meta"].get("include_auth") and not allow_authless_force:
|
|
251
|
+
raise CdxError(
|
|
252
|
+
"Import --force would overwrite existing session credentials, but this bundle has no auth payloads. "
|
|
253
|
+
"Re-export with --include-auth, use --merge, or pass --allow-authless-force to discard local credentials."
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
for session_payload in imported_sessions:
|
|
257
|
+
_validate_new_session_name(session_payload["name"])
|
|
258
|
+
_normalize_provider(session_payload["provider"])
|
|
259
|
+
decoded_profiles = _validate_import_profile_files(payload, imported_sessions)
|
|
260
|
+
|
|
261
|
+
for session_payload in imported_sessions:
|
|
262
|
+
name = session_payload["name"]
|
|
263
|
+
provider = _normalize_provider(session_payload["provider"])
|
|
264
|
+
is_existing = name in existing
|
|
265
|
+
|
|
266
|
+
session_root = _get_session_root(base_dir, name)
|
|
267
|
+
auth_home = _get_session_auth_home(base_dir, name, provider)
|
|
268
|
+
_ensure_private_dir(base_dir)
|
|
269
|
+
_ensure_private_dir(os.path.join(base_dir, "profiles"))
|
|
270
|
+
backup_root = None
|
|
271
|
+
old_record = None
|
|
272
|
+
old_state = None
|
|
273
|
+
preserved_profile_paths = []
|
|
274
|
+
if is_existing and force:
|
|
275
|
+
old_record = store["get_session"](name)
|
|
276
|
+
old_state = store["read_session_state"](name)
|
|
277
|
+
preserved_profile_paths = _force_import_preserved_profile_paths(session_root)
|
|
278
|
+
if os.path.exists(session_root):
|
|
279
|
+
backup_root = tempfile.mkdtemp(prefix=f".{_encode(name)}.import.", dir=os.path.dirname(session_root))
|
|
280
|
+
os.rmdir(backup_root)
|
|
281
|
+
os.rename(session_root, backup_root)
|
|
282
|
+
is_existing = False
|
|
283
|
+
_ensure_private_dir(session_root)
|
|
284
|
+
_ensure_private_dir(auth_home)
|
|
285
|
+
|
|
286
|
+
existing_state_before = None
|
|
287
|
+
try:
|
|
288
|
+
if is_existing and merge:
|
|
289
|
+
existing_record = store["get_session"](name) or {}
|
|
290
|
+
# replace_session resets the state file to defaults, so the
|
|
291
|
+
# local state must be captured before it runs.
|
|
292
|
+
existing_state_before = store["read_session_state"](name) or {}
|
|
293
|
+
bundle_record = {
|
|
294
|
+
**session_payload,
|
|
295
|
+
"provider": provider,
|
|
296
|
+
"enabled": session_payload.get("enabled", True) is not False,
|
|
297
|
+
"sessionRoot": session_root,
|
|
298
|
+
"authHome": auth_home,
|
|
299
|
+
}
|
|
300
|
+
# Existing values take precedence; bundle fills in missing keys only.
|
|
301
|
+
merged_record = {**bundle_record, **{k: v for k, v in existing_record.items() if v is not None}}
|
|
302
|
+
merged_record["sessionRoot"] = session_root
|
|
303
|
+
merged_record["authHome"] = auth_home
|
|
304
|
+
store["replace_session"](name, merged_record)
|
|
305
|
+
else:
|
|
306
|
+
session_record = {
|
|
307
|
+
**session_payload,
|
|
308
|
+
"provider": provider,
|
|
309
|
+
"enabled": session_payload.get("enabled", True) is not False,
|
|
310
|
+
"sessionRoot": session_root,
|
|
311
|
+
"authHome": auth_home,
|
|
312
|
+
}
|
|
313
|
+
store["replace_session"](name, session_record)
|
|
314
|
+
|
|
315
|
+
state = (payload.get("states") or {}).get(name)
|
|
316
|
+
if is_existing and merge:
|
|
317
|
+
merged_state = {**(state or {}), **{k: v for k, v in (existing_state_before or {}).items() if v is not None}}
|
|
318
|
+
if merged_state:
|
|
319
|
+
store["write_session_state"](name, merged_state)
|
|
320
|
+
elif state is not None:
|
|
321
|
+
store["write_session_state"](name, state)
|
|
322
|
+
|
|
323
|
+
for item in decoded_profiles.get(name, []):
|
|
324
|
+
dest_path = os.path.join(session_root, item["path"])
|
|
325
|
+
# In merge mode, skip files that already exist locally.
|
|
326
|
+
if is_existing and merge and os.path.exists(dest_path):
|
|
327
|
+
continue
|
|
328
|
+
_ensure_private_dir(os.path.dirname(dest_path))
|
|
329
|
+
# Decrypted credentials: 0o600 from creation, no umask window.
|
|
330
|
+
atomic_write(dest_path, item["content"], mode=0o600)
|
|
331
|
+
_restore_force_import_profile_paths(name, backup_root, session_root, preserved_profile_paths)
|
|
332
|
+
except Exception:
|
|
333
|
+
_restore_import_backup(store, name, backup_root, session_root, old_record, old_state)
|
|
334
|
+
raise
|
|
335
|
+
finally:
|
|
336
|
+
if backup_root and os.path.exists(backup_root):
|
|
337
|
+
remove_tree(backup_root, ignore_errors=True)
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
"path": file_path,
|
|
341
|
+
"session_names": names,
|
|
342
|
+
"include_auth": bool(decoded["meta"].get("include_auth")),
|
|
343
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Helpers shared by more than one session-service concern group.
|
|
2
|
+
|
|
3
|
+
Split out of session_service.py ahead of the file split: these are the ten
|
|
4
|
+
helpers the coupling measurement found reachable from two or more groups, plus
|
|
5
|
+
the four constants they read. Anything reached from a single group stays with
|
|
6
|
+
that group. Re-exported by session_service, so callers and tests that name
|
|
7
|
+
them there keep working.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from urllib.parse import quote
|
|
14
|
+
|
|
15
|
+
from .config import PROVIDER_ANTIGRAVITY, PROVIDER_CLAUDE, PROVIDER_CODEX, PROVIDERS
|
|
16
|
+
from .errors import CdxError
|
|
17
|
+
|
|
18
|
+
DEFAULT_PROVIDER = PROVIDER_CODEX
|
|
19
|
+
|
|
20
|
+
ALLOWED_PROVIDERS = set(PROVIDERS)
|
|
21
|
+
|
|
22
|
+
MAX_SESSION_NAME_LENGTH = 64
|
|
23
|
+
|
|
24
|
+
RESERVED_SESSION_NAMES = {
|
|
25
|
+
"add",
|
|
26
|
+
"can-resume",
|
|
27
|
+
"clean",
|
|
28
|
+
"context",
|
|
29
|
+
"configs",
|
|
30
|
+
"cp",
|
|
31
|
+
"disable",
|
|
32
|
+
"disk",
|
|
33
|
+
"doctor",
|
|
34
|
+
"enable",
|
|
35
|
+
"export",
|
|
36
|
+
"fast",
|
|
37
|
+
"help",
|
|
38
|
+
"handoff",
|
|
39
|
+
"history",
|
|
40
|
+
"import",
|
|
41
|
+
"last",
|
|
42
|
+
"label",
|
|
43
|
+
"login",
|
|
44
|
+
"logout",
|
|
45
|
+
"memory",
|
|
46
|
+
"model",
|
|
47
|
+
"mv",
|
|
48
|
+
"next",
|
|
49
|
+
"notify",
|
|
50
|
+
"perm",
|
|
51
|
+
"power",
|
|
52
|
+
"ready",
|
|
53
|
+
"repair",
|
|
54
|
+
"reset",
|
|
55
|
+
"resume",
|
|
56
|
+
"ren",
|
|
57
|
+
"rename",
|
|
58
|
+
"rmv",
|
|
59
|
+
"run",
|
|
60
|
+
"select",
|
|
61
|
+
"config",
|
|
62
|
+
"set",
|
|
63
|
+
"stats",
|
|
64
|
+
"status",
|
|
65
|
+
"unset",
|
|
66
|
+
"update",
|
|
67
|
+
"view",
|
|
68
|
+
"version",
|
|
69
|
+
"--help",
|
|
70
|
+
"-h",
|
|
71
|
+
"--version",
|
|
72
|
+
"-v",
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
def _encode(name):
|
|
76
|
+
return quote(name, safe="")
|
|
77
|
+
|
|
78
|
+
def _ensure_private_dir(path):
|
|
79
|
+
os.makedirs(path, exist_ok=True)
|
|
80
|
+
if sys.platform == "win32":
|
|
81
|
+
return
|
|
82
|
+
try:
|
|
83
|
+
os.chmod(path, 0o700)
|
|
84
|
+
except OSError:
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
def _get_global_codex_home(env=None):
|
|
88
|
+
env = env or os.environ
|
|
89
|
+
return env.get("CODEX_HOME") or os.path.join(os.path.expanduser("~"), ".codex")
|
|
90
|
+
|
|
91
|
+
def _local_now_iso():
|
|
92
|
+
return datetime.now().astimezone().isoformat()
|
|
93
|
+
|
|
94
|
+
def _process_is_running(pid):
|
|
95
|
+
try:
|
|
96
|
+
pid = int(pid)
|
|
97
|
+
except (TypeError, ValueError):
|
|
98
|
+
return False
|
|
99
|
+
if pid <= 0:
|
|
100
|
+
return False
|
|
101
|
+
try:
|
|
102
|
+
os.kill(pid, 0)
|
|
103
|
+
except ProcessLookupError:
|
|
104
|
+
return False
|
|
105
|
+
except PermissionError:
|
|
106
|
+
return True
|
|
107
|
+
except OSError:
|
|
108
|
+
return False
|
|
109
|
+
return True
|
|
110
|
+
|
|
111
|
+
def _normalize_provider(provider):
|
|
112
|
+
value = provider or DEFAULT_PROVIDER
|
|
113
|
+
if value not in ALLOWED_PROVIDERS:
|
|
114
|
+
raise CdxError(f"Unsupported provider: {value}")
|
|
115
|
+
return value
|
|
116
|
+
|
|
117
|
+
def _runtime_is_active(runtime):
|
|
118
|
+
return (
|
|
119
|
+
isinstance(runtime, dict)
|
|
120
|
+
and runtime.get("status") == "running"
|
|
121
|
+
and _process_is_running(runtime.get("pid"))
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def _validate_new_session_name(name):
|
|
125
|
+
if not name:
|
|
126
|
+
raise CdxError("Session name is required")
|
|
127
|
+
if str(name) != str(name).strip():
|
|
128
|
+
raise CdxError("Session name cannot start or end with whitespace")
|
|
129
|
+
if str(name) in (".", ".."):
|
|
130
|
+
raise CdxError("Session name cannot be . or ..")
|
|
131
|
+
if len(str(name)) > MAX_SESSION_NAME_LENGTH:
|
|
132
|
+
raise CdxError(f"Session name is too long (max {MAX_SESSION_NAME_LENGTH} characters)")
|
|
133
|
+
if any(ord(ch) < 32 or ord(ch) == 127 for ch in str(name)):
|
|
134
|
+
raise CdxError("Session name cannot contain control characters")
|
|
135
|
+
if name in RESERVED_SESSION_NAMES:
|
|
136
|
+
raise CdxError(f"Session name is reserved: {name}")
|
|
137
|
+
|
|
138
|
+
def _get_session_root(base_dir, name):
|
|
139
|
+
return os.path.join(base_dir, "profiles", _encode(name))
|
|
140
|
+
|
|
141
|
+
def _get_session_auth_home(base_dir, name, provider):
|
|
142
|
+
root = _get_session_root(base_dir, name)
|
|
143
|
+
if provider == PROVIDER_CLAUDE:
|
|
144
|
+
return os.path.join(root, "claude-home")
|
|
145
|
+
if provider == PROVIDER_ANTIGRAVITY:
|
|
146
|
+
return os.path.join(root, "antigravity-home")
|
|
147
|
+
return root
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# Store accessors used from more than one concern group. They are entry points
|
|
151
|
+
# of the crud and runtime groups, but status and backup both read through them,
|
|
152
|
+
# so they belong here rather than in either group's module.
|
|
153
|
+
def list_sessions(store):
|
|
154
|
+
return store["list_sessions"]()
|
|
155
|
+
|
|
156
|
+
def _session_runtime(store, name):
|
|
157
|
+
found = {}
|
|
158
|
+
|
|
159
|
+
def updater(state):
|
|
160
|
+
if not state:
|
|
161
|
+
return None
|
|
162
|
+
runtime = state.get("runtime")
|
|
163
|
+
if _runtime_is_active(runtime):
|
|
164
|
+
found["runtime"] = runtime
|
|
165
|
+
return None
|
|
166
|
+
if isinstance(runtime, dict) and runtime.get("status") == "running":
|
|
167
|
+
return {
|
|
168
|
+
**state,
|
|
169
|
+
"status": "ready",
|
|
170
|
+
"runtime": {
|
|
171
|
+
**runtime,
|
|
172
|
+
"status": "stale",
|
|
173
|
+
"endedAt": _local_now_iso(),
|
|
174
|
+
},
|
|
175
|
+
}
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
store["update_session_state"](name, updater)
|
|
179
|
+
return found.get("runtime")
|