cdx-manager 0.9.9 → 0.9.11

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.
@@ -6,11 +6,69 @@ import re
6
6
  import shlex
7
7
  import sys
8
8
  import time
9
- from datetime import datetime, timedelta
9
+ from datetime import datetime
10
10
 
11
+ from .backup_bundle import read_bundle_meta
11
12
  from .claude_refresh import _refresh_claude_sessions
13
+ from .cli_args import (
14
+ CAN_RESUME_USAGE,
15
+ CONTEXT_USAGE,
16
+ DOCTOR_USAGE,
17
+ HANDOFF_USAGE,
18
+ LAST_USAGE,
19
+ REPAIR_USAGE,
20
+ RESUME_USAGE,
21
+ RUN_REPORT_USAGE,
22
+ RUN_STATUS_USAGE,
23
+ RUNS_USAGE,
24
+ STATUS_USAGE,
25
+ _filter_history_period,
26
+ _has_history_period,
27
+ _parse_add_args,
28
+ _parse_config_args,
29
+ _parse_configs_args,
30
+ _parse_copy_args,
31
+ _parse_export_args,
32
+ _parse_flag_args,
33
+ _parse_history_args,
34
+ _parse_import_args,
35
+ _parse_json_flag,
36
+ _parse_launch_setting_alias_args,
37
+ _parse_next_args,
38
+ _parse_remove_args,
39
+ _parse_rename_args,
40
+ _parse_run_args,
41
+ _parse_run_id_json_args,
42
+ _parse_runs_args,
43
+ _parse_select_args,
44
+ _parse_set_args,
45
+ _parse_stats_args,
46
+ _parse_timeout_seconds,
47
+ _parse_toggle_args,
48
+ _parse_unset_args,
49
+ _parse_update_args,
50
+ _public_history_period,
51
+ )
52
+ from .cli_helpers import (
53
+ API_SCHEMA_VERSION,
54
+ _build_handoff_context,
55
+ _format_export_report,
56
+ _handoff_launch_prompt,
57
+ _json_failure,
58
+ _json_success,
59
+ _latest_handoff_transcript_path,
60
+ _local_now_iso,
61
+ _make_export_progress,
62
+ _make_notify_progress,
63
+ _make_status_progress,
64
+ _read_handoff_transcript,
65
+ _update_notice_warnings,
66
+ _write_json,
67
+ _write_update_notice,
68
+ )
12
69
  from .cli_render import _dim, _info, _style, _success, _warn
