cdx-manager 0.12.3 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,562 @@
1
+ """Run domain commands: run, runs, run-status, run-report, run-tail, select, schema.
2
+
3
+ Split out of cli_commands.py. Moved verbatim; re-exported by cli_commands.
4
+ """
5
+
6
+ import os
7
+ import subprocess
8
+ import sys
9
+
10
+ from ..cli_args import (
11
+ RUN_REPORT_USAGE,
12
+ RUN_STATUS_USAGE,
13
+ RUNS_USAGE,
14
+ _parse_run_args,
15
+ _parse_run_id_json_args,
16
+ _parse_run_tail_args,
17
+ _parse_runs_args,
18
+ _parse_schema_args,
19
+ _parse_select_args,
20
+ cdx_schema,
21
+ )
22
+ from ..cli_helpers import API_SCHEMA_VERSION, _json_failure, _json_success, _write_json
23
+ from ..errors import CdxError
24
+ from ..provider_runtime import _ensure_session_authentication, _headless_artifact_paths, _run_headless_provider_command
25
+ from ..run_command import read_run_prompt, run_cdx_error_code, run_launch_payload, run_result_payload
26
+ from ..run_registry import RunRegistry, build_code_review_report
27
+ from ..run_usage import extract_run_usage
28
+ from ..session_ranking import FACTOR_DESCRIPTIONS, rank_sessions, selection_policy
29
+ from ..status_view import _now_timestamp, _priority_reset_timestamp
30
+
31
+
32
+ def handle_select(rest, ctx):
33
+ parsed = _parse_select_args(rest)
34
+ selected = _select_headless_session(
35
+ ctx,
36
+ parsed["provider"],
37
+ min_reasoning_effort=parsed["min_reasoning_effort"],
38
+ require_ready=parsed["require_ready"],
39
+ force_refresh=parsed["refresh"],
40
+ )
41
+ # Derived from the ranking rather than written by hand: the previous
42
+ # literal omitted the reasoning-effort tie-break and described a candidate
43
+ # filter as a sort stage, and nothing failed when the sort key moved on
44
+ # without it.
45
+ policy = selection_policy()
46
+ if not selected:
47
+ message = "No suitable session found."
48
+ _write_json(ctx, _json_failure(
49
+ "select",
50
+ "no_suitable_session",
51
+ message,
52
+ provider=parsed["provider"],
53
+ min_reasoning_effort=parsed["min_reasoning_effort"],
54
+ require_ready=parsed["require_ready"],
55
+ selection_policy=policy,
56
+ ))
57
+ return 1
58
+ session = selected["session"]
59
+ row = selected.get("row") or {}
60
+ _write_json(ctx, _json_success(
61
+ "select",
62
+ f"Selected session {session['name']}",
63
+ session=session["name"],
64
+ provider=session["provider"],
65
+ reason=_selection_reason(selected.get("decision")),
66
+ deciding_factor=selected.get("decision"),
67
+ selection_policy=policy,
68
+ min_reasoning_effort=parsed["min_reasoning_effort"],
69
+ reasoning_effort=row.get("reasoning_effort"),
70
+ priority=row.get("priority"),
71
+ available_pct=row.get("available_pct"),
72
+ auth_status=row.get("auth_status"),
73
+ ))
74
+ return 0
75
+
76
+ def _selection_reason(decision):
77
+ """Why this session won, for this call.
78
+
79
+ Names the factor that actually separated it from the runner-up instead of
80
+ the factor that usually does: the old fixed sentence claimed availability
81
+ decided even when priority or the alphabetical fallback did.
82
+ """
83
+ if decision is None:
84
+ return "only suitable session"
85
+ return f"decided on {decision}: {FACTOR_DESCRIPTIONS[decision]}"
86
+
87
+ def _run_registry(ctx):
88
+ return RunRegistry(ctx["service"]["base_dir"])
89
+
90
+ DETACHED_RUN_ID_ENV = "CDX_RUN_ID"
91
+
92
+ RUN_TAIL_READ_BYTES = 1 << 20
93
+
94
+ def _tail_file_lines(path, lines):
95
+ """Last `lines` lines of a run's output, safe to call while it is still being written.
96
+
97
+ Reads a bounded tail of the file in binary and decodes with replacement, so
98
+ provider output containing undecodable bytes returns those lines instead of
99
+ raising. A final line that is still mid-write is returned as-is: the caller
100
+ asked what the run is doing right now, not for a complete record.
101
+ """
102
+ with open(path, "rb") as handle:
103
+ handle.seek(0, os.SEEK_END)
104
+ size = handle.tell()
105
+ truncated = size > RUN_TAIL_READ_BYTES
106
+ handle.seek(max(0, size - RUN_TAIL_READ_BYTES))
107
+ text = handle.read().decode("utf-8", errors="replace")
108
+ found = text.splitlines()
109
+ if truncated and found:
110
+ # The window opened mid-line, so the first entry is a fragment. Codex
111
+ # emits very long single-line JSON events, so this is reachable, and
112
+ # handing a caller half a line as if it were whole is worse than one
113
+ # line short.
114
+ found = found[1:]
115
+ return found[-lines:]
116
+
117
+ def _cdx_self_command():
118
+ """argv prefix that re-runs this same cdx as a child process.
119
+
120
+ `--detach` re-invokes cdx rather than forking: the child then walks the
121
+ ordinary blocking run path, so registry bookkeeping, usage extraction,
122
+ history, and the code-review report all happen exactly once, in code that
123
+ is already tested, without a second implementation that only detached runs
124
+ exercise. `os.fork()` would avoid the re-exec but is not available on
125
+ Windows, which this CLI supports.
126
+
127
+ Always goes through `sys.executable -m`, never `sys.argv[0]`: the console
128
+ script has no reliable executable bit on Windows (`os.access(X_OK)` is true
129
+ for any existing file there) and a shebang script handed to CreateProcess
130
+ does not start at all.
131
+ """
132
+ package_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
133
+ return [sys.executable, "-m", f"{__package__}.cli"], package_root
134
+
135
+ def _detached_spawn_options():
136
+ """Platform flags that keep the child alive after this process exits.
137
+
138
+ `start_new_session` is silently ignored on Windows, where a child stays
139
+ attached to the console and dies with the launcher unless it is explicitly
140
+ detached and given its own process group.
141
+ """
142
+ if sys.platform == "win32":
143
+ detached_process = getattr(subprocess, "DETACHED_PROCESS", 0x00000008)
144
+ new_group = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200)
145
+ return {"creationflags": detached_process | new_group}
146
+ return {"start_new_session": True}
147
+
148
+ def _detached_child_argv(parsed, prompt_path, session_name, cwd):
149
+ """Child argv for a detached run.
150
+
151
+ Always names the session the parent already resolved, never `--provider`:
152
+ the parent may have auto-selected an account, and letting the child select
153
+ again could land it on a different session than the one the launch payload
154
+ just reported to the caller.
155
+ """
156
+ # The resolved absolute cwd, not the raw user string: the child is started
157
+ # from the package root, so a relative "--cwd ." would resolve there.
158
+ argv = ["run", session_name, "--cwd", cwd, "--prompt-file", prompt_path]
159
+ for flag, key in (
160
+ ("--model", "model"),
161
+ ("--kind", "kind"),
162
+ ("--permission", "permission"),
163
+ ):
164
+ value = parsed.get(key)
165
+ if value:
166
+ argv += [flag, str(value)]
167
+ # reasoning_effort and power are normalized to the same value upstream;
168
+ # passing one back is enough and avoids re-tripping the must-match check.
169
+ if parsed.get("reasoning_effort"):
170
+ argv += ["--reasoning-effort", str(parsed["reasoning_effort"])]
171
+ if parsed.get("timeout_seconds"):
172
+ argv += ["--timeout-seconds", str(parsed["timeout_seconds"])]
173
+ argv.append("--json")
174
+ return argv
175
+
176
+ DETACHED_PROMPT_SUFFIX = ".prompt.txt"
177
+
178
+ def _consume_detached_prompt_file(parsed, detached_run_id):
179
+ """Delete the prompt file a detached launch staged for this child.
180
+
181
+ The prompt is arbitrary, often sensitive text, and the rest of the codebase
182
+ keeps it out of anything persisted (`REDACTED_PROMPT_ARG`, `sensitive_args`).
183
+ Leaving the staged copy behind would put a permanent cleartext prompt in the
184
+ session's log directory for every detached run. Only the child that was
185
+ handed a run id removes it, and only a file this code wrote.
186
+ """
187
+ prompt_file = parsed.get("prompt_file")
188
+ if not detached_run_id or not prompt_file or not prompt_file.endswith(DETACHED_PROMPT_SUFFIX):
189
+ return
190
+ try:
191
+ os.remove(prompt_file)
192
+ except OSError:
193
+ pass
194
+
195
+ def _spawn_detached_run(parsed, prompt, artifacts, ctx, session_name, cwd):
196
+ """Stage the prompt, then launch the child detached and return immediately.
197
+
198
+ The prompt is written beside the run's other artifacts rather than to a
199
+ temporary file so that nothing has to survive this process in order to
200
+ clean it up, and so the child cannot lose its input if the launcher exits
201
+ first.
202
+ """
203
+ # transcript_path is "<prefix>.log"; the other artifacts share that prefix.
204
+ prefix = os.path.splitext(artifacts["transcript_path"])[0]
205
+ prompt_path = f"{prefix}{DETACHED_PROMPT_SUFFIX}"
206
+ launch_log_path = f"{prefix}.launch.log"
207
+ with open(prompt_path, "w", encoding="utf-8") as handle:
208
+ handle.write(prompt)
209
+
210
+ command, package_root = _cdx_self_command()
211
+ env = {**os.environ, DETACHED_RUN_ID_ENV: artifacts["run_id"]}
212
+ if package_root:
213
+ existing = env.get("PYTHONPATH")
214
+ env["PYTHONPATH"] = f"{package_root}{os.pathsep}{existing}" if existing else package_root
215
+
216
+ spawn = ctx.get("spawn_detached") or subprocess.Popen
217
+ try:
218
+ with open(launch_log_path, "w", encoding="utf-8", errors="replace") as log_file:
219
+ child = spawn(
220
+ command + _detached_child_argv(parsed, prompt_path, session_name, cwd),
221
+ stdout=log_file,
222
+ stderr=subprocess.STDOUT,
223
+ stdin=subprocess.DEVNULL,
224
+ # Detach so the run outlives the launcher, including when cdx
225
+ # was invoked over a non-interactive SSH command that closes as
226
+ # soon as it returns.
227
+ cwd=package_root or None,
228
+ env=env,
229
+ **_detached_spawn_options(),
230
+ )
231
+ except OSError as error:
232
+ # Surface as a CdxError so handle_run's own handler marks the already
233
+ # registered run failed, instead of letting a raw OSError escape as an
234
+ # unhandled traceback on a --json call.
235
+ raise CdxError(f"Failed to start detached cdx run: {error}", 126) from error
236
+ return {"pid": getattr(child, "pid", None), "launch_log_path": launch_log_path}
237
+
238
+ def handle_schema(rest, ctx):
239
+ _parse_schema_args(rest)
240
+ _write_json(ctx, _json_success("schema", "Schema loaded.", **cdx_schema()))
241
+ return 0
242
+
243
+ def handle_runs(rest, ctx):
244
+ parsed = _parse_runs_args(rest)
245
+ if not parsed["json"]:
246
+ raise CdxError(RUNS_USAGE)
247
+ since = parsed.get("since")
248
+ runs = _run_registry(ctx).list(limit=parsed["limit"], since=since)
249
+ warnings = []
250
+ if since is not None and parsed.get("limit_given"):
251
+ warnings.append({
252
+ "code": "limit_ignored_with_since",
253
+ "message": "--since returns every run completed after the cursor; --limit was ignored.",
254
+ })
255
+ _write_json(ctx, _json_success(
256
+ "runs",
257
+ "Runs loaded.",
258
+ warnings=warnings,
259
+ runs=runs,
260
+ since=since.isoformat() if since is not None else None,
261
+ ))
262
+ return 0
263
+
264
+ def handle_run_tail(rest, ctx):
265
+ parsed = _parse_run_tail_args(rest)
266
+ run = _run_registry(ctx).get(parsed["run_id"])
267
+ if not run:
268
+ _write_json(ctx, _json_failure("run-tail", "run_not_found", f"Unknown run: {parsed['run_id']}"))
269
+ return 1
270
+ stdout_path = (run.get("artifacts") or {}).get("stdout_path")
271
+ if not stdout_path:
272
+ _write_json(ctx, _json_failure(
273
+ "run-tail",
274
+ "run_output_unavailable",
275
+ f"No stdout_path recorded for run: {parsed['run_id']}",
276
+ run_id=parsed["run_id"],
277
+ status=run.get("status"),
278
+ ))
279
+ return 1
280
+ try:
281
+ lines = _tail_file_lines(stdout_path, parsed["lines"])
282
+ except FileNotFoundError:
283
+ if run.get("status") != "running":
284
+ _write_json(ctx, _json_failure(
285
+ "run-tail",
286
+ "run_output_unreadable",
287
+ f"Missing output file for a finished run: {stdout_path}",
288
+ run_id=parsed["run_id"],
289
+ stdout_path=stdout_path,
290
+ ))
291
+ return 1
292
+ # A run registered a moment ago has not written its first byte yet.
293
+ # That is "no output so far", not a failure — the advertised flow is
294
+ # launch detached, then tail immediately, and a polling caller must not
295
+ # read that window as fatal.
296
+ lines = []
297
+ except OSError as error:
298
+ _write_json(ctx, _json_failure(
299
+ "run-tail",
300
+ "run_output_unreadable",
301
+ f"Failed to read {stdout_path}: {error}",
302
+ run_id=parsed["run_id"],
303
+ stdout_path=stdout_path,
304
+ ))
305
+ return 1
306
+ _write_json(ctx, _json_success(
307
+ "run-tail",
308
+ "Run output loaded.",
309
+ run_id=parsed["run_id"],
310
+ status=run.get("status"),
311
+ stdout_path=stdout_path,
312
+ lines=lines,
313
+ ))
314
+ return 0
315
+
316
+ def handle_run_status(rest, ctx):
317
+ parsed = _parse_run_id_json_args(rest, RUN_STATUS_USAGE)
318
+ run = _run_registry(ctx).get(parsed["run_id"])
319
+ if not run:
320
+ _write_json(ctx, _json_failure("run-status", "run_not_found", f"Unknown run: {parsed['run_id']}"))
321
+ return 1
322
+ _write_json(ctx, _json_success("run-status", "Run loaded.", run=run))
323
+ return 0
324
+
325
+ def handle_run_report(rest, ctx):
326
+ parsed = _parse_run_id_json_args(rest, RUN_REPORT_USAGE)
327
+ run = _run_registry(ctx).get(parsed["run_id"])
328
+ if not run:
329
+ _write_json(ctx, _json_failure("run-report", "run_not_found", f"Unknown run: {parsed['run_id']}"))
330
+ return 1
331
+ _write_json(ctx, _json_success("run-report", "Run report loaded.", report={
332
+ "run": run,
333
+ "final_payload": run.get("final_payload"),
334
+ "task_report": run.get("task_report"),
335
+ "artifacts": run.get("artifacts") or {},
336
+ "usage": run.get("usage"),
337
+ "error": run.get("error"),
338
+ }))
339
+ return 0
340
+
341
+ def handle_run(rest, ctx):
342
+ registry = None
343
+ run_id = None
344
+ selection_row = None
345
+ # pop, not get: the variable is consumed once. Left in the environment it is
346
+ # inherited by the provider process, and any nested `cdx run` the agent
347
+ # itself makes would claim this same run_id — deleting this run's registry
348
+ # record and truncating its stdout/stderr/transcript files.
349
+ detached_run_id = os.environ.pop(DETACHED_RUN_ID_ENV, None) or None
350
+ try:
351
+ parsed = _parse_run_args(rest)
352
+ session = None
353
+ if parsed["name"]:
354
+ session = ctx["service"]["get_session"](parsed["name"])
355
+ if not session:
356
+ raise CdxError(f"Unknown session: {parsed['name']}")
357
+ if session.get("enabled", True) is False:
358
+ raise CdxError(f"Session is disabled: {parsed['name']}")
359
+ else:
360
+ selected = _select_headless_session(
361
+ ctx,
362
+ parsed["provider"],
363
+ min_reasoning_effort=parsed["reasoning_effort"],
364
+ require_ready=True,
365
+ # Cached by default: refreshing would make every auto-selected
366
+ # run pay for a provider probe. `--refresh` is there for callers
367
+ # that would rather pay than choose on stale data.
368
+ force_refresh=parsed.get("refresh", False),
369
+ )
370
+ if not selected:
371
+ _write_json(ctx, _json_failure(
372
+ "run",
373
+ "no_suitable_session",
374
+ "No suitable session found.",
375
+ launcher="cdx",
376
+ provider=parsed["provider"],
377
+ ))
378
+ return 1
379
+ session = selected["session"]
380
+ selection_row = selected.get("row") or {}
381
+
382
+ cwd = os.path.abspath(parsed["cwd"])
383
+ if not os.path.isdir(cwd):
384
+ raise CdxError(f"Invalid cwd: {parsed['cwd']}")
385
+ prompt = read_run_prompt(parsed, stdin=ctx.get("prompt_stdin"))
386
+ _consume_detached_prompt_file(parsed, detached_run_id)
387
+ launch_updates = {}
388
+ if parsed.get("model"):
389
+ launch_updates["model"] = parsed["model"]
390
+ if parsed.get("permission"):
391
+ launch_updates["permission"] = parsed["permission"]
392
+ if parsed.get("reasoning_effort"):
393
+ launch_updates["reasoning_effort"] = parsed["reasoning_effort"]
394
+ launch_updates["power"] = parsed["reasoning_effort"]
395
+ run_session = {
396
+ **session,
397
+ "launch": {
398
+ **(session.get("launch") or {}),
399
+ **launch_updates,
400
+ },
401
+ }
402
+ _ensure_session_authentication(
403
+ run_session,
404
+ ctx["service"],
405
+ spawn=ctx.get("spawn"),
406
+ spawn_sync=ctx.get("spawn_sync"),
407
+ env_override=ctx.get("env"),
408
+ stdin_is_tty=False,
409
+ behavior="launch",
410
+ signal_emitter=ctx.get("signal_emitter"),
411
+ trust_local_credentials=False,
412
+ )
413
+ registry = _run_registry(ctx)
414
+ # A detached child is handed its parent's run_id so both agree on the
415
+ # identity the launch payload already reported to the caller.
416
+ # pop, not get: the variable is consumed once. Left in the environment
417
+ # it is inherited by the provider process, and any nested `cdx run` the
418
+ # agent itself makes would claim this same run_id — deleting this run's
419
+ # registry record and truncating its stdout/stderr/transcript files.
420
+ artifacts = _headless_artifact_paths(run_session, run_id=detached_run_id)
421
+ run_id = artifacts["run_id"]
422
+ registry.start(
423
+ run_id,
424
+ kind=parsed.get("kind"),
425
+ session=run_session.get("name"),
426
+ provider=run_session.get("provider"),
427
+ model=parsed.get("model") or ((run_session.get("launch") or {}).get("model")),
428
+ cwd=cwd,
429
+ artifacts={
430
+ "transcript_path": artifacts.get("transcript_path"),
431
+ "stdout_path": artifacts.get("stdout_path"),
432
+ "stderr_path": artifacts.get("stderr_path"),
433
+ },
434
+ )
435
+ if parsed.get("detach"):
436
+ launch = _spawn_detached_run(parsed, prompt, artifacts, ctx, run_session["name"], cwd)
437
+ # Hand the record the child's pid: this process is about to exit,
438
+ # and a run pointing at a dead pid gets swept up as stale.
439
+ if launch.get("pid"):
440
+ registry.set_pid(run_id, launch["pid"])
441
+ _write_json(ctx, run_launch_payload(
442
+ API_SCHEMA_VERSION,
443
+ parsed,
444
+ run_session,
445
+ artifacts,
446
+ cwd=cwd,
447
+ pid=launch.get("pid"),
448
+ launch_log_path=launch.get("launch_log_path"),
449
+ selection_row=selection_row,
450
+ ))
451
+ return 0
452
+
453
+ run_info = _run_headless_provider_command(
454
+ run_session,
455
+ cwd=cwd,
456
+ env_override=ctx.get("env"),
457
+ initial_prompt=prompt,
458
+ timeout_seconds=parsed.get("timeout_seconds"),
459
+ spawn=ctx.get("spawn_headless") or ctx.get("spawn"),
460
+ run_id=run_id,
461
+ )
462
+ usage = extract_run_usage(run_session.get("provider"), run_info.get("stdout_path"))
463
+ run_info = {**run_info, "usage": usage}
464
+ ok = run_info.get("returncode") == 0
465
+ if ok:
466
+ final_payload = run_result_payload(
467
+ API_SCHEMA_VERSION, True, parsed, run_session, run_info=run_info,
468
+ selection_row=selection_row,
469
+ )
470
+ task_report = build_code_review_report(run_id, final_payload) if parsed.get("kind") == "code-review" else None
471
+ registry.finish(
472
+ run_id,
473
+ status="succeeded",
474
+ final_payload=final_payload,
475
+ run_info=run_info,
476
+ task_report=task_report,
477
+ )
478
+ ctx["service"]["record_launch_history"](session["name"], {
479
+ "status": "success",
480
+ "cwd": cwd,
481
+ "exit_code": 0,
482
+ **run_info,
483
+ })
484
+ _write_json(ctx, final_payload)
485
+ return 0
486
+ message = "Provider process timed out." if run_info.get("timed_out") else "Provider process exited with a non-zero status."
487
+ error = CdxError(message, run_info.get("returncode") or 1)
488
+ final_payload = run_result_payload(
489
+ API_SCHEMA_VERSION,
490
+ False,
491
+ parsed,
492
+ run_session,
493
+ run_info=run_info,
494
+ error=error,
495
+ error_source="provider",
496
+ error_code="provider_timeout" if run_info.get("timed_out") else "provider_failed",
497
+ selection_row=selection_row,
498
+ )
499
+ registry.finish(
500
+ run_id,
501
+ status="timed_out" if run_info.get("timed_out") else "failed",
502
+ final_payload=final_payload,
503
+ run_info=run_info,
504
+ error=final_payload.get("error"),
505
+ task_report=build_code_review_report(run_id, final_payload) if parsed.get("kind") == "code-review" else None,
506
+ )
507
+ ctx["service"]["record_launch_history"](session["name"], {
508
+ "status": "failed",
509
+ "cwd": cwd,
510
+ "error": str(error),
511
+ "exit_code": error.exit_code,
512
+ **run_info,
513
+ })
514
+ _write_json(ctx, final_payload)
515
+ return error.exit_code or 1
516
+ except CdxError as error:
517
+ run_info = getattr(error, "run_info", None)
518
+ final_payload = run_result_payload(
519
+ API_SCHEMA_VERSION,
520
+ False,
521
+ locals().get("parsed", {}) or {},
522
+ locals().get("session"),
523
+ run_info=run_info,
524
+ error=error,
525
+ error_source="cdx",
526
+ error_code=run_cdx_error_code(error),
527
+ selection_row=selection_row,
528
+ )
529
+ if registry and run_id:
530
+ registry.finish(
531
+ run_id,
532
+ status="failed",
533
+ final_payload=final_payload,
534
+ run_info=run_info,
535
+ error=final_payload.get("error"),
536
+ )
537
+ _write_json(ctx, final_payload)
538
+ return error.exit_code
539
+
540
+ def _select_headless_session(ctx, provider, min_reasoning_effort=None, require_ready=False, force_refresh=False):
541
+ """The best session for a headless run, using the one shared ranking.
542
+
543
+ Returns the winning session with its status row and why it won, or None if
544
+ no candidate survived the filters.
545
+ """
546
+ sessions_by_name = {
547
+ session["name"]: session for session in ctx["service"]["list_sessions"]()
548
+ }
549
+ rows = ctx["service"]["get_status_rows"](force_refresh=force_refresh)
550
+ ordered, decision = rank_sessions(
551
+ rows,
552
+ _now_timestamp(),
553
+ _priority_reset_timestamp,
554
+ provider=provider,
555
+ require_ready=require_ready,
556
+ min_reasoning_effort=min_reasoning_effort,
557
+ )
558
+ for row in ordered:
559
+ session = sessions_by_name.get(row.get("session_name"))
560
+ if session:
561
+ return {"session": session, "row": row, "decision": decision}
562
+ return None
@@ -0,0 +1,107 @@
1
+ """Launch settings domain commands: set, unset, and the power/perm/fast/model aliases.
2
+
3
+ Split out of cli_commands.py. Moved verbatim; re-exported by cli_commands.
4
+ """
5
+
6
+ from ..cli_args import _parse_launch_setting_alias_args, _parse_set_args, _parse_unset_args
7
+ from ..cli_helpers import _format_launch_config, _json_success, _write_json
8
+ from ..cli_render import _success
9
+ from ..errors import CdxError
10
+
11
+
12
+ def _resolve_bulk_launch_targets(parsed, service):
13
+ sessions = service["list_sessions"]()
14
+ by_name = {session["name"]: session for session in sessions}
15
+ if parsed.get("name"):
16
+ session = by_name.get(parsed["name"])
17
+ if not session:
18
+ raise CdxError(f"Unknown session: {parsed['name']}")
19
+ targets = [session]
20
+ elif parsed.get("sessions") == "all":
21
+ targets = sessions
22
+ elif parsed.get("sessions"):
23
+ targets = []
24
+ for name in parsed["sessions"]:
25
+ session = by_name.get(name)
26
+ if not session:
27
+ raise CdxError(f"Unknown session: {name}")
28
+ targets.append(session)
29
+ else:
30
+ targets = sessions
31
+ if parsed.get("provider"):
32
+ targets = [session for session in targets if session["provider"] == parsed["provider"]]
33
+ if not targets:
34
+ raise CdxError("No sessions matched.")
35
+ return targets
36
+
37
+ def _format_bulk_launch_summary(sessions):
38
+ names = [session["name"] for session in sessions]
39
+ if len(names) <= 8:
40
+ return ", ".join(names)
41
+ return ", ".join(names[:8]) + f", +{len(names) - 8} more"
42
+
43
+ def _apply_launch_settings(parsed, ctx, action="set"):
44
+ targets = _resolve_bulk_launch_targets(parsed, ctx["service"])
45
+ sessions = [
46
+ ctx["service"]["set_launch_settings"](session["name"], parsed["settings"])
47
+ for session in targets
48
+ ]
49
+ if len(sessions) == 1:
50
+ session = sessions[0]
51
+ message = f"Updated launch settings for {session['name']}"
52
+ else:
53
+ message = f"Updated launch settings for {len(sessions)} sessions: {_format_bulk_launch_summary(sessions)}"
54
+ if parsed["json"]:
55
+ payload = _json_success(
56
+ action,
57
+ message,
58
+ updated_count=len(sessions),
59
+ session=sessions[0] if len(sessions) == 1 else None,
60
+ sessions=sessions,
61
+ launch=sessions[0].get("launch") or {} if len(sessions) == 1 else None,
62
+ )
63
+ _write_json(ctx, payload)
64
+ return 0
65
+ ctx["out"](f"{_success(message, ctx['use_color'])}\n")
66
+ if len(sessions) == 1:
67
+ ctx["out"](f"{_format_launch_config(sessions[0], ctx['use_color'])}\n")
68
+ return 0
69
+
70
+ def _clear_launch_settings(parsed, ctx, action="unset"):
71
+ targets = _resolve_bulk_launch_targets(parsed, ctx["service"])
72
+ sessions = [
73
+ ctx["service"]["unset_launch_settings"](session["name"], parsed["keys"])
74
+ for session in targets
75
+ ]
76
+ if len(sessions) == 1:
77
+ session = sessions[0]
78
+ message = f"Cleared launch settings for {session['name']}"
79
+ else:
80
+ message = f"Cleared launch settings for {len(sessions)} sessions: {_format_bulk_launch_summary(sessions)}"
81
+ if parsed["json"]:
82
+ payload = _json_success(
83
+ action,
84
+ message,
85
+ updated_count=len(sessions),
86
+ session=sessions[0] if len(sessions) == 1 else None,
87
+ sessions=sessions,
88
+ launch=sessions[0].get("launch") or {} if len(sessions) == 1 else None,
89
+ )
90
+ _write_json(ctx, payload)
91
+ return 0
92
+ ctx["out"](f"{_success(message, ctx['use_color'])}\n")
93
+ if len(sessions) == 1:
94
+ ctx["out"](f"{_format_launch_config(sessions[0], ctx['use_color'])}\n")
95
+ return 0
96
+
97
+ def handle_set(rest, ctx):
98
+ return _apply_launch_settings(_parse_set_args(rest), ctx)
99
+
100
+ def handle_unset(rest, ctx):
101
+ return _clear_launch_settings(_parse_unset_args(rest), ctx)
102
+
103
+ def handle_launch_setting_alias(kind, rest, ctx):
104
+ parsed = _parse_launch_setting_alias_args(kind, rest)
105
+ if parsed.get("settings"):
106
+ return _apply_launch_settings(parsed, ctx, action=kind)
107
+ return _clear_launch_settings(parsed, ctx, action=kind)