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/src/cli_args.py CHANGED
@@ -7,12 +7,20 @@ 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
+ PERMISSION_ALIASES,
12
+ PERMISSION_INPUT_VALUES,
13
+ PERMISSION_VALUES,
14
+ PROVIDER_CODEX,
15
+ PROVIDERS,
16
+ REASONING_EFFORT_VALUES,
17
+ normalize_permission,
18
+ )
19
+ from .errors import CdxArgumentError, CdxError
12
20
  from .provider_runtime import _normalize_reasoning_effort
13
21
 
14
22
  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]"
23
+ DOCTOR_USAGE = "Usage: cdx doctor [--severity OK|WARN|FAIL[,OK|WARN|FAIL...]] [--check-provider-flags] [--json]"
16
24
  DISK_USAGE = "Usage: cdx disk [profiles] [--candidates] [--json]"
17
25
  REPAIR_USAGE = "Usage: cdx repair [--dry-run] [--force] [--json]"
18
26
  UPDATE_USAGE = "Usage: cdx update [all] [--check] [--yes] [--json] [--version TAG]"
@@ -34,17 +42,36 @@ RESUME_USAGE = "Usage: cdx resume <name> [--json]"
34
42
  CAN_RESUME_USAGE = "Usage: cdx can-resume <name> [--json]"
35
43
  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
44
  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"
45
+ 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] [--refresh] --json"
38
46
  RUN_JSON_REQUIRED = "cdx run: --json is required."
39
47
  RUN_TARGET_REQUIRED = "cdx run: specify a session name or --provider PROVIDER."
40
48
  RUN_SESSION_PROVIDER_CONFLICT = "cdx run: cannot specify both a session name and --provider."
41
49
  RUN_CWD_REQUIRED = "cdx run: --cwd PATH is required."
42
50
  RUN_PROMPT_SOURCE_REQUIRED = "cdx run: specify exactly one prompt source: --prompt TEXT or --prompt-file PATH."
43
51
  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"
52
+ # Aliases of the shared definitions in config, kept only so the existing
53
+ # RUN_* spellings in this module keep reading naturally. They are references,
54
+ # not copies: config.py is the single owner.
55
+ RUN_PERMISSION_VALUES = PERMISSION_INPUT_VALUES
56
+ RUN_PERMISSION_CANONICAL_VALUES = PERMISSION_VALUES
57
+ RUN_PERMISSION_ALIASES = PERMISSION_ALIASES
58
+ RUN_EFFORT_VALUES = REASONING_EFFORT_VALUES
59
+ RUNS_USAGE = "Usage: cdx runs [--limit N] [--since 7d|today|DATE] --json"
46
60
  RUN_STATUS_USAGE = "Usage: cdx run-status <run_id> --json"
47
61
  RUN_REPORT_USAGE = "Usage: cdx run-report <run_id> --json"
62
+ RUN_TAIL_USAGE = "Usage: cdx run-tail <run_id> [--lines N] --json"
63
+ SCHEMA_USAGE = "Usage: cdx schema --json"
64
+
65
+ RUN_TAIL_DEFAULT_LINES = 50
66
+ RUN_TAIL_MAX_LINES = 2000
67
+
68
+ # Argument-failure codes emitted in `error.code`. Specific enough that a caller
69
+ # branches on the code alone, never on the human message.
70
+ ARG_CODE_MISSING = "missing_required_argument"
71
+ ARG_CODE_MUTUALLY_EXCLUSIVE = "mutually_exclusive_arguments"
72
+ ARG_CODE_INVALID_VALUE = "invalid_argument_value"
73
+ ARG_CODE_OUT_OF_RANGE = "argument_value_out_of_range"
74
+ ARG_CODE_UNKNOWN = "unknown_argument"
48
75
 
49
76
 
50
77
  def _parse_json_flag(args):
@@ -54,7 +81,7 @@ def _parse_json_flag(args):
54
81
 
55
82
 
56
83
  def _parse_doctor_args(args):