13
- from .config import PROVIDER_ANTIGRAVITY, PROVIDER_CLAUDE, PROVIDER_CODEX, PROVIDER_OLLAMA, PROVIDERS
70
+ from .cli_view import handle_view as handle_view # re-export for cli.py / tests
71
+ from .config import PROVIDER_ANTIGRAVITY, PROVIDER_CLAUDE, PROVIDER_OLLAMA
14
72
  from .context_store import (
15
73
  clear_context,
16
74
  edit_context,
@@ -22,7 +80,6 @@ from .context_store import (
22
80
  )
23
81
  from .errors import CdxError
24
82
  from .health import collect_health_report, format_health_report
25
- from .cli_view import handle_view
26
83
  from .notify import (
27
84
  format_notify_event,
28
85
  format_scheduled_notification,
@@ -36,992 +93,20 @@ from .provider_runtime import (
36
93
  _ensure_session_authentication,
37
94
  _headless_artifact_paths,
38
95
  _list_launch_transcript_paths,
39
- _normalize_reasoning_effort,
40
96
  _probe_provider_auth,
41
- get_resume_capability,
42
97
  _run_headless_provider_command,
43
98
  _run_interactive_provider_command,
99
+ get_resume_capability,
44
100
  )
45
101
  from .repair import format_repair_report, repair_health
46
- from .backup_bundle import read_bundle_meta
102
+ from .run_command import read_run_prompt, run_cdx_error_code, run_result_payload
47
103
  from .run_registry import RunRegistry, build_code_review_report
48
104
  from .run_usage import extract_run_usage
49
- from .run_command import read_run_prompt, run_cdx_error_code, run_result_payload
50
105
  from .status_view import _format_status_detail, _format_status_rows, format_priority_instruction, recommend_priority_rows
51
106
  from .update_check import LatestReleaseCheckError, fetch_latest_release, fetch_latest_release_or_raise, is_newer_version
52
107
  from .update_manager import build_update_plan, format_update_failure, run_update_plan, verify_updated_command
53
108
 
54
109
 
55
- STATUS_USAGE = "Usage: cdx status [--json] [--refresh|--cached] [--timeout SECONDS] | cdx status --small|-s [--refresh|--cached] [--timeout SECONDS] | cdx status <name> [--json] [--refresh|--cached] [--timeout SECONDS]"
56
- DOCTOR_USAGE = "Usage: cdx doctor [--json]"
57
- REPAIR_USAGE = "Usage: cdx repair [--dry-run] [--force] [--json]"
58
- UPDATE_USAGE = "Usage: cdx update [--check] [--yes] [--json] [--version TAG]"
59
- EXPORT_USAGE = "Usage: cdx export <file> [--include-auth] [--force] [--json] [--sessions name1,name2] [--passphrase-env VAR]"
60
- IMPORT_USAGE = "Usage: cdx import <file> [--force|--merge] [--json] [--sessions name1,name2] [--passphrase-env VAR]"
61
- CONTEXT_USAGE = "Usage: cdx context show|path|init|edit|clear|set [text...] [--json]"
62
- HANDOFF_USAGE = "Usage: cdx handoff <name> [--json] | cdx handoff <source> <target> [--json]"
63
- SET_USAGE = "Usage: cdx set <name>|--sessions all|a,b|--provider PROVIDER [--power minimal|low|medium|high|xhigh] [--permission review|default|auto|full] [--fast on|off] [--rtk on|off] [--logics on|off] [--model MODEL] [--priority 0..100] [--json]"
64
- UNSET_USAGE = "Usage: cdx unset <name>|--sessions all|a,b|--provider PROVIDER (--power|--permission|--fast|--rtk|--logics|--model|--priority|--all) [--json]"
65
- SETTING_ALIAS_USAGE = "Usage: cdx power|perm|fast|model <name|all|provider:PROVIDER|a,b> <value|default> [--json]"
66
- CONFIG_USAGE = "Usage: cdx config <name> [--json]"
67
- CONFIGS_USAGE = "Usage: cdx configs [--json]"
68
- HISTORY_USAGE = "Usage: cdx history [name] [--limit N] [--summary] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--json]"
69
- STATS_USAGE = "Usage: cdx stats [name] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--json]"
70
- LAST_USAGE = "Usage: cdx last [--json]"
71
- RESUME_USAGE = "Usage: cdx resume <name> [--json]"
72
- CAN_RESUME_USAGE = "Usage: cdx can-resume <name> [--json]"
73
- SELECT_USAGE = "Usage: cdx select --provider PROVIDER [--min-reasoning-effort minimal|low|medium|high|xhigh] [--min-power minimal|low|medium|high|xhigh] [--require-ready] [--refresh] --json"
74
- NEXT_USAGE = "Usage: cdx next [--json] [--refresh]"
75
- RUN_USAGE = "Usage: cdx run [session] --cwd PATH (--prompt-file PATH|--prompt TEXT) [--provider PROVIDER] [--model MODEL] [--kind assistant|code-review] [--reasoning-effort minimal|low|medium|high|xhigh] [--power minimal|low|medium|high|xhigh] [--permission review|default|auto|full|workspace-write|read-only|danger-full-access] [--timeout-seconds N] --json"
76
- RUNS_USAGE = "Usage: cdx runs [--limit N] --json"
77
- RUN_STATUS_USAGE = "Usage: cdx run-status <run_id> --json"
78
- RUN_REPORT_USAGE = "Usage: cdx run-report <run_id> --json"
79
- API_SCHEMA_VERSION = 1
80
- HANDOFF_TRANSCRIPT_CHARS = 120000
81
- HANDOFF_NATIVE_TRANSCRIPT_CANDIDATES = 64
82
-
83
-
84
- def _local_now_iso():
85
- return datetime.now().astimezone().isoformat()
86
-
87
-
88
- def _json_success(action, message, warnings=None, **extra):
89
- payload = {
90
- "schema_version": API_SCHEMA_VERSION,
91
- "ok": True,
92
- "action": action,
93
- "message": message,
94
- "warnings": warnings or [],
95
- }
96
- payload.update(extra)
97
- return payload
98
-
99
-
100
- def _json_failure(action, code, message, source="cdx", exit_code=1, warnings=None, **extra):
101
- payload = {
102
- "schema_version": API_SCHEMA_VERSION,
103
- "ok": False,
104
- "action": action,
105
- "warnings": warnings or [],
106
- "error": {
107
- "source": source,
108
- "code": code,
109
- "message": message,
110
- "exit_code": exit_code,
111
- },
112
- }
113
- payload.update(extra)
114
- return payload
115
-
116
-
117
- def _write_json(ctx, payload):
118
- ctx["out"](f"{json.dumps(payload, indent=2)}\n")
119
-
120
-
121
- def _update_notice_warning(ctx):
122
- notices = ctx.get("update_notices") or []
123
- if not notices:
124
- return None
125
- warnings = _update_notice_warnings(ctx)
126
- return warnings[0] if warnings else None
127
-
128
-
129
- def _update_notice_warnings(ctx):
130
- warnings = []
131
- for notice in ctx.get("update_notices") or []:
132
- tool = notice.get("tool") or "cdx-manager"
133
- current = notice.get("current_version") or ctx.get("version")
134
- command = notice.get("update_command") or ("cdx update" if tool == "cdx-manager" else None)
135
- message = f"Update available: {tool} {notice['latest_version']}"
136
- if current:
137
- message = f"{message} (current {current})"
138
- if command:
139
- message = f"{message}. Run: {command}"
140
- warnings.append({
141
- "code": "update_available" if tool == "cdx-manager" else f"{tool.replace('-', '_')}_update_available",
142
- "message": message,
143
- "tool": tool,
144
- "latest_version": notice["latest_version"],
145
- "current_version": current,
146
- "update_command": command,
147
- "url": notice.get("url"),
148
- })
149
- return warnings
150
-
151
-
152
- def _write_update_notice(ctx):
153
- warnings = _update_notice_warnings(ctx)
154
- if not warnings:
155
- return
156
- for warning in warnings:
157
- ctx["out"](f"{_warn(warning['message'], ctx['use_color'])}\n")
158
-
159
-
160
- def _format_bytes(value):
161
- if value is None:
162
- return "n/a"
163
- try:
164
- amount = float(value)
165
- except (TypeError, ValueError):
166
- return str(value)
167
- units = ["B", "KB", "MB", "GB", "TB"]
168
- unit = units[0]
169
- for unit in units:
170
- if amount < 1024 or unit == units[-1]:
171
- break
172
- amount /= 1024
173
- if unit == "B":
174
- return f"{int(amount)} B"
175
- return f"{amount:.1f} {unit}"
176
-
177
-
178
- def _format_export_report(result):
179
- lines = [
180
- f"Path: {result['path']}",
181
- f"Sessions: {', '.join(result['session_names']) or '-'}",
182
- f"Auth: {'included and encrypted' if result['include_auth'] else 'not included'}",
183
- f"Bundle size: {_format_bytes(result.get('bundle_size_bytes'))}",
184
- ]
185
- if result.get("include_auth"):
186
- lines.extend([
187
- f"Auth files: {result.get('profile_file_count', 0)}",
188
- f"Auth data: {_format_bytes(result.get('profile_bytes'))}",
189
- ])
190
- return "\n".join(lines)
191
-
192
-
193
- def _make_export_progress(ctx):
194
- def progress(event):
195
- kind = event.get("event")
196
- if kind == "export_started":
197
- auth = " with auth" if event.get("include_auth") else ""
198
- message = f"Exporting {event.get('session_count', 0)} session(s){auth}..."
199
- ctx["out"](f"{_info(message, ctx['use_color'])}\n")
200
- elif kind == "session_started":
201
- message = f"Collecting {event.get('session_name')}..."
202
- ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
203
- elif kind == "profile_progress":
204
- message = f" {event.get('session_name')}: {event.get('file_count', 0)} files, {_format_bytes(event.get('bytes'))}"
205
- ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
206
- elif kind == "encoding_started":
207
- ctx["out"](f"{_dim('Encoding and encrypting bundle...', ctx['use_color'])}\n")
208
- elif kind == "writing_started":
209
- message = f"Writing {_format_bytes(event.get('bundle_size_bytes'))}..."
210
- ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
211
- return progress
212
-
213
-
214
- def _make_status_progress(ctx):
215
- progress_state = {"checked": 0, "total": 0}
216
-
217
- def progress(event):
218
- kind = event.get("event")
219
- if kind == "status_started":
220
- progress_state["checked"] = 0
221
- progress_state["total"] = event.get("check_count", event.get("session_count", 0)) or 0
222
- message = f"Resolving status for {event.get('session_count', 0)} session(s)..."
223
- ctx["out"](f"{_info(message, ctx['use_color'])}\n")
224
- elif kind == "session_started":
225
- provider = event.get("provider") or "session"
226
- message = f"Checking {event.get('session_name')} ({provider})..."
227
- ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
228
- elif kind == "session_finished" and not event.get("cache_hit"):
229
- progress_state["checked"] += 1
230
- total = progress_state["total"] or progress_state["checked"]
231
- message = f"Checked {event.get('session_name')} ({progress_state['checked']}/{total})."
232
- ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
233
- elif kind == "status_finished":
234
- message = f"Resolved {event.get('row_count', 0)} status row(s)."
235
- ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
236
- return progress
237
-
238
-
239
- def _make_notify_progress(ctx):
240
- progress_state = {"checked": 0, "total": 0}
241
-
242
- def progress(event):
243
- kind = event.get("event")
244
- if kind == "notify_check_started":
245
- target = event.get("session_name") or "next ready session"
246
- ctx["out"](f"{_info(f'Checking notification target: {target}...', ctx['use_color'])}\n")
247
- elif kind == "status_started":
248
- progress_state["checked"] = 0
249
- progress_state["total"] = event.get("check_count", event.get("session_count", 0)) or 0
250
- message = f"Loading status for {event.get('session_count', 0)} session(s)..."
251
- ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
252
- elif kind == "session_started":
253
- provider = event.get("provider") or "session"
254
- message = f"Checking {event.get('session_name')} ({provider})..."
255
- ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
256
- elif kind == "session_finished" and not event.get("cache_hit"):
257
- progress_state["checked"] += 1
258
- total = progress_state["total"] or progress_state["checked"]
259
- message = f"Checked {event.get('session_name')} ({progress_state['checked']}/{total})."
260
- ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
261
- elif kind == "notify_waiting":
262
- message = f"{event.get('message')}; checking again in {event.get('poll')}s..."
263
- ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
264
- return progress
265
-
266
-
267
- def _latest_launch_transcript_path(session):
268
- paths = _list_launch_transcript_paths(session)
269
- if not paths:
270
- return None
271
- return max(paths, key=lambda path: (os.path.getmtime(path), path))
272
-
273
-
274
- def _get_session_home(session):
275
- return session.get("authHome") or session.get("sessionRoot") or session.get("codexHome", "")
276
-
277
-
278
- def _safe_stat(path):
279
- try:
280
- return os.stat(path)
281
- except OSError:
282
- return None
283
-
284
-
285
- def _sort_recent_paths(paths):
286
- stats = {path: stat for path, stat in ((_path, _safe_stat(_path)) for _path in set(paths)) if stat}
287
- return sorted(stats, key=lambda path: (stats[path].st_mtime, path), reverse=True)
288
-
289
-
290
- def _collect_native_handoff_transcript_paths(session):
291
- root = _get_session_home(session)
292
- if not root:
293
- return []
294
- candidates = []
295
- direct = [
296
- os.path.join(root, "history.jsonl"),
297
- os.path.join(root, "session_index.jsonl"),
298
- ]
299
- candidates.extend(path for path in direct if _safe_stat(path))
300
-
301
- scan_roots = [
302
- os.path.join(root, "sessions"),
303
- os.path.join(root, ".claude", "projects"),
304
- os.path.join(root, "projects"),
305
- ]
306
- skip_dirs = {"cache", "plugins", "skills", "memories", "sqlite", "shell_snapshots", "tmp", "__pycache__"}
307
- for scan_root in scan_roots:
308
- if not os.path.isdir(scan_root):
309
- continue
310
- for dirpath, dirnames, filenames in os.walk(scan_root):
311
- dirnames[:] = [name for name in dirnames if not name.startswith(".") and name not in skip_dirs]
312
- for filename in filenames:
313
- if filename.endswith(".jsonl") or filename.endswith(".log"):
314
- candidates.append(os.path.join(dirpath, filename))
315
- return _sort_recent_paths(candidates)[:HANDOFF_NATIVE_TRANSCRIPT_CANDIDATES]
316
-
317
-
318
- def _latest_handoff_transcript_path(session):
319
- launch_path = _latest_launch_transcript_path(session)
320
- if launch_path:
321
- return launch_path
322
- native_paths = _collect_native_handoff_transcript_paths(session)
323
- return native_paths[0] if native_paths else None
324
-
325
-
326
- def _collect_handoff_text_fragments(value):
327
- fragments = []
328
- if isinstance(value, str):
329
- text = value.strip()
330
- if text:
331
- fragments.append(text)
332
- elif isinstance(value, list):
333
- for item in value:
334
- fragments.extend(_collect_handoff_text_fragments(item))
335
- elif isinstance(value, dict):
336
- if isinstance(value.get("text"), str):
337
- fragments.extend(_collect_handoff_text_fragments(value.get("text")))
338
- if isinstance(value.get("content"), (str, list, dict)):
339
- fragments.extend(_collect_handoff_text_fragments(value.get("content")))
340
- if isinstance(value.get("message"), dict):
341
- fragments.extend(_collect_handoff_text_fragments(value.get("message")))
342
- if isinstance(value.get("payload"), dict):
343
- fragments.extend(_collect_handoff_text_fragments(value.get("payload")))
344
- return fragments
345
-
346
-
347
- def _jsonl_record_to_handoff_text(record):
348
- if not isinstance(record, dict):
349
- return None
350
- message = record.get("message") if isinstance(record.get("message"), dict) else {}
351
- role = (
352
- message.get("role")
353
- or record.get("role")
354
- or record.get("type")
355
- or record.get("sender")
356
- or "entry"
357
- )
358
- text_sources = []
359
- for key in ("content", "text", "payload"):
360
- if key in record:
361
- text_sources.append(record.get(key))
362
- for key in ("content", "text"):
363
- if key in message:
364
- text_sources.append(message.get(key))
365
- fragments = []
366
- for value in text_sources:
367
- fragments.extend(_collect_handoff_text_fragments(value))
368
- text = "\n".join(fragment for fragment in fragments if fragment).strip()
369
- if not text:
370
- return None
371
- role = re.sub(r"[^A-Za-z0-9_-]+", "-", str(role).strip()).strip("-") or "entry"
372
- return f"[{role}]\n{text}"
373
-
374
-
375
- def _read_jsonl_handoff_transcript(path):
376
- entries = []
377
- with open(path, "r", encoding="utf-8", errors="replace") as handle:
378
- for line in handle:
379
- line = line.strip()
380
- if not line:
381
- continue
382
- try:
383
- text = _jsonl_record_to_handoff_text(json.loads(line))
384
- except json.JSONDecodeError:
385
- text = None
386
- if text:
387
- entries.append(text)
388
- if entries:
389
- return "\n\n".join(entries)
390
- with open(path, "r", encoding="utf-8", errors="replace") as handle:
391
- return handle.read()
392
-
393
-
394
- def _read_handoff_transcript(path):
395
- if path.endswith(".jsonl"):
396
- content = _read_jsonl_handoff_transcript(path)
397
- else:
398
- with open(path, "r", encoding="utf-8", errors="replace") as handle:
399
- content = handle.read()
400
- if len(content) <= HANDOFF_TRANSCRIPT_CHARS:
401
- return content, False
402
- return content[-HANDOFF_TRANSCRIPT_CHARS:], True
403
-
404
-
405
- def _build_handoff_context(source, target, transcript_path, transcript, truncated=False):
406
- truncation_note = (
407
- "Only the tail of the previous transcript is included because the log was large."
408
- if truncated else
409
- "The previous transcript is included below."
410
- )
411
- return "\n".join([
412
- "# Shared Context",
413
- "",
414
- "## Goal",
415
- f"Resume the work from `{source['name']}` in `{target['name']}` because the source account has no usage left.",
416
- "",
417
- "## Current State",
418
- f"- Source session: `{source['name']}` ({source['provider']})",
419
- f"- Target session: `{target['name']}` ({target['provider']})",
420
- f"- Transcript source: `{transcript_path}`",
421
- f"- {truncation_note}",
422
- "",
423
- "## Previous Session Transcript",
424
- transcript.rstrip(),
425
- "",
426
- "## Next Steps",
427
- "- Read the transcript above.",
428
- "- Continue the task from the latest actionable state.",
429
- "- Preserve the target account authentication; do not copy source credentials.",
430
- ])
431
-
432
-
433
- def _handoff_launch_prompt(session, install=None):
434
- if session.get("provider") == PROVIDER_CODEX:
435
- context_ref = "$CODEX_HOME/shared-context.md"
436
- else:
437
- context_ref = (install or {}).get("target_path") or os.path.join(
438
- session.get("authHome") or session.get("sessionRoot") or "~",
439
- "shared-context.md",
440
- )
441
- return (
442
- f"Read {context_ref} first, then resume the previous session "
443
- "from the latest actionable state. Do not ask me to paste the context again."
444
- )
445
-
446
-
447
- def _parse_json_flag(args):
448
- json_flag = "--json" in args
449
- cleaned = [arg for arg in args if arg != "--json"]
450
- return json_flag, cleaned
451
-
452
-
453
- def _parse_flag_args(args, schema, usage, positionals_key=None, max_positionals=0):
454
- parsed = {spec["key"]: spec.get("default") for spec in schema.values()}
455
- positionals = []
456
- index = 0
457
- while index < len(args):
458
- arg = args[index]
459
- if arg in schema:
460
- spec = schema[arg]
461
- if spec["type"] == "bool":
462
- parsed[spec["key"]] = True
463
- index += 1
464
- continue
465
- value, index = _read_option_value(args, index, usage)
466
- parsed[spec["key"]] = spec.get("transform", lambda item: item)(value)
467
- continue
468
- if arg.startswith("--") and "=" in arg:
469
- flag, value = arg.split("=", 1)
470
- spec = schema.get(flag)
471
- if not spec or spec["type"] == "bool":
472
- raise CdxError(usage)
473
- parsed[spec["key"]] = spec.get("transform", lambda item: item)(value)
474
- index += 1
475
- continue
476
- if arg.startswith("-"):
477
- raise CdxError(usage)
478
- positionals.append(arg)
479
- if len(positionals) > max_positionals:
480
- raise CdxError(usage)
481
- index += 1
482
-
483
- if positionals_key is not None:
484
- parsed[positionals_key] = positionals
485
- return parsed
486
-
487
-
488
- def _parse_add_args(args):
489
- parsed = _parse_flag_args(
490
- args,
491
- {"--model": {"key": "model", "type": "str", "default": None}},
492
- "Usage: cdx add [provider] <name> [--model MODEL] [--json]",
493
- positionals_key="values",
494
- max_positionals=2,
495
- )
496
- values = parsed["values"]
497
- if len(values) == 1:
498
- return {"provider": PROVIDER_CODEX, "name": values[0], "model": parsed["model"]}
499
- if len(values) == 2:
500
- return {"provider": values[0], "name": values[1], "model": parsed["model"]}
501
- raise CdxError("Usage: cdx add [provider] <name> [--model MODEL] [--json]")
502
-
503
-
504
- def _parse_copy_args(args):
505
- if len(args) != 2:
506
- raise CdxError("Usage: cdx cp <source> <dest> [--json]")
507
- return {"source": args[0], "dest": args[1]}
508
-
509
-
510
- def _parse_rename_args(args):
511
- if len(args) != 2:
512
- raise CdxError("Usage: cdx ren <source> <dest> [--json]")
513
- return {"source": args[0], "dest": args[1]}
514
-
515
-
516
- def _parse_remove_args(args):
517
- parsed = _parse_flag_args(args, {
518
- "--force": {"key": "force", "type": "bool", "default": False},
519
- }, "Usage: cdx rmv <name> [--force] [--json]", positionals_key="names", max_positionals=1)
520
- if len(parsed["names"]) != 1:
521
- raise CdxError("Usage: cdx rmv <name> [--force] [--json]")
522
- return {"name": parsed["names"][0], "force": parsed["force"]}
523
-
524
-
525
- def _parse_toggle_args(args, usage):
526
- parsed = _parse_flag_args(args, {
527
- "--json": {"key": "json", "type": "bool", "default": False},
528
- }, usage, positionals_key="names", max_positionals=1)
529
- if len(parsed["names"]) != 1:
530
- raise CdxError(usage)
531
- return {"name": parsed["names"][0], "json": parsed["json"]}
532
-
533
-
534
- def _parse_fast_value(value):
535
- text = str(value).strip().lower()
536
- if text in ("on", "true", "1", "yes"):
537
- return True
538
- if text in ("off", "false", "0", "no"):
539
- return False
540
- raise CdxError(SET_USAGE)
541
-
542
-
543
- def _parse_priority_value(value):
544
- try:
545
- priority = int(value)
546
- except (TypeError, ValueError) as error:
547
- raise CdxError(SET_USAGE) from error
548
- if priority < 0 or priority > 100:
549
- raise CdxError(SET_USAGE)
550
- return priority
551
-
552
-
553
- def _parse_set_args(args):
554
- parsed = _parse_flag_args(args, {
555
- "--power": {"key": "power", "type": "str", "default": None},
556
- "--permission": {"key": "permission", "type": "str", "default": None},
557
- "--fast": {"key": "fast", "type": "str", "default": None, "transform": _parse_fast_value},
558
- "--rtk": {"key": "rtk", "type": "str", "default": None, "transform": _parse_fast_value},
559
- "--logics": {"key": "logics", "type": "str", "default": None, "transform": _parse_fast_value},
560
- "--model": {"key": "model", "type": "str", "default": None},
561
- "--priority": {"key": "priority", "type": "str", "default": None, "transform": _parse_priority_value},
562
- "--sessions": {"key": "sessions", "type": "str", "default": None, "transform": _parse_set_unset_sessions},
563
- "--provider": {"key": "provider", "type": "str", "default": None, "transform": lambda value: _parse_provider_filter(value, SET_USAGE)},
564
- "--json": {"key": "json", "type": "bool", "default": False},
565
- }, SET_USAGE, positionals_key="names", max_positionals=1)
566
- if len(parsed["names"]) > 1:
567
- raise CdxError(SET_USAGE)
568
- if parsed["names"] and parsed["sessions"]:
569
- raise CdxError(SET_USAGE)
570
- if parsed["names"] and parsed["provider"]:
571
- raise CdxError(SET_USAGE)
572
- if not parsed["names"] and not parsed["sessions"] and not parsed["provider"]:
573
- raise CdxError(SET_USAGE)
574
- settings = {
575
- key: parsed[key]
576
- for key in ("power", "permission", "fast", "rtk", "logics", "model", "priority")
577
- if parsed[key] is not None
578
- }
579
- if not settings:
580
- raise CdxError(SET_USAGE)
581
- return {
582
- "name": parsed["names"][0] if parsed["names"] else None,
583
- "sessions": parsed["sessions"],
584
- "provider": parsed["provider"],
585
- "settings": settings,
586
- "json": parsed["json"],
587
- }
588
-
589
-
590
- def _parse_unset_args(args):
591
- parsed = _parse_flag_args(args, {
592
- "--power": {"key": "power", "type": "bool", "default": False},
593
- "--permission": {"key": "permission", "type": "bool", "default": False},
594
- "--fast": {"key": "fast", "type": "bool", "default": False},
595
- "--rtk": {"key": "rtk", "type": "bool", "default": False},
596
- "--logics": {"key": "logics", "type": "bool", "default": False},
597
- "--model": {"key": "model", "type": "bool", "default": False},
598
- "--priority": {"key": "priority", "type": "bool", "default": False},
599
- "--all": {"key": "all", "type": "bool", "default": False},
600
- "--sessions": {"key": "sessions", "type": "str", "default": None, "transform": _parse_set_unset_sessions},
601
- "--provider": {"key": "provider", "type": "str", "default": None, "transform": lambda value: _parse_provider_filter(value, UNSET_USAGE)},
602
- "--json": {"key": "json", "type": "bool", "default": False},
603
- }, UNSET_USAGE, positionals_key="names", max_positionals=1)
604
- if len(parsed["names"]) > 1:
605
- raise CdxError(UNSET_USAGE)
606
- if parsed["names"] and parsed["sessions"]:
607
- raise CdxError(UNSET_USAGE)
608
- if parsed["names"] and parsed["provider"]:
609
- raise CdxError(UNSET_USAGE)
610
- if not parsed["names"] and not parsed["sessions"] and not parsed["provider"]:
611
- raise CdxError(UNSET_USAGE)
612
- keys = ["power", "permission", "fast", "rtk", "logics", "model", "priority"] if parsed["all"] else [
613
- key for key in ("power", "permission", "fast", "rtk", "logics", "model", "priority") if parsed[key]
614
- ]
615
- if not keys:
616
- raise CdxError(UNSET_USAGE)
617
- return {
618
- "name": parsed["names"][0] if parsed["names"] else None,
619
- "sessions": parsed["sessions"],
620
- "provider": parsed["provider"],
621
- "keys": keys,
622
- "json": parsed["json"],
623
- }
624
-
625
-
626
- def _parse_config_args(args):
627
- parsed = _parse_flag_args(args, {
628
- "--json": {"key": "json", "type": "bool", "default": False},
629
- }, CONFIG_USAGE, positionals_key="names", max_positionals=1)
630
- if len(parsed["names"]) != 1:
631
- raise CdxError(CONFIG_USAGE)
632
- return {"name": parsed["names"][0], "json": parsed["json"]}
633
-
634
-
635
- def _parse_configs_args(args):
636
- parsed = _parse_flag_args(args, {
637
- "--json": {"key": "json", "type": "bool", "default": False},
638
- }, CONFIGS_USAGE)
639
- return {"json": parsed["json"]}
640
-
641
-
642
- def _parse_select_args(args):
643
- parsed = _parse_flag_args(args, {
644
- "--provider": {"key": "provider", "type": "str", "default": None, "transform": lambda value: _parse_provider_filter(value, SELECT_USAGE)},
645
- "--min-reasoning-effort": {"key": "min_reasoning_effort", "type": "str", "default": None},
646
- "--min-power": {"key": "min_power", "type": "str", "default": None},
647
- "--require-ready": {"key": "require_ready", "type": "bool", "default": False},
648
- "--refresh": {"key": "refresh", "type": "bool", "default": False},
649
- "--json": {"key": "json", "type": "bool", "default": False},
650
- }, SELECT_USAGE)
651
- if not parsed["provider"] or not parsed["json"]:
652
- raise CdxError(SELECT_USAGE)
653
- effort = _normalize_reasoning_effort(
654
- reasoning_effort=parsed["min_reasoning_effort"],
655
- power=parsed["min_power"],
656
- usage=SELECT_USAGE,
657
- ).get("reasoning_effort")
658
- return {
659
- "provider": parsed["provider"],
660
- "min_reasoning_effort": effort,
661
- "require_ready": parsed["require_ready"],
662
- "refresh": parsed["refresh"],
663
- "json": parsed["json"],
664
- }
665
-
666
-
667
- def _parse_next_args(args):
668
- parsed = _parse_flag_args(args, {
669
- "--refresh": {"key": "refresh", "type": "bool", "default": False},
670
- "--json": {"key": "json", "type": "bool", "default": False},
671
- }, NEXT_USAGE)
672
- return {"json": parsed["json"], "refresh": parsed["refresh"]}
673
-
674
-
675
- def _parse_timeout_seconds(value, usage=RUN_USAGE):
676
- try:
677
- parsed = float(value)
678
- except (TypeError, ValueError) as error:
679
- raise CdxError(usage) from error
680
- if parsed <= 0:
681
- raise CdxError(usage)
682
- return parsed
683
-
684
-
685
- def _normalize_run_permission(value):
686
- if value is None:
687
- return None
688
- text = str(value).strip().lower()
689
- aliases = {
690
- "workspace-write": "default",
691
- "read-only": "review",
692
- "danger-full-access": "full",
693
- }
694
- text = aliases.get(text, text)
695
- if text not in ("review", "default", "auto", "full"):
696
- raise CdxError(RUN_USAGE)
697
- return text
698
-
699
-
700
- def _parse_run_args(args):
701
- parsed = _parse_flag_args(args, {
702
- "--cwd": {"key": "cwd", "type": "str", "default": None},
703
- "--prompt-file": {"key": "prompt_file", "type": "str", "default": None},
704
- "--prompt": {"key": "prompt", "type": "str", "default": None},
705
- "--provider": {"key": "provider", "type": "str", "default": None, "transform": lambda value: _parse_provider_filter(value, RUN_USAGE)},
706
- "--model": {"key": "model", "type": "str", "default": None},
707
- "--kind": {"key": "kind", "type": "str", "default": "assistant"},
708
- "--reasoning-effort": {"key": "reasoning_effort", "type": "str", "default": None},
709
- "--power": {"key": "power", "type": "str", "default": None},
710
- "--permission": {"key": "permission", "type": "str", "default": None, "transform": _normalize_run_permission},
711
- "--timeout-seconds": {"key": "timeout_seconds", "type": "str", "default": None, "transform": _parse_timeout_seconds},
712
- "--json": {"key": "json", "type": "bool", "default": False},
713
- }, RUN_USAGE, positionals_key="names", max_positionals=1)
714
- if not parsed["json"]:
715
- raise CdxError(RUN_USAGE)
716
- name = parsed["names"][0] if parsed["names"] else None
717
- if name and parsed["provider"]:
718
- raise CdxError(RUN_USAGE)
719
- if not name and not parsed["provider"]:
720
- raise CdxError(RUN_USAGE)
721
- if not parsed["cwd"]:
722
- raise CdxError(RUN_USAGE)
723
- if bool(parsed["prompt_file"]) == bool(parsed["prompt"]):
724
- raise CdxError(RUN_USAGE)
725
- if parsed["kind"] not in ("assistant", "code-review"):
726
- raise CdxError(RUN_USAGE)
727
- effort = _normalize_reasoning_effort(
728
- reasoning_effort=parsed["reasoning_effort"],
729
- power=parsed["power"],
730
- usage=RUN_USAGE,
731
- )
732
- return {
733
- "name": name,
734
- "provider": parsed["provider"],
735
- "cwd": parsed["cwd"],
736
- "prompt_file": parsed["prompt_file"],
737
- "prompt": parsed["prompt"],
738
- "kind": parsed["kind"],
739
- "model": parsed["model"],
740
- "permission": parsed["permission"],
741
- "timeout_seconds": parsed["timeout_seconds"],
742
- "reasoning_effort": effort.get("reasoning_effort"),
743
- "power": effort.get("power"),
744
- }
745
-
746
-
747
- def _parse_positive_int(value):
748
- try:
749
- parsed = int(value)
750
- except (TypeError, ValueError) as error:
751
- raise CdxError(HISTORY_USAGE) from error
752
- if parsed < 1:
753
- raise CdxError(HISTORY_USAGE)
754
- return parsed
755
-
756
-
757
- def _local_timezone():
758
- return datetime.now().astimezone().tzinfo
759
-
760
-
761
- def _parse_datetime_value(value, usage, end_of_day=False):
762
- text = str(value or "").strip()
763
- if not text:
764
- raise CdxError(usage)
765
- if text.endswith("Z"):
766
- text = f"{text[:-1]}+00:00"
767
- is_date_only = len(text) == 10 and text[4] == "-" and text[7] == "-"
768
- try:
769
- parsed = datetime.fromisoformat(text)
770
- except ValueError as error:
771
- raise CdxError(usage) from error
772
- if is_date_only and end_of_day:
773
- parsed = parsed.replace(hour=23, minute=59, second=59, microsecond=999999)
774
- if parsed.tzinfo is None:
775
- parsed = parsed.replace(tzinfo=_local_timezone())
776
- return parsed.astimezone()
777
-
778
-
779
- def _parse_since_value(value, now, usage):
780
- text = str(value or "").strip().lower()
781
- if not text:
782
- raise CdxError(usage)
783
- if text == "today":
784
- return now.replace(hour=0, minute=0, second=0, microsecond=0)
785
- if text == "yesterday":
786
- today = now.replace(hour=0, minute=0, second=0, microsecond=0)
787
- return today - timedelta(days=1)
788
- unit = text[-1:]
789
- amount_text = text[:-1]
790
- if unit in ("m", "h", "d", "w") and amount_text.isdigit():
791
- amount = int(amount_text)
792
- if amount < 1:
793
- raise CdxError(usage)
794
- multipliers = {
795
- "m": timedelta(minutes=amount),
796
- "h": timedelta(hours=amount),
797
- "d": timedelta(days=amount),
798
- "w": timedelta(weeks=amount),
799
- }
800
- return now - multipliers[unit]
801
- return _parse_datetime_value(value, usage, end_of_day=False)
802
-
803
-
804
- def _format_period_datetime(value):
805
- if value is None:
806
- return None
807
- return value.isoformat(timespec="seconds")
808
-
809
-
810
- def _parse_history_period(parsed, now):
811
- since = parsed.get("since")
812
- from_value = parsed.get("from")
813
- to_value = parsed.get("to")
814
- if since and from_value:
815
- raise CdxError("Usage: cdx history cannot combine --since and --from.")
816
- start = _parse_since_value(since, now, HISTORY_USAGE) if since else None
817
- if from_value:
818
- start = _parse_datetime_value(from_value, HISTORY_USAGE, end_of_day=False)
819
- end = _parse_datetime_value(to_value, HISTORY_USAGE, end_of_day=True) if to_value else None
820
- if start and end and start.timestamp() > end.timestamp():
821
- raise CdxError("Usage: cdx history period start must be before period end.")
822
- return {
823
- "from": _format_period_datetime(start),
824
- "to": _format_period_datetime(end),
825
- "from_ts": start.timestamp() if start else None,
826
- "to_ts": end.timestamp() if end else None,
827
- }
828
-
829
-
830
- def _has_history_period(period):
831
- return period.get("from_ts") is not None or period.get("to_ts") is not None
832
-
833
-
834
- def _parse_entry_timestamp(entry):
835
- value = entry.get("started_at")
836
- if not value:
837
- return None
838
- text = str(value)
839
- if text.endswith("Z"):
840
- text = f"{text[:-1]}+00:00"
841
- try:
842
- parsed = datetime.fromisoformat(text)
843
- except ValueError:
844
- return None
845
- if parsed.tzinfo is None:
846
- parsed = parsed.replace(tzinfo=_local_timezone())
847
- return parsed.timestamp()
848
-
849
-
850
- def _filter_history_period(entries, period):
851
- if not _has_history_period(period):
852
- return entries
853
- filtered = []
854
- start = period.get("from_ts")
855
- end = period.get("to_ts")
856
- for entry in entries:
857
- timestamp = _parse_entry_timestamp(entry)
858
- if timestamp is None:
859
- continue
860
- if start is not None and timestamp < start:
861
- continue
862
- if end is not None and timestamp > end:
863
- continue
864
- filtered.append(entry)
865
- return filtered
866
-
867
-
868
- def _public_history_period(period):
869
- return {
870
- "from": period.get("from"),
871
- "to": period.get("to"),
872
- }
873
-
874
-
875
- def _parse_history_args(args, now=None):
876
- now = now or datetime.now().astimezone()
877
- parsed = _parse_flag_args(args, {
878
- "--limit": {"key": "limit", "type": "str", "default": 20, "transform": _parse_positive_int},
879
- "--summary": {"key": "summary", "type": "bool", "default": False},
880
- "--since": {"key": "since", "type": "str", "default": None},
881
- "--from": {"key": "from", "type": "str", "default": None},
882
- "--to": {"key": "to", "type": "str", "default": None},
883
- "--json": {"key": "json", "type": "bool", "default": False},
884
- }, HISTORY_USAGE, positionals_key="names", max_positionals=1)
885
- period = _parse_history_period(parsed, now)
886
- return {
887
- "name": parsed["names"][0] if parsed["names"] else None,
888
- "limit": parsed["limit"],
889
- "summary": parsed["summary"],
890
- "period": period,
891
- "json": parsed["json"],
892
- }
893
-
894
-
895
- def _parse_stats_args(args, now=None):
896
- now = now or datetime.now().astimezone()
897
- parsed = _parse_flag_args(args, {
898
- "--since": {"key": "since", "type": "str", "default": None},
899
- "--from": {"key": "from", "type": "str", "default": None},
900
- "--to": {"key": "to", "type": "str", "default": None},
901
- "--json": {"key": "json", "type": "bool", "default": False},
902
- }, STATS_USAGE, positionals_key="names", max_positionals=1)
903
- period = _parse_history_period(parsed, now)
904
- return {
905
- "name": parsed["names"][0] if parsed["names"] else None,
906
- "period": period,
907
- "json": parsed["json"],
908
- }
909
-
910
-
911
- def _read_option_value(args, index, usage):
912
- if index + 1 >= len(args):
913
- raise CdxError(usage)
914
- return args[index + 1], index + 2
915
-
916
-
917
- def _parse_session_names(value):
918
- if value is None:
919
- return None
920
- names = [item.strip() for item in value.split(",") if item.strip()]
921
- if not names:
922
- raise CdxError("At least one session name is required in --sessions.")
923
- return names
924
-
925
-
926
- def _parse_set_unset_sessions(value):
927
- text = str(value or "").strip()
928
- if not text:
929
- raise CdxError("At least one session name is required in --sessions.")
930
- if text.lower() == "all":
931
- return "all"
932
- return _parse_session_names(text)
933
-
934
-
935
- def _parse_provider_filter(value, usage):
936
- provider = str(value or "").strip()
937
- if provider not in PROVIDERS:
938
- raise CdxError(usage)
939
- return provider
940
-
941
-
942
- def _parse_launch_setting_target(value, usage):
943
- text = str(value or "").strip()
944
- if not text:
945
- raise CdxError(usage)
946
- if text == "all":
947
- return {"name": None, "sessions": "all", "provider": None}
948
- if text.startswith("provider:"):
949
- return {"name": None, "sessions": None, "provider": _parse_provider_filter(text.split(":", 1)[1], usage)}
950
- if "," in text:
951
- return {"name": None, "sessions": _parse_session_names(text), "provider": None}
952
- return {"name": text, "sessions": None, "provider": None}
953
-
954
-
955
- def _parse_launch_setting_alias_args(kind, args):
956
- json_flag, cleaned = _parse_json_flag(args)
957
- if len(cleaned) != 2:
958
- raise CdxError(SETTING_ALIAS_USAGE)
959
- target = _parse_launch_setting_target(cleaned[0], SETTING_ALIAS_USAGE)
960
- value = cleaned[1]
961
- field = "permission" if kind == "perm" else kind
962
- if value == "default":
963
- return {**target, "keys": [field], "json": json_flag, "alias": kind}
964
- if field == "fast":
965
- value = _parse_fast_value(value)
966
- return {**target, "settings": {field: value}, "json": json_flag, "alias": kind}
967
-
968
-
969
- def _parse_update_args(args):
970
- parsed = _parse_flag_args(args, {
971
- "--check": {"key": "check", "type": "bool", "default": False},
972
- "--json": {"key": "json", "type": "bool", "default": False},
973
- "--yes": {"key": "yes", "type": "bool", "default": False},
974
- "--version": {"key": "version", "type": "str", "default": None},
975
- }, UPDATE_USAGE)
976
- if parsed["check"] and parsed["version"]:
977
- raise CdxError("Usage: cdx update --check cannot be combined with --version.")
978
- if parsed["version"] is not None and not parsed["version"].strip():
979
- raise CdxError("Usage: cdx update [--check] [--yes] [--json] [--version TAG]")
980
- return parsed
981
-
982
-
983
- def _parse_export_args(args):
984
- parsed = _parse_flag_args(args, {
985
- "--include-auth": {"key": "include_auth", "type": "bool", "default": False},
986
- "--force": {"key": "force", "type": "bool", "default": False},
987
- "--json": {"key": "json", "type": "bool", "default": False},
988
- "--sessions": {
989
- "key": "session_names",
990
- "type": "str",
991
- "default": None,
992
- "transform": _parse_session_names,
993
- },
994
- "--passphrase-env": {"key": "passphrase_env", "type": "str", "default": None},
995
- }, EXPORT_USAGE, positionals_key="positionals", max_positionals=1)
996
- parsed["file_path"] = parsed.pop("positionals")[0] if parsed["positionals"] else None
997
- if not parsed["file_path"]:
998
- raise CdxError(EXPORT_USAGE)
999
- if parsed["passphrase_env"] and not parsed["include_auth"]:
1000
- raise CdxError("--passphrase-env requires --include-auth for export.")
1001
- return parsed
1002
-
1003
-
1004
- def _parse_import_args(args):
1005
- parsed = _parse_flag_args(args, {
1006
- "--force": {"key": "force", "type": "bool", "default": False},
1007
- "--merge": {"key": "merge", "type": "bool", "default": False},
1008
- "--json": {"key": "json", "type": "bool", "default": False},
1009
- "--sessions": {
1010
- "key": "session_names",
1011
- "type": "str",
1012
- "default": None,
1013
- "transform": _parse_session_names,
1014
- },
1015
- "--passphrase-env": {"key": "passphrase_env", "type": "str", "default": None},
1016
- }, IMPORT_USAGE, positionals_key="positionals", max_positionals=1)
1017
- parsed["file_path"] = parsed.pop("positionals")[0] if parsed["positionals"] else None
1018
- if not parsed["file_path"]:
1019
- raise CdxError(IMPORT_USAGE)
1020
- if parsed["force"] and parsed["merge"]:
1021
- raise CdxError("--force and --merge are mutually exclusive.")
1022
- return parsed
1023
-
1024
-
1025
110
  def _resolve_bundle_passphrase(ctx, env_var, prompt, confirm=False):
1026
111
  env = ctx.get("env", {})
1027
112
  if env_var:
@@ -1112,7 +197,7 @@ def _bootstrap_claude_setup_token(session, ctx):
1112
197
  "Claude setup-token completed, but cdx could not capture the token. "
1113
198
  "Run claude setup-token and save the token under credentials/default.json."
1114
199
  )
1115
- with open(transcript_path, "r", encoding="utf-8", errors="replace") as handle:
200
+ with open(transcript_path, encoding="utf-8", errors="replace") as handle:
1116
201
  transcript = handle.read()
1117
202
  token = _extract_claude_oauth_token(transcript)
1118
203
  if not token:
@@ -1354,22 +439,6 @@ def handle_next(rest, ctx):
1354
439
  return 0
1355
440
 
1356
441
 
1357
- def _parse_runs_args(rest):
1358
- return _parse_flag_args(rest, {
1359
- "--limit": {"key": "limit", "type": "str", "default": 20, "transform": _parse_positive_int},
1360
- "--json": {"key": "json", "type": "bool", "default": False},
1361
- }, RUNS_USAGE, max_positionals=0)
1362
-
1363
-
1364
- def _parse_run_id_json_args(rest, usage):
1365
- parsed = _parse_flag_args(rest, {
1366
- "--json": {"key": "json", "type": "bool", "default": False},
1367
- }, usage, positionals_key="ids", max_positionals=1)
1368
- if not parsed["json"] or len(parsed["ids"]) != 1:
1369
- raise CdxError(usage)
1370
- return {"run_id": parsed["ids"][0], "json": True}
1371
-
1372
-
1373
442
  def _run_registry(ctx):
1374
443
  return RunRegistry(ctx["service"]["base_dir"])
1375
444
 
@@ -2533,16 +1602,6 @@ def handle_status(rest, ctx):
2533
1602
  }
