cdx-manager 0.12.4 → 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.
- package/README.md +132 -3
- package/changelogs/CHANGELOGS_0_14_0.md +146 -0
- package/package.json +3 -3
- package/pyproject.toml +1 -1
- package/src/cli.py +64 -4
- package/src/cli_args.py +274 -33
- package/src/cli_commands.py +251 -3049
- package/src/cli_helpers.py +177 -2
- package/src/commands/__init__.py +6 -0
- package/src/commands/auth.py +224 -0
- package/src/commands/backup.py +112 -0
- package/src/commands/context_memory.py +341 -0
- package/src/commands/launch.py +218 -0
- package/src/commands/lifecycle.py +154 -0
- package/src/commands/maintenance.py +723 -0
- package/src/commands/runs.py +562 -0
- package/src/commands/settings.py +107 -0
- package/src/commands/status.py +681 -0
- package/src/config.py +41 -0
- package/src/errors.py +17 -0
- package/src/health.py +151 -3
- package/src/notify.py +11 -5
- package/src/provider_runtime.py +46 -11
- package/src/run_command.py +112 -4
- package/src/run_registry.py +53 -2
- package/src/session_backup.py +343 -0
- package/src/session_helpers.py +179 -0
- package/src/session_ranking.py +259 -0
- package/src/session_service.py +574 -1426
- package/src/session_status.py +534 -0
- package/src/status_view.py +15 -34
package/src/cli_commands.py
CHANGED
|
@@ -1,3074 +1,276 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
from .
|
|
15
|
-
from .
|
|
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
|
-
|
|
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 .
|
|
79
|
-
|
|
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 .
|
|
100
|
-
|
|
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 .
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
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
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
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
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
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)
|