57
- parsed = {"json": False, "severity": None}
84
+ parsed = {"json": False, "severity": None, "check_provider_flags": False}
58
85
  index = 0
59
86
  while index < len(args):
60
87
  arg = args[index]
@@ -62,6 +89,10 @@ def _parse_doctor_args(args):
62
89
  parsed["json"] = True
63
90
  index += 1
64
91
  continue
92
+ if arg == "--check-provider-flags":
93
+ parsed["check_provider_flags"] = True
94
+ index += 1
95
+ continue
65
96
  if arg == "--severity":
66
97
  if parsed["severity"] is not None or index + 1 >= len(args):
67
98
  raise CdxError(DOCTOR_USAGE)
@@ -338,7 +369,11 @@ def _parse_run_timeout_seconds(value):
338
369
  try:
339
370
  return _parse_timeout_seconds(value, RUN_USAGE)
340
371
  except CdxError as error:
341
- raise CdxError(f"cdx run: --timeout-seconds must be a positive number; got {value!r}.") from error
372
+ raise CdxArgumentError(
373
+ f"cdx run: --timeout-seconds must be a positive number; got {value!r}.",
374
+ code=ARG_CODE_OUT_OF_RANGE,
375
+ arguments=["--timeout-seconds"],
376
+ ) from error
342
377
 
343
378
 
344
379
  def _parse_run_provider(value):
@@ -346,27 +381,168 @@ def _parse_run_provider(value):
346
381
  return _parse_provider_filter(value, RUN_USAGE)
347
382
  except CdxError as error:
348
383
  allowed = "|".join(PROVIDERS)
349
- raise CdxError(f"cdx run: invalid --provider {value!r}; allowed values: {allowed}.") from error
384
+ raise CdxArgumentError(
385
+ f"cdx run: invalid --provider {value!r}; allowed values: {allowed}.",
386
+ code=ARG_CODE_INVALID_VALUE,
387
+ arguments=["--provider"],
388
+ allowed=PROVIDERS,
389
+ ) from error
350
390
 
351
391
 
352
392
  def _normalize_run_permission(value):
353
393
  if value is None:
354
394
  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"):
395
+ text = normalize_permission(value)
396
+ if text is None:
363
397
  allowed = "|".join(RUN_PERMISSION_VALUES)
364
- raise CdxError(f"cdx run: invalid --permission {value!r}; allowed values: {allowed}.")
398
+ raise CdxArgumentError(
399
+ f"cdx run: invalid --permission {value!r}; allowed values: {allowed}.",
400
+ code=ARG_CODE_INVALID_VALUE,
401
+ arguments=["--permission"],
402
+ allowed=RUN_PERMISSION_VALUES,
403
+ )
365
404
  return text
366
405
 
367
406
 