2534
1603
  for item in refresh_result.get("errors", [])
2535
1604
  ]
2536
- warnings.extend([
2537
- {
2538
- "code": "claude_refresh_failed",
2539
- "session": item.get("session") or "unknown",
2540
- "message": item.get("error") or "unknown error",
2541
- }
2542
- for item in refresh_errors
2543
- ])
2544
- warnings.extend(_update_notice_warnings(ctx))
2545
-
2546
1605
  status_progress = None if parsed["json"] else _make_status_progress(ctx)
2547
1606
  if len(args) == 1:
2548
1607
  row = ctx["service"]["get_status_row"](
@@ -2552,11 +1611,16 @@ def handle_status(rest, ctx):
2552
1611
  cache_only=parsed["cached"],
2553
1612
  status_timeout_seconds=parsed["timeout"],
2554
1613
  )
1614
+ output_warnings = [
1615
+ *warnings,
1616
+ *_refresh_warning_payloads(refresh_errors, [row]),
1617
+ *_update_notice_warnings(ctx),
1618
+ ]
2555
1619
  if parsed["json"]:
2556
- _write_json(ctx, _json_success("status", f"Collected status for {args[0]}", warnings=warnings, session=row))
1620
+ _write_json(ctx, _json_success("status", f"Collected status for {args[0]}", warnings=output_warnings, session=row))
2557
1621
  return 0
