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.
@@ -5,14 +5,17 @@ Split out of cli_commands.py as the low-level, side-effect-light helper layer
5
5
  (no command dispatch). Imported back into cli_commands and used by handlers.
6
6
  """
7
7
 
8
+ import asyncio
8
9
  import json
9
10
  import os
10
11
  import re
11
12
  from datetime import datetime
12
13
 
13
- from .cli_render import _dim, _info, _warn
14
+ from .cli_render import _dim, _info, _pad_table, _style, _warn
14
15
  from .config import PROVIDER_CODEX
15
- from .provider_runtime import _list_launch_transcript_paths
16
+ from .errors import CdxError
17
+ from .fs_utils import atomic_write
18
+ from .provider_runtime import _list_launch_transcript_paths, _run_interactive_provider_command, get_resume_capability
16
19
  from .status_source import _normalize_terminal_transcript
17
20
 
18
21
  API_SCHEMA_VERSION = 1
@@ -383,3 +386,175 @@ def _handoff_launch_prompt(session, install=None):
383
386
  f"Read {context_ref} first, then resume the previous session "
384
387
  "from the latest actionable state. Do not ask me to paste the context again."
385
388
  )
389
+
390
+
391
+ def _resolve_confirmation(confirm_fn, name):
392
+ confirmed = (
393
+ asyncio.get_event_loop().run_until_complete(confirm_fn(name))
394
+ if asyncio.iscoroutinefunction(confirm_fn)
395
+ else confirm_fn(name)
396
+ )
397
+ if hasattr(confirmed, "__await__"):
398
+ confirmed = asyncio.get_event_loop().run_until_complete(confirmed)
399
+ return confirmed
400
+
401
+
402
+ def _extract_claude_oauth_token(text):
403
+ if not text:
404
+ return None
405
+ text = re.sub(r"\x1b\[[0-9;?]*[ -/]*[@-~]", "", str(text))
406
+ patterns = [
407
+ r"CLAUDE_CODE_OAUTH_TOKEN=([^\s\"']+)",
408
+ r"(sk-ant-oat[0-9A-Za-z._-]+)",
409
+ ]
410
+ for pattern in patterns:
411
+ match = re.search(pattern, text)
412
+ if match:
413
+ token = match.group(1).strip()
414
+ if token.startswith("<") or any(ord(ch) < 32 or ord(ch) == 127 for ch in token):
415
+ continue
416
+ return token
417
+ return None
418
+
419
+ def _write_claude_oauth_token(auth_home, token):
420
+ cred_dir = os.path.join(auth_home, "credentials")
421
+ os.makedirs(cred_dir, mode=0o700, exist_ok=True)
422
+ try:
423
+ os.chmod(cred_dir, 0o700)
424
+ except OSError:
425
+ pass
426
+ cred_path = os.path.join(cred_dir, "default.json")
427
+ payload = {
428
+ "version": "1.0",
429
+ "type": "oauth_token",
430
+ "access_token": token,
431
+ }
432
+ # atomic_write keeps the token 0o600 from creation — no umask window.
433
+ atomic_write(cred_path, json.dumps(payload, indent=2) + "\n", mode=0o600)
434
+ return cred_path
435
+
436
+ def _bootstrap_claude_setup_token(session, ctx):
437
+ ctx["out"](
438
+ "Claude login did not create isolated credentials; falling back to claude setup-token.\n"
439
+ )
440
+ run_info = _run_interactive_provider_command(
441
+ session,
442
+ "setup-token",
443
+ spawn=ctx.get("spawn"),
444
+ env_override=ctx.get("env"),
445
+ signal_emitter=ctx.get("signal_emitter"),
446
+ )
447
+ transcript_path = run_info.get("transcript_path")
448
+ if not transcript_path or not os.path.isfile(transcript_path):
449
+ raise CdxError(
450
+ "Claude setup-token completed, but cdx could not capture the token. "
451
+ "Run claude setup-token and save the token under credentials/default.json."
452
+ )
453
+ try:
454
+ with open(transcript_path, encoding="utf-8", errors="replace") as handle:
455
+ transcript = handle.read()
456
+ finally:
457
+ # The transcript holds the ~1-year OAuth token in cleartext; it must
458
+ # not outlive the extraction, whether it succeeds or not.
459
+ try:
460
+ os.remove(transcript_path)
461
+ except OSError:
462
+ pass
463
+ token = _extract_claude_oauth_token(transcript)
464
+ if not token:
465
+ raise CdxError(
466
+ "Claude setup-token completed, but cdx could not find CLAUDE_CODE_OAUTH_TOKEN in the output. "
467
+ "Run claude setup-token manually and save the token under credentials/default.json."
468
+ )
469
+ auth_home = session.get("authHome") or ""
470
+ cred_path = _write_claude_oauth_token(auth_home, token)
471
+ # ponytail: drop short-lived claude login creds so the ~1yr token wins at launch (provider_runtime _read_claude_launch_oauth_token)
472
+ try:
473
+ os.remove(os.path.join(auth_home, ".claude", ".credentials.json"))
474
+ except OSError:
475
+ pass
476
+ return cred_path
477
+
478
+ def _format_launch_config(session, use_color=False):
479
+ from .cli_render import _dim, _style
480
+
481
+ launch = session.get("launch") or {}
482
+ rows = [[_style("SETTING", "1", use_color), _style("VALUE", "1", use_color)]]
483
+ for key, label in [
484
+ ("power", "Power"),
485
+ ("permission", "Permission"),
486
+ ("fast", "Fast"),
487
+ ("rtk", "RTK"),
488
+ ("logics", "Logics"),
489
+ ("model", "Model"),
490
+ ("priority", "Priority"),
491
+ ]:
492
+ rows.append([
493
+ _dim(label, use_color),
494
+ _format_launch_setting_value(launch, key, use_color=use_color),
495
+ ])
496
+ provider_label = f"({session['provider']})"
497
+ return "\n".join([
498
+ _style("Launch settings:", "1", use_color),
499
+ f"{_style(session['name'], '36', use_color)} {_dim(provider_label, use_color)}",
500
+ _pad_table(rows),
501
+ "",
502
+ _dim(_format_launch_settings_hint(session["name"]), use_color),
503
+ ])
504
+
505
+ def _format_launch_settings_hint(name="<name>"):
506
+ return (
507
+ f"Set a value: cdx set {name} --power medium --permission auto "
508
+ "--fast on --rtk on --logics on --model MODEL --priority 80"
509
+ )
510
+
511
+ def _format_launch_setting_value(launch, key, use_color=False):
512
+ if key in ("fast", "rtk", "logics"):
513
+ if launch.get(key) is True:
514
+ return _style("on", "32", use_color)
515
+ if launch.get(key) is False:
516
+ return _style("off", "2", use_color)
517
+ if key == "logics":
518
+ return _dim("auto", use_color)
519
+ return _dim("default", use_color)
520
+ value = launch.get(key)
521
+ if value is None or value == "":
522
+ return _dim("default", use_color)
523
+ if key == "priority":
524
+ return _style(str(value), "33", use_color)
525
+ if key == "power":
526
+ return _style(str(value), "96", use_color)
527
+ if key == "permission":
528
+ return _style(str(value), "32", use_color)
529
+ return str(value)
530
+
531
+ def _resume_capability_for_session(session, ctx):
532
+ capability = get_resume_capability(session, cwd=ctx.get("cwd") or os.getcwd())
533
+ return {
534
+ "session": session["name"],
535
+ "provider": session["provider"],
536
+ "resumable": bool(capability.get("resumable")),
537
+ "strategy": capability.get("strategy"),
538
+ "reason": capability.get("reason"),
539
+ "command_preview": capability.get("command_preview") or [],
540
+ }
541
+
542
+ def _warn_if_session_already_running(name, ctx):
543
+ # ponytail: concurrent sessions on one profile share its single OAuth
544
+ # .credentials.json; both refresh near token expiry and the rotated refresh
545
+ # token invalidates the other session -> surprise logout. Warn before adding
546
+ # a second live session on the same profile.
547
+ active = ctx["service"].get("active_session_runtime")
548
+ runtime = active(name) if active else None
549
+ if not runtime:
550
+ return
551
+ pid = runtime.get("pid")
552
+ ctx["out"](f"{_warn(f'A session named {name} is already running (pid {pid}).', ctx['use_color'])}\n")
553
+ ctx["out"](
554
+ 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"
555
+ )
556
+ if not ctx["stdin_is_tty"]:
557
+ return
558
+ answer = input(f"Launch a second {name} session anyway? [y/N] ")
559
+ if answer.strip().lower() not in ("y", "yes"):
560
+ raise CdxError("Launch cancelled.")
@@ -0,0 +1,6 @@
1
+ """Per-domain command modules.
2
+
3
+ Each module owns one group of `handle_*` entry points plus the helpers used
4
+ only by that group. `cli_commands` re-exports them so existing imports of
5
+ `src.cli_commands` keep resolving; see `src/cli.py` for the same convention.
6
+ """
@@ -0,0 +1,224 @@
1
+ """Authentication domain commands: login, logout, reset, notify.
2
+
3
+ Split out of cli_commands.py. Moved verbatim; re-exported by cli_commands.
4
+ """
5
+
6
+ import time
7
+ import uuid
8
+
9
+ from ..cli_args import _parse_flag_args, _parse_json_flag
10
+ from ..cli_helpers import (
11
+ _bootstrap_claude_setup_token,
12
+ _json_success,
13
+ _local_now_iso,
14
+ _make_notify_progress,
15
+ _resolve_confirmation,
16
+ _write_json,
17
+ )
18
+ from ..cli_render import _success, _warn
19
+ from ..codex_usage import consume_codex_rate_limit_reset_credit
20
+ from ..config import PROVIDER_ANTIGRAVITY, PROVIDER_CLAUDE, PROVIDER_CODEX, PROVIDER_OLLAMA
21
+ from ..errors import CdxError
22
+ from ..notify import (
23
+ format_notify_event,
24
+ format_scheduled_notification,
25
+ parse_notify_args,
26
+ resolve_notify_event,
27
+ schedule_notification_event,
28
+ send_desktop_notification,
29
+ wait_for_notification_event,
30
+ )
31
+ from ..provider_runtime import _ensure_session_authentication, _run_interactive_provider_command
32
+
33
+
34
+ def _confirm_reset(name):
35
+ answer = input(f"Consume one banked Codex reset for {name}? [y/N] ")
36
+ return answer.strip().lower() in ("y", "yes")
37
+
38
+ def handle_notify(rest, ctx):
39
+ parsed = parse_notify_args(rest)
40
+
41
+ def notifier(title, message):
42
+ send_desktop_notification(
43
+ title,
44
+ message,
45
+ spawn_sync=ctx.get("spawn_sync"),
46
+ env=ctx.get("env"),
47
+ )
48
+
49
+ if parsed["schedule"]:
50
+ event = resolve_notify_event(
51
+ ctx["service"]["get_status_rows"](
52
+ progress_callback=None if parsed["json"] else _make_notify_progress(ctx),
53
+ force_refresh=parsed.get("refresh", False),
54
+ ),
55
+ parsed,
56
+ (ctx["options"].get("now") or time.time)(),
57
+ )
58
+ if event["ready"]:
59
+ notifier(event["title"], event["message"])
60
+ schedule = {
61
+ "scheduled": False,
62
+ "backend": "immediate",
63
+ "message": event["message"],
64
+ "target_timestamp": event.get("target_timestamp"),
65
+ }
66
+ else:
67
+ schedule = schedule_notification_event(
68
+ ctx["service"]["base_dir"],
69
+ parsed,
70
+ event,
71
+ spawn_sync=ctx.get("spawn_sync"),
72
+ env=ctx.get("env"),
73
+ now_fn=ctx["options"].get("now"),
74
+ )
75
+ if parsed["json"]:
76
+ _write_json(ctx, _json_success("notify", "Scheduled notification event", event=event, schedule=schedule))
77
+ else:
78
+ ctx["out"](f"{format_scheduled_notification(schedule)}\n")
79
+ return 0
80
+
81
+ event = wait_for_notification_event(
82
+ ctx["service"],
83
+ parsed,
84
+ notifier=notifier,
85
+ sleep_fn=ctx["options"].get("sleep"),
86
+ now_fn=ctx["options"].get("now"),
87
+ progress_callback=None if parsed["json"] else _make_notify_progress(ctx),
88
+ )
89
+ if parsed["json"]:
90
+ _write_json(ctx, _json_success("notify", "Resolved notification event", event=event))
91
+ else:
92
+ ctx["out"](f"{format_notify_event(event)}\n")
93
+ return 0
94
+
95
+ def handle_reset(rest, ctx):
96
+ parsed = _parse_flag_args(rest, {
97
+ "--json": {"key": "json", "type": "bool", "default": False},
98
+ "--yes": {"key": "yes", "type": "bool", "default": False},
99
+ }, "Usage: cdx reset <name> [--yes] [--json]", positionals_key="args", max_positionals=1)
100
+ if len(parsed["args"]) != 1:
101
+ raise CdxError("Usage: cdx reset <name> [--yes] [--json]")
102
+ name = parsed["args"][0]
103
+ session = ctx["service"]["get_session"](name)
104
+ if not session:
105
+ raise CdxError(f"Unknown session: {name}")
106
+ if session["provider"] != PROVIDER_CODEX:
107
+ raise CdxError("Banked rate-limit resets are only available for Codex sessions.")
108
+
109
+ status = ctx["service"]["get_status_row"](name, force_refresh=True)
110
+ if status.get("reset_credits_available") == 0:
111
+ raise CdxError(f"No banked Codex reset is available for {name}.")
112
+ if not parsed["yes"]:
113
+ confirm_fn = ctx["options"].get("confirmReset")
114
+ if confirm_fn:
115
+ confirmed = _resolve_confirmation(confirm_fn, name)
116
+ elif not ctx["stdin_is_tty"]:
117
+ raise CdxError("Reset activation requires an interactive terminal or --yes in non-interactive mode.")
118
+ else:
119
+ confirmed = _confirm_reset(name)
120
+ if not confirmed:
121
+ if parsed["json"]:
122
+ _write_json(ctx, _json_success("reset", "Cancelled.", cancelled=True, session=name))
123
+ return 0
124
+ ctx["out"](f"{_warn('Cancelled.', ctx['use_color'])}\n")
125
+ return 0
126
+
127
+ credit_rows = status.get("reset_credits") or []
128
+ credit_id = credit_rows[0].get("id") if credit_rows else None
129
+ consumer = ctx["options"].get("consumeCodexReset") or consume_codex_rate_limit_reset_credit
130
+ result = consumer(
131
+ session,
132
+ ctx["options"].get("resetIdempotencyKey") or str(uuid.uuid4()),
133
+ credit_id=credit_id,
134
+ )
135
+ if not result.get("ok"):
136
+ if result.get("reason") == "reset_consume_failed":
137
+ raise CdxError(
138
+ f"Unable to activate Codex reset for {name}. "
139
+ "The installed Codex version or account may not support reset activation."
140
+ )
141
+ raise CdxError(f"Unable to activate Codex reset for {name}: {result.get('reason') or 'unknown error'}")
142
+ outcome = result.get("outcome")
143
+ if outcome == "noCredit":
144
+ raise CdxError(f"No banked Codex reset is available for {name}.")
145
+ if outcome == "nothingToReset":
146
+ raise CdxError(f"No current Codex rate-limit window is eligible for reset on {name}.")
147
+ if outcome not in ("reset", "alreadyRedeemed"):
148
+ raise CdxError(f"Unexpected Codex reset outcome for {name}: {outcome or 'missing'}")
149
+ refreshed = ctx["service"]["get_status_row"](name, force_refresh=True)
150
+ message = f"Activated banked Codex reset for {name}"
151
+ if parsed["json"]:
152
+ _write_json(ctx, _json_success("reset", message, cancelled=False, outcome=outcome, session=refreshed))
153
+ return 0
154
+ ctx["out"](f"{_success(message, ctx['use_color'])}\n")
155
+ return 0
156
+
157
+ def handle_login(rest, ctx):
158
+ rest = [arg for arg in rest if arg != "--setup-token"] # accepted for back-compat; setup-token is now the default for Claude
159
+ json_flag, args = _parse_json_flag(rest)
160
+ if len(args) != 1:
161
+ raise CdxError("Usage: cdx login <name> [--json]")
162
+ if not ctx["stdin_is_tty"]:
163
+ raise CdxError("Login requires an interactive terminal.")
164
+ session = ctx["service"]["get_session"](args[0])
165
+ if not session:
166
+ raise CdxError(f"Unknown session: {args[0]}")
167
+ if session["provider"] == PROVIDER_CLAUDE:
168
+ # ponytail: setup-token mints a ~1yr token; claude login mints a ~daily one that forces reconnects
169
+ _bootstrap_claude_setup_token(session, ctx)
170
+ else:
171
+ _run_interactive_provider_command(
172
+ session, "login", spawn=ctx.get("spawn"), env_override=ctx.get("env"),
173
+ signal_emitter=ctx.get("signal_emitter")
174
+ )
175
+ auth_probe = _ensure_session_authentication(
176
+ session,
177
+ ctx["service"],
178
+ spawn_sync=ctx.get("spawn_sync"),
179
+ env_override=ctx.get("env"),
180
+ behavior="probe-only",
181
+ trust_local_credentials=False,
182
+ )
183
+ if not auth_probe.get("authenticated"):
184
+ raise CdxError(
185
+ f"Login command completed, but session {session['name']} is still not authenticated."
186
+ )
187
+ now = _local_now_iso()
188
+ ctx["service"]["update_auth_state"](args[0], lambda auth: {
189
+ **auth, "status": "authenticated",
190
+ "lastCheckedAt": now, "lastAuthenticatedAt": now,
191
+ })
192
+ message = f"Reauthenticated session {session['name']} ({session['provider']})"
193
+ if json_flag:
194
+ _write_json(ctx, _json_success("login", message, session=ctx["service"]["get_session"](session["name"])))
195
+ return 0
196
+ ctx["out"](f"{_success(message, ctx['use_color'])}\n")
197
+ return 0
198
+
199
+ def handle_logout(rest, ctx):
200
+ json_flag, args = _parse_json_flag(rest)
201
+ if len(args) != 1:
202
+ raise CdxError("Usage: cdx logout <name> [--json]")
203
+ session = ctx["service"]["get_session"](args[0])
204
+ if not session:
205
+ raise CdxError(f"Unknown session: {args[0]}")
206
+ if session["provider"] == PROVIDER_ANTIGRAVITY:
207
+ raise CdxError("Antigravity logout is managed inside agy. Launch the session and run /logout.")
208
+ if session["provider"] == PROVIDER_OLLAMA:
209
+ raise CdxError("Ollama sessions do not use cdx-managed authentication.")
210
+ _run_interactive_provider_command(
211
+ session, "logout", spawn=ctx.get("spawn"), env_override=ctx.get("env"),
212
+ signal_emitter=ctx.get("signal_emitter")
213
+ )
214
+ now = _local_now_iso()
215
+ ctx["service"]["update_auth_state"](args[0], lambda auth: {
216
+ **auth, "status": "logged_out",
217
+ "lastCheckedAt": now, "lastLoggedOutAt": now,
218
+ })
219
+ message = f"Logged out session {session['name']} ({session['provider']})"
220
+ if json_flag:
221
+ _write_json(ctx, _json_success("logout", message, session=ctx["service"]["get_session"](session["name"])))
222
+ return 0
223
+ ctx["out"](f"{_success(message, ctx['use_color'])}\n")
224
+ return 0
@@ -0,0 +1,112 @@
1
+ """Backup domain commands: export, import.
2
+
3
+ Split out of cli_commands.py. Moved verbatim; re-exported by cli_commands.
4
+ """
5
+
6
+ import getpass
7
+ import sys
8
+
9
+ from ..backup_bundle import read_bundle_meta
10
+ from ..cli_args import _parse_export_args, _parse_import_args
11
+ from ..cli_helpers import _format_export_report, _json_success, _make_export_progress, _write_json
12
+ from ..cli_render import _success
13
+ from ..errors import CdxError
14
+
15
+
16
+ def _resolve_bundle_passphrase(ctx, env_var, prompt, confirm=False, use_stdin=False):
17
+ env = ctx.get("env", {})
18
+ if env_var:
19
+ passphrase = env.get(env_var)
20
+ if not passphrase:
21
+ raise CdxError(f"Environment variable {env_var} is empty or unset.")
22
+ return passphrase
23
+ if use_stdin:
24
+ reader = ctx["options"].get("read_stdin")
25
+ raw = reader() if callable(reader) else sys.stdin.readline()
26
+ passphrase = (raw or "").rstrip("\r\n")
27
+ if not passphrase:
28
+ raise CdxError("Bundle passphrase from stdin is empty.")
29
+ return passphrase
30
+ if not ctx["stdin_is_tty"]:
31
+ raise CdxError("Encrypted bundle export/import requires an interactive terminal, --passphrase-env, or --passphrase-stdin.")
32
+ getpass_fn = ctx["options"].get("getpass") or getpass.getpass
33
+ passphrase = getpass_fn(prompt)
34
+ if not passphrase:
35
+ raise CdxError("Bundle passphrase cannot be empty.")
36
+ if confirm:
37
+ confirmation = getpass_fn("Confirm bundle passphrase: ")
38
+ if passphrase != confirmation:
39
+ raise CdxError("Bundle passphrase confirmation does not match.")
40
+ return passphrase
41
+
42
+ def handle_export(rest, ctx):
43
+ parsed = _parse_export_args(rest)
44
+ passphrase = None
45
+ if parsed["include_auth"]:
46
+ passphrase = _resolve_bundle_passphrase(
47
+ ctx,
48
+ parsed["passphrase_env"],
49
+ "Bundle passphrase: ",
50
+ confirm=True,
51
+ use_stdin=parsed["passphrase_stdin"],
52
+ )
53
+ result = ctx["service"]["export_bundle"](
54
+ parsed["file_path"],
55
+ include_auth=parsed["include_auth"],
56
+ session_names=parsed["session_names"],
57
+ passphrase=passphrase,
58
+ force=parsed["force"],
59
+ progress_callback=None if parsed["json"] else _make_export_progress(ctx),
60
+ )
61
+ session_count = len(result["session_names"])
62
+ auth_suffix = " with auth" if result["include_auth"] else ""
63
+ message = f"Exported {session_count} session{'s' if session_count != 1 else ''}{auth_suffix} to {result['path']}"
64
+ payload = _json_success(
65
+ "export",
66
+ message,
67
+ bundle=result,
68
+ )
69
+ if parsed["json"]:
70
+ _write_json(ctx, payload)
71
+ return 0
72
+ ctx["out"](f"{_success(message, ctx['use_color'])}\n")
73
+ ctx["out"](f"{_format_export_report(result)}\n")
74
+ return 0
75
+
76
+ def handle_import(rest, ctx):
77
+ parsed = _parse_import_args(rest)
78
+ passphrase = None
79
+ try:
80
+ with open(parsed["file_path"], "rb") as handle:
81
+ meta = read_bundle_meta(handle.read())
82
+ except OSError as error:
83
+ raise CdxError(f"Bundle file not found: {parsed['file_path']}") from error
84
+ if meta.get("encrypted"):
85
+ passphrase = _resolve_bundle_passphrase(
86
+ ctx,
87
+ parsed["passphrase_env"],
88
+ "Bundle passphrase: ",
89
+ confirm=False,
90
+ use_stdin=parsed["passphrase_stdin"],
91
+ )
92
+ result = ctx["service"]["import_bundle"](
93
+ parsed["file_path"],
94
+ passphrase=passphrase,
95
+ session_names=parsed["session_names"],
96
+ force=parsed["force"],
97
+ merge=parsed["merge"],
98
+ allow_authless_force=parsed["allow_authless_force"],
99
+ )
100
+ session_count = len(result["session_names"])
101
+ auth_suffix = " with auth" if result["include_auth"] else ""
102
+ message = f"Imported {session_count} session{'s' if session_count != 1 else ''}{auth_suffix} from {result['path']}"
103
+ payload = _json_success(
104
+ "import",
105
+ message,
106
+ bundle=result,
107
+ )
108
+ if parsed["json"]:
109
+ _write_json(ctx, payload)
110
+ return 0
111
+ ctx["out"](f"{_success(message, ctx['use_color'])}\n")
112
+ return 0