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,84 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import urllib.request
|
|
4
|
+
import urllib.error
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
|
|
7
|
+
MONTH_ABBR = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
|
8
|
+
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
|
|
9
|
+
CLAUDE_STATUS_PROBE_MODEL = os.environ.get("CDX_CLAUDE_STATUS_MODEL", "claude-haiku-4-5-20251001")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _read_claude_credentials(auth_home):
|
|
13
|
+
cred_path = os.path.join(auth_home, ".claude", ".credentials.json")
|
|
14
|
+
try:
|
|
15
|
+
with open(cred_path, "r", encoding="utf-8") as f:
|
|
16
|
+
data = json.load(f)
|
|
17
|
+
return data.get("claudeAiOauth")
|
|
18
|
+
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
19
|
+
return None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _format_reset_date(unix_seconds):
|
|
23
|
+
dt = datetime.fromtimestamp(unix_seconds, tz=timezone.utc).astimezone()
|
|
24
|
+
return f"{MONTH_ABBR[dt.month - 1]} {dt.day} {str(dt.hour).zfill(2)}:{str(dt.minute).zfill(2)}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def fetch_claude_rate_limit_headers(access_token):
|
|
28
|
+
body = json.dumps({
|
|
29
|
+
"model": CLAUDE_STATUS_PROBE_MODEL,
|
|
30
|
+
"max_tokens": 1,
|
|
31
|
+
"messages": [{"role": "user", "content": "hi"}],
|
|
32
|
+
}).encode("utf-8")
|
|
33
|
+
|
|
34
|
+
req = urllib.request.Request(
|
|
35
|
+
"https://api.anthropic.com/v1/messages",
|
|
36
|
+
data=body,
|
|
37
|
+
headers={
|
|
38
|
+
"x-api-key": access_token,
|
|
39
|
+
"anthropic-version": "2023-06-01",
|
|
40
|
+
"content-type": "application/json",
|
|
41
|
+
},
|
|
42
|
+
method="POST",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
47
|
+
headers = {k.lower(): v for k, v in resp.getheaders()}
|
|
48
|
+
except urllib.error.HTTPError as e:
|
|
49
|
+
headers = {k.lower(): v for k, v in e.headers.items()}
|
|
50
|
+
except urllib.error.URLError:
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
util_5h = headers.get("anthropic-ratelimit-unified-5h-utilization")
|
|
54
|
+
reset_5h = headers.get("anthropic-ratelimit-unified-5h-reset")
|
|
55
|
+
util_7d = headers.get("anthropic-ratelimit-unified-7d-utilization")
|
|
56
|
+
reset_7d = headers.get("anthropic-ratelimit-unified-7d-reset")
|
|
57
|
+
|
|
58
|
+
if util_5h is None and util_7d is None:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
utilization_5h = float(util_5h) if util_5h is not None else None
|
|
62
|
+
utilization_7d = float(util_7d) if util_7d is not None else None
|
|
63
|
+
|
|
64
|
+
reset_5h_at = _format_reset_date(int(reset_5h)) if reset_5h else None
|
|
65
|
+
reset_week_at = _format_reset_date(int(reset_7d)) if reset_7d else None
|
|
66
|
+
reset_at = reset_week_at or reset_5h_at
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
"remaining_5h_pct": round((1 - utilization_5h) * 100) if utilization_5h is not None else None,
|
|
70
|
+
"remaining_week_pct": round((1 - utilization_7d) * 100) if utilization_7d is not None else None,
|
|
71
|
+
"reset_5h_at": reset_5h_at,
|
|
72
|
+
"reset_week_at": reset_week_at,
|
|
73
|
+
"reset_at": reset_at,
|
|
74
|
+
"updated_at": datetime.now().astimezone().isoformat(),
|
|
75
|
+
"raw_status_text": None,
|
|
76
|
+
"source_ref": "api:anthropic-ratelimit-headers",
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def refresh_claude_session_status(session):
|
|
81
|
+
creds = _read_claude_credentials(session.get("authHome", ""))
|
|
82
|
+
if not creds or not creds.get("accessToken"):
|
|
83
|
+
return None
|
|
84
|
+
return fetch_claude_rate_limit_headers(creds["accessToken"])
|
package/src/cli.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from .cli_commands import (
|
|
7
|
+
STATUS_USAGE,
|
|
8
|
+
handle_add,
|
|
9
|
+
handle_clean,
|
|
10
|
+
handle_copy,
|
|
11
|
+
handle_doctor,
|
|
12
|
+
handle_launch,
|
|
13
|
+
handle_login,
|
|
14
|
+
handle_logout,
|
|
15
|
+
handle_notify,
|
|
16
|
+
handle_remove,
|
|
17
|
+
handle_repair,
|
|
18
|
+
handle_rename,
|
|
19
|
+
handle_status,
|
|
20
|
+
)
|
|
21
|
+
from .cli_render import (
|
|
22
|
+
_format_sessions,
|
|
23
|
+
_pad_table,
|
|
24
|
+
_should_use_color,
|
|
25
|
+
_style,
|
|
26
|
+
_visible_len,
|
|
27
|
+
format_error,
|
|
28
|
+
)
|
|
29
|
+
from .errors import CdxError
|
|
30
|
+
from .provider_runtime import (
|
|
31
|
+
LOG_ROTATE_BYTES,
|
|
32
|
+
_rotate_log_if_needed,
|
|
33
|
+
)
|
|
34
|
+
from .session_service import create_session_service
|
|
35
|
+
from .status_view import (
|
|
36
|
+
_format_blocking_quota,
|
|
37
|
+
_format_reset_time,
|
|
38
|
+
_format_status_detail,
|
|
39
|
+
_format_status_rows,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
VERSION = "0.2.1"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# Help / version
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
def _print_help(use_color=False):
|
|
50
|
+
return "\n".join([
|
|
51
|
+
_style("cdx - terminal session manager", "1", use_color),
|
|
52
|
+
"",
|
|
53
|
+
_style("Usage:", "1", use_color),
|
|
54
|
+
f" {_style('cdx', '36', use_color)}",
|
|
55
|
+
f" {_style('cdx status [--json] [--refresh]', '36', use_color)}",
|
|
56
|
+
f" {_style('cdx status --small|-s [--refresh]', '36', use_color)}",
|
|
57
|
+
f" {_style('cdx status <name> [--json] [--refresh]', '36', use_color)}",
|
|
58
|
+
f" {_style('cdx add [provider] <name>', '36', use_color)}",
|
|
59
|
+
f" {_style('cdx cp <source> <dest>', '36', use_color)}",
|
|
60
|
+
f" {_style('cdx ren <source> <dest>', '36', use_color)}",
|
|
61
|
+
f" {_style('cdx login <name>', '36', use_color)}",
|
|
62
|
+
f" {_style('cdx logout <name>', '36', use_color)}",
|
|
63
|
+
f" {_style('cdx rmv <name> [--force]', '36', use_color)}",
|
|
64
|
+
f" {_style('cdx clean [name]', '36', use_color)}",
|
|
65
|
+
f" {_style('cdx doctor [--json]', '36', use_color)}",
|
|
66
|
+
f" {_style('cdx repair [--dry-run] [--force] [--json]', '36', use_color)}",
|
|
67
|
+
f" {_style('cdx notify <name> --at-reset', '36', use_color)}",
|
|
68
|
+
f" {_style('cdx notify --next-ready', '36', use_color)}",
|
|
69
|
+
f" {_style('cdx <name>', '36', use_color)}",
|
|
70
|
+
f" {_style('cdx --help', '36', use_color)}",
|
|
71
|
+
f" {_style('cdx --version', '36', use_color)}",
|
|
72
|
+
])
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _print_version():
|
|
76
|
+
return VERSION
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
# main()
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
def main(argv, options=None):
|
|
84
|
+
if options is None:
|
|
85
|
+
options = {}
|
|
86
|
+
|
|
87
|
+
env = options.get("env", os.environ)
|
|
88
|
+
stdout = options.get("stdout", sys.stdout)
|
|
89
|
+
stderr = options.get("stderr", sys.stderr)
|
|
90
|
+
stdin_is_tty = options.get("stdin", {}).get("isTTY", hasattr(sys.stdin, "isatty") and sys.stdin.isatty())
|
|
91
|
+
use_color = _should_use_color(env, stdout)
|
|
92
|
+
service = options.get("service") or create_session_service({"env": env})
|
|
93
|
+
spawn = options.get("spawn")
|
|
94
|
+
spawn_sync = options.get("spawn_sync")
|
|
95
|
+
refresh_fn = options.get("refreshClaudeSessionStatus")
|
|
96
|
+
signal_emitter = options.get("signalEmitter")
|
|
97
|
+
|
|
98
|
+
def out(text):
|
|
99
|
+
stdout.write(text)
|
|
100
|
+
|
|
101
|
+
def err(text):
|
|
102
|
+
stderr.write(text)
|
|
103
|
+
|
|
104
|
+
# Flags
|
|
105
|
+
if "--help" in argv or "-h" in argv:
|
|
106
|
+
if len(argv) != 1:
|
|
107
|
+
raise CdxError("Usage: cdx --help")
|
|
108
|
+
out(f"{_print_help(use_color=use_color)}\n")
|
|
109
|
+
return 0
|
|
110
|
+
|
|
111
|
+
if "--version" in argv or "-v" in argv:
|
|
112
|
+
if len(argv) != 1:
|
|
113
|
+
raise CdxError("Usage: cdx --version")
|
|
114
|
+
out(f"{_print_version()}\n")
|
|
115
|
+
return 0
|
|
116
|
+
|
|
117
|
+
if not argv:
|
|
118
|
+
out(f"{_format_sessions(service, use_color=use_color)}\n")
|
|
119
|
+
return 0
|
|
120
|
+
|
|
121
|
+
command, *rest = argv
|
|
122
|
+
ctx = {
|
|
123
|
+
"env": env,
|
|
124
|
+
"options": options,
|
|
125
|
+
"err": err,
|
|
126
|
+
"out": out,
|
|
127
|
+
"refresh_fn": refresh_fn,
|
|
128
|
+
"service": service,
|
|
129
|
+
"signal_emitter": signal_emitter,
|
|
130
|
+
"spawn": spawn,
|
|
131
|
+
"spawn_sync": spawn_sync,
|
|
132
|
+
"stdin_is_tty": stdin_is_tty,
|
|
133
|
+
"use_color": use_color,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if command == "add":
|
|
137
|
+
return handle_add(rest, ctx)
|
|
138
|
+
|
|
139
|
+
if command == "cp":
|
|
140
|
+
return handle_copy(rest, ctx)
|
|
141
|
+
|
|
142
|
+
if command in ("ren", "rename", "mv"):
|
|
143
|
+
return handle_rename(rest, ctx)
|
|
144
|
+
|
|
145
|
+
if command == "rmv":
|
|
146
|
+
return handle_remove(rest, ctx)
|
|
147
|
+
|
|
148
|
+
if command == "clean":
|
|
149
|
+
return handle_clean(rest, ctx)
|
|
150
|
+
|
|
151
|
+
if command == "doctor":
|
|
152
|
+
return handle_doctor(rest, ctx)
|
|
153
|
+
|
|
154
|
+
if command == "repair":
|
|
155
|
+
return handle_repair(rest, ctx)
|
|
156
|
+
|
|
157
|
+
if command == "notify":
|
|
158
|
+
return handle_notify(rest, ctx)
|
|
159
|
+
|
|
160
|
+
if command == "status":
|
|
161
|
+
return handle_status(rest, ctx)
|
|
162
|
+
|
|
163
|
+
if command == "login":
|
|
164
|
+
return handle_login(rest, ctx)
|
|
165
|
+
|
|
166
|
+
if command == "logout":
|
|
167
|
+
return handle_logout(rest, ctx)
|
|
168
|
+
|
|
169
|
+
if command in ("help",):
|
|
170
|
+
out(f"{_print_help(use_color=use_color)}\n")
|
|
171
|
+
return 0
|
|
172
|
+
|
|
173
|
+
if command in ("version",):
|
|
174
|
+
out(f"{_print_version()}\n")
|
|
175
|
+
return 0
|
|
176
|
+
|
|
177
|
+
if not rest:
|
|
178
|
+
return handle_launch(command, ctx)
|
|
179
|
+
|
|
180
|
+
raise CdxError(f"Unknown command: {command}. Use cdx --help.")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
if __name__ == "__main__":
|
|
184
|
+
try:
|
|
185
|
+
raise SystemExit(main(sys.argv[1:]))
|
|
186
|
+
except CdxError as error:
|
|
187
|
+
sys.stderr.write(f"{format_error(error)}\n")
|
|
188
|
+
raise SystemExit(error.exit_code)
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
from .claude_refresh import _refresh_claude_sessions
|
|
7
|
+
from .cli_render import _dim, _info, _success, _warn
|
|
8
|
+
from .errors import CdxError
|
|
9
|
+
from .health import collect_health_report, format_health_report, health_json
|
|
10
|
+
from .notify import (
|
|
11
|
+
format_notify_event,
|
|
12
|
+
notify_json,
|
|
13
|
+
parse_notify_args,
|
|
14
|
+
send_desktop_notification,
|
|
15
|
+
wait_for_notification_event,
|
|
16
|
+
)
|
|
17
|
+
from .provider_runtime import (
|
|
18
|
+
_ensure_session_authentication,
|
|
19
|
+
_list_launch_transcript_paths,
|
|
20
|
+
_run_interactive_provider_command,
|
|
21
|
+
)
|
|
22
|
+
from .repair import format_repair_report, repair_health, repair_json
|
|
23
|
+
from .status_view import _format_status_detail, _format_status_rows
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
STATUS_USAGE = "Usage: cdx status [--json] [--refresh] | cdx status --small|-s [--refresh] | cdx status <name> [--json] [--refresh]"
|
|
27
|
+
DOCTOR_USAGE = "Usage: cdx doctor [--json]"
|
|
28
|
+
REPAIR_USAGE = "Usage: cdx repair [--dry-run] [--force] [--json]"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _local_now_iso():
|
|
32
|
+
return datetime.now().astimezone().isoformat()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _parse_add_args(args):
|
|
36
|
+
if len(args) == 1:
|
|
37
|
+
return {"provider": "codex", "name": args[0]}
|
|
38
|
+
if len(args) == 2:
|
|
39
|
+
return {"provider": args[0], "name": args[1]}
|
|
40
|
+
raise CdxError("Usage: cdx add [provider] <name>")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _parse_copy_args(args):
|
|
44
|
+
if len(args) != 2:
|
|
45
|
+
raise CdxError("Usage: cdx cp <source> <dest>")
|
|
46
|
+
return {"source": args[0], "dest": args[1]}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _parse_rename_args(args):
|
|
50
|
+
if len(args) != 2:
|
|
51
|
+
raise CdxError("Usage: cdx ren <source> <dest>")
|
|
52
|
+
return {"source": args[0], "dest": args[1]}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _parse_remove_args(args):
|
|
56
|
+
force = "--force" in args
|
|
57
|
+
names = [a for a in args if a != "--force"]
|
|
58
|
+
unknown = [a for a in args if a.startswith("-") and a != "--force"]
|
|
59
|
+
if unknown or len(names) != 1 or len(args) > 2:
|
|
60
|
+
raise CdxError("Usage: cdx rmv <name> [--force]")
|
|
61
|
+
return {"name": names[0], "force": force}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _confirm_removal(name):
|
|
65
|
+
answer = input(f"Remove session {name}? [y/N] ")
|
|
66
|
+
return answer.strip().lower() in ("y", "yes")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _resolve_confirmation(confirm_fn, name):
|
|
70
|
+
confirmed = (
|
|
71
|
+
asyncio.get_event_loop().run_until_complete(confirm_fn(name))
|
|
72
|
+
if asyncio.iscoroutinefunction(confirm_fn)
|
|
73
|
+
else confirm_fn(name)
|
|
74
|
+
)
|
|
75
|
+
if hasattr(confirmed, "__await__"):
|
|
76
|
+
confirmed = asyncio.get_event_loop().run_until_complete(confirmed)
|
|
77
|
+
return confirmed
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def handle_add(rest, ctx):
|
|
81
|
+
parsed = _parse_add_args(rest)
|
|
82
|
+
session = ctx["service"]["create_session"](parsed["name"], parsed["provider"])
|
|
83
|
+
message = f"Created session {parsed['name']} ({parsed['provider']})"
|
|
84
|
+
ctx["out"](f"{_success(message, ctx['use_color'])}\n")
|
|
85
|
+
_ensure_session_authentication(
|
|
86
|
+
session,
|
|
87
|
+
ctx["service"],
|
|
88
|
+
spawn=ctx.get("spawn"),
|
|
89
|
+
spawn_sync=ctx.get("spawn_sync"),
|
|
90
|
+
env_override=ctx.get("env"),
|
|
91
|
+
stdin_is_tty=ctx["stdin_is_tty"],
|
|
92
|
+
behavior="bootstrap",
|
|
93
|
+
signal_emitter=ctx.get("signal_emitter"),
|
|
94
|
+
)
|
|
95
|
+
now = _local_now_iso()
|
|
96
|
+
ctx["service"]["update_auth_state"](parsed["name"], lambda auth: {
|
|
97
|
+
**auth,
|
|
98
|
+
"status": "authenticated",
|
|
99
|
+
"lastCheckedAt": now,
|
|
100
|
+
"lastAuthenticatedAt": now,
|
|
101
|
+
"lastLoggedOutAt": auth.get("lastLoggedOutAt"),
|
|
102
|
+
})
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def handle_copy(rest, ctx):
|
|
107
|
+
parsed = _parse_copy_args(rest)
|
|
108
|
+
result = ctx["service"]["copy_session"](parsed["source"], parsed["dest"])
|
|
109
|
+
overwritten = " (overwritten)" if result["overwritten"] else ""
|
|
110
|
+
message = f"Copied session {parsed['source']} to {parsed['dest']}{overwritten}"
|
|
111
|
+
ctx["out"](f"{_success(message, ctx['use_color'])}\n")
|
|
112
|
+
return 0
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def handle_rename(rest, ctx):
|
|
116
|
+
parsed = _parse_rename_args(rest)
|
|
117
|
+
ctx["service"]["rename_session"](parsed["source"], parsed["dest"])
|
|
118
|
+
message = f"Renamed session {parsed['source']} to {parsed['dest']}"
|
|
119
|
+
ctx["out"](f"{_success(message, ctx['use_color'])}\n")
|
|
120
|
+
return 0
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def handle_remove(rest, ctx):
|
|
124
|
+
parsed = _parse_remove_args(rest)
|
|
125
|
+
if not parsed["force"]:
|
|
126
|
+
confirm_fn = ctx["options"].get("confirmRemove")
|
|
127
|
+
if confirm_fn:
|
|
128
|
+
confirmed = _resolve_confirmation(confirm_fn, parsed["name"])
|
|
129
|
+
elif not ctx["stdin_is_tty"]:
|
|
130
|
+
raise CdxError("Removal requires confirmation in an interactive terminal or --force in non-interactive mode.")
|
|
131
|
+
else:
|
|
132
|
+
confirmed = _confirm_removal(parsed["name"])
|
|
133
|
+
if not confirmed:
|
|
134
|
+
ctx["out"](f"{_warn('Cancelled.', ctx['use_color'])}\n")
|
|
135
|
+
return 0
|
|
136
|
+
ctx["service"]["remove_session"](parsed["name"])
|
|
137
|
+
message = f"Removed session {parsed['name']}"
|
|
138
|
+
ctx["out"](f"{_success(message, ctx['use_color'])}\n")
|
|
139
|
+
return 0
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def handle_clean(rest, ctx):
|
|
143
|
+
service = ctx["service"]
|
|
144
|
+
if len(rest) == 0:
|
|
145
|
+
targets = service["list_sessions"]()
|
|
146
|
+
elif len(rest) == 1:
|
|
147
|
+
session = service["get_session"](rest[0])
|
|
148
|
+
if not session:
|
|
149
|
+
raise CdxError(f"Unknown session: {rest[0]}")
|
|
150
|
+
targets = [session]
|
|
151
|
+
else:
|
|
152
|
+
raise CdxError("Usage: cdx clean [name]")
|
|
153
|
+
|
|
154
|
+
for session in targets:
|
|
155
|
+
log_paths = _list_launch_transcript_paths(session)
|
|
156
|
+
if not log_paths:
|
|
157
|
+
message = f"{session['name']}: no log found"
|
|
158
|
+
ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
|
|
159
|
+
continue
|
|
160
|
+
total_size = 0
|
|
161
|
+
cleared = 0
|
|
162
|
+
for log_path in log_paths:
|
|
163
|
+
try:
|
|
164
|
+
total_size += os.path.getsize(log_path)
|
|
165
|
+
open(log_path, "w").close()
|
|
166
|
+
cleared += 1
|
|
167
|
+
except OSError:
|
|
168
|
+
continue
|
|
169
|
+
if cleared:
|
|
170
|
+
message = (
|
|
171
|
+
f"Cleared {session['name']} logs ({cleared} file"
|
|
172
|
+
f"{'' if cleared == 1 else 's'}, {round(total_size / 1024)} KB freed)"
|
|
173
|
+
)
|
|
174
|
+
ctx["out"](f"{_success(message, ctx['use_color'])}\n")
|
|
175
|
+
else:
|
|
176
|
+
message = f"{session['name']}: no log found"
|
|
177
|
+
ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
|
|
178
|
+
return 0
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def handle_doctor(rest, ctx):
|
|
182
|
+
json_flag = "--json" in rest
|
|
183
|
+
unknown = [arg for arg in rest if arg != "--json"]
|
|
184
|
+
if unknown:
|
|
185
|
+
raise CdxError(DOCTOR_USAGE)
|
|
186
|
+
report = collect_health_report(
|
|
187
|
+
ctx["service"],
|
|
188
|
+
ctx["service"]["base_dir"],
|
|
189
|
+
env=ctx.get("env"),
|
|
190
|
+
)
|
|
191
|
+
if json_flag:
|
|
192
|
+
ctx["out"](f"{health_json(report)}\n")
|
|
193
|
+
else:
|
|
194
|
+
ctx["out"](f"{format_health_report(report, use_color=ctx['use_color'])}\n")
|
|
195
|
+
return 0
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def handle_repair(rest, ctx):
|
|
199
|
+
json_flag = "--json" in rest
|
|
200
|
+
dry_run = "--dry-run" in rest or "--force" not in rest
|
|
201
|
+
force = "--force" in rest
|
|
202
|
+
allowed = {"--json", "--dry-run", "--force"}
|
|
203
|
+
unknown = [arg for arg in rest if arg not in allowed]
|
|
204
|
+
if unknown:
|
|
205
|
+
raise CdxError(REPAIR_USAGE)
|
|
206
|
+
report = repair_health(
|
|
207
|
+
ctx["service"],
|
|
208
|
+
ctx["service"]["base_dir"],
|
|
209
|
+
env=ctx.get("env"),
|
|
210
|
+
dry_run=dry_run,
|
|
211
|
+
force=force,
|
|
212
|
+
)
|
|
213
|
+
if json_flag:
|
|
214
|
+
ctx["out"](f"{repair_json(report)}\n")
|
|
215
|
+
else:
|
|
216
|
+
ctx["out"](f"{format_repair_report(report, use_color=ctx['use_color'])}\n")
|
|
217
|
+
if dry_run:
|
|
218
|
+
ctx["out"](f"{_dim('Tip: run cdx repair --force to apply safe repairs.', ctx['use_color'])}\n")
|
|
219
|
+
return 0
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def handle_notify(rest, ctx):
|
|
223
|
+
parsed = parse_notify_args(rest)
|
|
224
|
+
|
|
225
|
+
def notifier(title, message):
|
|
226
|
+
send_desktop_notification(
|
|
227
|
+
title,
|
|
228
|
+
message,
|
|
229
|
+
spawn_sync=ctx.get("spawn_sync"),
|
|
230
|
+
env=ctx.get("env"),
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
event = wait_for_notification_event(
|
|
234
|
+
ctx["service"],
|
|
235
|
+
parsed,
|
|
236
|
+
notifier=notifier,
|
|
237
|
+
sleep_fn=ctx["options"].get("sleep"),
|
|
238
|
+
now_fn=ctx["options"].get("now"),
|
|
239
|
+
)
|
|
240
|
+
if parsed["json"]:
|
|
241
|
+
ctx["out"](f"{notify_json(event)}\n")
|
|
242
|
+
else:
|
|
243
|
+
ctx["out"](f"{format_notify_event(event)}\n")
|
|
244
|
+
return 0
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def handle_status(rest, ctx):
|
|
248
|
+
json_flag = "--json" in rest
|
|
249
|
+
small_flag = "--small" in rest or "-s" in rest
|
|
250
|
+
refresh_flag = "--refresh" in rest
|
|
251
|
+
status_flags = {"--json", "--small", "-s", "--refresh"}
|
|
252
|
+
args = [a for a in rest if a not in status_flags]
|
|
253
|
+
unknown_flags = [a for a in args if a.startswith("-")]
|
|
254
|
+
if unknown_flags or (json_flag and small_flag):
|
|
255
|
+
raise CdxError(STATUS_USAGE)
|
|
256
|
+
if len(args) > 1 or (len(args) == 1 and small_flag):
|
|
257
|
+
raise CdxError(STATUS_USAGE)
|
|
258
|
+
|
|
259
|
+
refresh_result = _refresh_claude_sessions(
|
|
260
|
+
ctx["service"],
|
|
261
|
+
ctx.get("refresh_fn"),
|
|
262
|
+
target_names=args if len(args) == 1 else None,
|
|
263
|
+
force=refresh_flag,
|
|
264
|
+
)
|
|
265
|
+
refresh_errors = [
|
|
266
|
+
{
|
|
267
|
+
"session": item.get("session"),
|
|
268
|
+
"error": str(item.get("error")),
|
|
269
|
+
}
|
|
270
|
+
for item in refresh_result.get("errors", [])
|
|
271
|
+
]
|
|
272
|
+
|
|
273
|
+
rows = ctx["service"]["get_status_rows"]()
|
|
274
|
+
if len(args) == 0:
|
|
275
|
+
if json_flag:
|
|
276
|
+
ctx["out"](f"{json.dumps(rows, indent=2)}\n")
|
|
277
|
+
_write_refresh_warnings(refresh_errors, ctx, stream="err")
|
|
278
|
+
return 0
|
|
279
|
+
ctx["out"](f"{_format_status_rows(rows, use_color=ctx['use_color'], small=small_flag)}\n")
|
|
280
|
+
_write_refresh_warnings(refresh_errors, ctx)
|
|
281
|
+
return 0
|
|
282
|
+
|
|
283
|
+
row = next((r for r in rows if r["session_name"] == args[0]), None)
|
|
284
|
+
if not row:
|
|
285
|
+
raise CdxError(f"Unknown session: {args[0]}")
|
|
286
|
+
if json_flag:
|
|
287
|
+
ctx["out"](f"{json.dumps(row, indent=2)}\n")
|
|
288
|
+
_write_refresh_warnings(refresh_errors, ctx, stream="err")
|
|
289
|
+
return 0
|
|
290
|
+
ctx["out"](f"{_format_status_detail(row, use_color=ctx['use_color'])}\n")
|
|
291
|
+
_write_refresh_warnings(refresh_errors, ctx)
|
|
292
|
+
return 0
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _write_refresh_warnings(refresh_errors, ctx, stream="out"):
|
|
296
|
+
write = ctx["err"] if stream == "err" and "err" in ctx else ctx["out"]
|
|
297
|
+
for item in refresh_errors:
|
|
298
|
+
session = item.get("session") or "unknown"
|
|
299
|
+
error = item.get("error") or "unknown error"
|
|
300
|
+
write(f"{_warn(f'Warning: Claude refresh failed for {session}: {error}', ctx['use_color'])}\n")
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def handle_login(rest, ctx):
|
|
304
|
+
if len(rest) != 1:
|
|
305
|
+
raise CdxError("Usage: cdx login <name>")
|
|
306
|
+
if not ctx["stdin_is_tty"]:
|
|
307
|
+
raise CdxError("Login requires an interactive terminal.")
|
|
308
|
+
session = ctx["service"]["get_session"](rest[0])
|
|
309
|
+
if not session:
|
|
310
|
+
raise CdxError(f"Unknown session: {rest[0]}")
|
|
311
|
+
_run_interactive_provider_command(
|
|
312
|
+
session, "logout", spawn=ctx.get("spawn"), env_override=ctx.get("env"),
|
|
313
|
+
signal_emitter=ctx.get("signal_emitter")
|
|
314
|
+
)
|
|
315
|
+
_run_interactive_provider_command(
|
|
316
|
+
session, "login", spawn=ctx.get("spawn"), env_override=ctx.get("env"),
|
|
317
|
+
signal_emitter=ctx.get("signal_emitter")
|
|
318
|
+
)
|
|
319
|
+
now = _local_now_iso()
|
|
320
|
+
ctx["service"]["update_auth_state"](rest[0], lambda auth: {
|
|
321
|
+
**auth, "status": "authenticated",
|
|
322
|
+
"lastCheckedAt": now, "lastAuthenticatedAt": now,
|
|
323
|
+
})
|
|
324
|
+
message = f"Reauthenticated session {session['name']} ({session['provider']})"
|
|
325
|
+
ctx["out"](f"{_success(message, ctx['use_color'])}\n")
|
|
326
|
+
return 0
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def handle_logout(rest, ctx):
|
|
330
|
+
if len(rest) != 1:
|
|
331
|
+
raise CdxError("Usage: cdx logout <name>")
|
|
332
|
+
session = ctx["service"]["get_session"](rest[0])
|
|
333
|
+
if not session:
|
|
334
|
+
raise CdxError(f"Unknown session: {rest[0]}")
|
|
335
|
+
_run_interactive_provider_command(
|
|
336
|
+
session, "logout", spawn=ctx.get("spawn"), env_override=ctx.get("env"),
|
|
337
|
+
signal_emitter=ctx.get("signal_emitter")
|
|
338
|
+
)
|
|
339
|
+
now = _local_now_iso()
|
|
340
|
+
ctx["service"]["update_auth_state"](rest[0], lambda auth: {
|
|
341
|
+
**auth, "status": "logged_out",
|
|
342
|
+
"lastCheckedAt": now, "lastLoggedOutAt": now,
|
|
343
|
+
})
|
|
344
|
+
message = f"Logged out session {session['name']} ({session['provider']})"
|
|
345
|
+
ctx["out"](f"{_success(message, ctx['use_color'])}\n")
|
|
346
|
+
return 0
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def handle_launch(command, ctx):
|
|
350
|
+
session = ctx["service"]["launch_session"](command)
|
|
351
|
+
_ensure_session_authentication(
|
|
352
|
+
session,
|
|
353
|
+
ctx["service"],
|
|
354
|
+
spawn=ctx.get("spawn"),
|
|
355
|
+
spawn_sync=ctx.get("spawn_sync"),
|
|
356
|
+
env_override=ctx.get("env"),
|
|
357
|
+
stdin_is_tty=ctx["stdin_is_tty"],
|
|
358
|
+
behavior="launch",
|
|
359
|
+
signal_emitter=ctx.get("signal_emitter"),
|
|
360
|
+
)
|
|
361
|
+
message = f"Launching {session['provider']} session {session['name']}"
|
|
362
|
+
ctx["out"](f"{_info(message, ctx['use_color'])}\n")
|
|
363
|
+
if session["provider"] == "codex":
|
|
364
|
+
ctx["out"](f"{_dim('Tip: run /status once the Codex session opens.', ctx['use_color'])}\n")
|
|
365
|
+
_run_interactive_provider_command(
|
|
366
|
+
session, "launch", spawn=ctx.get("spawn"), env_override=ctx.get("env"),
|
|
367
|
+
signal_emitter=ctx.get("signal_emitter")
|
|
368
|
+
)
|
|
369
|
+
return 0
|