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.
@@ -1,3074 +1,276 @@
1
- import asyncio
2
- import getpass
3
- import json
4
- import os
5
- import re
6
- import shlex
7
- import shutil
8
- import subprocess
9
- import sys
10
- import time
11
- import uuid
12
- from datetime import datetime
13
-
14
- from .backup_bundle import read_bundle_meta
15
- from .claude_refresh import _refresh_claude_sessions
16
- from .cli_args import (
17
- CAN_RESUME_USAGE,
18
- CONTEXT_USAGE,
19
- DISK_USAGE,
20
- HANDOFF_USAGE,
21
- LAST_USAGE,
22
- MEMORY_USAGE,
23
- REPAIR_USAGE,
24
- RESUME_USAGE,
25
- RUN_REPORT_USAGE,
26
- RUN_STATUS_USAGE,
27
- RUNS_USAGE,
28
- STATUS_USAGE,
29
- _filter_history_period,
30
- _has_history_period,
31
- _parse_add_args,
32
- _parse_config_args,
33
- _parse_configs_args,
34
- _parse_copy_args,
35
- _parse_doctor_args,
36
- _parse_export_args,
37
- _parse_flag_args,
38
- _parse_history_args,
39
- _parse_import_args,
40
- _parse_json_flag,
41
- _parse_label_args,
42
- _parse_launch_setting_alias_args,
43
- _parse_next_args,
44
- _parse_remove_args,
45
- _parse_rename_args,
46
- _parse_run_args,
47
- _parse_run_id_json_args,
48
- _parse_runs_args,
49
- _parse_select_args,
50
- _parse_set_args,
51
- _parse_stats_args,
52
- _parse_timeout_seconds,
53
- _parse_toggle_args,
54
- _parse_unset_args,
55
- _parse_update_args,
56
- _public_history_period,
57
- )
58
- from .cli_helpers import (
1
+ #!/usr/bin/env python3
2
+ """Facade over the per-domain command modules in src/commands/.
3
+
4
+ Every `handle_*` entry point now lives in one of the nine domain modules; this
5
+ module defines nothing and only re-exports them, so `from .cli_commands import
6
+ handle_x` keeps resolving for src/cli.py and the tests. Add a new command to
7
+ its domain module and re-export it here.
8
+
9
+ The `# noqa: F401` markers are load-bearing: without them ruff removes the
10
+ re-exports as unused, and callers importing through this facade break.
11
+ `test_cli_commands_facade_still_exposes_every_name_its_callers_import` guards
12
+ that contract.
13
+ """
14
+ from .cli_args import STATUS_USAGE # noqa: F401 (re-exported for cli.py)
15
+ from .cli_helpers import ( # noqa: F401 (several names are re-exported for cli.py / tests)
59
16
  API_SCHEMA_VERSION,
17
+ _bootstrap_claude_setup_token,
60
18
  _build_handoff_context,
19
+ _extract_claude_oauth_token,
61
20
  _format_bytes,
62
- _format_export_report,
21
+ _format_launch_config,
22
+ _format_launch_setting_value,
23
+ _format_launch_settings_hint,
63
24
  _handoff_launch_prompt,
64
25
  _json_failure,
65
26
  _json_success,
66
27
  _latest_handoff_transcript_path,
67
28
  _local_now_iso,
68
- _make_export_progress,
69
29
  _make_notify_progress,
70
30
  _make_status_progress,
71
31
  _read_handoff_transcript,
32
+ _resolve_confirmation,
33
+ _resume_capability_for_session,
72
34
  _update_notice_warnings,
35
+ _warn_if_session_already_running,
36
+ _write_claude_oauth_token,
73
37
  _write_json,
74
38
  _write_update_notice,
75
39
  )
76
- from .cli_render import _dim, _info, _pad_table, _style, _success, _warn
77
40
  from .cli_view import handle_view as handle_view # re-export for cli.py / tests
78
- from .codex_usage import consume_codex_rate_limit_reset_credit
79
- from .config import PROVIDER_ANTIGRAVITY, PROVIDER_CLAUDE, PROVIDER_CODEX, PROVIDER_OLLAMA
80
- from .context_store import (
81
- append_context,
82
- append_context_path,
83
- clear_context,
84
- clear_context_path,
85
- edit_context,
86
- edit_context_path,
87
- get_context_path,
88
- get_global_context_path,
89
- get_named_project_context_path,
90
- init_context,
91
- init_context_path,
92
- install_context_for_session,
93
- list_named_project_contexts,
94
- read_context,
95
- read_context_path,
96
- write_context,
97
- write_context_path,
41
+ from .commands.auth import ( # re-export for cli.py / tests
42
+ _confirm_reset as _confirm_reset,
98
43
  )
99
- from .errors import CdxError
100
- from .fs_utils import atomic_write
101
- from .health import collect_health_report, filter_health_report, format_health_report
102
- from .notify import (
103
- format_notify_event,
104
- format_scheduled_notification,
105
- parse_notify_args,
106
- resolve_notify_event,
107
- schedule_notification_event,
108
- send_desktop_notification,
109
- wait_for_notification_event,
44
+ from .commands.auth import (
45
+ handle_login as handle_login,
110
46
  )
111
- from .provider_runtime import (
112
- AUTH_PROBE_AUTHENTICATED,
113
- AUTH_PROBE_DEGRADED,
114
- _ensure_session_authentication,
115
- _headless_artifact_paths,
116
- _list_launch_transcript_paths,
117
- _probe_provider_auth_status,
118
- _run_headless_provider_command,
119
- _run_interactive_provider_command,
120
- get_resume_capability,
47
+ from .commands.auth import (
48
+ handle_logout as handle_logout,
49
+ )
50
+ from .commands.auth import (
51
+ handle_notify as handle_notify,
52
+ )
53
+ from .commands.auth import (
54
+ handle_reset as handle_reset,
55
+ )
56
+ from .commands.backup import ( # noqa: F401
57
+ _resolve_bundle_passphrase,
58
+ handle_export,
59
+ handle_import,
121
60
  )
122
- from .repair import format_repair_report, repair_health
123
- from .run_command import read_run_prompt, run_cdx_error_code, run_result_payload
124
- from .run_registry import RunRegistry, build_code_review_report
125
- from .run_usage import extract_run_usage
126
- from .status_view import _format_status_detail, _format_status_rows, format_priority_instruction, recommend_priority_rows
127
- from .update_all import apply_update_all_plan, collect_update_all_plan
128
- from .update_check import LatestReleaseCheckError, fetch_latest_release, fetch_latest_release_or_raise, is_newer_version
129
- from .update_manager import build_update_plan, format_update_failure, run_update_plan, verify_updated_command
130
-
131
-
132
- def _format_update_all(plan, use_color=False):
133
- labels = {
134
- "up_to_date": _success("OK", use_color),
135
- "update_available": _warn("UPDATE", use_color),
136
- "not_installed": _warn("MISSING", use_color),
137
- "check_with_native_updater": _info("CHECK", use_color),
138
- }
139
- rows = [[_style("TOOL", "1", use_color), _style("CURRENT", "1", use_color), _style("LATEST", "1", use_color), _style("STATUS", "1", use_color)]]
140
- for item in plan["items"]:
141
- rows.append([_info(item["name"], use_color), item.get("version") or _dim("-", use_color), item.get("latest_version") or _dim("-", use_color), labels.get(item["status"], item["status"].replace("_", " "))])
142
- lines = [_style("Workstation update", "1", use_color), _dim("Inventory only — nothing has changed.", use_color), "", _pad_table(rows)]
143
- missing_rtk = plan["setup"]["rtk_missing_sessions"]
144
- setup = [f"RTK: enable for {', '.join(missing_rtk)}" if missing_rtk else "RTK: configured for every session"]
145
- setup.extend(f"Ponytail {plugin['session']}: {labels.get(plugin['status'], plugin['status'].replace('_', ' '))}" for plugin in plan["setup"]["ponytail"])
146
- lines.extend(["", _style("Session setup", "1", use_color), *setup])
147
- if plan["steps"]:
148
- lines.extend(["", _warn(f"{len(plan['steps'])} action(s) ready", use_color), _dim("Confirm to update only the tools listed above.", use_color)])
149
- else:
150
- lines.extend(["", _success("Everything managed by CDX is current and configured.", use_color)])
151
- return "\n".join(lines)
152
-
153
-
154
- def _format_update_all_result(result, use_color=False):
155
- if result.get("skipped"):
156
- return _warn(f"skipped: {result['name']} (blocked by {result['blocked_by']})", use_color)
157
- if result.get("returncode") == 0:
158
- return _success(f"done: {result['name']}", use_color)
159
- detail = str(result.get("stderr") or result.get("stdout") or "No error output.").strip().replace("\n", " ")
160
- detail = detail[-500:]
161
- command = shlex.join(result.get("command") or [])
162
- suffix = f"\n {_dim(command, use_color)}" if command else ""
163
- label = _warn(f"failed: {result['name']} (exit {result.get('returncode')})", use_color)
164
- return f"{label}\n {detail}{suffix}"
165
-
166
-
167
- def _resolve_bundle_passphrase(ctx, env_var, prompt, confirm=False, use_stdin=False):
168
- env = ctx.get("env", {})
169
- if env_var:
170
- passphrase = env.get(env_var)
171
- if not passphrase:
172
- raise CdxError(f"Environment variable {env_var} is empty or unset.")
173
- return passphrase
174
- if use_stdin:
175
- reader = ctx["options"].get("read_stdin")
176
- raw = reader() if callable(reader) else sys.stdin.readline()
177
- passphrase = (raw or "").rstrip("\r\n")
178
- if not passphrase:
179
- raise CdxError("Bundle passphrase from stdin is empty.")
180
- return passphrase
181
- if not ctx["stdin_is_tty"]:
182
- raise CdxError("Encrypted bundle export/import requires an interactive terminal, --passphrase-env, or --passphrase-stdin.")
183
- getpass_fn = ctx["options"].get("getpass") or getpass.getpass
184
- passphrase = getpass_fn(prompt)
185
- if not passphrase:
186
- raise CdxError("Bundle passphrase cannot be empty.")
187
- if confirm:
188
- confirmation = getpass_fn("Confirm bundle passphrase: ")
189
- if passphrase != confirmation:
190
- raise CdxError("Bundle passphrase confirmation does not match.")
191
- return passphrase
192
-
193
-
194
- def _confirm_removal(name):
195
- answer = input(f"Remove session {name}? [y/N] ")
196
- return answer.strip().lower() in ("y", "yes")
197
-
198
-
199
- def _confirm_reset(name):
200
- answer = input(f"Consume one banked Codex reset for {name}? [y/N] ")
201
- return answer.strip().lower() in ("y", "yes")
202
-
203
-
204
- def _confirm_profile_cleanup(action):
205
- answer = input(f"Delete matching profile {action} candidates? [y/N] ")
206
- return answer.strip().lower() in ("y", "yes")
207
-
208
-
209
- def _confirm_log_cleanup(target):
210
- answer = input(f"Clear launch transcript logs for {target}? [y/N] ")
211
- return answer.strip().lower() in ("y", "yes")
212
-
213
-
214
- def _resolve_confirmation(confirm_fn, name):
215
- confirmed = (
216
- asyncio.get_event_loop().run_until_complete(confirm_fn(name))
217
- if asyncio.iscoroutinefunction(confirm_fn)
218
- else confirm_fn(name)
219
- )
220
- if hasattr(confirmed, "__await__"):
221
- confirmed = asyncio.get_event_loop().run_until_complete(confirmed)
222
- return confirmed
223
-
224
-
225
- def _directory_size_bytes(path, runner=None):
226
- if not os.path.exists(path):
227
- raise CdxError(f"CDX home does not exist: {path}")
228
- runner = runner or subprocess.check_output
229
- try:
230
- output = runner(["du", "-sk", path], text=True, stderr=subprocess.DEVNULL)
231
- return int(str(output).split()[0]) * 1024
232
- except (OSError, subprocess.CalledProcessError, ValueError, IndexError):
233
- total = 0
234
- for root, _, files in os.walk(path):
235
- for name in files:
236
- try:
237
- total += os.path.getsize(os.path.join(root, name))
238
- except OSError:
239
- pass
240
- return total
241
-
242
-
243
- def _directory_child_sizes(path, runner=None, progress=None):
244
- rows = []
245
- try:
246
- names = os.listdir(path)
247
- except OSError:
248
- return rows
249
- children = [(name, os.path.join(path, name)) for name in names if os.path.isdir(os.path.join(path, name))]
250
- for index, (name, child_path) in enumerate(children, start=1):
251
- if progress:
252
- progress("profile", name, index, len(children))
253
- rows.append({
254
- "name": name,
255
- "path": child_path,
256
- "bytes": _directory_size_bytes(child_path, runner=runner),
257
- })
258
- rows.sort(key=lambda row: row["bytes"], reverse=True)
259
- for row in rows:
260
- row["size"] = _format_bytes(row["bytes"])
261
- return rows
262
-
263
-
264
- def _parse_days(value, usage):
265
- match = re.fullmatch(r"(\d+)(?:d)?", str(value or "").strip())
266
- if not match or int(match.group(1)) <= 0:
267
- raise CdxError(usage)
268
- return int(match.group(1))
269
-
270
-
271
- def _candidate(profile, kind, path, bytes_used, reason, risk="safe", evidence=None):
272
- return {
273
- "profile": profile,
274
- "kind": kind,
275
- "path": path,
276
- "bytes": bytes_used,
277
- "size": _format_bytes(bytes_used),
278
- "reason": reason,
279
- "risk": risk,
280
- "evidence": evidence or {},
281
- }
282
-
283
-
284
- def _iter_profile_dirs(base_dir):
285
- profiles_dir = os.path.join(base_dir, "profiles")
286
- try:
287
- names = sorted(os.listdir(profiles_dir))
288
- except OSError:
289
- return
290
- for name in names:
291
- path = os.path.join(profiles_dir, name)
292
- if os.path.isdir(path):
293
- yield name, path
294
-
295
-
296
- def _collect_old_logs(profile_name, profile_path, days, now=None):
297
- cutoff = (time.time() if now is None else now) - (days * 86400)
298
- paths = []
299
- bytes_used = 0
300
- latest_mtime = 0
301
- for root, _, files in os.walk(profile_path):
302
- if os.path.basename(root) != "log":
303
- continue
304
- for filename in files:
305
- if not filename.endswith(".log"):
306
- continue
307
- path = os.path.join(root, filename)
308
- try:
309
- stat = os.stat(path)
310
- except OSError:
311
- continue
312
- if stat.st_mtime >= cutoff:
313
- continue
314
- paths.append(path)
315
- bytes_used += stat.st_size
316
- latest_mtime = max(latest_mtime, stat.st_mtime)
317
- if not paths:
318
- return None
319
- return _candidate(
320
- profile_name,
321
- f"old-logs-{days}d",
322
- profile_path,
323
- bytes_used,
324
- f"{len(paths)} .log files older than {days}d",
325
- risk="review",
326
- evidence={
327
- "days": days,
328
- "file_count": len(paths),
329
- "newest_old_log_mtime": datetime.fromtimestamp(latest_mtime).isoformat() if latest_mtime else None,
330
- "sample_paths": paths[:5],
331
- },
332
- )
333
-
334
-
335
- def _collect_profile_cleanup_candidates(base_dir, *, old_log_days=30, runner=None, now=None, progress=None):
336
- candidates = []
337
- profiles = list(_iter_profile_dirs(base_dir))
338
- for index, (profile_name, profile_path) in enumerate(profiles, start=1):
339
- if progress:
340
- progress("candidates", profile_name, index, len(profiles))
341
- tmp_dir = os.path.join(profile_path, ".tmp")
342
- tmp_targets = [
343
- ("tmp-marketplaces", os.path.join(tmp_dir, "marketplaces"), "temporary marketplace cache/staging"),
344
- ]
345
- try:
346
- tmp_names = os.listdir(tmp_dir)
347
- except OSError:
348
- tmp_names = []
349
- for name in tmp_names:
350
- if name.startswith("plugins-clone-"):
351
- tmp_targets.append(("tmp-plugin-clone", os.path.join(tmp_dir, name), "temporary plugin clone"))
352
- elif name.startswith("plugins-backup-"):
353
- tmp_targets.append(("tmp-plugin-backup", os.path.join(tmp_dir, name), "temporary plugin backup"))
354
- for kind, path, reason in tmp_targets:
355
- if not os.path.exists(path):
356
- continue
357
- bytes_used = _directory_size_bytes(path, runner=runner)
358
- if bytes_used <= 0:
359
- continue
360
- try:
361
- mtime = datetime.fromtimestamp(os.path.getmtime(path)).isoformat()
362
- except OSError:
363
- mtime = None
364
- candidates.append(_candidate(
365
- profile_name,
366
- kind,
367
- path,
368
- bytes_used,
369
- reason,
370
- evidence={"modified_at": mtime},
371
- ))
372
- old_logs = _collect_old_logs(profile_name, profile_path, old_log_days, now=now)
373
- if old_logs and old_logs["bytes"] > 0:
374
- candidates.append(old_logs)
375
- candidates.sort(key=lambda item: item["bytes"], reverse=True)
376
- return candidates
377
-
378
-
379
- def _format_cleanup_candidates(candidates, use_color=False):
380
- if not candidates:
381
- return _dim("No cleanup candidates found.", use_color)
382
- reclaimable = sum(item["bytes"] for item in candidates)
383
- lines = [
384
- _style("Cleanup candidates", "1", use_color),
385
- f"{_style(_format_bytes(reclaimable), '33', use_color)} reclaimable across {len({item['profile'] for item in candidates})} profile(s)",
386
- ]
387
- by_profile = {}
388
- for item in candidates:
389
- by_profile.setdefault(item["profile"], []).append(item)
390
- for profile, items in sorted(by_profile.items(), key=lambda row: sum(i["bytes"] for i in row[1]), reverse=True):
391
- total = sum(item["bytes"] for item in items)
392
- lines.extend(["", f"{_style(profile, '1', use_color)} {_style(_format_bytes(total), '33', use_color)}"])
393
- rows = [[
394
- _style("SIZE", "1", use_color),
395
- _style("TYPE", "1", use_color),
396
- _style("RISK", "1", use_color),
397
- _style("EVIDENCE", "1", use_color),
398
- ]]
399
- for item in items:
400
- risk_color = "32" if item["risk"] == "safe" else "33"
401
- rows.append([
402
- _style(item["size"], "36", use_color),
403
- item["kind"],
404
- _style(item["risk"], risk_color, use_color),
405
- item["reason"],
406
- ])
407
- lines.append(_pad_table(rows))
408
- return "\n".join(lines)
409
-
410
-
411
- def _format_disk_report(payload, use_color=False):
412
- title = "CDX profiles" if payload["target"] == "profiles" else "CDX home"
413
- lines = [
414
- _style(title, "1", use_color),
415
- f"Path: {_dim(payload['path'], use_color)}",
416
- f"Total: {_style(payload['size'], '36', use_color)}",
417
- ]
418
- children = payload.get("children") or []
419
- if children:
420
- reclaimable_by_profile = {}
421
- for item in payload.get("candidates") or []:
422
- reclaimable_by_profile[item["profile"]] = reclaimable_by_profile.get(item["profile"], 0) + item["bytes"]
423
- headers = ["PROFILE", "SIZE", "SHARE"]
424
- if "candidates" in payload:
425
- headers.append("RECLAIMABLE")
426
- rows = [[_style(header, "1", use_color) for header in headers]]
427
- for child in children:
428
- share = (child["bytes"] / payload["bytes"] * 100) if payload["bytes"] else 0
429
- row = [
430
- _style(child["name"], "1", use_color),
431
- _style(child["size"], "36", use_color),
432
- f"{share:.1f}%",
433
- ]
434
- if "candidates" in payload:
435
- reclaimable = reclaimable_by_profile.get(child["name"], 0)
436
- row.append(_style(_format_bytes(reclaimable), "33", use_color) if reclaimable else _dim("-", use_color))
437
- rows.append(row)
438
- lines.extend(["", _style(f"Profiles ({len(children)})", "1", use_color), _pad_table(rows)])
439
- if "candidates" in payload:
440
- lines.extend(["", _format_cleanup_candidates(payload["candidates"], use_color=use_color)])
441
- return "\n".join(lines)
442
-
443
-
444
- def _remove_path(path):
445
- try:
446
- if os.path.isdir(path):
447
- size = _directory_size_bytes(path)
448
- shutil.rmtree(path)
449
- else:
450
- size = os.path.getsize(path)
451
- os.remove(path)
452
- return size
453
- except OSError as error:
454
- raise CdxError(f"Failed to remove cleanup candidate {path}: {error}") from error
455
-
456
-
457
- def _clean_profile_tmp(profile_path):
458
- tmp_dir = os.path.join(profile_path, ".tmp")
459
- targets = [os.path.join(tmp_dir, "marketplaces")]
460
- try:
461
- names = os.listdir(tmp_dir)
462
- except OSError:
463
- names = []
464
- targets.extend(os.path.join(tmp_dir, name) for name in names if name.startswith(("plugins-clone-", "plugins-backup-")))
465
- freed = 0
466
- removed = []
467
- for path in targets:
468
- if os.path.exists(path):
469
- size = _remove_path(path)
470
- if size:
471
- freed += size
472
- removed.append(path)
473
- return freed, removed
474
-
475
-
476
- def _clean_profile_old_logs(profile_path, days, now=None):
477
- cutoff = (time.time() if now is None else now) - (days * 86400)
478
- freed = 0
479
- removed = []
480
- for root, _, files in os.walk(profile_path):
481
- if os.path.basename(root) != "log":
482
- continue
483
- for filename in files:
484
- if not filename.endswith(".log"):
485
- continue
486
- path = os.path.join(root, filename)
487
- try:
488
- stat = os.stat(path)
489
- except OSError:
490
- continue
491
- if stat.st_mtime >= cutoff:
492
- continue
493
- try:
494
- os.remove(path)
495
- except OSError as error:
496
- raise CdxError(f"Failed to remove old log {path}: {error}") from error
497
- freed += stat.st_size
498
- removed.append(path)
499
- return freed, removed
500
-
501
-
502
- def _handle_clean_profiles(args, ctx, json_flag):
503
- usage = "Usage: cdx clean profiles (--tmp|--old-logs DAYS) [--yes] [--json]"
504
- yes = "--yes" in args
505
- args = [arg for arg in args if arg != "--yes"]
506
- clean_tmp = "--tmp" in args
507
- old_logs = None
508
- if "--old-logs" in args:
509
- index = args.index("--old-logs")
510
- if index + 1 >= len(args):
511
- raise CdxError(usage)
512
- old_logs = _parse_days(args[index + 1], usage)
513
- args = [arg for i, arg in enumerate(args) if i not in (index, index + 1)]
514
- else:
515
- old_log_equals = [arg for arg in args if arg.startswith("--old-logs=")]
516
- if old_log_equals:
517
- if len(old_log_equals) > 1:
518
- raise CdxError(usage)
519
- old_logs = _parse_days(old_log_equals[0].split("=", 1)[1], usage)
520
- args = [arg for arg in args if arg != old_log_equals[0]]
521
- args = [arg for arg in args if arg != "--tmp"]
522
- if args or clean_tmp == (old_logs is not None):
523
- raise CdxError(usage)
524
-
525
- action = "temporary cache" if clean_tmp else f"logs older than {old_logs}d"
526
- if not yes:
527
- confirm_fn = ctx["options"].get("confirmProfileCleanup")
528
- if confirm_fn:
529
- confirmed = _resolve_confirmation(confirm_fn, action)
530
- elif not ctx["stdin_is_tty"]:
531
- raise CdxError("Profile cleanup requires an interactive terminal or --yes in non-interactive mode.")
532
- else:
533
- confirmed = _confirm_profile_cleanup(action)
534
- if not confirmed:
535
- if json_flag:
536
- _write_json(ctx, _json_success("clean.profiles", "Cancelled.", cancelled=True, freed_bytes=0, freed_size="0 B", profiles=[]))
537
- return 0
538
- ctx["out"](f"{_warn('Cancelled.', ctx['use_color'])}\n")
539
- return 0
540
-
541
- results = []
542
- now = ctx["options"].get("now")() if callable(ctx["options"].get("now")) else None
543
- for profile_name, profile_path in _iter_profile_dirs(ctx["service"]["base_dir"]):
544
- if clean_tmp:
545
- freed, removed = _clean_profile_tmp(profile_path)
546
- result_action = "tmp"
547
- else:
548
- freed, removed = _clean_profile_old_logs(profile_path, old_logs, now=now)
549
- result_action = f"old-logs-{old_logs}d"
550
- if not freed and not removed:
551
- continue
552
- results.append({
553
- "profile": profile_name,
554
- "action": result_action,
555
- "freed_bytes": freed,
556
- "freed_size": _format_bytes(freed),
557
- "removed_count": len(removed),
558
- "removed_paths": removed,
559
- })
560
- total = sum(item["freed_bytes"] for item in results)
561
- if json_flag:
562
- _write_json(ctx, _json_success("clean.profiles", f"Cleaned profiles ({_format_bytes(total)} freed)", cancelled=False, freed_bytes=total, freed_size=_format_bytes(total), profiles=results))
563
- return 0
564
- if not results:
565
- ctx["out"](f"{_dim('No matching profile cleanup candidates found.', ctx['use_color'])}\n")
566
- return 0
567
- for item in results:
568
- message = f"Cleaned {item['profile']} {item['action']} ({item['freed_size']} freed)"
569
- ctx["out"](f"{_success(message, ctx['use_color'])}\n")
570
- ctx["out"](f"{_success(f'Total freed: {_format_bytes(total)}', ctx['use_color'])}\n")
571
- return 0
572
-
573
-
574
- def handle_disk(rest, ctx):
575
- parsed = _parse_flag_args(rest, {
576
- "--candidates": {"key": "candidates", "type": "bool", "default": False},
577
- "--json": {"key": "json", "type": "bool", "default": False},
578
- }, DISK_USAGE, positionals_key="targets", max_positionals=1)
579
- target = parsed["targets"][0] if parsed["targets"] else "home"
580
- if parsed["candidates"] and target != "profiles":
581
- raise CdxError(DISK_USAGE)
582
- if target == "home":
583
- path = ctx["service"]["base_dir"]
584
- elif target == "profiles":
585
- path = os.path.join(ctx["service"]["base_dir"], "profiles")
586
- else:
587
- raise CdxError(DISK_USAGE)
588
- stderr = ctx["options"].get("stderr", sys.stderr)
589
- show_progress = not parsed["json"] and hasattr(stderr, "isatty") and stderr.isatty()
590
-
591
- def progress(stage, name=None, index=None, total=None):
592
- if not show_progress:
593
- return
594
- if stage == "total":
595
- label = "CDX profiles" if target == "profiles" else "CDX home"
596
- ctx["err"](f"Measuring {label} disk usage...\n")
597
- elif stage == "profile":
598
- ctx["err"](f"Measuring profile {name} ({index}/{total})...\n")
599
- elif stage == "candidates":
600
- ctx["err"](f"Scanning cleanup candidates in {name} ({index}/{total})...\n")
601
-
602
- progress("total")
603
- bytes_used = _directory_size_bytes(path, runner=ctx["options"].get("diskUsageRunner"))
604
- payload = {
605
- "target": target,
606
- "path": path,
607
- "bytes": bytes_used,
608
- "size": _format_bytes(bytes_used),
609
- }
610
- if target == "profiles":
611
- payload["children"] = _directory_child_sizes(path, runner=ctx["options"].get("diskUsageRunner"), progress=progress)
612
- if parsed["candidates"]:
613
- candidates = _collect_profile_cleanup_candidates(
614
- ctx["service"]["base_dir"],
615
- runner=ctx["options"].get("diskUsageRunner"),
616
- now=ctx["options"].get("now")() if callable(ctx["options"].get("now")) else None,
617
- progress=progress,
618
- )
619
- payload["candidates"] = candidates
620
- payload["reclaimable_bytes"] = sum(item["bytes"] for item in candidates)
621
- payload["reclaimable_size"] = _format_bytes(payload["reclaimable_bytes"])
622
- if parsed["json"]:
623
- label = "CDX profiles" if target == "profiles" else "CDX home"
624
- _write_json(ctx, _json_success("disk", f"Measured {label} disk usage: {payload['size']}", disk=payload))
625
- return 0
626
- ctx["out"](f"{_format_disk_report(payload, use_color=ctx['use_color'])}\n")
627
- return 0
628
-
629
-
630
- def _extract_claude_oauth_token(text):
631
- if not text:
632
- return None
633
- text = re.sub(r"\x1b\[[0-9;?]*[ -/]*[@-~]", "", str(text))
634
- patterns = [
635
- r"CLAUDE_CODE_OAUTH_TOKEN=([^\s\"']+)",
636
- r"(sk-ant-oat[0-9A-Za-z._-]+)",
637
- ]
638
- for pattern in patterns:
639
- match = re.search(pattern, text)
640
- if match:
641
- token = match.group(1).strip()
642
- if token.startswith("<") or any(ord(ch) < 32 or ord(ch) == 127 for ch in token):
643
- continue
644
- return token
645
- return None
646
-
647
-
648
- def _write_claude_oauth_token(auth_home, token):
649
- cred_dir = os.path.join(auth_home, "credentials")
650
- os.makedirs(cred_dir, mode=0o700, exist_ok=True)
651
- try:
652
- os.chmod(cred_dir, 0o700)
653
- except OSError:
654
- pass
655
- cred_path = os.path.join(cred_dir, "default.json")
656
- payload = {
657
- "version": "1.0",
658
- "type": "oauth_token",
659
- "access_token": token,
660
- }
661
- # atomic_write keeps the token 0o600 from creation — no umask window.
662
- atomic_write(cred_path, json.dumps(payload, indent=2) + "\n", mode=0o600)
663
- return cred_path
664
-
665
-
666
- def _bootstrap_claude_setup_token(session, ctx):
667
- ctx["out"](
668
- "Claude login did not create isolated credentials; falling back to claude setup-token.\n"
669
- )
670
- run_info = _run_interactive_provider_command(
671
- session,
672
- "setup-token",
673
- spawn=ctx.get("spawn"),
674
- env_override=ctx.get("env"),
675
- signal_emitter=ctx.get("signal_emitter"),
676
- )
677
- transcript_path = run_info.get("transcript_path")
678
- if not transcript_path or not os.path.isfile(transcript_path):
679
- raise CdxError(
680
- "Claude setup-token completed, but cdx could not capture the token. "
681
- "Run claude setup-token and save the token under credentials/default.json."
682
- )
683
- try:
684
- with open(transcript_path, encoding="utf-8", errors="replace") as handle:
685
- transcript = handle.read()
686
- finally:
687
- # The transcript holds the ~1-year OAuth token in cleartext; it must
688
- # not outlive the extraction, whether it succeeds or not.
689
- try:
690
- os.remove(transcript_path)
691
- except OSError:
692
- pass
693
- token = _extract_claude_oauth_token(transcript)
694
- if not token:
695
- raise CdxError(
696
- "Claude setup-token completed, but cdx could not find CLAUDE_CODE_OAUTH_TOKEN in the output. "
697
- "Run claude setup-token manually and save the token under credentials/default.json."
698
- )
699
- auth_home = session.get("authHome") or ""
700
- cred_path = _write_claude_oauth_token(auth_home, token)
701
- # ponytail: drop short-lived claude login creds so the ~1yr token wins at launch (provider_runtime _read_claude_launch_oauth_token)
702
- try:
703
- os.remove(os.path.join(auth_home, ".claude", ".credentials.json"))
704
- except OSError:
705
- pass
706
- return cred_path
707
-
708
-
709
- def handle_add(rest, ctx):
710
- json_flag, args = _parse_json_flag(rest)
711
- parsed = _parse_add_args(args)
712
- if parsed["provider"] == PROVIDER_OLLAMA and not parsed.get("model"):
713
- raise CdxError("Usage: cdx add ollama <name> --model MODEL [--json]")
714
- if parsed.get("model") and parsed["provider"] != PROVIDER_OLLAMA:
715
- raise CdxError("--model is only supported when adding an ollama session.")
716
- session = ctx["service"]["create_session"](parsed["name"], parsed["provider"])
717
- if parsed.get("model"):
718
- session = ctx["service"]["set_launch_settings"](parsed["name"], {"model": parsed["model"]})
719
- message = f"Created session {parsed['name']} ({parsed['provider']})"
720
- if session["provider"] == PROVIDER_CLAUDE and ctx["stdin_is_tty"]:
721
- # ponytail: mint a ~1yr setup-token instead of the ~daily claude login token
722
- _bootstrap_claude_setup_token(session, ctx)
723
- else:
724
- _ensure_session_authentication(
725
- session,
726
- ctx["service"],
727
- spawn=ctx.get("spawn"),
728
- spawn_sync=ctx.get("spawn_sync"),
729
- env_override=ctx.get("env"),
730
- stdin_is_tty=ctx["stdin_is_tty"],
731
- behavior="bootstrap",
732
- signal_emitter=ctx.get("signal_emitter"),
733
- )
734
- now = _local_now_iso()
735
- ctx["service"]["update_auth_state"](parsed["name"], lambda auth: {
736
- **auth,
737
- "status": "authenticated",
738
- "lastCheckedAt": now,
739
- "lastAuthenticatedAt": now,
740
- "lastLoggedOutAt": auth.get("lastLoggedOutAt"),
741
- })
742
- if json_flag:
743
- _write_json(ctx, _json_success(
744
- "add",
745
- message,
746
- session=ctx["service"]["get_session"](parsed["name"]),
747
- ))
748
- return 0
749
- ctx["out"](f"{_success(message, ctx['use_color'])}\n")
750
- return 0
751
-
752
-
753
- def handle_copy(rest, ctx):
754
- json_flag, args = _parse_json_flag(rest)
755
- parsed = _parse_copy_args(args)
756
- result = ctx["service"]["copy_session"](parsed["source"], parsed["dest"])
757
- overwritten = " (overwritten)" if result["overwritten"] else ""
758
- message = f"Copied session {parsed['source']} to {parsed['dest']}{overwritten}"
759
- if json_flag:
760
- _write_json(ctx, _json_success(
761
- "copy",
762
- message,
763
- session=result["session"],
764
- overwritten=result["overwritten"],
765
- ))
766
- return 0
767
- ctx["out"](f"{_success(message, ctx['use_color'])}\n")
768
- return 0
769
-
770
-
771
- def handle_rename(rest, ctx):
772
- json_flag, args = _parse_json_flag(rest)
773
- parsed = _parse_rename_args(args)
774
- session = ctx["service"]["rename_session"](parsed["source"], parsed["dest"])
775
- message = f"Renamed session {parsed['source']} to {parsed['dest']}"
776
- if json_flag:
777
- _write_json(ctx, _json_success("rename", message, session=session))
778
- return 0
779
- ctx["out"](f"{_success(message, ctx['use_color'])}\n")
780
- return 0
781
-
782
-
783
- def handle_label(rest, ctx):
784
- parsed = _parse_label_args(rest)
785
- if parsed["clear"]:
786
- session = ctx["service"]["clear_session_label"](parsed["name"])
787
- message = f"Cleared label for {parsed['name']}"
788
- else:
789
- session = ctx["service"]["set_session_label"](parsed["name"], parsed["label"])
790
- message = f"Set label for {parsed['name']}: {session['label']}"
791
- if parsed["json"]:
792
- _write_json(ctx, _json_success("label", message, session=session, label=session.get("label")))
793
- return 0
794
- ctx["out"](f"{_success(message, ctx['use_color'])}\n")
795
- return 0
796
-
797
-
798
- def handle_remove(rest, ctx):
799
- json_flag, args = _parse_json_flag(rest)
800
- parsed = _parse_remove_args(args)
801
- if not parsed["force"]:
802
- confirm_fn = ctx["options"].get("confirmRemove")
803
- if confirm_fn:
804
- confirmed = _resolve_confirmation(confirm_fn, parsed["name"])
805
- elif not ctx["stdin_is_tty"]:
806
- raise CdxError("Removal requires confirmation in an interactive terminal or --force in non-interactive mode.")
807
- else:
808
- confirmed = _confirm_removal(parsed["name"])
809
- if not confirmed:
810
- if json_flag:
811
- _write_json(ctx, _json_success("remove", "Cancelled.", cancelled=True, session=None))
812
- return 0
813
- ctx["out"](f"{_warn('Cancelled.', ctx['use_color'])}\n")
814
- return 0
815
- removed = ctx["service"]["remove_session"](parsed["name"])
816
- message = f"Removed session {parsed['name']}"
817
- if json_flag:
818
- _write_json(ctx, _json_success("remove", message, session=removed, cancelled=False))
819
- return 0
820
- ctx["out"](f"{_success(message, ctx['use_color'])}\n")
821
- return 0
822
-
823
-
824
- def handle_disable(rest, ctx):
825
- parsed = _parse_toggle_args(rest, "Usage: cdx disable <name> [--json]")
826
- session = ctx["service"]["set_session_enabled"](parsed["name"], False)
827
- message = f"Disabled session {parsed['name']}"
828
- if parsed["json"]:
829
- _write_json(ctx, _json_success("disable", message, session=session))
830
- return 0
831
- ctx["out"](f"{_success(message, ctx['use_color'])}\n")
832
- return 0
833
-
834
-
835
- def handle_enable(rest, ctx):
836
- parsed = _parse_toggle_args(rest, "Usage: cdx enable <name> [--json]")
837
- session = ctx["service"]["set_session_enabled"](parsed["name"], True)
838
- message = f"Enabled session {parsed['name']}"
839
- if parsed["json"]:
840
- _write_json(ctx, _json_success("enable", message, session=session))
841
- return 0
842
- ctx["out"](f"{_success(message, ctx['use_color'])}\n")
843
- return 0
844
-
845
-
846
- def handle_select(rest, ctx):
847
- parsed = _parse_select_args(rest)
848
- selected = _select_headless_session(
849
- ctx,
850
- parsed["provider"],
851
- min_reasoning_effort=parsed["min_reasoning_effort"],
852
- require_ready=parsed["require_ready"],
853
- force_refresh=parsed["refresh"],
854
- )
855
- policy = "ready_then_cooldown_then_health_then_priority_then_name"
856
- if not selected:
857
- message = "No suitable session found."
858
- _write_json(ctx, _json_failure(
859
- "select",
860
- "no_suitable_session",
861
- message,
862
- provider=parsed["provider"],
863
- min_reasoning_effort=parsed["min_reasoning_effort"],
864
- require_ready=parsed["require_ready"],
865
- selection_policy=policy,
866
- ))
867
- return 1
868
- session = selected["session"]
869
- row = selected.get("row") or {}
870
- reason = "highest availability suitable session"
871
- _write_json(ctx, _json_success(
872
- "select",
873
- f"Selected session {session['name']}",
874
- session=session["name"],
875
- provider=session["provider"],
876
- reason=reason,
877
- selection_policy=policy,
878
- min_reasoning_effort=parsed["min_reasoning_effort"],
879
- reasoning_effort=_session_reasoning_effort(session),
880
- available_pct=row.get("available_pct"),
881
- auth_status=row.get("auth_status"),
882
- ))
883
- return 0
884
-
885
-
886
- def _format_next_pct(value):
887
- return "n/a" if value is None else f"{value}%"
888
-
889
-
890
- def _next_action(row):
891
- instruction = format_priority_instruction(row, "first")
892
- return "refresh" if instruction.startswith("refresh ") else "use"
893
-
894
-
895
- def _format_next_selection(session, row, use_color=False):
896
- from .cli_render import _pad_table
897
-
898
- action = _next_action(row)
899
- command = f"cdx status {session['name']} --refresh" if action == "refresh" else f"cdx {session['name']}"
900
- rows = [[_style(value, "1", use_color) for value in ["SESSION", "PROVIDER", "OK", "5H", "WEEK", "REASON"]]]
901
- rows.append([
902
- _style(session["name"], "36", use_color),
903
- _dim(session.get("provider") or "-", use_color),
904
- _format_next_pct(row.get("available_pct")),
905
- _format_next_pct(row.get("remaining_5h_pct")),
906
- _format_next_pct(row.get("remaining_week_pct")),
907
- format_priority_instruction(row, "first"),
908
- ])
909
- return "\n".join([
910
- _style("Next assistant:", "1", use_color),
911
- _pad_table(rows),
912
- "",
913
- f"{_style('Run:', '1', use_color)} {_style(command, '36', use_color)}",
914
- ])
915
-
916
-
917
- def handle_next(rest, ctx):
918
- parsed = _parse_next_args(rest)
919
- rows = ctx["service"]["get_status_rows"](force_refresh=parsed["refresh"])
920
- priority = recommend_priority_rows(rows)
921
- if not priority:
922
- message = "No usable session status yet."
923
- if parsed["json"]:
924
- _write_json(ctx, _json_failure("next", "no_suitable_session", message, selection_policy="status_priority"))
925
- return 1
926
- ctx["out"](f"{_warn(message, ctx['use_color'])}\n")
927
- return 1
928
- row = priority[0]
929
- session_name = row.get("session_name")
930
- session = ctx["service"]["get_session"](session_name)
931
- if not session:
932
- message = f"Selected status row has no matching session: {session_name}"
933
- if parsed["json"]:
934
- _write_json(ctx, _json_failure("next", "missing_session", message, selection_policy="status_priority", row=row))
935
- return 1
936
- raise CdxError(message)
937
- action = _next_action(row)
938
- message = f"Selected next session {session['name']}"
939
- if parsed["json"]:
940
- _write_json(ctx, _json_success(
941
- "next",
942
- message,
943
- session=session,
944
- row=row,
945
- recommended_action=action,
946
- command=f"cdx status {session['name']} --refresh" if action == "refresh" else f"cdx {session['name']}",
947
- reason=format_priority_instruction(row, "first"),
948
- selection_policy="status_priority",
949
- ))
950
- return 0
951
- ctx["out"](f"{_format_next_selection(session, row, ctx['use_color'])}\n")
952
- return 0
953
-
954
-
955
- def _run_registry(ctx):
956
- return RunRegistry(ctx["service"]["base_dir"])
957
-
958
-
959
- def handle_runs(rest, ctx):
960
- parsed = _parse_runs_args(rest)
961
- if not parsed["json"]:
962
- raise CdxError(RUNS_USAGE)
963
- runs = _run_registry(ctx).list(limit=parsed["limit"])
964
- _write_json(ctx, _json_success("runs", "Runs loaded.", runs=runs))
965
- return 0
966
-
967
-
968
- def handle_run_status(rest, ctx):
969
- parsed = _parse_run_id_json_args(rest, RUN_STATUS_USAGE)
970
- run = _run_registry(ctx).get(parsed["run_id"])
971
- if not run:
972
- _write_json(ctx, _json_failure("run-status", "run_not_found", f"Unknown run: {parsed['run_id']}"))
973
- return 1
974
- _write_json(ctx, _json_success("run-status", "Run loaded.", run=run))
975
- return 0
976
-
977
-
978
- def handle_run_report(rest, ctx):
979
- parsed = _parse_run_id_json_args(rest, RUN_REPORT_USAGE)
980
- run = _run_registry(ctx).get(parsed["run_id"])
981
- if not run:
982
- _write_json(ctx, _json_failure("run-report", "run_not_found", f"Unknown run: {parsed['run_id']}"))
983
- return 1
984
- _write_json(ctx, _json_success("run-report", "Run report loaded.", report={
985
- "run": run,
986
- "final_payload": run.get("final_payload"),
987
- "task_report": run.get("task_report"),
988
- "artifacts": run.get("artifacts") or {},
989
- "usage": run.get("usage"),
990
- "error": run.get("error"),
991
- }))
992
- return 0
993
-
994
-
995
- def handle_run(rest, ctx):
996
- registry = None
997
- run_id = None
998
- try:
999
- parsed = _parse_run_args(rest)
1000
- session = None
1001
- if parsed["name"]:
1002
- session = ctx["service"]["get_session"](parsed["name"])
1003
- if not session:
1004
- raise CdxError(f"Unknown session: {parsed['name']}")
1005
- if session.get("enabled", True) is False:
1006
- raise CdxError(f"Session is disabled: {parsed['name']}")
1007
- else:
1008
- selected = _select_headless_session(
1009
- ctx,
1010
- parsed["provider"],
1011
- min_reasoning_effort=parsed["reasoning_effort"],
1012
- require_ready=True,
1013
- force_refresh=False,
1014
- )
1015
- if not selected:
1016
- _write_json(ctx, _json_failure(
1017
- "run",
1018
- "no_suitable_session",
1019
- "No suitable session found.",
1020
- launcher="cdx",
1021
- provider=parsed["provider"],
1022
- ))
1023
- return 1
1024
- session = selected["session"]
1025
-
1026
- cwd = os.path.abspath(parsed["cwd"])
1027
- if not os.path.isdir(cwd):
1028
- raise CdxError(f"Invalid cwd: {parsed['cwd']}")
1029
- prompt = read_run_prompt(parsed)
1030
- launch_updates = {}
1031
- if parsed.get("model"):
1032
- launch_updates["model"] = parsed["model"]
1033
- if parsed.get("permission"):
1034
- launch_updates["permission"] = parsed["permission"]
1035
- if parsed.get("reasoning_effort"):
1036
- launch_updates["reasoning_effort"] = parsed["reasoning_effort"]
1037
- launch_updates["power"] = parsed["reasoning_effort"]
1038
- run_session = {
1039
- **session,
1040
- "launch": {
1041
- **(session.get("launch") or {}),
1042
- **launch_updates,
1043
- },
1044
- }
1045
- _ensure_session_authentication(
1046
- run_session,
1047
- ctx["service"],
1048
- spawn=ctx.get("spawn"),
1049
- spawn_sync=ctx.get("spawn_sync"),
1050
- env_override=ctx.get("env"),
1051
- stdin_is_tty=False,
1052
- behavior="launch",
1053
- signal_emitter=ctx.get("signal_emitter"),
1054
- trust_local_credentials=False,
1055
- )
1056
- registry = _run_registry(ctx)
1057
- artifacts = _headless_artifact_paths(run_session)
1058
- run_id = artifacts["run_id"]
1059
- registry.start(
1060
- run_id,
1061
- kind=parsed.get("kind"),
1062
- session=run_session.get("name"),
1063
- provider=run_session.get("provider"),
1064
- model=parsed.get("model") or ((run_session.get("launch") or {}).get("model")),
1065
- cwd=cwd,
1066
- artifacts={
1067
- "transcript_path": artifacts.get("transcript_path"),
1068
- "stdout_path": artifacts.get("stdout_path"),
1069
- "stderr_path": artifacts.get("stderr_path"),
1070
- },
1071
- )
1072
- run_info = _run_headless_provider_command(
1073
- run_session,
1074
- cwd=cwd,
1075
- env_override=ctx.get("env"),
1076
- initial_prompt=prompt,
1077
- timeout_seconds=parsed.get("timeout_seconds"),
1078
- spawn=ctx.get("spawn_headless") or ctx.get("spawn"),
1079
- run_id=run_id,
1080
- )
1081
- usage = extract_run_usage(run_session.get("provider"), run_info.get("stdout_path"))
1082
- run_info = {**run_info, "usage": usage}
1083
- ok = run_info.get("returncode") == 0
1084
- if ok:
1085
- final_payload = run_result_payload(API_SCHEMA_VERSION, True, parsed, run_session, run_info=run_info)
1086
- task_report = build_code_review_report(run_id, final_payload) if parsed.get("kind") == "code-review" else None
1087
- registry.finish(
1088
- run_id,
1089
- status="succeeded",
1090
- final_payload=final_payload,
1091
- run_info=run_info,
1092
- task_report=task_report,
1093
- )
1094
- ctx["service"]["record_launch_history"](session["name"], {
1095
- "status": "success",
1096
- "cwd": cwd,
1097
- "exit_code": 0,
1098
- **run_info,
1099
- })
1100
- _write_json(ctx, final_payload)
1101
- return 0
1102
- message = "Provider process timed out." if run_info.get("timed_out") else "Provider process exited with a non-zero status."
1103
- error = CdxError(message, run_info.get("returncode") or 1)
1104
- final_payload = run_result_payload(
1105
- API_SCHEMA_VERSION,
1106
- False,
1107
- parsed,
1108
- run_session,
1109
- run_info=run_info,
1110
- error=error,
1111
- error_source="provider",
1112
- error_code="provider_timeout" if run_info.get("timed_out") else "provider_failed",
1113
- )
1114
- registry.finish(
1115
- run_id,
1116
- status="timed_out" if run_info.get("timed_out") else "failed",
1117
- final_payload=final_payload,
1118
- run_info=run_info,
1119
- error=final_payload.get("error"),
1120
- task_report=build_code_review_report(run_id, final_payload) if parsed.get("kind") == "code-review" else None,
1121
- )
1122
- ctx["service"]["record_launch_history"](session["name"], {
1123
- "status": "failed",
1124
- "cwd": cwd,
1125
- "error": str(error),
1126
- "exit_code": error.exit_code,
1127
- **run_info,
1128
- })
1129
- _write_json(ctx, final_payload)
1130
- return error.exit_code or 1
1131
- except CdxError as error:
1132
- run_info = getattr(error, "run_info", None)
1133
- final_payload = run_result_payload(
1134
- API_SCHEMA_VERSION,
1135
- False,
1136
- locals().get("parsed", {}) or {},
1137
- locals().get("session"),
1138
- run_info=run_info,
1139
- error=error,
1140
- error_source="cdx",
1141
- error_code=run_cdx_error_code(error),
1142
- )
1143
- if registry and run_id:
1144
- registry.finish(
1145
- run_id,
1146
- status="failed",
1147
- final_payload=final_payload,
1148
- run_info=run_info,
1149
- error=final_payload.get("error"),
1150
- )
1151
- _write_json(ctx, final_payload)
1152
- return error.exit_code
1153
-
1154
-
1155
- def _format_launch_config(session, use_color=False):
1156
- from .cli_render import _dim, _pad_table, _style
1157
-
1158
- launch = session.get("launch") or {}
1159
- rows = [[_style("SETTING", "1", use_color), _style("VALUE", "1", use_color)]]
1160
- for key, label in [
1161
- ("power", "Power"),
1162
- ("permission", "Permission"),
1163
- ("fast", "Fast"),
1164
- ("rtk", "RTK"),
1165
- ("logics", "Logics"),
1166
- ("model", "Model"),
1167
- ("priority", "Priority"),
1168
- ]:
1169
- rows.append([
1170
- _dim(label, use_color),
1171
- _format_launch_setting_value(launch, key, use_color=use_color),
1172
- ])
1173
- provider_label = f"({session['provider']})"
1174
- return "\n".join([
1175
- _style("Launch settings:", "1", use_color),
1176
- f"{_style(session['name'], '36', use_color)} {_dim(provider_label, use_color)}",
1177
- _pad_table(rows),
1178
- "",
1179
- _dim(_format_launch_settings_hint(session["name"]), use_color),
1180
- ])
1181
-
1182
-
1183
- def _format_launch_settings_hint(name="<name>"):
1184
- return (
1185
- f"Set a value: cdx set {name} --power medium --permission auto "
1186
- "--fast on --rtk on --logics on --model MODEL --priority 80"
1187
- )
1188
-
1189
-
1190
- def _format_launch_setting_value(launch, key, use_color=False):
1191
- if key in ("fast", "rtk", "logics"):
1192
- if launch.get(key) is True:
1193
- return _style("on", "32", use_color)
1194
- if launch.get(key) is False:
1195
- return _style("off", "2", use_color)
1196
- if key == "logics":
1197
- return _dim("auto", use_color)
1198
- return _dim("default", use_color)
1199
- value = launch.get(key)
1200
- if value is None or value == "":
1201
- return _dim("default", use_color)
1202
- if key == "priority":
1203
- return _style(str(value), "33", use_color)
1204
- if key == "power":
1205
- return _style(str(value), "96", use_color)
1206
- if key == "permission":
1207
- return _style(str(value), "32", use_color)
1208
- return str(value)
1209
-
1210
-
1211
- def _format_launch_configs(sessions, use_color=False):
1212
- from .cli_render import _dim, _pad_table, _style
1213
-
1214
- if not sessions:
1215
- return "\n".join([
1216
- _style("Launch settings:", "1", use_color),
1217
- "No sessions.",
1218
- "",
1219
- _dim(_format_launch_settings_hint(), use_color),
1220
- ])
1221
- rows = [[_style(value, "1", use_color) for value in [
1222
- "SESSION", "PROVIDER", "POWER", "PERMISSION", "FAST", "RTK", "LOGICS", "MODEL", "PRIORITY"
1223
- ]]]
1224
- for session in sessions:
1225
- launch = session.get("launch") or {}
1226
- rows.append([
1227
- _style(session["name"], "36", use_color),
1228
- _dim(session.get("provider") or "-", use_color),
1229
- _format_launch_setting_value(launch, "power", use_color),
1230
- _format_launch_setting_value(launch, "permission", use_color),
1231
- _format_launch_setting_value(launch, "fast", use_color),
1232
- _format_launch_setting_value(launch, "rtk", use_color),
1233
- _format_launch_setting_value(launch, "logics", use_color),
1234
- _format_launch_setting_value(launch, "model", use_color),
1235
- _format_launch_setting_value(launch, "priority", use_color),
1236
- ])
1237
- return "\n".join([
1238
- _style("Launch settings:", "1", use_color),
1239
- _pad_table(rows),
1240
- "",
1241
- _dim(_format_launch_settings_hint(), use_color),
1242
- ])
1243
-
1244
-
1245
- def _resolve_bulk_launch_targets(parsed, service):
1246
- sessions = service["list_sessions"]()
1247
- by_name = {session["name"]: session for session in sessions}
1248
- if parsed.get("name"):
1249
- session = by_name.get(parsed["name"])
1250
- if not session:
1251
- raise CdxError(f"Unknown session: {parsed['name']}")
1252
- targets = [session]
1253
- elif parsed.get("sessions") == "all":
1254
- targets = sessions
1255
- elif parsed.get("sessions"):
1256
- targets = []
1257
- for name in parsed["sessions"]:
1258
- session = by_name.get(name)
1259
- if not session:
1260
- raise CdxError(f"Unknown session: {name}")
1261
- targets.append(session)
1262
- else:
1263
- targets = sessions
1264
- if parsed.get("provider"):
1265
- targets = [session for session in targets if session["provider"] == parsed["provider"]]
1266
- if not targets:
1267
- raise CdxError("No sessions matched.")
1268
- return targets
1269
-
1270
-
1271
- def _reasoning_rank(value):
1272
- order = {"minimal": 0, "low": 1, "medium": 2, "high": 3, "xhigh": 4}
1273
- return order.get(str(value or "low").lower(), -1)
1274
-
1275
-
1276
- def _session_reasoning_effort(session):
1277
- launch = session.get("launch") or {}
1278
- return (
1279
- launch.get("reasoning_effort")
1280
- or launch.get("reasoningEffort")
1281
- or launch.get("power")
1282
- or ("low" if launch.get("fast") is True and launch.get("fastMode") != "service_tier" else None)
1283
- or "low"
1284
- )
1285
-
1286
-
1287
- def _session_selection_priority(session):
1288
- launch = session.get("launch") or {}
1289
- try:
1290
- return int(launch.get("priority") or 0)
1291
- except (TypeError, ValueError):
1292
- return 0
1293
-
1294
-
1295
- def _row_blocks_ready(row):
1296
- if row.get("provider") in (PROVIDER_ANTIGRAVITY, PROVIDER_OLLAMA):
1297
- auth = "n/a"
1298
- else:
1299
- auth = row.get("auth_status") or "unknown"
1300
- if auth not in ("authenticated", "n/a"):
1301
- return True
1302
- available = row.get("available_pct")
1303
- if available is not None:
1304
- try:
1305
- if float(available) <= 0:
1306
- return True
1307
- except (TypeError, ValueError):
1308
- return True
1309
- return False
1310
-
1311
-
1312
- def _select_headless_session(ctx, provider, min_reasoning_effort=None, require_ready=False, force_refresh=False):
1313
- sessions = [
1314
- session for session in ctx["service"]["list_sessions"]()
1315
- if session.get("provider") == provider and session.get("enabled", True) is not False
1316
- ]
1317
- if min_reasoning_effort:
1318
- minimum = _reasoning_rank(min_reasoning_effort)
1319
- sessions = [
1320
- session for session in sessions
1321
- if _reasoning_rank(_session_reasoning_effort(session)) >= minimum
1322
- ]
1323
- rows = ctx["service"]["get_status_rows"](force_refresh=force_refresh)
1324
- row_by_name = {row.get("session_name"): row for row in rows}
1325
- candidates = []
1326
- for session in sessions:
1327
- row = row_by_name.get(session["name"], {})
1328
- if require_ready and _row_blocks_ready(row):
1329
- continue
1330
- available = row.get("available_pct")
1331
- try:
1332
- available_sort = float(available) if available is not None else -1.0
1333
- except (TypeError, ValueError):
1334
- available_sort = -1.0
1335
- cooldown_sort = 1 if available_sort > 0 else 0
1336
- candidates.append({
1337
- "session": session,
1338
- "row": row,
1339
- "sort_key": (
1340
- -cooldown_sort,
1341
- -available_sort,
1342
- -_session_selection_priority(session),
1343
- _reasoning_rank(_session_reasoning_effort(session)),
1344
- session["name"],
1345
- ),
1346
- })
1347
- if not candidates:
1348
- return None
1349
- candidates.sort(key=lambda item: item["sort_key"])
1350
- return candidates[0]
1351
-
1352
-
1353
- def _format_bulk_launch_summary(sessions):
1354
- names = [session["name"] for session in sessions]
1355
- if len(names) <= 8:
1356
- return ", ".join(names)
1357
- return ", ".join(names[:8]) + f", +{len(names) - 8} more"
1358
-
1359
-
1360
- def _format_duration_ms(value):
1361
- if value is None:
1362
- return "-"
1363
- try:
1364
- amount = int(value)
1365
- except (TypeError, ValueError):
1366
- return str(value)
1367
- if amount < 1000:
1368
- return f"{amount}ms"
1369
- seconds = amount / 1000
1370
- if seconds < 60:
1371
- return f"{seconds:.1f}s"
1372
- minutes = int(seconds // 60)
1373
- remaining = int(seconds % 60)
1374
- if minutes < 60:
1375
- return f"{minutes}m {remaining:02d}s"
1376
- hours = minutes // 60
1377
- remaining_minutes = minutes % 60
1378
- if hours < 24:
1379
- return f"{hours}h {remaining_minutes:02d}m"
1380
- days = hours // 24
1381
- remaining_hours = hours % 24
1382
- return f"{days}d {remaining_hours:02d}h"
1383
-
1384
-
1385
- def _summarize_history(entries):
1386
- by_session = {}
1387
- for entry in entries:
1388
- name = entry.get("session_name") or "-"
1389
- row = by_session.setdefault(name, {
1390
- "session_name": name,
1391
- "provider": entry.get("provider") or "-",
1392
- "launches": 0,
1393
- "successes": 0,
1394
- "failures": 0,
1395
- "duration_ms": 0,
1396
- "last_started_at": None,
1397
- })
1398
- row["launches"] += 1
1399
- if entry.get("status") == "success":
1400
- row["successes"] += 1
1401
- elif entry.get("status") == "failed":
1402
- row["failures"] += 1
1403
- try:
1404
- row["duration_ms"] += int(entry.get("duration_ms") or 0)
1405
- except (TypeError, ValueError):
1406
- pass
1407
- started = entry.get("started_at")
1408
- if started and (not row["last_started_at"] or started > row["last_started_at"]):
1409
- row["last_started_at"] = started
1410
- row["provider"] = entry.get("provider") or row["provider"]
1411
- return sorted(
1412
- by_session.values(),
1413
- key=lambda item: (item["duration_ms"], item.get("last_started_at") or "", item["session_name"]),
1414
- reverse=True,
1415
- )
1416
-
1417
-
1418
- def _token_value(usage, key):
1419
- if not isinstance(usage, dict):
1420
- return None
1421
- value = usage.get(key)
1422
- try:
1423
- parsed = int(value)
1424
- except (TypeError, ValueError):
1425
- return None
1426
- return parsed if parsed >= 0 else None
1427
-
1428
-
1429
- def _summarize_stats(entries):
1430
- rows = {}
1431
- for entry in entries:
1432
- name = entry.get("session_name") or "-"
1433
- row = rows.setdefault(name, {
1434
- "session_name": name,
1435
- "provider": entry.get("provider") or "-",
1436
- "launches": 0,
1437
- "successes": 0,
1438
- "failures": 0,
1439
- "duration_ms": 0,
1440
- "usage_runs": 0,
1441
- "input_tokens": 0,
1442
- "output_tokens": 0,
1443
- "reasoning_tokens": 0,
1444
- "total_tokens": 0,
1445
- "last_started_at": None,
1446
- })
1447
- row["launches"] += 1
1448
- if entry.get("status") == "success":
1449
- row["successes"] += 1
1450
- elif entry.get("status") == "failed":
1451
- row["failures"] += 1
1452
- try:
1453
- row["duration_ms"] += int(entry.get("duration_ms") or 0)
1454
- except (TypeError, ValueError):
1455
- pass
1456
- usage = entry.get("usage") if isinstance(entry.get("usage"), dict) else {}
1457
- parsed_usage = {
1458
- key: _token_value(usage, key)
1459
- for key in ("input_tokens", "output_tokens", "reasoning_tokens", "total_tokens")
1460
- }
1461
- if any(value is not None for value in parsed_usage.values()):
1462
- row["usage_runs"] += 1
1463
- for key, value in parsed_usage.items():
1464
- row[key] += value or 0
1465
- started = entry.get("started_at")
1466
- if started and (not row["last_started_at"] or started > row["last_started_at"]):
1467
- row["last_started_at"] = started
1468
- row["provider"] = entry.get("provider") or row["provider"]
1469
- return sorted(
1470
- rows.values(),
1471
- key=lambda item: (item["total_tokens"], item["duration_ms"], item.get("last_started_at") or "", item["session_name"]),
1472
- reverse=True,
1473
- )
1474
-
1475
-
1476
- def _stats_totals(rows):
1477
- return {
1478
- "sessions": len(rows),
1479
- "launches": sum(row["launches"] for row in rows),
1480
- "successes": sum(row["successes"] for row in rows),
1481
- "failures": sum(row["failures"] for row in rows),
1482
- "duration_ms": sum(row["duration_ms"] for row in rows),
1483
- "usage_runs": sum(row["usage_runs"] for row in rows),
1484
- "input_tokens": sum(row["input_tokens"] for row in rows),
1485
- "output_tokens": sum(row["output_tokens"] for row in rows),
1486
- "reasoning_tokens": sum(row["reasoning_tokens"] for row in rows),
1487
- "total_tokens": sum(row["total_tokens"] for row in rows),
1488
- }
1489
-
1490
-
1491
- def _format_history_period(period):
1492
- if not _has_history_period(period or {}):
1493
- return None
1494
- start = _format_period_display(period.get("from")) or "beginning"
1495
- end = _format_period_display(period.get("to")) or "now"
1496
- return f"Period: {start} -> {end}"
1497
-
1498
-
1499
- def _format_period_display(value):
1500
- if not value:
1501
- return None
1502
- try:
1503
- parsed = datetime.fromisoformat(str(value))
1504
- except ValueError:
1505
- return str(value)
1506
- return parsed.strftime("%Y-%m-%d %H:%M")
1507
-
1508
-
1509
- def _format_history_summary(entries, period=None, use_color=False, active_sessions=None):
1510
- from .cli_render import _format_relative_age, _pad_table
1511
-
1512
- active_sessions = active_sessions or set()
1513
- summary = _summarize_history(entries)
1514
- if not summary:
1515
- return "No launch history for this period." if _has_history_period(period or {}) else "No launch history."
1516
- rows = [[_style(value, "1", use_color) for value in ["SESSION", "PROV.", "LAUNCHES", "OK", "FAIL", "TIME", "LAST"]]]
1517
- for row in summary:
1518
- session_name = row["session_name"]
1519
- display_name = f"{session_name}*" if session_name in active_sessions else session_name
1520
- rows.append([
1521
- _style(display_name, "36", use_color),
1522
- _dim(row["provider"], use_color),
1523
- _style(str(row["launches"]), "1", use_color),
1524
- _style(str(row["successes"]), "32" if row["successes"] else "2", use_color),
1525
- _style(str(row["failures"]), "31" if row["failures"] else "2", use_color),
1526
- _style(_format_duration_ms(row["duration_ms"]), "33" if row["duration_ms"] else "2", use_color),
1527
- _dim(_format_relative_age(row.get("last_started_at")), use_color),
1528
- ])
1529
- lines = [_style("Assistant time:", "1", use_color)]
1530
- period_line = _format_history_period(period or {})
1531
- if period_line:
1532
- lines.extend([_dim(period_line, use_color), ""])
1533
- lines.append(_pad_table(rows))
1534
- return "\n".join(lines)
1535
-
1536
-
1537
- def _format_history(entries, use_color=False, active_sessions=None):
1538
- from .cli_render import _format_relative_age, _pad_table
1539
-
1540
- active_sessions = active_sessions or set()
1541
- if not entries:
1542
- return "No launch history."
1543
- rows = [[_style(value, "1", use_color) for value in ["SESSION", "PROV.", "RESULT", "DURATION", "WHEN", "TRANSCRIPT"]]]
1544
- for entry in entries:
1545
- transcript_path = entry.get("transcript_path")
1546
- session_name = entry.get("session_name") or "-"
1547
- display_name = f"{session_name}*" if session_name in active_sessions else session_name
1548
- status = entry.get("status") or "-"
1549
- status_color = "32" if status == "success" else "31" if status == "failed" else "2"
1550
- rows.append([
1551
- _style(display_name, "36", use_color),
1552
- _dim(entry.get("provider") or "-", use_color),
1553
- _style(status, status_color, use_color),
1554
- _style(_format_duration_ms(entry.get("duration_ms")), "33" if entry.get("duration_ms") else "2", use_color),
1555
- _dim(_format_relative_age(entry.get("started_at")), use_color),
1556
- _dim(os.path.basename(transcript_path) if transcript_path else "-", use_color),
1557
- ])
1558
- return "\n".join([
1559
- _style("Recent launches:", "1", use_color),
1560
- _pad_table(rows),
1561
- "",
1562
- _dim("Full transcript paths and cwd are available with --json.", use_color),
1563
- ])
1564
-
1565
-
1566
- def _format_token_count(value):
1567
- try:
1568
- amount = int(value)
1569
- except (TypeError, ValueError):
1570
- return "-"
1571
- if amount >= 1000000:
1572
- return f"{amount / 1000000:.1f}M"
1573
- if amount >= 1000:
1574
- return f"{amount / 1000:.1f}K"
1575
- return str(amount)
1576
-
1577
-
1578
- def _format_stats(rows, totals, period=None, use_color=False, active_sessions=None):
1579
- from .cli_render import _format_relative_age, _pad_table
1580
-
1581
- active_sessions = active_sessions or set()
1582
- if not rows:
1583
- return "No launch stats for this period." if _has_history_period(period or {}) else "No launch stats."
1584
- table = [[_style(value, "1", use_color) for value in [
1585
- "SESSION", "PROV.", "RUNS", "USAGE", "IN", "OUT", "REASON", "TOTAL", "TIME", "LAST"
1586
- ]]]
1587
- for row in rows:
1588
- session_name = row["session_name"]
1589
- display_name = f"{session_name}*" if session_name in active_sessions else session_name
1590
- table.append([
1591
- _style(display_name, "36", use_color),
1592
- _dim(row["provider"], use_color),
1593
- _style(str(row["launches"]), "1", use_color),
1594
- _style(str(row["usage_runs"]), "32" if row["usage_runs"] else "2", use_color),
1595
- _style(_format_token_count(row["input_tokens"]), "96" if row["input_tokens"] else "2", use_color),
1596
- _style(_format_token_count(row["output_tokens"]), "96" if row["output_tokens"] else "2", use_color),
1597
- _style(_format_token_count(row["reasoning_tokens"]), "95" if row["reasoning_tokens"] else "2", use_color),
1598
- _style(_format_token_count(row["total_tokens"]), "1;96" if row["total_tokens"] else "2", use_color),
1599
- _style(_format_duration_ms(row["duration_ms"]), "33" if row["duration_ms"] else "2", use_color),
1600
- _dim(_format_relative_age(row.get("last_started_at")), use_color),
1601
- ])
1602
- lines = [_style("Assistant stats:", "1", use_color)]
1603
- period_line = _format_history_period(period or {})
1604
- if period_line:
1605
- lines.extend([_dim(period_line, use_color), ""])
1606
- lines.append(_pad_table(table))
1607
- lines.extend([
1608
- "",
1609
- _dim(
1610
- "Totals: "
1611
- f"{totals['launches']} runs, {totals['usage_runs']} with usage, "
1612
- f"{_format_token_count(totals['total_tokens'])} tokens, "
1613
- f"{_format_duration_ms(totals['duration_ms'])}.",
1614
- use_color,
1615
- ),
1616
- ])
1617
- return "\n".join(lines)
1618
-
1619
-
1620
- def _active_session_names(ctx):
1621
- return {
1622
- row["name"]
1623
- for row in ctx["service"]["format_list_rows"]()
1624
- if row.get("active")
1625
- }
1626
-
1627
-
1628
- def _apply_launch_settings(parsed, ctx, action="set"):
1629
- targets = _resolve_bulk_launch_targets(parsed, ctx["service"])
1630
- sessions = [
1631
- ctx["service"]["set_launch_settings"](session["name"], parsed["settings"])
1632
- for session in targets
1633
- ]
1634
- if len(sessions) == 1:
1635
- session = sessions[0]
1636
- message = f"Updated launch settings for {session['name']}"
1637
- else:
1638
- message = f"Updated launch settings for {len(sessions)} sessions: {_format_bulk_launch_summary(sessions)}"
1639
- if parsed["json"]:
1640
- payload = _json_success(
1641
- action,
1642
- message,
1643
- updated_count=len(sessions),
1644
- session=sessions[0] if len(sessions) == 1 else None,
1645
- sessions=sessions,
1646
- launch=sessions[0].get("launch") or {} if len(sessions) == 1 else None,
1647
- )
1648
- _write_json(ctx, payload)
1649
- return 0
1650
- ctx["out"](f"{_success(message, ctx['use_color'])}\n")
1651
- if len(sessions) == 1:
1652
- ctx["out"](f"{_format_launch_config(sessions[0], ctx['use_color'])}\n")
1653
- return 0
1654
-
1655
-
1656
- def _clear_launch_settings(parsed, ctx, action="unset"):
1657
- targets = _resolve_bulk_launch_targets(parsed, ctx["service"])
1658
- sessions = [
1659
- ctx["service"]["unset_launch_settings"](session["name"], parsed["keys"])
1660
- for session in targets
1661
- ]
1662
- if len(sessions) == 1:
1663
- session = sessions[0]
1664
- message = f"Cleared launch settings for {session['name']}"
1665
- else:
1666
- message = f"Cleared launch settings for {len(sessions)} sessions: {_format_bulk_launch_summary(sessions)}"
1667
- if parsed["json"]:
1668
- payload = _json_success(
1669
- action,
1670
- message,
1671
- updated_count=len(sessions),
1672
- session=sessions[0] if len(sessions) == 1 else None,
1673
- sessions=sessions,
1674
- launch=sessions[0].get("launch") or {} if len(sessions) == 1 else None,
1675
- )
1676
- _write_json(ctx, payload)
1677
- return 0
1678
- ctx["out"](f"{_success(message, ctx['use_color'])}\n")
1679
- if len(sessions) == 1:
1680
- ctx["out"](f"{_format_launch_config(sessions[0], ctx['use_color'])}\n")
1681
- return 0
1682
-
1683
-
1684
- def handle_set(rest, ctx):
1685
- return _apply_launch_settings(_parse_set_args(rest), ctx)
1686
-
1687
-
1688
- def handle_unset(rest, ctx):
1689
- return _clear_launch_settings(_parse_unset_args(rest), ctx)
1690
-
1691
-
1692
- def handle_launch_setting_alias(kind, rest, ctx):
1693
- parsed = _parse_launch_setting_alias_args(kind, rest)
1694
- if parsed.get("settings"):
1695
- return _apply_launch_settings(parsed, ctx, action=kind)
1696
- return _clear_launch_settings(parsed, ctx, action=kind)
1697
-
1698
-
1699
- def handle_config(rest, ctx):
1700
- parsed = _parse_config_args(rest)
1701
- session = ctx["service"]["get_session"](parsed["name"])
1702
- if not session:
1703
- raise CdxError(
1704
- f"Unknown session: {parsed['name']}. Run cdx configs to inspect existing sessions or "
1705
- f"cdx add {parsed['name']} to create it."
1706
- )
1707
- message = f"Launch settings for {parsed['name']}"
1708
- if parsed["json"]:
1709
- _write_json(ctx, _json_success("config", message, session=session, launch=session.get("launch") or {}))
1710
- return 0
1711
- ctx["out"](f"{_format_launch_config(session, ctx['use_color'])}\n")
1712
- return 0
1713
-
1714
-
1715
- def handle_configs(rest, ctx):
1716
- parsed = _parse_configs_args(rest)
1717
- sessions = ctx["service"]["list_sessions"]()
1718
- message = f"Listed launch settings for {len(sessions)} session{'s' if len(sessions) != 1 else ''}"
1719
- if parsed["json"]:
1720
- _write_json(ctx, _json_success("configs", message, count=len(sessions), sessions=sessions))
1721
- return 0
1722
- ctx["out"](f"{_format_launch_configs(sessions, ctx['use_color'])}\n")
1723
- return 0
1724
-
1725
-
1726
- def handle_history(rest, ctx):
1727
- now_fn = ctx["options"].get("now") or time.time
1728
- now = datetime.fromtimestamp(now_fn()).astimezone()
1729
- parsed = _parse_history_args(rest, now=now)
1730
- has_period = _has_history_period(parsed["period"])
1731
- limit = 0 if parsed["summary"] or has_period else parsed["limit"]
1732
- entries = ctx["service"]["get_launch_history"](parsed["name"], limit=limit)
1733
- entries = _filter_history_period(entries, parsed["period"])
1734
- if has_period and not parsed["summary"]:
1735
- entries = entries[:parsed["limit"]]
1736
- message = (
1737
- f"Listed launch history for {parsed['name']}"
1738
- if parsed["name"]
1739
- else "Listed launch history"
1740
- )
1741
- if parsed["json"]:
1742
- payload = {"history": entries, "period": _public_history_period(parsed["period"])}
1743
- if parsed["summary"]:
1744
- payload["summary"] = _summarize_history(entries)
1745
- _write_json(ctx, _json_success("history", message, **payload))
1746
- return 0
1747
- active_sessions = _active_session_names(ctx)
1748
- if parsed["summary"]:
1749
- ctx["out"](f"{_format_history_summary(entries, period=parsed['period'], use_color=ctx['use_color'], active_sessions=active_sessions)}\n")
1750
- return 0
1751
- ctx["out"](f"{_format_history(entries, use_color=ctx['use_color'], active_sessions=active_sessions)}\n")
1752
- return 0
1753
-
1754
-
1755
- def handle_stats(rest, ctx):
1756
- now_fn = ctx["options"].get("now") or time.time
1757
- now = datetime.fromtimestamp(now_fn()).astimezone()
1758
- parsed = _parse_stats_args(rest, now=now)
1759
- entries = ctx["service"]["get_launch_history"](parsed["name"], limit=0)
1760
- entries = _filter_history_period(entries, parsed["period"])
1761
- rows = _summarize_stats(entries)
1762
- totals = _stats_totals(rows)
1763
- message = (
1764
- f"Calculated stats for {parsed['name']}"
1765
- if parsed["name"]
1766
- else "Calculated stats"
1767
- )
1768
- if parsed["json"]:
1769
- _write_json(ctx, _json_success(
1770
- "stats",
1771
- message,
1772
- period=_public_history_period(parsed["period"]),
1773
- stats=rows,
1774
- totals=totals,
1775
- ))
1776
- return 0
1777
- active_sessions = _active_session_names(ctx)
1778
- ctx["out"](f"{_format_stats(rows, totals, period=parsed['period'], use_color=ctx['use_color'], active_sessions=active_sessions)}\n")
1779
- return 0
1780
-
1781
-
1782
- def _resolve_last_launch_session(ctx):
1783
- for entry in ctx["service"]["get_launch_history"](limit=0):
1784
- name = entry.get("session_name")
1785
- if not name:
1786
- continue
1787
- session = ctx["service"]["get_session"](name)
1788
- if session:
1789
- return session
1790
- raise CdxError("No launch history yet. Run cdx <name> first.")
1791
-
1792
-
1793
- def handle_last(rest, ctx):
1794
- json_flag, args = _parse_json_flag(rest)
1795
- if args:
1796
- raise CdxError(LAST_USAGE)
1797
- session = _resolve_last_launch_session(ctx)
1798
- if not json_flag:
1799
- message = f"Launching last session: {session['name']}"
1800
- ctx["out"](f"{_info(message, ctx['use_color'])}\n")
1801
- return handle_launch(session["name"], ctx)
1802
-
1803
-
1804
- def handle_clean(rest, ctx):
1805
- json_flag, args = _parse_json_flag(rest)
1806
- profile_flags = ("--tmp", "--old-logs")
1807
- has_profile_flag = any(arg in profile_flags or arg.startswith("--old-logs=") for arg in args)
1808
- if args[:1] == ["profiles"] or has_profile_flag:
1809
- if args[:1] == ["profiles"]:
1810
- return _handle_clean_profiles(args[1:], ctx, json_flag)
1811
- return _handle_clean_profiles(args, ctx, json_flag)
1812
- yes = "--yes" in args
1813
- args = [arg for arg in args if arg != "--yes"]
1814
- service = ctx["service"]
1815
- if len(args) == 0:
1816
- targets = service["list_sessions"]()
1817
- elif len(args) == 1:
1818
- session = service["get_session"](args[0])
1819
- if not session:
1820
- raise CdxError(f"Unknown session: {args[0]}")
1821
- targets = [session]
1822
- else:
1823
- raise CdxError("Usage: cdx clean [name] [--yes] [--json]")
1824
-
1825
- if not yes:
1826
- target = targets[0]["name"] if len(targets) == 1 else f"all {len(targets)} sessions"
1827
- confirm_fn = ctx["options"].get("confirmClean")
1828
- if confirm_fn:
1829
- confirmed = _resolve_confirmation(confirm_fn, target)
1830
- elif not ctx["stdin_is_tty"]:
1831
- raise CdxError("Log cleanup requires an interactive terminal or --yes in non-interactive mode.")
1832
- else:
1833
- confirmed = _confirm_log_cleanup(target)
1834
- if not confirmed:
1835
- if json_flag:
1836
- _write_json(ctx, _json_success("clean", "Cancelled.", cancelled=True, sessions=[]))
1837
- return 0
1838
- ctx["out"](f"{_warn('Cancelled.', ctx['use_color'])}\n")
1839
- return 0
1840
-
1841
- cleaned_sessions = []
1842
- for session in targets:
1843
- log_paths = _list_launch_transcript_paths(session)
1844
- if not log_paths:
1845
- message = f"{session['name']}: no log found"
1846
- cleaned_sessions.append({
1847
- "session_name": session["name"],
1848
- "cleared": False,
1849
- "files_cleared": 0,
1850
- "freed_kb": 0,
1851
- "message": message,
1852
- })
1853
- if json_flag:
1854
- continue
1855
- ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
1856
- continue
1857
- total_size = 0
1858
- cleared = 0
1859
- for log_path in log_paths:
1860
- try:
1861
- total_size += os.path.getsize(log_path)
1862
- open(log_path, "w").close()
1863
- cleared += 1
1864
- except OSError:
1865
- continue
1866
- if cleared:
1867
- message = (
1868
- f"Cleared {session['name']} logs ({cleared} file"
1869
- f"{'' if cleared == 1 else 's'}, {round(total_size / 1024)} KB freed)"
1870
- )
1871
- cleaned_sessions.append({
1872
- "session_name": session["name"],
1873
- "cleared": True,
1874
- "files_cleared": cleared,
1875
- "freed_kb": round(total_size / 1024),
1876
- "message": message,
1877
- })
1878
- if json_flag:
1879
- continue
1880
- ctx["out"](f"{_success(message, ctx['use_color'])}\n")
1881
- else:
1882
- message = f"{session['name']}: no log found"
1883
- cleaned_sessions.append({
1884
- "session_name": session["name"],
1885
- "cleared": False,
1886
- "files_cleared": 0,
1887
- "freed_kb": 0,
1888
- "message": message,
1889
- })
1890
- if json_flag:
1891
- continue
1892
- ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
1893
- if json_flag:
1894
- _write_json(ctx, _json_success("clean", "Cleaned session logs", cancelled=False, sessions=cleaned_sessions))
1895
- return 0
1896
-
1897
-
1898
- def handle_doctor(rest, ctx):
1899
- parsed = _parse_doctor_args(rest)
1900
- report = collect_health_report(
1901
- ctx["service"],
1902
- ctx["service"]["base_dir"],
1903
- env=ctx.get("env"),
1904
- spawn_sync=ctx.get("spawn_sync"),
1905
- )
1906
- report = filter_health_report(report, parsed["severity"])
1907
- if parsed["json"]:
1908
- _write_json(ctx, _json_success("doctor", "Collected health report", report=report))
1909
- else:
1910
- ctx["out"](f"{format_health_report(report, use_color=ctx['use_color'])}\n")
1911
- return 0
1912
-
1913
-
1914
- def handle_repair(rest, ctx):
1915
- parsed = _parse_flag_args(rest, {
1916
- "--json": {"key": "json", "type": "bool", "default": False},
1917
- "--dry-run": {"key": "dry_run", "type": "bool", "default": False},
1918
- "--force": {"key": "force", "type": "bool", "default": False},
1919
- }, REPAIR_USAGE)
1920
- dry_run = parsed["dry_run"] or not parsed["force"]
1921
- report = repair_health(
1922
- ctx["service"],
1923
- ctx["service"]["base_dir"],
1924
- env=ctx.get("env"),
1925
- dry_run=dry_run,
1926
- force=parsed["force"],
1927
- )
1928
- if parsed["json"]:
1929
- _write_json(ctx, _json_success("repair", "Collected repair report", report=report))
1930
- else:
1931
- ctx["out"](f"{format_repair_report(report, use_color=ctx['use_color'])}\n")
1932
- if dry_run:
1933
- ctx["out"](f"{_dim('Tip: run cdx repair --force to apply safe repairs.', ctx['use_color'])}\n")
1934
- return 0
1935
-
1936
-
1937
- def handle_notify(rest, ctx):
1938
- parsed = parse_notify_args(rest)
1939
-
1940
- def notifier(title, message):
1941
- send_desktop_notification(
1942
- title,
1943
- message,
1944
- spawn_sync=ctx.get("spawn_sync"),
1945
- env=ctx.get("env"),
1946
- )
1947
-
1948
- if parsed["schedule"]:
1949
- event = resolve_notify_event(
1950
- ctx["service"]["get_status_rows"](
1951
- progress_callback=None if parsed["json"] else _make_notify_progress(ctx),
1952
- force_refresh=parsed.get("refresh", False),
1953
- ),
1954
- parsed,
1955
- (ctx["options"].get("now") or time.time)(),
1956
- )
1957
- if event["ready"]:
1958
- notifier(event["title"], event["message"])
1959
- schedule = {
1960
- "scheduled": False,
1961
- "backend": "immediate",
1962
- "message": event["message"],
1963
- "target_timestamp": event.get("target_timestamp"),
1964
- }
1965
- else:
1966
- schedule = schedule_notification_event(
1967
- ctx["service"]["base_dir"],
1968
- parsed,
1969
- event,
1970
- spawn_sync=ctx.get("spawn_sync"),
1971
- env=ctx.get("env"),
1972
- now_fn=ctx["options"].get("now"),
1973
- )
1974
- if parsed["json"]:
1975
- _write_json(ctx, _json_success("notify", "Scheduled notification event", event=event, schedule=schedule))
1976
- else:
1977
- ctx["out"](f"{format_scheduled_notification(schedule)}\n")
1978
- return 0
1979
-
1980
- event = wait_for_notification_event(
1981
- ctx["service"],
1982
- parsed,
1983
- notifier=notifier,
1984
- sleep_fn=ctx["options"].get("sleep"),
1985
- now_fn=ctx["options"].get("now"),
1986
- progress_callback=None if parsed["json"] else _make_notify_progress(ctx),
1987
- )
1988
- if parsed["json"]:
1989
- _write_json(ctx, _json_success("notify", "Resolved notification event", event=event))
1990
- else:
1991
- ctx["out"](f"{format_notify_event(event)}\n")
1992
- return 0
1993
-
1994
-
1995
- def handle_context(rest, ctx):
1996
- json_flag, args = _parse_json_flag(rest)
1997
- if not args:
1998
- args = ["show"]
1999
- action = args[0]
2000
- base_dir = ctx["service"]["base_dir"]
2001
- cwd = ctx.get("cwd")
2002
-
2003
- if action == "show":
2004
- if len(args) != 1:
2005
- raise CdxError(CONTEXT_USAGE)
2006
- content = read_context(base_dir, cwd)
2007
- payload = {
2008
- "path": get_context_path(base_dir, cwd),
2009
- "exists": bool(content.strip()),
2010
- "content": content,
2011
- }
2012
- if json_flag:
2013
- _write_json(ctx, _json_success("context.show", "Loaded shared context", context=payload))
2014
- return 0
2015
- if content.strip():
2016
- ctx["out"](content if content.endswith("\n") else f"{content}\n")
2017
- else:
2018
- ctx["out"](f"{_dim('No shared context for this workspace. Run: cdx context init or cdx context set <text>', ctx['use_color'])}\n")
2019
- return 0
2020
-
2021
- if action == "path":
2022
- if len(args) != 1:
2023
- raise CdxError(CONTEXT_USAGE)
2024
- path = get_context_path(base_dir, cwd)
2025
- if json_flag:
2026
- _write_json(ctx, _json_success("context.path", "Resolved shared context path", path=path))
2027
- return 0
2028
- ctx["out"](f"{path}\n")
2029
- return 0
2030
-
2031
- if action == "init":
2032
- if len(args) != 1:
2033
- raise CdxError(CONTEXT_USAGE)
2034
- result = init_context(base_dir, cwd)
2035
- message = "Created shared context" if result.get("created") else "Shared context already exists"
2036
- if json_flag:
2037
- _write_json(ctx, _json_success("context.init", message, context=result))
2038
- return 0
2039
- text = f"{message}: {result['path']}"
2040
- ctx["out"](f"{_success(text, ctx['use_color'])}\n")
2041
- return 0
2042
-
2043
- if action == "edit":
2044
- if len(args) != 1:
2045
- raise CdxError(CONTEXT_USAGE)
2046
- result = edit_context(
2047
- base_dir,
2048
- cwd,
2049
- env=ctx.get("env"),
2050
- spawn_sync=ctx.get("spawn_sync"),
2051
- )
2052
- if json_flag:
2053
- _write_json(ctx, _json_success("context.edit", "Edited shared context", context=result))
2054
- return 0
2055
- text = f"Edited shared context: {result['path']}"
2056
- ctx["out"](f"{_success(text, ctx['use_color'])}\n")
2057
- return 0
2058
-
2059
- if action == "clear":
2060
- if len(args) != 1:
2061
- raise CdxError(CONTEXT_USAGE)
2062
- result = clear_context(base_dir, cwd)
2063
- message = "Cleared shared context" if result["removed"] else "No shared context to clear"
2064
- if json_flag:
2065
- _write_json(ctx, _json_success("context.clear", message, context=result))
2066
- return 0
2067
- ctx["out"](f"{_success(message, ctx['use_color'])}\n")
2068
- return 0
2069
-
2070
- if action == "set":
2071
- content_args = args[1:]
2072
- if content_args:
2073
- content = " ".join(content_args)
2074
- else:
2075
- stdin = ctx["options"].get("stdin_data")
2076
- if stdin is None and not ctx.get("stdin_is_tty"):
2077
- stdin = sys.stdin.read()
2078
- if stdin is None:
2079
- raise CdxError("Usage: cdx context set <text> [--json]")
2080
- content = stdin
2081
- result = write_context(base_dir, content, cwd)
2082
- if json_flag:
2083
- _write_json(ctx, _json_success("context.set", "Saved shared context", context=result))
2084
- return 0
2085
- text = f"Saved shared context: {result['path']}"
2086
- ctx["out"](f"{_success(text, ctx['use_color'])}\n")
2087
- return 0
2088
-
2089
- if action == "append":
2090
- note = " ".join(args[1:])
2091
- if not note.strip():
2092
- raise CdxError("Usage: cdx context append <text> [--json]")
2093
- result = append_context(base_dir, note, cwd)
2094
- if json_flag:
2095
- _write_json(ctx, _json_success("context.append", "Appended shared context", context=result))
2096
- return 0
2097
- text = f"Appended shared context: {result['path']}"
2098
- ctx["out"](f"{_success(text, ctx['use_color'])}\n")
2099
- return 0
2100
-
2101
- raise CdxError(CONTEXT_USAGE)
2102
-
2103
-
2104
- def _parse_memory_args(rest):
2105
- json_flag, args = _parse_json_flag(rest)
2106
- scope = "current"
2107
- project = None
2108
- cleaned = []
2109
- index = 0
2110
- while index < len(args):
2111
- arg = args[index]
2112
- if arg == "--global":
2113
- if scope != "current":
2114
- raise CdxError(MEMORY_USAGE)
2115
- scope = "global"
2116
- index += 1
2117
- continue
2118
- if arg == "--project":
2119
- if scope != "current" or index + 1 >= len(args) or not args[index + 1].strip():
2120
- raise CdxError(MEMORY_USAGE)
2121
- scope = "project"
2122
- project = args[index + 1].strip()
2123
- index += 2
2124
- continue
2125
- if arg.startswith("--project="):
2126
- if scope != "current":
2127
- raise CdxError(MEMORY_USAGE)
2128
- project = arg.split("=", 1)[1].strip()
2129
- if not project:
2130
- raise CdxError(MEMORY_USAGE)
2131
- scope = "project"
2132
- index += 1
2133
- continue
2134
- cleaned.append(arg)
2135
- index += 1
2136
- if not cleaned:
2137
- cleaned = ["show"]
2138
- if cleaned[0] == "view":
2139
- cleaned[0] = "show"
2140
- return json_flag, scope, project, cleaned
2141
-
2142
-
2143
- def _memory_target(base_dir, cwd, scope, project):
2144
- if scope == "global":
2145
- return {"scope": "global", "path": get_global_context_path(base_dir)}
2146
- if scope == "project":
2147
- if os.path.exists(project):
2148
- return {
2149
- "scope": "project",
2150
- "project": project,
2151
- "project_kind": "path",
2152
- "path": get_context_path(base_dir, project),
2153
- }
2154
- return {
2155
- "scope": "project",
2156
- "project": project,
2157
- "project_kind": "name",
2158
- "path": get_named_project_context_path(base_dir, project),
2159
- }
2160
- return {"scope": "current", "path": get_context_path(base_dir, cwd)}
2161
-
2162
-
2163
- def _memory_payload(target, content=None):
2164
- payload = {
2165
- "scope": target["scope"],
2166
- "path": target["path"],
2167
- "exists": os.path.isfile(target["path"]),
2168
- }
2169
- if target.get("project") is not None:
2170
- payload["project"] = target["project"]
2171
- payload["project_kind"] = target.get("project_kind")
2172
- if payload["exists"]:
2173
- payload["bytes"] = os.path.getsize(target["path"])
2174
- if content is not None:
2175
- payload["content"] = content
2176
- return payload
2177
-
2178
-
2179
- def _list_memory_entries(base_dir, cwd):
2180
- entries = []
2181
- global_path = get_global_context_path(base_dir)
2182
- if os.path.isfile(global_path):
2183
- entries.append({"scope": "global", "path": global_path, "bytes": os.path.getsize(global_path)})
2184
- entries.extend(list_named_project_contexts(base_dir))
2185
- current_path = get_context_path(base_dir, cwd)
2186
- if os.path.isfile(current_path):
2187
- entries.append({"scope": "current", "path": current_path, "bytes": os.path.getsize(current_path)})
2188
- return entries
2189
-
2190
-
2191
- def handle_memory(rest, ctx):
2192
- json_flag, scope, project, args = _parse_memory_args(rest)
2193
- action = args[0]
2194
- base_dir = ctx["service"]["base_dir"]
2195
- cwd = ctx.get("cwd")
2196
-
2197
- if action == "list":
2198
- if len(args) != 1 or scope != "current":
2199
- raise CdxError(MEMORY_USAGE)
2200
- entries = _list_memory_entries(base_dir, cwd)
2201
- if json_flag:
2202
- _write_json(ctx, _json_success("memory.list", "Listed memory scopes", memories=entries))
2203
- return 0
2204
- if not entries:
2205
- ctx["out"](f"{_dim('No cdx memory files found.', ctx['use_color'])}\n")
2206
- return 0
2207
- rows = [["SCOPE", "PROJECT", "SIZE", "PATH"]]
2208
- for entry in entries:
2209
- rows.append([
2210
- entry["scope"],
2211
- entry.get("project") or "-",
2212
- _format_bytes(entry.get("bytes") or 0),
2213
- entry["path"],
2214
- ])
2215
- ctx["out"](_pad_table(rows) + "\n")
2216
- return 0
2217
-
2218
- target = _memory_target(base_dir, cwd, scope, project)
2219
- path = target["path"]
2220
-
2221
- if action == "show":
2222
- if len(args) != 1:
2223
- raise CdxError(MEMORY_USAGE)
2224
- content = read_context_path(path)
2225
- payload = _memory_payload(target, content=content)
2226
- if json_flag:
2227
- _write_json(ctx, _json_success("memory.show", "Loaded memory", memory=payload))
2228
- return 0
2229
- if content.strip():
2230
- ctx["out"](content if content.endswith("\n") else f"{content}\n")
2231
- else:
2232
- ctx["out"](f"{_dim('No memory for this scope. Run: cdx memory append <text>', ctx['use_color'])}\n")
2233
- return 0
2234
-
2235
- if action == "path":
2236
- if len(args) != 1:
2237
- raise CdxError(MEMORY_USAGE)
2238
- if json_flag:
2239
- _write_json(ctx, _json_success("memory.path", "Resolved memory path", memory=_memory_payload(target)))
2240
- return 0
2241
- ctx["out"](f"{path}\n")
2242
- return 0
2243
-
2244
- if action == "init":
2245
- if len(args) != 1:
2246
- raise CdxError(MEMORY_USAGE)
2247
- result = init_context_path(path)
2248
- result = {**result, **{key: value for key, value in target.items() if key != "path"}}
2249
- message = "Created memory" if result.get("created") else "Memory already exists"
2250
- if json_flag:
2251
- _write_json(ctx, _json_success("memory.init", message, memory=result))
2252
- return 0
2253
- ctx["out"](f"{_success(f'{message}: {path}', ctx['use_color'])}\n")
2254
- return 0
2255
-
2256
- if action == "edit":
2257
- if len(args) != 1:
2258
- raise CdxError(MEMORY_USAGE)
2259
- result = edit_context_path(path, env=ctx.get("env"), spawn_sync=ctx.get("spawn_sync"))
2260
- result = {**result, **{key: value for key, value in target.items() if key != "path"}}
2261
- if json_flag:
2262
- _write_json(ctx, _json_success("memory.edit", "Edited memory", memory=result))
2263
- return 0
2264
- ctx["out"](f"{_success(f'Edited memory: {path}', ctx['use_color'])}\n")
2265
- return 0
2266
-
2267
- if action == "clear":
2268
- if len(args) != 1:
2269
- raise CdxError(MEMORY_USAGE)
2270
- result = clear_context_path(path)
2271
- result = {**result, **{key: value for key, value in target.items() if key != "path"}}
2272
- message = "Cleared memory" if result["removed"] else "No memory to clear"
2273
- if json_flag:
2274
- _write_json(ctx, _json_success("memory.clear", message, memory=result))
2275
- return 0
2276
- ctx["out"](f"{_success(message, ctx['use_color'])}\n")
2277
- return 0
2278
-
2279
- if action == "set":
2280
- content_args = args[1:]
2281
- if content_args:
2282
- content = " ".join(content_args)
2283
- else:
2284
- stdin = ctx["options"].get("stdin_data")
2285
- if stdin is None and not ctx.get("stdin_is_tty"):
2286
- stdin = sys.stdin.read()
2287
- if stdin is None:
2288
- raise CdxError("Usage: cdx memory set <text> [--json]")
2289
- content = stdin
2290
- result = write_context_path(path, content)
2291
- result = {**result, **{key: value for key, value in target.items() if key != "path"}}
2292
- if json_flag:
2293
- _write_json(ctx, _json_success("memory.set", "Saved memory", memory=result))
2294
- return 0
2295
- ctx["out"](f"{_success(f'Saved memory: {path}', ctx['use_color'])}\n")
2296
- return 0
2297
-
2298
- if action == "append":
2299
- note = " ".join(args[1:])
2300
- result = append_context_path(path, note)
2301
- result = {**result, **{key: value for key, value in target.items() if key != "path"}}
2302
- if json_flag:
2303
- _write_json(ctx, _json_success("memory.append", "Appended memory", memory=result))
2304
- return 0
2305
- ctx["out"](f"{_success(f'Appended memory: {path}', ctx['use_color'])}\n")
2306
- return 0
2307
-
2308
- raise CdxError(MEMORY_USAGE)
2309
-
2310
-
2311
- def handle_status(rest, ctx):
2312
- parsed = _parse_flag_args(rest, {
2313
- "--json": {"key": "json", "type": "bool", "default": False},
2314
- "--small": {"key": "small", "type": "bool", "default": False},
2315
- "-s": {"key": "small", "type": "bool", "default": False},
2316
- "--refresh": {"key": "refresh", "type": "bool", "default": False},
2317
- "--cached": {"key": "cached", "type": "bool", "default": False},
2318
- "--timeout": {"key": "timeout", "type": "str", "default": None, "transform": lambda value: _parse_timeout_seconds(value, STATUS_USAGE)},
2319
- }, STATUS_USAGE, positionals_key="args", max_positionals=1)
2320
- if parsed["json"] and parsed["small"]:
2321
- raise CdxError(STATUS_USAGE)
2322
- if parsed["refresh"] and parsed["cached"]:
2323
- raise CdxError(STATUS_USAGE)
2324
- args = parsed["args"]
2325
- if len(args) == 1 and parsed["small"]:
2326
- raise CdxError(STATUS_USAGE)
2327
-
2328
- auth_refresh = (
2329
- {"errors": []}
2330
- if parsed["cached"]
2331
- else _refresh_claude_auth_states(
2332
- ctx["service"],
2333
- target_names=args if len(args) == 1 else None,
2334
- spawn_sync=ctx.get("spawn_sync"),
2335
- env_override=ctx.get("env"),
2336
- )
2337
- )
2338
- warnings = [
2339
- {
2340
- "code": "claude_auth_probe_failed",
2341
- "session": item.get("session") or "unknown",
2342
- "message": item.get("error") or "unknown error",
2343
- }
2344
- for item in auth_refresh.get("errors", [])
2345
- ]
2346
- refresh_result = (
2347
- {"errors": []}
2348
- if parsed["cached"]
2349
- else _refresh_claude_sessions(
2350
- ctx["service"],
2351
- ctx.get("refresh_fn"),
2352
- target_names=args if len(args) == 1 else None,
2353
- force=parsed["refresh"],
2354
- )
2355
- )
2356
- refresh_errors = [
2357
- {
2358
- "session": item.get("session"),
2359
- "error": str(item.get("error")),
2360
- }
2361
- for item in refresh_result.get("errors", [])
2362
- ]
2363
- status_progress = None if parsed["json"] else _make_status_progress(ctx)
2364
- if len(args) == 1:
2365
- row = ctx["service"]["get_status_row"](
2366
- args[0],
2367
- progress_callback=status_progress,
2368
- force_refresh=parsed["refresh"],
2369
- cache_only=parsed["cached"],
2370
- status_timeout_seconds=parsed["timeout"],
2371
- )
2372
- output_warnings = [
2373
- *warnings,
2374
- *_refresh_warning_payloads(refresh_errors, [row]),
2375
- *_update_notice_warnings(ctx),
2376
- ]
2377
- if parsed["json"]:
2378
- _write_json(ctx, _json_success("status", f"Collected status for {args[0]}", warnings=output_warnings, session=row))
2379
- return 0
2380
- ctx["out"](f"{_format_status_detail(row, use_color=ctx['use_color'])}\n")
2381
- _write_refresh_warnings(refresh_errors, ctx, rows=[row])
2382
- _write_update_notice(ctx)
2383
- return 0
2384
-
2385
- rows = ctx["service"]["get_status_rows"](
2386
- progress_callback=status_progress,
2387
- force_refresh=parsed["refresh"],
2388
- cache_only=parsed["cached"],
2389
- status_timeout_seconds=parsed["timeout"],
2390
- )
2391
- output_warnings = [
2392
- *warnings,
2393
- *_refresh_warning_payloads(refresh_errors, rows),
2394
- *_update_notice_warnings(ctx),
2395
- ]
2396
- if parsed["json"]:
2397
- _write_json(ctx, _json_success("status", "Collected session status rows", warnings=output_warnings, rows=rows))
2398
- return 0
2399
- ctx["out"](f"{_format_status_rows(rows, use_color=ctx['use_color'], small=parsed['small'])}\n")
2400
- _write_refresh_warnings(refresh_errors, ctx, rows=rows)
2401
- _write_update_notice(ctx)
2402
- return 0
2403
-
2404
-
2405
- def handle_reset(rest, ctx):
2406
- parsed = _parse_flag_args(rest, {
2407
- "--json": {"key": "json", "type": "bool", "default": False},
2408
- "--yes": {"key": "yes", "type": "bool", "default": False},
2409
- }, "Usage: cdx reset <name> [--yes] [--json]", positionals_key="args", max_positionals=1)
2410
- if len(parsed["args"]) != 1:
2411
- raise CdxError("Usage: cdx reset <name> [--yes] [--json]")
2412
- name = parsed["args"][0]
2413
- session = ctx["service"]["get_session"](name)
2414
- if not session:
2415
- raise CdxError(f"Unknown session: {name}")
2416
- if session["provider"] != PROVIDER_CODEX:
2417
- raise CdxError("Banked rate-limit resets are only available for Codex sessions.")
2418
-
2419
- status = ctx["service"]["get_status_row"](name, force_refresh=True)
2420
- if status.get("reset_credits_available") == 0:
2421
- raise CdxError(f"No banked Codex reset is available for {name}.")
2422
- if not parsed["yes"]:
2423
- confirm_fn = ctx["options"].get("confirmReset")
2424
- if confirm_fn:
2425
- confirmed = _resolve_confirmation(confirm_fn, name)
2426
- elif not ctx["stdin_is_tty"]:
2427
- raise CdxError("Reset activation requires an interactive terminal or --yes in non-interactive mode.")
2428
- else:
2429
- confirmed = _confirm_reset(name)
2430
- if not confirmed:
2431
- if parsed["json"]:
2432
- _write_json(ctx, _json_success("reset", "Cancelled.", cancelled=True, session=name))
2433
- return 0
2434
- ctx["out"](f"{_warn('Cancelled.', ctx['use_color'])}\n")
2435
- return 0
2436
-
2437
- credit_rows = status.get("reset_credits") or []
2438
- credit_id = credit_rows[0].get("id") if credit_rows else None
2439
- consumer = ctx["options"].get("consumeCodexReset") or consume_codex_rate_limit_reset_credit
2440
- result = consumer(
2441
- session,
2442
- ctx["options"].get("resetIdempotencyKey") or str(uuid.uuid4()),
2443
- credit_id=credit_id,
2444
- )
2445
- if not result.get("ok"):
2446
- if result.get("reason") == "reset_consume_failed":
2447
- raise CdxError(
2448
- f"Unable to activate Codex reset for {name}. "
2449
- "The installed Codex version or account may not support reset activation."
2450
- )
2451
- raise CdxError(f"Unable to activate Codex reset for {name}: {result.get('reason') or 'unknown error'}")
2452
- outcome = result.get("outcome")
2453
- if outcome == "noCredit":
2454
- raise CdxError(f"No banked Codex reset is available for {name}.")
2455
- if outcome == "nothingToReset":
2456
- raise CdxError(f"No current Codex rate-limit window is eligible for reset on {name}.")
2457
- if outcome not in ("reset", "alreadyRedeemed"):
2458
- raise CdxError(f"Unexpected Codex reset outcome for {name}: {outcome or 'missing'}")
2459
- refreshed = ctx["service"]["get_status_row"](name, force_refresh=True)
2460
- message = f"Activated banked Codex reset for {name}"
2461
- if parsed["json"]:
2462
- _write_json(ctx, _json_success("reset", message, cancelled=False, outcome=outcome, session=refreshed))
2463
- return 0
2464
- ctx["out"](f"{_success(message, ctx['use_color'])}\n")
2465
- return 0
2466
-
2467
-
2468
- def _refresh_claude_auth_states(service, target_names=None, spawn_sync=None, env_override=None):
2469
- target_names = set(target_names or [])
2470
- errors = []
2471
- updated = []
2472
- for session in service["list_sessions"]():
2473
- if session["provider"] != PROVIDER_CLAUDE:
2474
- continue
2475
- if session.get("enabled", True) is False:
2476
- continue
2477
- if target_names and session["name"] not in target_names:
2478
- continue
2479
- if (session.get("auth") or {}).get("status") not in ("authenticated", "logged_out"):
2480
- continue
2481
- try:
2482
- auth_status = _probe_provider_auth_status(
2483
- session,
2484
- spawn_sync=spawn_sync,
2485
- env_override=env_override,
2486
- )
2487
- if auth_status == AUTH_PROBE_DEGRADED:
2488
- errors.append({
2489
- "session": session["name"],
2490
- "error": "Auth probe timed out; authentication status is degraded.",
2491
- })
2492
- continue
2493
- now = _local_now_iso()
2494
- service["update_auth_state"](session["name"], lambda auth, auth_status=auth_status, now=now: {
2495
- **auth,
2496
- "status": "authenticated" if auth_status == AUTH_PROBE_AUTHENTICATED else "logged_out",
2497
- "lastCheckedAt": now,
2498
- **({"lastAuthenticatedAt": now} if auth_status == AUTH_PROBE_AUTHENTICATED else {}),
2499
- })
2500
- updated.append(session["name"])
2501
- except Exception as error:
2502
- errors.append({"session": session["name"], "error": str(error)})
2503
- return {"updated": updated, "errors": errors}
2504
-
2505
-
2506
- def _rows_by_session(rows):
2507
- return {
2508
- row.get("session_name"): row
2509
- for row in (rows or [])
2510
- if row.get("session_name")
2511
- }
2512
-
2513
-
2514
- def _is_invalid_claude_usage_auth(error):
2515
- text = str(error or "").lower()
2516
- return "http 401" in text and "invalid authentication credentials" in text
2517
-
2518
-
2519
- def _has_valid_local_claude_auth(item, rows_by_name):
2520
- session = item.get("session")
2521
- row = rows_by_name.get(session)
2522
- return bool(row and row.get("provider") == PROVIDER_CLAUDE and row.get("auth_status") == "authenticated")
2523
-
2524
-
2525
- def _refresh_warning_payloads(refresh_errors, rows=None):
2526
- rows_by_name = _rows_by_session(rows)
2527
- warnings = []
2528
- for item in refresh_errors:
2529
- session = item.get("session") or "unknown"
2530
- error = item.get("error") or "unknown error"
2531
- warning = {
2532
- "code": "claude_refresh_failed",
2533
- "session": session,
2534
- "message": error,
2535
- }
2536
- if _is_invalid_claude_usage_auth(error) and _has_valid_local_claude_auth(item, rows_by_name):
2537
- warning.update({
2538
- "auth_status": "authenticated",
2539
- "status_freshness": "stale",
2540
- "message": (
2541
- "Claude quota refresh failed, but local Claude auth is still valid; "
2542
- f"cached quota may be stale. Original error: {error}"
2543
- ),
2544
- })
2545
- warnings.append(warning)
2546
- return warnings
2547
-
2548
-
2549
- def _format_refresh_warning(item, rows_by_name):
2550
- session = item.get("session") or "unknown"
2551
- error = item.get("error") or "unknown error"
2552
- if _is_invalid_claude_usage_auth(error) and _has_valid_local_claude_auth(item, rows_by_name):
2553
- return (
2554
- f"Warning: Claude quota refresh failed for {session}, but local auth is still valid; "
2555
- f"cached quota may be stale. ({error})"
2556
- )
2557
- return f"Warning: Claude refresh failed for {session}: {error}"
2558
-
2559
-
2560
- def _write_refresh_warnings(refresh_errors, ctx, stream="out", rows=None):
2561
- write = ctx["err"] if stream == "err" and "err" in ctx else ctx["out"]
2562
- rows_by_name = _rows_by_session(rows)
2563
- for item in refresh_errors:
2564
- write(f"{_warn(_format_refresh_warning(item, rows_by_name), ctx['use_color'])}\n")
2565
-
2566
-
2567
- def handle_export(rest, ctx):
2568
- parsed = _parse_export_args(rest)
2569
- passphrase = None
2570
- if parsed["include_auth"]:
2571
- passphrase = _resolve_bundle_passphrase(
2572
- ctx,
2573
- parsed["passphrase_env"],
2574
- "Bundle passphrase: ",
2575
- confirm=True,
2576
- use_stdin=parsed["passphrase_stdin"],
2577
- )
2578
- result = ctx["service"]["export_bundle"](
2579
- parsed["file_path"],
2580
- include_auth=parsed["include_auth"],
2581
- session_names=parsed["session_names"],
2582
- passphrase=passphrase,
2583
- force=parsed["force"],
2584
- progress_callback=None if parsed["json"] else _make_export_progress(ctx),
2585
- )
2586
- session_count = len(result["session_names"])
2587
- auth_suffix = " with auth" if result["include_auth"] else ""
2588
- message = f"Exported {session_count} session{'s' if session_count != 1 else ''}{auth_suffix} to {result['path']}"
2589
- payload = _json_success(
2590
- "export",
2591
- message,
2592
- bundle=result,
2593
- )
2594
- if parsed["json"]:
2595
- _write_json(ctx, payload)
2596
- return 0
2597
- ctx["out"](f"{_success(message, ctx['use_color'])}\n")
2598
- ctx["out"](f"{_format_export_report(result)}\n")
2599
- return 0
2600
-
2601
-
2602
- def handle_import(rest, ctx):
2603
- parsed = _parse_import_args(rest)
2604
- passphrase = None
2605
- try:
2606
- with open(parsed["file_path"], "rb") as handle:
2607
- meta = read_bundle_meta(handle.read())
2608
- except OSError as error:
2609
- raise CdxError(f"Bundle file not found: {parsed['file_path']}") from error
2610
- if meta.get("encrypted"):
2611
- passphrase = _resolve_bundle_passphrase(
2612
- ctx,
2613
- parsed["passphrase_env"],
2614
- "Bundle passphrase: ",
2615
- confirm=False,
2616
- use_stdin=parsed["passphrase_stdin"],
2617
- )
2618
- result = ctx["service"]["import_bundle"](
2619
- parsed["file_path"],
2620
- passphrase=passphrase,
2621
- session_names=parsed["session_names"],
2622
- force=parsed["force"],
2623
- merge=parsed["merge"],
2624
- allow_authless_force=parsed["allow_authless_force"],
2625
- )
2626
- session_count = len(result["session_names"])
2627
- auth_suffix = " with auth" if result["include_auth"] else ""
2628
- message = f"Imported {session_count} session{'s' if session_count != 1 else ''}{auth_suffix} from {result['path']}"
2629
- payload = _json_success(
2630
- "import",
2631
- message,
2632
- bundle=result,
2633
- )
2634
- if parsed["json"]:
2635
- _write_json(ctx, payload)
2636
- return 0
2637
- ctx["out"](f"{_success(message, ctx['use_color'])}\n")
2638
- return 0
2639
-
2640
-
2641
- def handle_login(rest, ctx):
2642
- rest = [arg for arg in rest if arg != "--setup-token"] # accepted for back-compat; setup-token is now the default for Claude
2643
- json_flag, args = _parse_json_flag(rest)
2644
- if len(args) != 1:
2645
- raise CdxError("Usage: cdx login <name> [--json]")
2646
- if not ctx["stdin_is_tty"]:
2647
- raise CdxError("Login requires an interactive terminal.")
2648
- session = ctx["service"]["get_session"](args[0])
2649
- if not session:
2650
- raise CdxError(f"Unknown session: {args[0]}")
2651
- if session["provider"] == PROVIDER_CLAUDE:
2652
- # ponytail: setup-token mints a ~1yr token; claude login mints a ~daily one that forces reconnects
2653
- _bootstrap_claude_setup_token(session, ctx)
2654
- else:
2655
- _run_interactive_provider_command(
2656
- session, "login", spawn=ctx.get("spawn"), env_override=ctx.get("env"),
2657
- signal_emitter=ctx.get("signal_emitter")
2658
- )
2659
- auth_probe = _ensure_session_authentication(
2660
- session,
2661
- ctx["service"],
2662
- spawn_sync=ctx.get("spawn_sync"),
2663
- env_override=ctx.get("env"),
2664
- behavior="probe-only",
2665
- trust_local_credentials=False,
2666
- )
2667
- if not auth_probe.get("authenticated"):
2668
- raise CdxError(
2669
- f"Login command completed, but session {session['name']} is still not authenticated."
2670
- )
2671
- now = _local_now_iso()
2672
- ctx["service"]["update_auth_state"](args[0], lambda auth: {
2673
- **auth, "status": "authenticated",
2674
- "lastCheckedAt": now, "lastAuthenticatedAt": now,
2675
- })
2676
- message = f"Reauthenticated session {session['name']} ({session['provider']})"
2677
- if json_flag:
2678
- _write_json(ctx, _json_success("login", message, session=ctx["service"]["get_session"](session["name"])))
2679
- return 0
2680
- ctx["out"](f"{_success(message, ctx['use_color'])}\n")
2681
- return 0
2682
-
2683
-
2684
- def handle_logout(rest, ctx):
2685
- json_flag, args = _parse_json_flag(rest)
2686
- if len(args) != 1:
2687
- raise CdxError("Usage: cdx logout <name> [--json]")
2688
- session = ctx["service"]["get_session"](args[0])
2689
- if not session:
2690
- raise CdxError(f"Unknown session: {args[0]}")
2691
- if session["provider"] == PROVIDER_ANTIGRAVITY:
2692
- raise CdxError("Antigravity logout is managed inside agy. Launch the session and run /logout.")
2693
- if session["provider"] == PROVIDER_OLLAMA:
2694
- raise CdxError("Ollama sessions do not use cdx-managed authentication.")
2695
- _run_interactive_provider_command(
2696
- session, "logout", spawn=ctx.get("spawn"), env_override=ctx.get("env"),
2697
- signal_emitter=ctx.get("signal_emitter")
2698
- )
2699
- now = _local_now_iso()
2700
- ctx["service"]["update_auth_state"](args[0], lambda auth: {
2701
- **auth, "status": "logged_out",
2702
- "lastCheckedAt": now, "lastLoggedOutAt": now,
2703
- })
2704
- message = f"Logged out session {session['name']} ({session['provider']})"
2705
- if json_flag:
2706
- _write_json(ctx, _json_success("logout", message, session=ctx["service"]["get_session"](session["name"])))
2707
- return 0
2708
- ctx["out"](f"{_success(message, ctx['use_color'])}\n")
2709
- return 0
2710
-
2711
-
2712
- def handle_update(rest, ctx):
2713
- parsed = _parse_update_args(rest)
2714
- if parsed["all"]:
2715
- plan = collect_update_all_plan(ctx["service"], env=ctx.get("env"), runner=ctx["options"].get("runUpdateAll"))
2716
- if not parsed["yes"]:
2717
- if parsed["json"]:
2718
- _write_json(ctx, _json_success("update.all", "Collected workstation update plan", plan=plan, apply_required=bool(plan["steps"])))
2719
- return 0
2720
- ctx["out"](f"{_format_update_all(plan, ctx['use_color'])}\n")
2721
- if not plan["steps"]:
2722
- return 0
2723
- if not ctx["stdin_is_tty"]:
2724
- raise CdxError("cdx update all requires an interactive terminal or --yes.")
2725
- if input("Apply this plan? [y/N] ").strip().lower() not in ("y", "yes"):
2726
- ctx["out"](f"{_warn('Cancelled.', ctx['use_color'])}\n")
2727
- return 0
2728
- def progress(event):
2729
- if parsed["json"]:
2730
- return
2731
- step_name = event["step"]["name"]
2732
- if event["phase"] == "start":
2733
- progress_label = _info(f"[{event['index']}/{event['total']}]", ctx["use_color"])
2734
- ctx["out"](f"{progress_label} {step_name}...\n")
2735
- else:
2736
- ctx["out"](f" {_format_update_all_result(event['result'], ctx['use_color'])}\n")
2737
-
2738
- results = apply_update_all_plan(plan, ctx["service"], env=ctx.get("env"), runner=ctx["options"].get("runUpdateAll"), progress=progress)
2739
- failed = [row for row in results if row.get("returncode") != 0]
2740
- message = "Applied workstation update plan" if not failed else "Workstation update plan completed with failures"
2741
- if parsed["json"]:
2742
- _write_json(ctx, _json_success("update.all", message, plan=plan, results=results, failed=len(failed)))
2743
- else:
2744
- ctx["out"](f"{_success(message, ctx['use_color']) if not failed else _warn(message, ctx['use_color'])}\n")
2745
- return 1 if failed else 0
2746
- json_flag = parsed["json"]
2747
- current_version = str(ctx.get("version") or "").strip()
2748
- release_fetcher = ctx["options"].get("fetchLatestRelease") or fetch_latest_release
2749
- target_version = None
2750
- release_url = None
2751
- update_available = False
2752
-
2753
- if parsed["version"] is not None:
2754
- target_version = str(parsed["version"]).strip().lstrip("v")
2755
- else:
2756
- try:
2757
- latest = (
2758
- release_fetcher()
2759
- if ctx["options"].get("fetchLatestRelease")
2760
- else fetch_latest_release_or_raise(env=ctx.get("env"))
2761
- )
2762
- except LatestReleaseCheckError as error:
2763
- raise CdxError(str(error)) from error
2764
- if not latest:
2765
- raise CdxError("Unable to check for the latest cdx-manager release. Check your network and try again.")
2766
- target_version = str(latest.get("latest_version") or "").strip()
2767
- release_url = latest.get("url")
2768
- if not target_version:
2769
- raise CdxError("Unable to determine the latest cdx-manager release.")
2770
- update_available = is_newer_version(current_version, target_version)
2771
- if parsed["check"] or not update_available:
2772
- message = (
2773
- f"Update available: cdx-manager {target_version} (current {current_version})"
2774
- if update_available
2775
- else f"cdx-manager {current_version} is already up to date."
2776
- )
2777
- if json_flag:
2778
- _write_json(ctx, _json_success(
2779
- "update",
2780
- message,
2781
- checked=True,
2782
- update_available=update_available,
2783
- current_version=current_version,
2784
- target_version=target_version,
2785
- release_url=release_url,
2786
- warnings=[{
2787
- "code": "update_available",
2788
- "message": message,
2789
- "latest_version": target_version,
2790
- "url": release_url,
2791
- }] if update_available else [],
2792
- ))
2793
- return 0
2794
- ctx["out"](f"{_warn(message, ctx['use_color']) if update_available else _success(message, ctx['use_color'])}\n")
2795
- return 0
2796
-
2797
- if not parsed["yes"]:
2798
- if not ctx["stdin_is_tty"]:
2799
- raise CdxError("Update requires an interactive terminal or --yes in non-interactive mode.")
2800
- answer = input(f"Update cdx-manager to {target_version}? [y/N] ")
2801
- if answer.strip().lower() not in ("y", "yes"):
2802
- message = "Cancelled."
2803
- if json_flag:
2804
- _write_json(ctx, _json_success("update", message, cancelled=True, current_version=current_version, target_version=target_version))
2805
- return 0
2806
- ctx["out"](f"{_warn(message, ctx['use_color'])}\n")
2807
- return 0
2808
-
2809
- plan = build_update_plan(
2810
- target_version=target_version,
2811
- package_root=ctx["options"].get("packageRoot"),
2812
- prefix=ctx["options"].get("prefix"),
2813
- base_prefix=ctx["options"].get("basePrefix"),
2814
- )
2815
- results = run_update_plan(plan, runner=ctx["options"].get("runUpdate"), env=ctx.get("env"))
2816
- failed = any((result.get("returncode") != 0) for result in results)
2817
- if failed:
2818
- raise CdxError(format_update_failure(results))
2819
-
2820
- warnings = []
2821
- version_warning = verify_updated_command(
2822
- target_version,
2823
- runner=ctx["options"].get("runVersionCheck"),
2824
- env=ctx.get("env"),
2825
- )
2826
- if version_warning:
2827
- warnings.append(version_warning)
2828
-
2829
- message = f"Updated cdx-manager to {target_version}"
2830
- if json_flag:
2831
- _write_json(ctx, _json_success(
2832
- "update",
2833
- message,
2834
- warnings=warnings,
2835
- updated=True,
2836
- current_version=current_version,
2837
- target_version=target_version,
2838
- mode=plan["mode"],
2839
- steps=results,
2840
- ))
2841
- return 0
2842
- ctx["out"](f"{_success(message, ctx['use_color'])}\n")
2843
- for warning in warnings:
2844
- ctx["out"](f"{_warn(warning['message'], ctx['use_color'])}\n")
2845
- return 0
2846
-
2847
-
2848
- def _resume_capability_for_session(session, ctx):
2849
- capability = get_resume_capability(session, cwd=ctx.get("cwd") or os.getcwd())
2850
- return {
2851
- "session": session["name"],
2852
- "provider": session["provider"],
2853
- "resumable": bool(capability.get("resumable")),
2854
- "strategy": capability.get("strategy"),
2855
- "reason": capability.get("reason"),
2856
- "command_preview": capability.get("command_preview") or [],
2857
- }
2858
-
2859
-
2860
- def _format_resume_capability(capability, use_color=False):
2861
- name = capability["session"]
2862
- provider = capability["provider"]
2863
- if capability["resumable"]:
2864
- preview = shlex.join(capability.get("command_preview") or [])
2865
- detail = f"({provider}, {capability['strategy']})"
2866
- return (
2867
- f"{_success(f'{name} can resume', use_color)} "
2868
- f"{_dim(detail, use_color)}\n"
2869
- f"{_dim(preview, use_color)}"
2870
- )
2871
- reason = capability.get("reason") or "not_supported"
2872
- return (
2873
- f"{_warn(f'{name} cannot resume', use_color)} "
2874
- f"{_dim(f'({provider}, {reason})', use_color)}"
2875
- )
2876
-
2877
-
2878
- def handle_can_resume(rest, ctx):
2879
- json_flag, args = _parse_json_flag(rest)
2880
- if len(args) != 1:
2881
- raise CdxError(CAN_RESUME_USAGE)
2882
- session = ctx["service"]["get_session"](args[0])
2883
- if not session:
2884
- raise CdxError(f"Unknown session: {args[0]}")
2885
- if session.get("enabled", True) is False:
2886
- capability = {
2887
- "session": session["name"],
2888
- "provider": session["provider"],
2889
- "resumable": False,
2890
- "strategy": "session_disabled",
2891
- "reason": "session_disabled",
2892
- "command_preview": [],
2893
- }
2894
- else:
2895
- capability = _resume_capability_for_session(session, ctx)
2896
- if json_flag:
2897
- _write_json(ctx, {
2898
- "schema_version": API_SCHEMA_VERSION,
2899
- "ok": True,
2900
- **capability,
2901
- })
2902
- return 0
2903
- ctx["out"](f"{_format_resume_capability(capability, ctx['use_color'])}\n")
2904
- return 0
2905
-
2906
-
2907
- def handle_resume(rest, ctx):
2908
- json_flag, args = _parse_json_flag(rest)
2909
- if len(args) != 1:
2910
- raise CdxError(RESUME_USAGE)
2911
- return handle_launch(args[0], ctx, resume=True, force_json=json_flag)
2912
-
2913
-
2914
- def _warn_if_session_already_running(name, ctx):
2915
- # ponytail: concurrent sessions on one profile share its single OAuth
2916
- # .credentials.json; both refresh near token expiry and the rotated refresh
2917
- # token invalidates the other session -> surprise logout. Warn before adding
2918
- # a second live session on the same profile.
2919
- active = ctx["service"].get("active_session_runtime")
2920
- runtime = active(name) if active else None
2921
- if not runtime:
2922
- return
2923
- pid = runtime.get("pid")
2924
- ctx["out"](f"{_warn(f'A session named {name} is already running (pid {pid}).', ctx['use_color'])}\n")
2925
- ctx["out"](
2926
- f"{_dim('Two sessions on one profile share its login and can log each other out. Use a different profile instead.', ctx['use_color'])}\n"
2927
- )
2928
- if not ctx["stdin_is_tty"]:
2929
- return
2930
- answer = input(f"Launch a second {name} session anyway? [y/N] ")
2931
- if answer.strip().lower() not in ("y", "yes"):
2932
- raise CdxError("Launch cancelled.")
2933
-
2934
61
 
2935
- def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=None):
2936
- json_flag = "--json" in ctx.get("raw_args", ctx["options"].get("raw_args", []))
2937
- if force_json is not None:
2938
- json_flag = force_json
2939
- warnings = _update_notice_warnings(ctx)
2940
- _warn_if_session_already_running(command, ctx)
2941
- session = ctx["service"]["launch_session"](command)
2942
- capability = _resume_capability_for_session(session, ctx) if resume else None
2943
- if capability and not capability["resumable"]:
2944
- raise CdxError(
2945
- f"Provider {session['provider']} does not support native resume through cdx."
2946
- )
2947
- _ensure_session_authentication(
2948
- session,
2949
- ctx["service"],
2950
- spawn=ctx.get("spawn"),
2951
- spawn_sync=ctx.get("spawn_sync"),
2952
- env_override=ctx.get("env"),
2953
- stdin_is_tty=ctx["stdin_is_tty"],
2954
- behavior="launch",
2955
- signal_emitter=ctx.get("signal_emitter"),
2956
- trust_local_credentials=False,
2957
- )
2958
- message = (
2959
- f"Resuming {session['provider']} session {session['name']}"
2960
- if resume else
2961
- f"Launching {session['provider']} session {session['name']}"
2962
- )
2963
- if not json_flag:
2964
- ctx["out"](f"{_info(message, ctx['use_color'])}\n")
2965
- _write_update_notice(ctx)
2966
- cwd = ctx.get("cwd") or os.getcwd()
2967
- runtime_run_id = None
62
+ # Re-exported for cli.py and tests; see src/commands/__init__.py.
63
+ from .commands.context_memory import ( # noqa: F401
64
+ _list_memory_entries,
65
+ _memory_payload,
66
+ _memory_target,
67
+ _parse_memory_args,
68
+ handle_context,
69
+ handle_memory,
70
+ )
71
+ from .commands.launch import ( # re-export for cli.py / tests
72
+ _format_resume_capability as _format_resume_capability,
73
+ )
74
+ from .commands.launch import (
75
+ handle_can_resume as handle_can_resume,
76
+ )
77
+ from .commands.launch import (
78
+ handle_handoff as handle_handoff,
79
+ )
80
+ from .commands.launch import (
81
+ handle_launch as handle_launch,
82
+ )
83
+ from .commands.launch import (
84
+ handle_resume as handle_resume,
85
+ )
86
+ from .commands.lifecycle import ( # re-export for cli.py / tests
87
+ _confirm_removal as _confirm_removal,
88
+ )
89
+ from .commands.lifecycle import (
90
+ handle_add as handle_add,
91
+ )
92
+ from .commands.lifecycle import (
93
+ handle_copy as handle_copy,
94
+ )
95
+ from .commands.lifecycle import (
96
+ handle_disable as handle_disable,
97
+ )
98
+ from .commands.lifecycle import (
99
+ handle_enable as handle_enable,
100
+ )
101
+ from .commands.lifecycle import (
102
+ handle_label as handle_label,
103
+ )
104
+ from .commands.lifecycle import (
105
+ handle_remove as handle_remove,
106
+ )
107
+ from .commands.lifecycle import (
108
+ handle_rename as handle_rename,
109
+ )
2968
110
 
2969
- def runtime_lifecycle(event, info):
2970
- nonlocal runtime_run_id
2971
- if event == "started":
2972
- runtime = ctx["service"]["start_session_runtime"](session["name"], info)
2973
- runtime_run_id = runtime.get("runId")
2974
- elif event == "finished" and runtime_run_id:
2975
- ctx["service"]["finish_session_runtime"](
2976
- session["name"],
2977
- runtime_run_id,
2978
- {"status": "stopped", "returncode": info.get("returncode")},
2979
- )
111
+ # Re-exported for cli.py and tests; see src/commands/__init__.py.
112
+ from .commands.maintenance import ( # noqa: F401
113
+ _candidate,
114
+ _clean_profile_old_logs,
115
+ _clean_profile_tmp,
116
+ _collect_old_logs,
117
+ _collect_profile_cleanup_candidates,
118
+ _confirm_log_cleanup,
119
+ _confirm_profile_cleanup,
120
+ _directory_child_sizes,
121
+ _directory_size_bytes,
122
+ _format_cleanup_candidates,
123
+ _format_disk_report,
124
+ _format_update_all,
125
+ _format_update_all_result,
126
+ _handle_clean_profiles,
127
+ _iter_profile_dirs,
128
+ _parse_days,
129
+ _remove_path,
130
+ handle_clean,
131
+ handle_disk,
132
+ handle_doctor,
133
+ handle_repair,
134
+ handle_update,
135
+ )
2980
136
 
2981
- try:
2982
- run_info = _run_interactive_provider_command(
2983
- session, "resume" if resume else "launch", spawn=ctx.get("spawn"), cwd=cwd, env_override=ctx.get("env"),
2984
- signal_emitter=ctx.get("signal_emitter"), initial_prompt=initial_prompt,
2985
- lifecycle_callback=runtime_lifecycle,
2986
- )
2987
- except CdxError as error:
2988
- run_info = getattr(error, "run_info", {}) or {}
2989
- if runtime_run_id:
2990
- ctx["service"]["finish_session_runtime"](
2991
- session["name"],
2992
- runtime_run_id,
2993
- {"status": "failed", "returncode": run_info.get("returncode")},
2994
- )
2995
- if run_info:
2996
- ctx["service"]["record_launch_history"](session["name"], {
2997
- "status": "failed",
2998
- "cwd": cwd,
2999
- "error": str(error),
3000
- "exit_code": error.exit_code,
3001
- **run_info,
3002
- })
3003
- raise
3004
- ctx["service"]["record_launch_history"](session["name"], {
3005
- "status": "success",
3006
- "action": "resume" if resume else "launch",
3007
- "cwd": cwd,
3008
- "exit_code": 0,
3009
- **run_info,
3010
- })
3011
- if json_flag:
3012
- extra = {"session": ctx["service"]["get_session"](session["name"])}
3013
- if capability:
3014
- extra["resume"] = capability
3015
- _write_json(ctx, _json_success("resume" if resume else "launch", message, warnings=warnings, **extra))
3016
- return 0
137
+ # Re-exported for cli.py and tests; see src/commands/__init__.py.
138
+ from .commands.runs import ( # noqa: F401
139
+ DETACHED_PROMPT_SUFFIX,
140
+ DETACHED_RUN_ID_ENV,
141
+ RUN_TAIL_READ_BYTES,
142
+ _cdx_self_command,
143
+ _consume_detached_prompt_file,
144
+ _detached_child_argv,
145
+ _detached_spawn_options,
146
+ _run_registry,
147
+ _select_headless_session,
148
+ _selection_reason,
149
+ _spawn_detached_run,
150
+ _tail_file_lines,
151
+ handle_run,
152
+ handle_run_report,
153
+ handle_run_status,
154
+ handle_run_tail,
155
+ handle_runs,
156
+ handle_schema,
157
+ handle_select,
158
+ )
3017
159
 
160
+ # Re-exported for cli.py and tests; see src/commands/__init__.py.
161
+ from .commands.settings import ( # re-export for cli.py / tests
162
+ _apply_launch_settings as _apply_launch_settings,
163
+ )
164
+ from .commands.settings import (
165
+ _clear_launch_settings as _clear_launch_settings,
166
+ )
167
+ from .commands.settings import (
168
+ _format_bulk_launch_summary as _format_bulk_launch_summary,
169
+ )
170
+ from .commands.settings import (
171
+ _resolve_bulk_launch_targets as _resolve_bulk_launch_targets,
172
+ )
173
+ from .commands.settings import (
174
+ handle_launch_setting_alias as handle_launch_setting_alias,
175
+ )
176
+ from .commands.settings import (
177
+ handle_set as handle_set,
178
+ )
179
+ from .commands.settings import (
180
+ handle_unset as handle_unset,
181
+ )
182
+ from .commands.status import ( # re-export for cli.py / tests
183
+ _active_session_names as _active_session_names,
184
+ )
185
+ from .commands.status import (
186
+ _format_duration_ms as _format_duration_ms,
187
+ )
188
+ from .commands.status import (
189
+ _format_history as _format_history,
190
+ )
191
+ from .commands.status import (
192
+ _format_history_period as _format_history_period,
193
+ )
194
+ from .commands.status import (
195
+ _format_history_summary as _format_history_summary,
196
+ )
197
+ from .commands.status import (
198
+ _format_launch_configs as _format_launch_configs,
199
+ )
200
+ from .commands.status import (
201
+ _format_next_pct as _format_next_pct,
202
+ )
203
+ from .commands.status import (
204
+ _format_next_selection as _format_next_selection,
205
+ )
206
+ from .commands.status import (
207
+ _format_period_display as _format_period_display,
208
+ )
209
+ from .commands.status import (
210
+ _format_refresh_warning as _format_refresh_warning,
211
+ )
212
+ from .commands.status import (
213
+ _format_stats as _format_stats,
214
+ )
215
+ from .commands.status import (
216
+ _format_token_count as _format_token_count,
217
+ )
218
+ from .commands.status import (
219
+ _has_valid_local_claude_auth as _has_valid_local_claude_auth,
220
+ )
221
+ from .commands.status import (
222
+ _is_invalid_claude_usage_auth as _is_invalid_claude_usage_auth,
223
+ )
224
+ from .commands.status import (
225
+ _next_action as _next_action,
226
+ )
227
+ from .commands.status import (
228
+ _refresh_claude_auth_states as _refresh_claude_auth_states,
229
+ )
230
+ from .commands.status import (
231
+ _refresh_warning_payloads as _refresh_warning_payloads,
232
+ )
233
+ from .commands.status import (
234
+ _resolve_last_launch_session as _resolve_last_launch_session,
235
+ )
236
+ from .commands.status import (
237
+ _rows_by_session as _rows_by_session,
238
+ )
239
+ from .commands.status import (
240
+ _stats_totals as _stats_totals,
241
+ )
242
+ from .commands.status import (
243
+ _summarize_history as _summarize_history,
244
+ )
245
+ from .commands.status import (
246
+ _summarize_stats as _summarize_stats,
247
+ )
248
+ from .commands.status import (
249
+ _token_value as _token_value,
250
+ )
251
+ from .commands.status import (
252
+ _write_refresh_warnings as _write_refresh_warnings,
253
+ )
254
+ from .commands.status import (
255
+ handle_config as handle_config,
256
+ )
257
+ from .commands.status import (
258
+ handle_configs as handle_configs,
259
+ )
260
+ from .commands.status import (
261
+ handle_history as handle_history,
262
+ )
263
+ from .commands.status import (
264
+ handle_last as handle_last,
265
+ )
266
+ from .commands.status import (
267
+ handle_next as handle_next,
268
+ )
269
+ from .commands.status import (
270
+ handle_stats as handle_stats,
271
+ )
272
+ from .commands.status import (
273
+ handle_status as handle_status,
274
+ )
3018
275
 
3019
- def handle_handoff(rest, ctx):
3020
- json_flag, args = _parse_json_flag(rest)
3021
- if len(args) not in (1, 2):
3022
- raise CdxError(HANDOFF_USAGE)
3023
- if len(args) == 1:
3024
- name = args[0]
3025
- session = ctx["service"]["get_session"](name)
3026
- if not session:
3027
- raise CdxError(f"Unknown session: {name}")
3028
- install = install_context_for_session(ctx["service"]["base_dir"], session, ctx.get("cwd"))
3029
- launch_prompt = _handoff_launch_prompt(session, install)
3030
- if json_flag:
3031
- _write_json(ctx, _json_success(
3032
- "handoff",
3033
- f"Installed shared context for {name}",
3034
- context=install,
3035
- launch_prompt=launch_prompt,
3036
- session=session,
3037
- ))
3038
- return 0
3039
- text = f"Shared context installed for {name}: {install['target_path']}"
3040
- ctx["out"](f"{_info(text, ctx['use_color'])}\n")
3041
- return handle_launch(name, ctx, initial_prompt=launch_prompt)
3042
276
 
3043
- source_name, target_name = args
3044
- if source_name == target_name:
3045
- raise CdxError("Source and target sessions must be different")
3046
- source = ctx["service"]["get_session"](source_name)
3047
- if not source:
3048
- raise CdxError(f"Unknown session: {source_name}")
3049
- target = ctx["service"]["get_session"](target_name)
3050
- if not target:
3051
- raise CdxError(f"Unknown session: {target_name}")
3052
- transcript_path = _latest_handoff_transcript_path(source)
3053
- if not transcript_path:
3054
- raise CdxError(f"No transcript found for session: {source_name}")
3055
- transcript, truncated = _read_handoff_transcript(transcript_path)
3056
- context = _build_handoff_context(source, target, transcript_path, transcript, truncated=truncated)
3057
- write_result = write_context(ctx["service"]["base_dir"], context, ctx.get("cwd"))
3058
- install = install_context_for_session(ctx["service"]["base_dir"], target, ctx.get("cwd"))
3059
- launch_prompt = _handoff_launch_prompt(target, install)
3060
- if json_flag:
3061
- _write_json(ctx, _json_success(
3062
- "handoff",
3063
- f"Prepared handoff from {source_name} to {target_name}",
3064
- context=install,
3065
- source_session=source,
3066
- target_session=target,
3067
- source_transcript=transcript_path,
3068
- shared_context=write_result,
3069
- launch_prompt=launch_prompt,
3070
- ))
3071
- return 0
3072
- text = f"Handoff prepared from {source_name} to {target_name}: {install['target_path']}"
3073
- ctx["out"](f"{_info(text, ctx['use_color'])}\n")
3074
- return handle_launch(target_name, ctx, initial_prompt=launch_prompt)