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.
@@ -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