cdx-manager 0.2.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/LICENSE +21 -0
- package/README.md +243 -0
- package/bin/cdx +20 -0
- package/changelogs/CHANGELOGS_0_1_1.md +98 -0
- package/changelogs/CHANGELOGS_0_2_0.md +68 -0
- package/changelogs/CHANGELOGS_0_2_1.md +29 -0
- package/package.json +44 -0
- package/src/__init__.py +17 -0
- package/src/claude_refresh.py +72 -0
- package/src/claude_usage.py +84 -0
- package/src/cli.py +188 -0
- package/src/cli_commands.py +369 -0
- package/src/cli_render.py +131 -0
- package/src/config.py +8 -0
- package/src/errors.py +4 -0
- package/src/health.py +125 -0
- package/src/notify.py +138 -0
- package/src/provider_runtime.py +290 -0
- package/src/repair.py +121 -0
- package/src/session_service.py +563 -0
- package/src/session_store.py +244 -0
- package/src/status_source.py +572 -0
- package/src/status_view.py +270 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import signal
|
|
4
|
+
import shlex
|
|
5
|
+
import subprocess
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
|
|
8
|
+
from .errors import CdxError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
LOG_ROTATE_BYTES = 10 * 1024 * 1024 # 10 MB
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _get_auth_home(session):
|
|
15
|
+
return session.get("authHome") or session.get("sessionRoot") or session.get("codexHome", "")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _get_launch_transcript_path(session):
|
|
19
|
+
return os.path.join(_get_auth_home(session), "log", "cdx-session.log")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _get_launch_transcript_dir(session):
|
|
23
|
+
return os.path.join(_get_auth_home(session), "log")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _build_launch_transcript_path(session):
|
|
27
|
+
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S.%fZ")
|
|
28
|
+
return os.path.join(
|
|
29
|
+
_get_launch_transcript_dir(session),
|
|
30
|
+
f"cdx-session-{stamp}-{os.getpid()}.log",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _list_launch_transcript_paths(session, glob_fn=None):
|
|
35
|
+
import glob
|
|
36
|
+
|
|
37
|
+
glob_fn = glob_fn or glob.glob
|
|
38
|
+
log_dir = _get_launch_transcript_dir(session)
|
|
39
|
+
if not os.path.isdir(log_dir):
|
|
40
|
+
return []
|
|
41
|
+
paths = set(glob_fn(os.path.join(log_dir, "cdx-session*.log")))
|
|
42
|
+
legacy = _get_launch_transcript_path(session)
|
|
43
|
+
if os.path.exists(legacy):
|
|
44
|
+
paths.add(legacy)
|
|
45
|
+
return sorted(paths)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _rotate_log_if_needed(log_path):
|
|
49
|
+
try:
|
|
50
|
+
if os.path.getsize(log_path) >= LOG_ROTATE_BYTES:
|
|
51
|
+
open(log_path, "w").close()
|
|
52
|
+
except OSError:
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _wrap_launch_with_transcript(session, spec, capture_transcript=True, env=None):
|
|
57
|
+
if not capture_transcript:
|
|
58
|
+
return spec
|
|
59
|
+
env = env or os.environ
|
|
60
|
+
script_bin = env.get("CDX_SCRIPT_BIN", "script")
|
|
61
|
+
script_args = env.get("CDX_SCRIPT_ARGS")
|
|
62
|
+
transcript_path = _build_launch_transcript_path(session)
|
|
63
|
+
os.makedirs(os.path.dirname(transcript_path), exist_ok=True)
|
|
64
|
+
_rotate_log_if_needed(transcript_path)
|
|
65
|
+
if script_args:
|
|
66
|
+
args = shlex.split(script_args)
|
|
67
|
+
if "{transcript}" in args:
|
|
68
|
+
args = [transcript_path if arg == "{transcript}" else arg for arg in args]
|
|
69
|
+
else:
|
|
70
|
+
args = args + [transcript_path]
|
|
71
|
+
args = args + [spec["command"]] + spec["args"]
|
|
72
|
+
else:
|
|
73
|
+
args = ["-q", "-F", transcript_path, spec["command"]] + spec["args"]
|
|
74
|
+
return {
|
|
75
|
+
"command": script_bin,
|
|
76
|
+
"args": args,
|
|
77
|
+
"options": spec["options"],
|
|
78
|
+
"label": spec["label"],
|
|
79
|
+
"fallback": spec,
|
|
80
|
+
"transcript_path": transcript_path,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _build_launch_spec(session, cwd=None, env_override=None):
|
|
85
|
+
cwd = cwd or os.getcwd()
|
|
86
|
+
env_override = env_override or {}
|
|
87
|
+
env = {**os.environ, **env_override}
|
|
88
|
+
if session["provider"] == "claude":
|
|
89
|
+
return {
|
|
90
|
+
"command": "claude",
|
|
91
|
+
"args": ["--name", session["name"]],
|
|
92
|
+
"options": {
|
|
93
|
+
"cwd": cwd,
|
|
94
|
+
"env": {**env, "HOME": _get_auth_home(session)},
|
|
95
|
+
},
|
|
96
|
+
"label": "claude",
|
|
97
|
+
}
|
|
98
|
+
return _wrap_launch_with_transcript(session, {
|
|
99
|
+
"command": "codex",
|
|
100
|
+
"args": ["--no-alt-screen", "--cd", cwd],
|
|
101
|
+
"options": {
|
|
102
|
+
"env": {**env, "CODEX_HOME": _get_auth_home(session)},
|
|
103
|
+
},
|
|
104
|
+
"label": "codex",
|
|
105
|
+
}, env=env)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _build_login_status_spec(session, env_override=None):
|
|
109
|
+
env = {**os.environ, **(env_override or {})}
|
|
110
|
+
if session["provider"] == "claude":
|
|
111
|
+
env["HOME"] = _get_auth_home(session)
|
|
112
|
+
|
|
113
|
+
def parser(output):
|
|
114
|
+
try:
|
|
115
|
+
return bool(json.loads(output or "{}").get("loggedIn"))
|
|
116
|
+
except (json.JSONDecodeError, AttributeError):
|
|
117
|
+
return False
|
|
118
|
+
|
|
119
|
+
return {"command": "claude", "args": ["auth", "status"], "env": env,
|
|
120
|
+
"parser": parser, "label": "claude auth status"}
|
|
121
|
+
env["CODEX_HOME"] = _get_auth_home(session)
|
|
122
|
+
|
|
123
|
+
def parser(output):
|
|
124
|
+
if "Not logged in" in (output or ""):
|
|
125
|
+
return False
|
|
126
|
+
return "Logged in" in (output or "")
|
|
127
|
+
|
|
128
|
+
return {"command": "codex", "args": ["login", "status"], "env": env,
|
|
129
|
+
"parser": parser, "label": "codex login status"}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _build_auth_action_spec(session, action, cwd=None, env_override=None):
|
|
133
|
+
cwd = cwd or os.getcwd()
|
|
134
|
+
env = {**os.environ, **(env_override or {})}
|
|
135
|
+
if session["provider"] == "claude":
|
|
136
|
+
env["HOME"] = _get_auth_home(session)
|
|
137
|
+
return {"command": "claude", "args": ["auth", action],
|
|
138
|
+
"options": {"cwd": cwd, "env": env}, "label": f"claude auth {action}"}
|
|
139
|
+
env["CODEX_HOME"] = _get_auth_home(session)
|
|
140
|
+
return {"command": "codex", "args": [action],
|
|
141
|
+
"options": {"cwd": cwd, "env": env}, "label": f"codex {action}"}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _probe_provider_auth(session, spawn_sync=None, env_override=None):
|
|
145
|
+
spawn_sync = spawn_sync or subprocess.run
|
|
146
|
+
spec = _build_login_status_spec(session, env_override)
|
|
147
|
+
if spawn_sync is subprocess.run:
|
|
148
|
+
result = subprocess.run(
|
|
149
|
+
[spec["command"]] + spec["args"],
|
|
150
|
+
env=spec["env"],
|
|
151
|
+
capture_output=True, text=True,
|
|
152
|
+
)
|
|
153
|
+
output = (result.stdout or "") + (result.stderr or "")
|
|
154
|
+
else:
|
|
155
|
+
result = spawn_sync(spec["command"], spec["args"], spec)
|
|
156
|
+
error = result.get("error") if isinstance(result, dict) else getattr(result, "error", None)
|
|
157
|
+
if error:
|
|
158
|
+
raise CdxError(
|
|
159
|
+
f"Failed to check login status for {session['name']}: {getattr(error, 'message', str(error))}"
|
|
160
|
+
)
|
|
161
|
+
stdout = result.get("stdout") if isinstance(result, dict) else getattr(result, "stdout", "")
|
|
162
|
+
stderr = result.get("stderr") if isinstance(result, dict) else getattr(result, "stderr", "")
|
|
163
|
+
output = (stdout or "") + (stderr or "")
|
|
164
|
+
return spec["parser"](output)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _signal_exit_code(sig):
|
|
168
|
+
return {signal.SIGHUP: 129, signal.SIGINT: 130, signal.SIGTERM: 143}.get(sig, 1)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
|
|
172
|
+
env_override=None, signal_emitter=None):
|
|
173
|
+
spawn = spawn or subprocess.Popen
|
|
174
|
+
spec = (
|
|
175
|
+
_build_launch_spec(session, cwd=cwd, env_override=env_override)
|
|
176
|
+
if action == "launch"
|
|
177
|
+
else _build_auth_action_spec(session, action, cwd=cwd, env_override=env_override)
|
|
178
|
+
)
|
|
179
|
+
def start_child(current_spec):
|
|
180
|
+
return spawn(
|
|
181
|
+
[current_spec["command"]] + current_spec["args"],
|
|
182
|
+
**{k: v for k, v in current_spec.get("options", {}).items() if k != "stdio"},
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
child = start_child(spec)
|
|
187
|
+
except FileNotFoundError as error:
|
|
188
|
+
spec = _fallback_launch_spec_or_raise(spec, error)
|
|
189
|
+
child = start_child(spec)
|
|
190
|
+
|
|
191
|
+
forwarded_signal = [None]
|
|
192
|
+
handlers = []
|
|
193
|
+
|
|
194
|
+
def forward(sig, _frame=None):
|
|
195
|
+
forwarded_signal[0] = sig
|
|
196
|
+
try:
|
|
197
|
+
if hasattr(child, "send_signal"):
|
|
198
|
+
child.send_signal(sig)
|
|
199
|
+
elif hasattr(child, "kill"):
|
|
200
|
+
child.kill(sig)
|
|
201
|
+
except Exception:
|
|
202
|
+
pass
|
|
203
|
+
|
|
204
|
+
original_handlers = {}
|
|
205
|
+
use_emitter = hasattr(signal_emitter, "on") and hasattr(signal_emitter, "removeListener")
|
|
206
|
+
|
|
207
|
+
if use_emitter:
|
|
208
|
+
for sig in ("SIGINT", "SIGTERM", "SIGHUP"):
|
|
209
|
+
handler = lambda current_sig=sig: forward(getattr(signal, current_sig), None)
|
|
210
|
+
handlers.append((sig, handler))
|
|
211
|
+
signal_emitter.on(sig, handler)
|
|
212
|
+
else:
|
|
213
|
+
for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
|
|
214
|
+
try:
|
|
215
|
+
original_handlers[sig] = signal.signal(sig, forward)
|
|
216
|
+
except (OSError, ValueError):
|
|
217
|
+
pass
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
child.wait()
|
|
221
|
+
if forwarded_signal[0] is None and child.returncode != 0 and _should_retry_without_transcript(spec):
|
|
222
|
+
spec = _fallback_launch_spec_or_raise(spec)
|
|
223
|
+
child = start_child(spec)
|
|
224
|
+
child.wait()
|
|
225
|
+
finally:
|
|
226
|
+
if use_emitter:
|
|
227
|
+
for sig, handler in handlers:
|
|
228
|
+
try:
|
|
229
|
+
signal_emitter.removeListener(sig, handler)
|
|
230
|
+
except Exception:
|
|
231
|
+
pass
|
|
232
|
+
else:
|
|
233
|
+
for sig, handler in original_handlers.items():
|
|
234
|
+
try:
|
|
235
|
+
signal.signal(sig, handler)
|
|
236
|
+
except (OSError, ValueError):
|
|
237
|
+
pass
|
|
238
|
+
|
|
239
|
+
if forwarded_signal[0] is not None:
|
|
240
|
+
raise CdxError(
|
|
241
|
+
f"{spec['label']} interrupted by {forwarded_signal[0].name} for session {session['name']}",
|
|
242
|
+
_signal_exit_code(forwarded_signal[0]),
|
|
243
|
+
)
|
|
244
|
+
if child.returncode != 0:
|
|
245
|
+
raise CdxError(
|
|
246
|
+
f"{spec['label']} exited with code {child.returncode} for session {session['name']}"
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _fallback_launch_spec_or_raise(spec, original_error=None):
|
|
251
|
+
fallback = spec.get("fallback")
|
|
252
|
+
if not fallback:
|
|
253
|
+
if original_error is not None:
|
|
254
|
+
raise original_error
|
|
255
|
+
raise CdxError(f"{spec['label']} cannot run without a fallback")
|
|
256
|
+
return {**fallback, "label": f"{fallback['label']} (without transcript)"}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _should_retry_without_transcript(spec):
|
|
260
|
+
if not spec.get("fallback"):
|
|
261
|
+
return False
|
|
262
|
+
transcript_path = spec.get("transcript_path")
|
|
263
|
+
if not transcript_path:
|
|
264
|
+
return False
|
|
265
|
+
try:
|
|
266
|
+
return not os.path.exists(transcript_path) or os.path.getsize(transcript_path) == 0
|
|
267
|
+
except OSError:
|
|
268
|
+
return True
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _ensure_session_authentication(session, service, spawn=None, spawn_sync=None,
|
|
272
|
+
stdin_is_tty=True, env_override=None, behavior="launch",
|
|
273
|
+
signal_emitter=None):
|
|
274
|
+
is_authenticated = _probe_provider_auth(session, spawn_sync=spawn_sync, env_override=env_override)
|
|
275
|
+
if is_authenticated:
|
|
276
|
+
return {"authenticated": True, "checked": True}
|
|
277
|
+
if behavior == "probe-only":
|
|
278
|
+
return {"authenticated": False, "checked": True}
|
|
279
|
+
if behavior == "launch":
|
|
280
|
+
raise CdxError(
|
|
281
|
+
f"Session {session['name']} is not authenticated. Run: cdx login {session['name']}"
|
|
282
|
+
)
|
|
283
|
+
if not stdin_is_tty:
|
|
284
|
+
raise CdxError(
|
|
285
|
+
f"Session {session['name']} is not authenticated. Run: cdx login {session['name']}"
|
|
286
|
+
)
|
|
287
|
+
_run_interactive_provider_command(
|
|
288
|
+
session, "login", spawn=spawn, env_override=env_override, signal_emitter=signal_emitter
|
|
289
|
+
)
|
|
290
|
+
return {"authenticated": True, "checked": True, "bootstrapped": True}
|
package/src/repair.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import shutil
|
|
4
|
+
|
|
5
|
+
from .health import collect_health_report
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def repair_health(service, base_dir, env=None, dry_run=True, force=False):
|
|
9
|
+
report = collect_health_report(service, base_dir, env)
|
|
10
|
+
actions = []
|
|
11
|
+
for issue in report["issues"]:
|
|
12
|
+
code = issue["code"]
|
|
13
|
+
detail = issue.get("detail")
|
|
14
|
+
if code == "missing_state":
|
|
15
|
+
name = _session_name_from_state_path(detail)
|
|
16
|
+
actions.append(_action(
|
|
17
|
+
"recreate_state",
|
|
18
|
+
f"recreate missing state for {name}",
|
|
19
|
+
detail,
|
|
20
|
+
_apply_recreate_state(service, name) if not dry_run else None,
|
|
21
|
+
))
|
|
22
|
+
elif code == "quarantine_profile":
|
|
23
|
+
actions.append(_action(
|
|
24
|
+
"remove_quarantine",
|
|
25
|
+
f"remove quarantine profile {os.path.basename(detail)}",
|
|
26
|
+
detail,
|
|
27
|
+
_apply_remove_path(detail) if not dry_run else None,
|
|
28
|
+
))
|
|
29
|
+
elif code == "orphan_profile":
|
|
30
|
+
if force:
|
|
31
|
+
actions.append(_action(
|
|
32
|
+
"quarantine_orphan",
|
|
33
|
+
f"move orphan profile {os.path.basename(detail)} to quarantine",
|
|
34
|
+
detail,
|
|
35
|
+
_apply_quarantine_orphan(detail) if not dry_run else None,
|
|
36
|
+
))
|
|
37
|
+
else:
|
|
38
|
+
actions.append(_action(
|
|
39
|
+
"skip_orphan",
|
|
40
|
+
f"orphan profile needs --force: {os.path.basename(detail)}",
|
|
41
|
+
detail,
|
|
42
|
+
"skipped",
|
|
43
|
+
))
|
|
44
|
+
return {
|
|
45
|
+
"dry_run": dry_run,
|
|
46
|
+
"force": force,
|
|
47
|
+
"actions": actions,
|
|
48
|
+
"summary": {
|
|
49
|
+
"planned": len(actions),
|
|
50
|
+
"applied": sum(1 for action in actions if action["status"] == "applied"),
|
|
51
|
+
"skipped": sum(1 for action in actions if action["status"] == "skipped"),
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _session_name_from_state_path(path):
|
|
57
|
+
if not path:
|
|
58
|
+
return None
|
|
59
|
+
filename = os.path.basename(path)
|
|
60
|
+
if filename.endswith(".json"):
|
|
61
|
+
filename = filename[:-5]
|
|
62
|
+
from urllib.parse import unquote
|
|
63
|
+
return unquote(filename)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _apply_recreate_state(service, name):
|
|
67
|
+
service["ensure_session_state"](name)
|
|
68
|
+
return "applied"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _apply_remove_path(path):
|
|
72
|
+
shutil.rmtree(path)
|
|
73
|
+
return "applied"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _apply_quarantine_orphan(path):
|
|
77
|
+
parent = os.path.dirname(path)
|
|
78
|
+
dest = os.path.join(parent, f".{os.path.basename(path)}.remove.orphan")
|
|
79
|
+
suffix = 1
|
|
80
|
+
candidate = dest
|
|
81
|
+
while os.path.exists(candidate):
|
|
82
|
+
suffix += 1
|
|
83
|
+
candidate = f"{dest}.{suffix}"
|
|
84
|
+
os.rename(path, candidate)
|
|
85
|
+
return "applied"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _action(code, message, path, result):
|
|
89
|
+
if result is None:
|
|
90
|
+
status = "planned"
|
|
91
|
+
elif result == "skipped":
|
|
92
|
+
status = "skipped"
|
|
93
|
+
else:
|
|
94
|
+
status = "applied"
|
|
95
|
+
return {"status": status, "code": code, "message": message, "path": path}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def format_repair_report(report, use_color=False):
|
|
99
|
+
from .cli_render import _pad_table, _style
|
|
100
|
+
|
|
101
|
+
rows = [["STATUS", "ACTION", "MESSAGE"]]
|
|
102
|
+
for action in report["actions"]:
|
|
103
|
+
status = action["status"]
|
|
104
|
+
style = "32" if status == "applied" else "33" if status == "planned" else "2"
|
|
105
|
+
rows.append([_style(status.upper(), style, use_color), action["code"], action["message"]])
|
|
106
|
+
if len(rows) == 1:
|
|
107
|
+
rows.append([_style("OK", "32", use_color), "-", "nothing to repair"])
|
|
108
|
+
summary = report["summary"]
|
|
109
|
+
return "\n".join([
|
|
110
|
+
_pad_table(rows),
|
|
111
|
+
"",
|
|
112
|
+
_style(
|
|
113
|
+
f"Summary: {summary['planned']} planned, {summary['applied']} applied, {summary['skipped']} skipped.",
|
|
114
|
+
"1",
|
|
115
|
+
use_color,
|
|
116
|
+
),
|
|
117
|
+
])
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def repair_json(report):
|
|
121
|
+
return json.dumps(report, indent=2)
|