368
- def _parse_run_args(args):
407
+ def cdx_schema():
408
+ """The machine-readable contract for programmatic callers.
409
+
410
+ Every value here is the same object the parser validates against, not a
411
+ parallel copy — `cdx schema --json` cannot advertise a value `cdx run`
412
+ rejects, which is exactly how the ollama `--experimental-yolo` mapping
413
+ survived unexercised (GitHub issue #8).
414
+ """
415
+ # No `schema_version` here: the JSON envelope already carries it, and two
416
+ # keys of that name in one payload would be one too many.
417
+ return {
418
+ "enums": {
419
+ "permission": {
420
+ "accepted": list(RUN_PERMISSION_VALUES),
421
+ "canonical": list(RUN_PERMISSION_CANONICAL_VALUES),
422
+ "aliases": dict(RUN_PERMISSION_ALIASES),
423
+ },
424
+ "reasoning_effort": {"accepted": list(RUN_EFFORT_VALUES)},
425
+ "power": {"accepted": list(RUN_EFFORT_VALUES)},
426
+ "kind": {"accepted": list(RUN_KIND_VALUES)},
427
+ "provider": {"accepted": list(PROVIDERS)},
428
+ },
429
+ "mutually_exclusive": [
430
+ {
431
+ "command": "run",
432
+ "arguments": ["session", "--provider"],
433
+ "reason": "A session already fixes its provider; pass one or the other.",
434
+ },
435
+ {
436
+ "command": "run",
437
+ "arguments": ["--prompt-file", "--prompt"],
438
+ "reason": "Exactly one prompt source.",
439
+ },
440
+ ],
441
+ # Not mutually exclusive: both may be given, and cdx accepts them when
442
+ # they agree. A caller generating validation from `mutually_exclusive`
443
+ # would otherwise reject a command line cdx would have run.
444
+ "must_match": [
445
+ {
446
+ "command": "run",
447
+ "arguments": ["--reasoning-effort", "--power"],
448
+ "reason": "Aliases of one setting; supplying both with different values fails.",
449
+ },
450
+ ],
451
+ # Every code a run-family command can put in `error.code`, not just the
452
+ # argument ones — a caller matching exhaustively over this list must not
453
+ # fall through on a code it will certainly see.
454
+ "error_codes": {
455
+ "arguments": [
456
+ ARG_CODE_MISSING,
457
+ ARG_CODE_MUTUALLY_EXCLUSIVE,
458
+ ARG_CODE_INVALID_VALUE,
459
+ ARG_CODE_OUT_OF_RANGE,
460
+ ARG_CODE_UNKNOWN,
461
+ "invalid_reasoning_effort",
462
+ ],
463
+ "run": [
464
+ "invalid_cwd",
465
+ "session_disabled",
466
+ "no_suitable_session",
467
+ "provider_cli_not_found",
468
+ "provider_start_failed",
469
+ "provider_failed",
470
+ "provider_timeout",
471
+ "cdx_error",
472
+ ],
473
+ "run_lookup": [
474
+ "run_not_found",
475
+ "run_output_unavailable",
476
+ "run_output_unreadable",
477
+ ],
478
+ },
479
+ "warning_codes": [
480
+ "network_disabled_by_permission",
481
+ "session_selected_without_status",
482
+ "limit_ignored_with_since",
483
+ ],
484
+ }
485
+
486
+
487
+ def _parse_schema_args(args):
369
488
  parsed = _parse_flag_args(args, {
489
+ "--json": {"key": "json", "type": "bool", "default": False},
490
+ }, SCHEMA_USAGE)
491
+ if not parsed["json"]:
492
+ raise CdxError(SCHEMA_USAGE)
493
+ return parsed
494
+
495
+
496
+ def _parse_run_flag_args(args, schema, usage, positionals_key=None, max_positionals=0):
497
+ """`_parse_flag_args` is shared by every command and raises a bare usage
498
+ error for an unrecognized flag or a value-less option. Only `run` promises a
499
+ specific code for those, so translate here rather than teaching the shared
500
+ parser about codes it does not need. Transforms that already raised a
501
+ `CdxArgumentError` keep their own, more precise code."""
502
+ try:
503
+ return _parse_flag_args(args, schema, usage, positionals_key, max_positionals)
504
+ except CdxArgumentError:
505
+ raise
506
+ except CdxError as error:
507
+ raise CdxArgumentError(str(error), code=ARG_CODE_UNKNOWN) from error
508
+
509
+
510
+ def _parse_run_effort(reasoning_effort, power):
511
+ try:
512
+ return _normalize_reasoning_effort(
513
+ reasoning_effort=reasoning_effort,
514
+ power=power,
515
+ usage=RUN_USAGE,
516
+ )
517
+ except CdxError as error:
518
+ message = str(error)
519
+ if "must match" in message:
520
+ raise CdxArgumentError(
521
+ message,
522
+ code=ARG_CODE_MUTUALLY_EXCLUSIVE,
523
+ arguments=["--reasoning-effort", "--power"],
524
+ ) from error
525
+ # An empty value raises the bare usage line, which names neither flag.
526
+ # Blame the one actually supplied rather than guessing, since naming the
527
+ # offending argument is the entire point of this error.
528
+ if message.startswith("Unsupported power:") or (power is not None and reasoning_effort is None):
529
+ argument = "--power"
530
+ else:
531
+ argument = "--reasoning-effort"
532
+ # Keeps the pre-existing `invalid_reasoning_effort` code rather than
533
+ # flattening it into the generic invalid-value one: it was already
534
+ # specific, and callers may already branch on it. The new codes exist to
535
+ # split what used to be indistinguishable, not to rename what wasn't.
536
+ raise CdxArgumentError(
537
+ message,
538
+ code="invalid_reasoning_effort",
539
+ arguments=[argument],
540
+ allowed=RUN_EFFORT_VALUES,
541
+ ) from error
542
+
543
+
544
+ def _parse_run_args(args):
545
+ parsed = _parse_run_flag_args(args, {
370
546
  "--cwd": {"key": "cwd", "type": "str", "default": None},
371
547
  "--prompt-file": {"key": "prompt_file", "type": "str", "default": None},
372
548
  "--prompt": {"key": "prompt", "type": "str", "default": None},
@@ -377,27 +553,44 @@ def _parse_run_args(args):
377
553
  "--power": {"key": "power", "type": "str", "default": None},
378
554
  "--permission": {"key": "permission", "type": "str", "default": None, "transform": _normalize_run_permission},
379
555
  "--timeout-seconds": {"key": "timeout_seconds", "type": "str", "default": None, "transform": _parse_run_timeout_seconds},
556
+ "--detach": {"key": "detach", "type": "bool", "default": False},
557
+ "--refresh": {"key": "refresh", "type": "bool", "default": False},
380
558
  "--json": {"key": "json", "type": "bool", "default": False},
381
559
  }, RUN_USAGE, positionals_key="names", max_positionals=1)
