cdx-manager 0.12.4 → 0.15.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.12.4",
3
+ "version": "0.15.0",
4
4
  "description": "Terminal session manager for Codex and Claude accounts.",
5
5
  "license": "MIT",
6
6
  "author": "Alexandre Agostini",
@@ -30,7 +30,7 @@
30
30
  "checksums",
31
31
  "changelogs",
32
32
  "src",
33
- "!src/__pycache__",
33
+ "!src/**/__pycache__",
34
34
  "!src/**/*.pyc",
35
35
  "install.sh",
36
36
  "install.ps1",
@@ -46,7 +46,7 @@
46
46
  "test": "npm run test:py",
47
47
  "test:py": "node bin/python-runner.js -m pytest",
48
48
  "test:coverage": "node bin/python-runner.js -m pytest --cov=src --cov-report=term-missing",
49
- "lint": "node --check bin/cdx.js && node --check bin/python-runner.js && node bin/python-runner.js scripts/verify_project_docs.py && node bin/python-runner.js -m ruff check . && node bin/python-runner.js -m py_compile bin/cdx src/*.py scripts/*.py test/test_*_py.py",
49
+ "lint": "node --check bin/cdx.js && node --check bin/python-runner.js && node bin/python-runner.js scripts/verify_project_docs.py && node bin/python-runner.js -m ruff check . && node bin/python-runner.js -m py_compile bin/cdx src/*.py src/commands/*.py scripts/*.py test/*.py",
50
50
  "release:validate": "node bin/python-runner.js scripts/verify_release_checksums.py",
51
51
  "prepublishOnly": "npm run release:validate && npm run lint && npm test",
52
52
  "link": "npm link",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cdx-manager"
7
- version = "0.12.4"
7
+ version = "0.15.0"
8
8
  description = "Terminal session manager for Codex and Claude accounts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