2558
1622
  ctx["out"](f"{_format_status_detail(row, use_color=ctx['use_color'])}\n")
2559
- _write_refresh_warnings(refresh_errors, ctx)
1623
+ _write_refresh_warnings(refresh_errors, ctx, rows=[row])
2560
1624
  _write_update_notice(ctx)
2561
1625
  return 0
2562
1626
 
@@ -2566,11 +1630,16 @@ def handle_status(rest, ctx):
2566
1630
  cache_only=parsed["cached"],
2567
1631
  status_timeout_seconds=parsed["timeout"],
2568
1632
  )
1633
+ output_warnings = [
1634
+ *warnings,
1635
+ *_refresh_warning_payloads(refresh_errors, rows),
1636
+ *_update_notice_warnings(ctx),
1637
+ ]
2569
1638
  if parsed["json"]:
2570
- _write_json(ctx, _json_success("status", "Collected session status rows", warnings=warnings, rows=rows))
1639
+ _write_json(ctx, _json_success("status", "Collected session status rows", warnings=output_warnings, rows=rows))
2571
1640
  return 0
2572
1641
  ctx["out"](f"{_format_status_rows(rows, use_color=ctx['use_color'], small=parsed['small'])}\n")
2573
- _write_refresh_warnings(refresh_errors, ctx)
1642
+ _write_refresh_warnings(refresh_errors, ctx, rows=rows)
2574
1643
  _write_update_notice(ctx)