382
560
  if not parsed["json"]:
383
- raise CdxError(RUN_JSON_REQUIRED)
561
+ raise CdxArgumentError(RUN_JSON_REQUIRED, code=ARG_CODE_MISSING, arguments=["--json"])
384
562
  name = parsed["names"][0] if parsed["names"] else None
385
563
  if name and parsed["provider"]:
386
- raise CdxError(RUN_SESSION_PROVIDER_CONFLICT)
564
+ raise CdxArgumentError(
565
+ RUN_SESSION_PROVIDER_CONFLICT,
566
+ code=ARG_CODE_MUTUALLY_EXCLUSIVE,
567
+ arguments=["session", "--provider"],
568
+ )
387
569
  if not name and not parsed["provider"]:
388
- raise CdxError(RUN_TARGET_REQUIRED)
570
+ raise CdxArgumentError(RUN_TARGET_REQUIRED, code=ARG_CODE_MISSING, arguments=["session", "--provider"])
389
571
  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)
572
+ raise CdxArgumentError(RUN_CWD_REQUIRED, code=ARG_CODE_MISSING, arguments=["--cwd"])
573
+ if parsed["prompt_file"] and parsed["prompt"]:
574
+ raise CdxArgumentError(
575
+ RUN_PROMPT_SOURCE_REQUIRED,
576
+ code=ARG_CODE_MUTUALLY_EXCLUSIVE,
577
+ arguments=["--prompt-file", "--prompt"],
578
+ )
579
+ if not parsed["prompt_file"] and not parsed["prompt"]:
580
+ raise CdxArgumentError(
581
+ RUN_PROMPT_SOURCE_REQUIRED,
582
+ code=ARG_CODE_MISSING,
583
+ arguments=["--prompt-file", "--prompt"],
584
+ )
393
585
  if parsed["kind"] not in RUN_KIND_VALUES:
394
586
  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