package/src/cli.py CHANGED
@@ -43,7 +43,9 @@ from .cli_commands import (
43
43
  handle_run,
44
44
  handle_run_report,
45
45
  handle_run_status,
46
+ handle_run_tail,
46
47
  handle_runs,
48
+ handle_schema,
47
49
  handle_select,
48
50
  handle_set,
49
51
  handle_stats,
@@ -78,7 +80,38 @@ from .status_view import (
78
80
  )
79
81
  from .update_check import check_for_update, check_logics_manager_for_update
80
82
 
81
- VERSION = "0.12.4"
83
+
84
+ def _resolve_version():
85
+ """The version, resolved rather than restated.
86
+
87
+ This was a hardcoded string, a fourth copy alongside VERSION, package.json,
88
+ and pyproject.toml, with nothing keeping the four in step — so
89
+ `cdx --version` could report a release it was not.
90
+
91
+ The VERSION file next to the package wins when it exists, because that only
92
+ happens in a checkout, where it is the thing being edited and the installed
93
+ metadata is whatever was last `pip install`ed. Installed packages have no
94
+ such file and fall through to their own metadata.
95
+ """
96
+ version_file = os.path.join(
97
+ os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "VERSION"
98
+ )
99
+ try:
100
+ with open(version_file, encoding="utf-8") as handle:
101
+ text = handle.read().strip()
102
+ if text:
103
+ return text
104
+ except OSError:
105
+ pass
106
+ try:
107
+ from importlib.metadata import version
108
+
109
+ return version("cdx-manager")
110
+ except Exception: # noqa: BLE001 - any resolution failure falls back below
111
+ return "0.0.0"
112
+
113
+
114
+ VERSION = _resolve_version()
82
115
 
83
116
  # Public surface: this module is a facade. Names below are imported above
84
117
  # purely to be re-exported (consumed by tests and external callers); listing
@@ -130,7 +163,9 @@ _COMMAND_HANDLERS = {
130
163
  "run": handle_run,
131
164
  "run-report": handle_run_report,
132
165
  "run-status": handle_run_status,
166
+ "run-tail": handle_run_tail,
133
167
  "runs": handle_runs,
168
+ "schema": handle_schema,
134
169
  "select": handle_select,
135
170
  "set": handle_set,
136
171
  "stats": handle_stats,
@@ -183,10 +218,12 @@ def _print_help(use_color=False):
183
218
  f" {_style('cdx next [--json] [--refresh]', '36', use_color)}",
184
219
  f" {_style('cdx reset <name> [--yes] [--json]', '36', use_color)}",
185
220
  f" {_style('cdx select --provider PROVIDER [--min-reasoning-effort minimal|low|medium|high|xhigh] [--min-power minimal|low|medium|high|xhigh] [--require-ready] [--refresh] --json', '36', use_color)}",
186
- f" {_style('cdx run [session] --cwd PATH (--prompt-file PATH|--prompt TEXT) [--provider PROVIDER] [--model MODEL] [--kind assistant|code-review] [--reasoning-effort minimal|low|medium|high|xhigh] [--power minimal|low|medium|high|xhigh] [--permission review|default|auto|full|workspace-write|read-only|danger-full-access] [--timeout-seconds N] --json', '36', use_color)}",
187
- f" {_style('cdx runs [--limit N] --json', '36', use_color)}",
221
+ f" {_style('cdx run [session] --cwd PATH (--prompt-file PATH|--prompt TEXT|--prompt-file -) [--provider PROVIDER] [--model MODEL] [--kind assistant|code-review] [--reasoning-effort minimal|low|medium|high|xhigh] [--power minimal|low|medium|high|xhigh] [--permission review|default|auto|full|workspace-write|read-only|danger-full-access] [--timeout-seconds N] [--detach] [--refresh] --json', '36', use_color)}",
222
+ f" {_style('cdx runs [--limit N] [--since 7d|today|DATE] --json', '36', use_color)}",
188
223
  f" {_style('cdx run-status <run_id> --json', '36', use_color)}",
189
224
  f" {_style('cdx run-report <run_id> --json', '36', use_color)}",
225
+ f" {_style('cdx run-tail <run_id> [--lines N] --json', '36', use_color)}",
226
+ f" {_style('cdx schema --json', '36', use_color)}",
190
227
  f" {_style('cdx context show|path|init|edit|clear|set|append [text...] [--json]', '36', use_color)}",
191
228
  f" {_style('cdx memory [--global|--project NAME_OR_PATH] [show|view|path|init|edit|clear|set|append|list] [text...] [--json]', '36', use_color)}",
192
229
  f" {_style('cdx disk [profiles] [--candidates] [--json]', '36', use_color)}",
@@ -216,7 +253,7 @@ def _print_help(use_color=False):
216
253
  f" {_style('cdx clean profiles (--tmp|--old-logs DAYS) [--yes] [--json]', '36', use_color)}",
217
254
  f" {_style('cdx export <file> [--include-auth] [--sessions a,b] [--passphrase-env VAR|--passphrase-stdin] [--force] [--json]', '36', use_color)}",
218
255
  f" {_style('cdx import <file> [--sessions a,b] [--passphrase-env VAR|--passphrase-stdin] [--force|--merge] [--allow-authless-force] [--json]', '36', use_color)}",
219
- f" {_style('cdx doctor [--severity OK|WARN|FAIL[,OK|WARN|FAIL...]] [--json]', '36', use_color)}",
256
+ f" {_style('cdx doctor [--severity OK|WARN|FAIL[,OK|WARN|FAIL...]] [--check-provider-flags] [--json]', '36', use_color)}",
220
257
  f" {_style('cdx repair [--dry-run] [--force] [--json]', '36', use_color)}",
221
258
  f" {_style('cdx view [--json]', '36', use_color)}",
222
259
  f" {_style('cdx update [all] [--check] [--yes] [--json] [--version TAG]', '36', use_color)}",
@@ -239,6 +276,25 @@ def wants_json(argv):
239
276
 
240
277
  def format_json_error(error):
241
278
  message = str(error)
279
+ # An error that already knows its own code keeps it. Only errors that never
280
+ # declared one fall back to sniffing the message prefix below.
281
+ declared = getattr(error, "code", None)
282
+ if declared:
283
+ allowed = getattr(error, "allowed", None)
284
+ # Same field set as run_result_payload's error object, so a caller does
285
+ # not need two shapes depending on which command raised.
286
+ return json.dumps({
287
+ "schema_version": API_SCHEMA_VERSION,
288
+ "ok": False,
289
+ "error": {
290
+ "source": "cdx",
291
+ "code": declared,
292
+ "message": message,
293
+ "arguments": list(getattr(error, "arguments", ()) or []),
294
+ "allowed_values": list(allowed) if allowed else None,
295
+ "exit_code": error.exit_code,
296
+ },
297
+ }, indent=2)
242
298
  code = "cdx_error"
243
299
  if message.startswith("Usage:"):
244
300
  code = "invalid_usage"
@@ -506,7 +562,11 @@ def main(argv, options=None):
506
562
  "service": service,
507
563
  "signal_emitter": signal_emitter,
508
564
  "spawn": spawn,
565
+ "spawn_detached": options.get("spawn_detached"),
509
566
  "spawn_headless": options.get("spawn_headless"),
567
+ # Stream `--prompt-file -` reads from. Distinct from "stdin", which is
568
+ # the {"isTTY": ...} descriptor the interactive paths use.
569
+ "prompt_stdin": options.get("prompt_stdin"),
510
570
  "spawn_sync": spawn_sync,
511
571
  "stdin_is_tty": stdin_is_tty,
512
572
  "version": VERSION,
package/src/cli_args.py CHANGED
@@ -7,12 +7,21 @@ imported back there and used by the handlers.
7
7
 
8
8
  from datetime import datetime, timedelta
9
9
 
10
- from .config import PROVIDER_CODEX, PROVIDERS
11
- from .errors import CdxError
10
+ from .config import (
11
+ MAX_LAUNCH_BUDGET_USD,
12
+ PERMISSION_ALIASES,
13
+ PERMISSION_INPUT_VALUES,
14
+ PERMISSION_VALUES,
15
+ PROVIDER_CODEX,
16
+ PROVIDERS,
17
+ REASONING_EFFORT_VALUES,
18
+ normalize_permission,
19
+ )
20
+ from .errors import CdxArgumentError, CdxError
12
21
  from .provider_runtime import _normalize_reasoning_effort
13
22
 
14
23
  STATUS_USAGE = "Usage: cdx status [--json] [--refresh|--cached] [--timeout SECONDS] | cdx status --small|-s [--refresh|--cached] [--timeout SECONDS] | cdx status <name> [--json] [--refresh|--cached] [--timeout SECONDS]"
15
- DOCTOR_USAGE = "Usage: cdx doctor [--severity OK|WARN|FAIL[,OK|WARN|FAIL...]] [--json]"
24
+ DOCTOR_USAGE = "Usage: cdx doctor [--severity OK|WARN|FAIL[,OK|WARN|FAIL...]] [--check-provider-flags] [--json]"
16
25
  DISK_USAGE = "Usage: cdx disk [profiles] [--candidates] [--json]"
17
26
  REPAIR_USAGE = "Usage: cdx repair [--dry-run] [--force] [--json]"
18
27
  UPDATE_USAGE = "Usage: cdx update [all] [--check] [--yes] [--json] [--version TAG]"
@@ -22,8 +31,8 @@ CONTEXT_USAGE = "Usage: cdx context show|path|init|edit|clear|set|append [text..
22
31
  MEMORY_USAGE = "Usage: cdx memory [--global|--project NAME_OR_PATH] [show|view|path|init|edit|clear|set|append|list] [text...] [--json]"
23
32
  HANDOFF_USAGE = "Usage: cdx handoff <name> [--json] | cdx handoff <source> <target> [--json]"
24
33
  LABEL_USAGE = "Usage: cdx label <name> <label> [--json] | cdx label <name> --clear [--json]"
25
- SET_USAGE = "Usage: cdx set <name>|--sessions all|a,b|--provider PROVIDER [--power minimal|low|medium|high|xhigh] [--permission review|default|auto|full] [--fast on|off] [--rtk on|off] [--logics on|off] [--model MODEL] [--priority 0..100] [--json]"
26
- UNSET_USAGE = "Usage: cdx unset <name>|--sessions all|a,b|--provider PROVIDER (--power|--reasoning-effort|--permission|--fast|--rtk|--logics|--model|--priority|--all) [--json]"
34
+ SET_USAGE = "Usage: cdx set <name>|--sessions all|a,b|--provider PROVIDER [--power minimal|low|medium|high|xhigh] [--permission review|default|auto|full] [--fast on|off] [--rtk on|off] [--logics on|off] [--model MODEL] [--fallback-model MODEL[,MODEL...]] [--budget USD] [--extra-args 'ARGS'] [--priority 0..100] [--json]"
35
+ UNSET_USAGE = "Usage: cdx unset <name>|--sessions all|a,b|--provider PROVIDER (--power|--reasoning-effort|--permission|--fast|--rtk|--logics|--model|--fallback-model|--budget|--extra-args|--priority|--all) [--json]"
27
36
  SETTING_ALIAS_USAGE = "Usage: cdx power|perm|fast|model <name|all|provider:PROVIDER|a,b> <value|default> [--json]"
28
37
  CONFIG_USAGE = "Usage: cdx config <name> [--json]"
29
38
  CONFIGS_USAGE = "Usage: cdx configs [--json]"
@@ -34,17 +43,37 @@ RESUME_USAGE = "Usage: cdx resume <name> [--json]"
34
43
  CAN_RESUME_USAGE = "Usage: cdx can-resume <name> [--json]"
35
44
  SELECT_USAGE = "Usage: cdx select --provider PROVIDER [--min-reasoning-effort minimal|low|medium|high|xhigh] [--min-power minimal|low|medium|high|xhigh] [--require-ready] [--refresh] --json"
36
45
  NEXT_USAGE = "Usage: cdx next [--json] [--refresh]"
37
- RUN_USAGE = "Usage: cdx run [session] --cwd PATH (--prompt-file PATH|--prompt TEXT) [--provider PROVIDER] [--model MODEL] [--kind assistant|code-review] [--reasoning-effort minimal|low|medium|high|xhigh] [--power minimal|low|medium|high|xhigh] [--permission review|default|auto|full|workspace-write|read-only|danger-full-access] [--timeout-seconds N] --json"
46
+ RUN_USAGE = "Usage: cdx run [session] --cwd PATH (--prompt-file PATH|--prompt TEXT|--prompt-file -) [--provider PROVIDER] [--model MODEL] [--kind assistant|code-review] [--reasoning-effort minimal|low|medium|high|xhigh] [--power minimal|low|medium|high|xhigh] [--permission review|default|auto|full|workspace-write|read-only|danger-full-access] [--timeout-seconds N] [--detach] [--failover] [--refresh] --json"
38
47
  RUN_JSON_REQUIRED = "cdx run: --json is required."
39
48
  RUN_TARGET_REQUIRED = "cdx run: specify a session name or --provider PROVIDER."
40
49
  RUN_SESSION_PROVIDER_CONFLICT = "cdx run: cannot specify both a session name and --provider."
41
50
  RUN_CWD_REQUIRED = "cdx run: --cwd PATH is required."
42
51
  RUN_PROMPT_SOURCE_REQUIRED = "cdx run: specify exactly one prompt source: --prompt TEXT or --prompt-file PATH."
52
+ RUN_DETACH_FAILOVER_CONFLICT = "cdx run: --failover needs to watch the run, which --detach gives up."
43
53
  RUN_KIND_VALUES = ("assistant", "code-review")
44
- RUN_PERMISSION_VALUES = ("review", "default", "auto", "full", "workspace-write", "read-only", "danger-full-access")
45
- RUNS_USAGE = "Usage: cdx runs [--limit N] --json"
54
+ # Aliases of the shared definitions in config, kept only so the existing
55
+ # RUN_* spellings in this module keep reading naturally. They are references,
56
+ # not copies: config.py is the single owner.
57
+ RUN_PERMISSION_VALUES = PERMISSION_INPUT_VALUES
58
+ RUN_PERMISSION_CANONICAL_VALUES = PERMISSION_VALUES
59
+ RUN_PERMISSION_ALIASES = PERMISSION_ALIASES
60
+ RUN_EFFORT_VALUES = REASONING_EFFORT_VALUES
61
+ RUNS_USAGE = "Usage: cdx runs [--limit N] [--since 7d|today|DATE] --json"
46
62
  RUN_STATUS_USAGE = "Usage: cdx run-status <run_id> --json"
47
63
  RUN_REPORT_USAGE = "Usage: cdx run-report <run_id> --json"
64
+ RUN_TAIL_USAGE = "Usage: cdx run-tail <run_id> [--lines N] --json"
65
+ SCHEMA_USAGE = "Usage: cdx schema --json"
66
+
67
+ RUN_TAIL_DEFAULT_LINES = 50
68
+ RUN_TAIL_MAX_LINES = 2000
69
+
70
+ # Argument-failure codes emitted in `error.code`. Specific enough that a caller
71
+ # branches on the code alone, never on the human message.
72
+ ARG_CODE_MISSING = "missing_required_argument"
73
+ ARG_CODE_MUTUALLY_EXCLUSIVE = "mutually_exclusive_arguments"
74
+ ARG_CODE_INVALID_VALUE = "invalid_argument_value"
75
+ ARG_CODE_OUT_OF_RANGE = "argument_value_out_of_range"
76
+ ARG_CODE_UNKNOWN = "unknown_argument"
48
77
 
49
78
 
50
79
  def _parse_json_flag(args):
@@ -54,7 +83,7 @@ def _parse_json_flag(args):
54
83
 
55
84
 
56
85
  def _parse_doctor_args(args):
57
- parsed = {"json": False, "severity": None}
86
+ parsed = {"json": False, "severity": None, "check_provider_flags": False}
58
87
  index = 0
59
88
  while index < len(args):
60
89
  arg = args[index]
@@ -62,6 +91,10 @@ def _parse_doctor_args(args):
62
91
  parsed["json"] = True
63
92
  index += 1
64
93
  continue
94
+ if arg == "--check-provider-flags":
95
+ parsed["check_provider_flags"] = True
96
+ index += 1
97
+ continue
65
98
  if arg == "--severity":
66
99
  if parsed["severity"] is not None or index + 1 >= len(args):
67
100
  raise CdxError(DOCTOR_USAGE)
@@ -197,6 +230,17 @@ def _parse_priority_value(value):
197
230
  return priority
198
231
 
199
232
 
233
+ def _parse_budget_value(value):
234
+ try:
235
+ budget = float(value)
236
+ except (TypeError, ValueError) as error:
237
+ raise CdxError(SET_USAGE) from error
238
+ # NaN compares false against every bound, so reject it before the range check.
239
+ if budget != budget or budget <= 0 or budget > MAX_LAUNCH_BUDGET_USD:
240
+ raise CdxError(SET_USAGE)
241
+ return budget
242
+
243
+
200
244
  def _parse_set_args(args):
201
245
  parsed = _parse_flag_args(args, {
202
246
  "--power": {"key": "power", "type": "str", "default": None},
@@ -205,6 +249,9 @@ def _parse_set_args(args):
205
249
  "--rtk": {"key": "rtk", "type": "str", "default": None, "transform": _parse_fast_value},
206
250
  "--logics": {"key": "logics", "type": "str", "default": None, "transform": _parse_fast_value},
207
251
  "--model": {"key": "model", "type": "str", "default": None},
252
+ "--fallback-model": {"key": "fallback_model", "type": "str", "default": None},
253
+ "--budget": {"key": "budget", "type": "str", "default": None, "transform": _parse_budget_value},
254
+ "--extra-args": {"key": "extra_args", "type": "str", "default": None},
208
255
  "--priority": {"key": "priority", "type": "str", "default": None, "transform": _parse_priority_value},
209
256
  "--sessions": {"key": "sessions", "type": "str", "default": None, "transform": _parse_set_unset_sessions},
210
257
  "--provider": {"key": "provider", "type": "str", "default": None, "transform": lambda value: _parse_provider_filter(value, SET_USAGE)},
@@ -222,7 +269,7 @@ def _parse_set_args(args):
222
269
  raise CdxError(SET_USAGE)
223
270
  settings = {
224
271
  key: parsed[key]
225
- for key in ("power", "permission", "fast", "rtk", "logics", "model", "priority")
272
+ for key in ("power", "permission", "fast", "rtk", "logics", "model", "fallback_model", "budget", "extra_args", "priority")
226
273
  if parsed[key] is not None
227
274
  }
228
275
  if not settings:
@@ -245,6 +292,9 @@ def _parse_unset_args(args):
245
292
  "--rtk": {"key": "rtk", "type": "bool", "default": False},
246
293
  "--logics": {"key": "logics", "type": "bool", "default": False},
247
294
  "--model": {"key": "model", "type": "bool", "default": False},
295
+ "--fallback-model": {"key": "fallback_model", "type": "bool", "default": False},
296
+ "--budget": {"key": "budget", "type": "bool", "default": False},
297
+ "--extra-args": {"key": "extra_args", "type": "bool", "default": False},
248
298
  "--priority": {"key": "priority", "type": "bool", "default": False},
249
299
  "--all": {"key": "all", "type": "bool", "default": False},
250
300
  "--sessions": {"key": "sessions", "type": "str", "default": None, "transform": _parse_set_unset_sessions},
@@ -261,9 +311,11 @@ def _parse_unset_args(args):
261
311
  raise CdxError(UNSET_USAGE)
262
312
  if not parsed["names"] and not parsed["sessions"] and not parsed["provider"]:
263
313
  raise CdxError(UNSET_USAGE)
264
- keys = ["power", "reasoning_effort", "permission", "fast", "rtk", "logics", "model", "priority"] if parsed["all"] else [
265
- key for key in ("power", "reasoning_effort", "permission", "fast", "rtk", "logics", "model", "priority") if parsed[key]
266
- ]
314
+ _UNSET_KEYS = (
315
+ "power", "reasoning_effort", "permission", "fast", "rtk", "logics",
316
+ "model", "fallback_model", "budget", "extra_args", "priority",
317
+ )
318
+ keys = list(_UNSET_KEYS) if parsed["all"] else [key for key in _UNSET_KEYS if parsed[key]]
267
319
  if not keys:
268
320
  raise CdxError(UNSET_USAGE)
269
321
  return {
@@ -338,7 +390,11 @@ def _parse_run_timeout_seconds(value):
338
390
  try:
339
391
  return _parse_timeout_seconds(value, RUN_USAGE)
340
392
  except CdxError as error:
341
- raise CdxError(f"cdx run: --timeout-seconds must be a positive number; got {value!r}.") from error
393
+ raise CdxArgumentError(
394
+ f"cdx run: --timeout-seconds must be a positive number; got {value!r}.",
395
+ code=ARG_CODE_OUT_OF_RANGE,
396
+ arguments=["--timeout-seconds"],
397
+ ) from error
342
398
 
343
399
 
344
400
  def _parse_run_provider(value):
@@ -346,27 +402,183 @@ def _parse_run_provider(value):
346
402
  return _parse_provider_filter(value, RUN_USAGE)
347
403
  except CdxError as error:
348
404
  allowed = "|".join(PROVIDERS)
349
- raise CdxError(f"cdx run: invalid --provider {value!r}; allowed values: {allowed}.") from error
405
+ raise CdxArgumentError(
406
+ f"cdx run: invalid --provider {value!r}; allowed values: {allowed}.",
407
+ code=ARG_CODE_INVALID_VALUE,
408
+ arguments=["--provider"],
409
+ allowed=PROVIDERS,
410
+ ) from error
350
411
 
351
412
 
352
413
  def _normalize_run_permission(value):
353
414
  if value is None:
354
415
  return None
355
- text = str(value).strip().lower()
356
- aliases = {
357
- "workspace-write": "default",
358
- "read-only": "review",
359
- "danger-full-access": "full",
360
- }
361
- text = aliases.get(text, text)
362
- if text not in ("review", "default", "auto", "full"):
416
+ text = normalize_permission(value)
417
+ if text is None:
363
418
  allowed = "|".join(RUN_PERMISSION_VALUES)
364
- raise CdxError(f"cdx run: invalid --permission {value!r}; allowed values: {allowed}.")
419
+ raise CdxArgumentError(
420
+ f"cdx run: invalid --permission {value!r}; allowed values: {allowed}.",
421
+ code=ARG_CODE_INVALID_VALUE,
422
+ arguments=["--permission"],
423
+ allowed=RUN_PERMISSION_VALUES,
424
+ )
365
425
  return text
366
426
 
367
427
 
368
- def _parse_run_args(args):
428
+ def cdx_schema():
429
+ """The machine-readable contract for programmatic callers.
430
+
431
+ Every value here is the same object the parser validates against, not a
432
+ parallel copy — `cdx schema --json` cannot advertise a value `cdx run`
433
+ rejects, which is exactly how the ollama `--experimental-yolo` mapping
434
+ survived unexercised (GitHub issue #8).
435
+ """
436
+ # No `schema_version` here: the JSON envelope already carries it, and two
437
+ # keys of that name in one payload would be one too many.
438
+ return {
439
+ "enums": {
440
+ "permission": {
441
+ "accepted": list(RUN_PERMISSION_VALUES),
442
+ "canonical": list(RUN_PERMISSION_CANONICAL_VALUES),
443
+ "aliases": dict(RUN_PERMISSION_ALIASES),
444
+ },
445
+ "reasoning_effort": {"accepted": list(RUN_EFFORT_VALUES)},
446
+ "power": {"accepted": list(RUN_EFFORT_VALUES)},
447
+ "kind": {"accepted": list(RUN_KIND_VALUES)},
448
+ "provider": {"accepted": list(PROVIDERS)},
449
+ },
450
+ # Passthrough arguments are deliberately absent from this contract.
451
+ # `cdx set --extra-args` reaches the provider unchanged and cdx never
452
+ # validates it, so publishing it here would claim a guarantee that does
453
+ # not exist. A caller must treat it as opaque.
454
+ "unvalidated": {
455
+ "extra_args": {
456
+ "reason": "Passed to the provider CLI unchanged; cdx neither parses nor verifies these arguments.",
457
+ "not_checked_by": ["cdx schema", "cdx doctor --check-provider-flags"],
458
+ },
459
+ },
460
+ "mutually_exclusive": [
461
+ {
462
+ "command": "run",
463
+ "arguments": ["session", "--provider"],
464
+ "reason": "A session already fixes its provider; pass one or the other.",
465
+ },
466
+ {
467
+ "command": "run",
468
+ "arguments": ["--prompt-file", "--prompt"],
469
+ "reason": "Exactly one prompt source.",
470
+ },
471
+ {
472
+ "command": "run",
473
+ "arguments": ["--detach", "--failover"],
474
+ "reason": "Failover has to watch the run to react to a rate limit; --detach stops watching.",
475
+ },
476
+ ],
477
+ # Not mutually exclusive: both may be given, and cdx accepts them when
478
+ # they agree. A caller generating validation from `mutually_exclusive`
479
+ # would otherwise reject a command line cdx would have run.
480
+ "must_match": [
481
+ {
482
+ "command": "run",
483
+ "arguments": ["--reasoning-effort", "--power"],
484
+ "reason": "Aliases of one setting; supplying both with different values fails.",
485
+ },
486
+ ],
487
+ # Every code a run-family command can put in `error.code`, not just the
488
+ # argument ones — a caller matching exhaustively over this list must not
489
+ # fall through on a code it will certainly see.
490
+ "error_codes": {
491
+ "arguments": [
492
+ ARG_CODE_MISSING,
493
+ ARG_CODE_MUTUALLY_EXCLUSIVE,
494
+ ARG_CODE_INVALID_VALUE,
495
+ ARG_CODE_OUT_OF_RANGE,
496
+ ARG_CODE_UNKNOWN,
497
+ "invalid_reasoning_effort",
498
+ ],
499
+ "run": [
500
+ "invalid_cwd",
501
+ "session_disabled",
502
+ "no_suitable_session",
503
+ "provider_cli_not_found",
504
+ "provider_start_failed",
505
+ "provider_failed",
506
+ "provider_timeout",
507
+ "cdx_error",
508
+ ],
509
+ "run_lookup": [
510
+ "run_not_found",
511
+ "run_output_unavailable",
512
+ "run_output_unreadable",
513
+ ],
514
+ },
515
+ "warning_codes": [
516
+ "network_disabled_by_permission",
517
+ "session_selected_without_status",
518
+ "limit_ignored_with_since",
519
+ ],
520
+ }
521
+
522
+
523
+ def _parse_schema_args(args):
369
524
  parsed = _parse_flag_args(args, {
525
+ "--json": {"key": "json", "type": "bool", "default": False},
526
+ }, SCHEMA_USAGE)
527
+ if not parsed["json"]:
528
+ raise CdxError(SCHEMA_USAGE)
529
+ return parsed
530
+
531
+
532
+ def _parse_run_flag_args(args, schema, usage, positionals_key=None, max_positionals=0):
533
+ """`_parse_flag_args` is shared by every command and raises a bare usage
534
+ error for an unrecognized flag or a value-less option. Only `run` promises a
535
+ specific code for those, so translate here rather than teaching the shared
536
+ parser about codes it does not need. Transforms that already raised a
537
+ `CdxArgumentError` keep their own, more precise code."""
538
+ try:
539
+ return _parse_flag_args(args, schema, usage, positionals_key, max_positionals)
540
+ except CdxArgumentError:
541
+ raise
542
+ except CdxError as error:
543
+ raise CdxArgumentError(str(error), code=ARG_CODE_UNKNOWN) from error
544
+
545
+
546
+ def _parse_run_effort(reasoning_effort, power):
547
+ try:
548
+ return _normalize_reasoning_effort(
549
+ reasoning_effort=reasoning_effort,
550
+ power=power,
551
+ usage=RUN_USAGE,
552
+ )
553
+ except CdxError as error:
554
+ message = str(error)
555
+ if "must match" in message:
556
+ raise CdxArgumentError(
557
+ message,
558
+ code=ARG_CODE_MUTUALLY_EXCLUSIVE,
559
+ arguments=["--reasoning-effort", "--power"],
560
+ ) from error
561
+ # An empty value raises the bare usage line, which names neither flag.
562
+ # Blame the one actually supplied rather than guessing, since naming the
563
+ # offending argument is the entire point of this error.
564
+ if message.startswith("Unsupported power:") or (power is not None and reasoning_effort is None):
565
+ argument = "--power"
566
+ else:
567
+ argument = "--reasoning-effort"
568
+ # Keeps the pre-existing `invalid_reasoning_effort` code rather than
569
+ # flattening it into the generic invalid-value one: it was already
570
+ # specific, and callers may already branch on it. The new codes exist to
571
+ # split what used to be indistinguishable, not to rename what wasn't.
572
+ raise CdxArgumentError(
573
+ message,
574
+ code="invalid_reasoning_effort",
575
+ arguments=[argument],
576
+ allowed=RUN_EFFORT_VALUES,
577
+ ) from error
578
+
579
+
580
+ def _parse_run_args(args):
581
+ parsed = _parse_run_flag_args(args, {
370
582
  "--cwd": {"key": "cwd", "type": "str", "default": None},
371
583
  "--prompt-file": {"key": "prompt_file", "type": "str", "default": None},
372
584
  "--prompt": {"key": "prompt", "type": "str", "default": None},
@@ -377,27 +589,51 @@ def _parse_run_args(args):
377
589
  "--power": {"key": "power", "type": "str", "default": None},
378
590
  "--permission": {"key": "permission", "type": "str", "default": None, "transform": _normalize_run_permission},
379
591
  "--timeout-seconds": {"key": "timeout_seconds", "type": "str", "default": None, "transform": _parse_run_timeout_seconds},
592
+ "--detach": {"key": "detach", "type": "bool", "default": False},
593
+ "--failover": {"key": "failover", "type": "bool", "default": False},
594
+ "--refresh": {"key": "refresh", "type": "bool", "default": False},
380
595
  "--json": {"key": "json", "type": "bool", "default": False},
381
596
  }, RUN_USAGE, positionals_key="names", max_positionals=1)
382
597
  if not parsed["json"]:
383
- raise CdxError(RUN_JSON_REQUIRED)
598
+ raise CdxArgumentError(RUN_JSON_REQUIRED, code=ARG_CODE_MISSING, arguments=["--json"])
384
599
  name = parsed["names"][0] if parsed["names"] else None
385
600
  if name and parsed["provider"]:
386
- raise CdxError(RUN_SESSION_PROVIDER_CONFLICT)
601
+ raise CdxArgumentError(
602
+ RUN_SESSION_PROVIDER_CONFLICT,
603
+ code=ARG_CODE_MUTUALLY_EXCLUSIVE,
604
+ arguments=["session", "--provider"],
605
+ )
387
606
  if not name and not parsed["provider"]:
388
- raise CdxError(RUN_TARGET_REQUIRED)
607
+ raise CdxArgumentError(RUN_TARGET_REQUIRED, code=ARG_CODE_MISSING, arguments=["session", "--provider"])
389
608
  if not parsed["cwd"]:
390
- raise CdxError(RUN_CWD_REQUIRED)
391
- if bool(parsed["prompt_file"]) == bool(parsed["prompt"]):
392
- raise CdxError(RUN_PROMPT_SOURCE_REQUIRED)
609
+ raise CdxArgumentError(RUN_CWD_REQUIRED, code=ARG_CODE_MISSING, arguments=["--cwd"])
610
+ if parsed["prompt_file"] and parsed["prompt"]:
611
+ raise CdxArgumentError(
612
+ RUN_PROMPT_SOURCE_REQUIRED,
613
+ code=ARG_CODE_MUTUALLY_EXCLUSIVE,
614
+ arguments=["--prompt-file", "--prompt"],
615
+ )
616
+ if parsed["detach"] and parsed["failover"]:
617
+ raise CdxArgumentError(
618
+ RUN_DETACH_FAILOVER_CONFLICT,
619
+ code=ARG_CODE_MUTUALLY_EXCLUSIVE,
620
+ arguments=["--detach", "--failover"],
621
+ )
622
+ if not parsed["prompt_file"] and not parsed["prompt"]:
623
+ raise CdxArgumentError(
624
+ RUN_PROMPT_SOURCE_REQUIRED,
625
+ code=ARG_CODE_MISSING,
626
+ arguments=["--prompt-file", "--prompt"],
627
+ )
393
628
  if parsed["kind"] not in RUN_KIND_VALUES:
394
629
  allowed = "|".join(RUN_KIND_VALUES)
395
- raise CdxError(f"cdx run: invalid --kind {parsed['kind']!r}; allowed values: {allowed}.")
396
- effort = _normalize_reasoning_effort(
397
- reasoning_effort=parsed["reasoning_effort"],
398
- power=parsed["power"],
399
- usage=RUN_USAGE,
400
- )
630
+ raise CdxArgumentError(
631
+ f"cdx run: invalid --kind {parsed['kind']!r}; allowed values: {allowed}.",
632
+ code=ARG_CODE_INVALID_VALUE,
633
+ arguments=["--kind"],
634
+ allowed=RUN_KIND_VALUES,
635
+ )
636
+ effort = _parse_run_effort(parsed["reasoning_effort"], parsed["power"])
401
637
  return {
402
638
  "name": name,
403
639
  "provider": parsed["provider"],
@@ -408,6 +644,9 @@ def _parse_run_args(args):
408
644
  "model": parsed["model"],
409
645
  "permission": parsed["permission"],
410
646
  "timeout_seconds": parsed["timeout_seconds"],
647
+ "detach": parsed["detach"],
648
+ "failover": parsed["failover"],
649
+ "refresh": parsed["refresh"],
411
650
  "reasoning_effort": effort.get("reasoning_effort"),
412
651
  "power": effort.get("power"),
413
652
  }
@@ -709,11 +948,28 @@ def _parse_import_args(args):
709
948
  return parsed
710
949
 
711
950
 
712
- def _parse_runs_args(rest):
713
- return _parse_flag_args(rest, {
951
+ def _parse_runs_args(rest, now=None):
952
+ now = now or datetime.now().astimezone()
953
+ parsed = _parse_flag_args(rest, {
714
954
  "--limit": {"key": "limit", "type": "str", "default": 20, "transform": _parse_positive_int},
955
+ "--since": {"key": "since", "type": "str", "default": None},
715
956
  "--json": {"key": "json", "type": "bool", "default": False},
716
957
  }, RUNS_USAGE, max_positionals=0)
958
+ since = None
959
+ if parsed["since"] is not None:
960
+ # Same cursor forms as `cdx history --since`, parsed by the same code:
961
+ # a caller that learned one syntax does not learn a second one.
962
+ since = _parse_since_value(parsed["since"], now, RUNS_USAGE)
963
+ return {
964
+ "limit": parsed["limit"],
965
+ "since": since,
966
+ # A cursor selects by completion time, not by row count. Keeping the
967
+ # caller's own --limit would silently reintroduce the miss it removes:
968
+ # a watchdog behind by more than `limit` completions would never see
969
+ # the older ones at all.
970
+ "limit_given": any(arg == "--limit" or arg.startswith("--limit=") for arg in rest),
971
+ "json": parsed["json"],
972
+ }
717
973
 
718
974
 
719
975
  def _parse_run_id_json_args(rest, usage):
@@ -723,3 +979,32 @@ def _parse_run_id_json_args(rest, usage):
723
979
  if not parsed["json"] or len(parsed["ids"]) != 1:
724
980
  raise CdxError(usage)
725
981
  return {"run_id": parsed["ids"][0], "json": True}
982
+
983
+
984
+ def _parse_run_tail_args(rest):
985
+ parsed = _parse_flag_args(rest, {
986
+ "--lines": {"key": "lines", "type": "str", "default": RUN_TAIL_DEFAULT_LINES,
987
+ "transform": _parse_run_tail_lines},
988
+ "--json": {"key": "json", "type": "bool", "default": False},
989
+ }, RUN_TAIL_USAGE, positionals_key="ids", max_positionals=1)
990
+ if not parsed["json"] or len(parsed["ids"]) != 1:
991
+ raise CdxError(RUN_TAIL_USAGE)
992
+ return {"run_id": parsed["ids"][0], "lines": parsed["lines"], "json": True}
993
+
994
+
995
+ def _parse_run_tail_lines(value):
996
+ try:
997
+ parsed = int(value)
998
+ except (TypeError, ValueError) as error:
999
+ raise CdxArgumentError(
1000
+ f"cdx run-tail: --lines must be an integer; got {value!r}.",
1001
+ code=ARG_CODE_INVALID_VALUE,
1002
+ arguments=["--lines"],
1003
+ ) from error
1004
+ if not 1 <= parsed <= RUN_TAIL_MAX_LINES:
1005
+ raise CdxArgumentError(
1006
+ f"cdx run-tail: --lines must be between 1 and {RUN_TAIL_MAX_LINES}; got {parsed}.",
1007
+ code=ARG_CODE_OUT_OF_RANGE,
1008
+ arguments=["--lines"],
1009
+ )
1010
+ return parsed