2575
1644
  return 0
2576
1645
 
@@ -2595,7 +1664,7 @@ def _refresh_claude_auth_states(service, target_names=None, spawn_sync=None, env
2595
1664
  env_override=env_override,
2596
1665
  )
2597
1666
  now = _local_now_iso()
2598
- service["update_auth_state"](session["name"], lambda auth: {
1667
+ service["update_auth_state"](session["name"], lambda auth, authenticated=authenticated, now=now: {
2599
1668
  **auth,
2600
1669
  "status": "authenticated" if authenticated else "logged_out",
2601
1670
  "lastCheckedAt": now,
@@ -2607,12 +1676,65 @@ def _refresh_claude_auth_states(service, target_names=None, spawn_sync=None, env
2607
1676
  return {"updated": updated, "errors": errors}
2608
1677
 
2609
1678
 
2610
- def _write_refresh_warnings(refresh_errors, ctx, stream="out"):
2611
- write = ctx["err"] if stream == "err" and "err" in ctx else ctx["out"]
1679
+ def _rows_by_session(rows):
1680
+ return {
1681
+ row.get("session_name"): row
1682
+ for row in (rows or [])
1683
+ if row.get("session_name")
1684
+ }
1685
+
1686
+
1687
+ def _is_invalid_claude_usage_auth(error):
1688
+ text = str(error or "").lower()
1689
+ return "http 401" in text and "invalid authentication credentials" in text
1690
+
1691
+
1692
+ def _has_valid_local_claude_auth(item, rows_by_name):
1693
+ session = item.get("session")
1694
+ row = rows_by_name.get(session)
1695
+ return bool(row and row.get("provider") == PROVIDER_CLAUDE and row.get("auth_status") == "authenticated")
1696
+
1697
+
1698
+ def _refresh_warning_payloads(refresh_errors, rows=None):
1699
+ rows_by_name = _rows_by_session(rows)
1700
+ warnings = []
2612
1701
  for item in refresh_errors:
2613
1702
  session = item.get("session") or "unknown"
2614
1703
  error = item.get("error") or "unknown error"
2615
- write(f"{_warn(f'Warning: Claude refresh failed for {session}: {error}', ctx['use_color'])}\n")
1704
+ warning = {
1705
+ "code": "claude_refresh_failed",
1706
+ "session": session,
1707
+ "message": error,
1708
+ }
1709
+ if _is_invalid_claude_usage_auth(error) and _has_valid_local_claude_auth(item, rows_by_name):
1710
+ warning.update({
1711
+ "auth_status": "authenticated",
1712
+ "status_freshness": "stale",
1713
+ "message": (
1714
+ "Claude quota refresh failed, but local Claude auth is still valid; "
1715
+ f"cached quota may be stale. Original error: {error}"
1716
+ ),
1717
+ })
1718
+ warnings.append(warning)
1719
+ return warnings
1720
+
1721
+
1722
+ def _format_refresh_warning(item, rows_by_name):
1723
+ session = item.get("session") or "unknown"
1724
+ error = item.get("error") or "unknown error"
1725
+ if _is_invalid_claude_usage_auth(error) and _has_valid_local_claude_auth(item, rows_by_name):
1726
+ return (
1727
+ f"Warning: Claude quota refresh failed for {session}, but local auth is still valid; "
1728
+ f"cached quota may be stale. ({error})"
1729
+ )
1730
+ return f"Warning: Claude refresh failed for {session}: {error}"
1731
+
1732
+
1733
+ def _write_refresh_warnings(refresh_errors, ctx, stream="out", rows=None):
1734
+ write = ctx["err"] if stream == "err" and "err" in ctx else ctx["out"]
1735
+ rows_by_name = _rows_by_session(rows)
1736
+ for item in refresh_errors:
1737
+ write(f"{_warn(_format_refresh_warning(item, rows_by_name), ctx['use_color'])}\n")
2616
1738
 
2617
1739
 
2618
1740
  def handle_export(rest, ctx):