- )
587
+ raise CdxArgumentError(
588
+ f"cdx run: invalid --kind {parsed['kind']!r}; allowed values: {allowed}.",
589
+ code=ARG_CODE_INVALID_VALUE,
590
+ arguments=["--kind"],
591
+ allowed=RUN_KIND_VALUES,
592
+ )
593
+ effort = _parse_run_effort(parsed["reasoning_effort"], parsed["power"])
401
594
  return {
402
595
  "name": name,
403
596
  "provider": parsed["provider"],
@@ -408,6 +601,8 @@ def _parse_run_args(args):
408
601
  "model": parsed["model"],
409
602
  "permission": parsed["permission"],
410
603
  "timeout_seconds": parsed["timeout_seconds"],
604
+ "detach": parsed["detach"],
605
+ "refresh": parsed["refresh"],
411
606
  "reasoning_effort": effort.get("reasoning_effort"),
412
607
  "power": effort.get("power"),
413
608
  }
@@ -709,11 +904,28 @@ def _parse_import_args(args):
709
904
  return parsed
710
905
 
711
906
 
712
- def _parse_runs_args(rest):
713
- return _parse_flag_args(rest, {
907
+ def _parse_runs_args(rest, now=None):
908
+ now = now or datetime.now().astimezone()
909
+ parsed = _parse_flag_args(rest, {
714
910
  "--limit": {"key": "limit", "type": "str", "default": 20, "transform": _parse_positive_int},
911
+ "--since": {"key": "since", "type": "str", "default": None},
715
912
  "--json": {"key": "json", "type": "bool", "default": False},
716
913
  }, RUNS_USAGE, max_positionals=0)
914
+ since = None
915
+ if parsed["since"] is not None:
916
+ # Same cursor forms as `cdx history --since`, parsed by the same code:
917
+ # a caller that learned one syntax does not learn a second one.
918
+ since = _parse_since_value(parsed["since"], now, RUNS_USAGE)
919
+ return {
920
+ "limit": parsed["limit"],
921
+ "since": since,
922
+ # A cursor selects by completion time, not by row count. Keeping the
923
+ # caller's own --limit would silently reintroduce the miss it removes:
924
+ # a watchdog behind by more than `limit` completions would never see
925
+ # the older ones at all.
926
+ "limit_given": any(arg == "--limit" or arg.startswith("--limit=") for arg in rest),
927
+ "json": parsed["json"],
928
+ }
717
929
 
718
930
 
719
931
  def _parse_run_id_json_args(rest, usage):
@@ -723,3 +935,32 @@ def _parse_run_id_json_args(rest, usage):
723
935
  if not parsed["json"] or len(parsed["ids"]) != 1:
724
936
  raise CdxError(usage)
725
937
  return {"run_id": parsed["ids"][0], "json": True}
938
+
939
+
940
+ def _parse_run_tail_args(rest):
941
+ parsed = _parse_flag_args(rest, {
942
+ "--lines": {"key": "lines", "type": "str", "default": RUN_TAIL_DEFAULT_LINES,
943
+ "transform": _parse_run_tail_lines},
944
+ "--json": {"key": "json", "type": "bool", "default": False},
945
+ }, RUN_TAIL_USAGE, positionals_key="ids", max_positionals=1)
946
+ if not parsed["json"] or len(parsed["ids"]) != 1:
947
+ raise CdxError(RUN_TAIL_USAGE)
948
+ return {"run_id": parsed["ids"][0], "lines": parsed["lines"], "json": True}
949
+
950
+
951
+ def _parse_run_tail_lines(value):
952
+ try:
953
+ parsed = int(value)
954
+ except (TypeError, ValueError) as error:
955
+ raise CdxArgumentError(
956
+ f"cdx run-tail: --lines must be an integer; got {value!r}.",
957
+ code=ARG_CODE_INVALID_VALUE,
958
+ arguments=["--lines"],
959
+ ) from error
960
+ if not 1 <= parsed <= RUN_TAIL_MAX_LINES:
961
+ raise CdxArgumentError(
962
+ f"cdx run-tail: --lines must be between 1 and {RUN_TAIL_MAX_LINES}; got {parsed}.",
963
+ code=ARG_CODE_OUT_OF_RANGE,
964
+ arguments=["--lines"],
965
+ )
966
+ return parsed