Skip to content

Commit 703e212

Browse files
committed
feat: --require-payload-override + --required-keys + payload display (#589 + #590 port)
Cross-package parity port for two pending hosted-API PRs in cueapi/cueapi: - #589 (executions GET surfaces effective payload): `cueapi executions get` now pretty-prints the new `payload` field from the response when present (omits when null). - #590 (per-cue server-side enforcement of payload_override on /fire): adds --require-payload-override / --no-require-payload-override and --required-keys to `create` and `update` commands. Click tri-state for require-payload-override (None=omit, True/False=send explicitly). --required-keys is comma-separated; empty string sends `[]` (explicit clear). Tests: 14 new (60 → 74 total). Mock-based body-construction. Depends on cueapi/cueapi#589 + cueapi/cueapi#590 merging to staging then prod for user-visible behavior. PR can merge independently — new options are no-ops against an old server. Rebased against main 2026-05-04 (post-#28 agents-group merge). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent 5640f77 commit 703e212

2 files changed

Lines changed: 340 additions & 1 deletion

File tree

cueapi/cli.py

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,27 @@ def quickstart(ctx: click.Context) -> None:
8282
@click.option("--description", default=None, help="Cue description")
8383
@click.option("--worker", is_flag=True, default=False, help="Use worker transport (no public URL needed)")
8484
@click.option("--on-failure", "on_failure", default=None, help="JSON on_failure config, e.g. '{\"email\": false, \"pause\": true}'")
85+
@click.option(
86+
"--require-payload-override/--no-require-payload-override",
87+
"require_payload_override",
88+
default=None,
89+
help=(
90+
"Require payload_override on every fire (server-side enforcement, hosted PR #590). "
91+
"Use on team-comm cues where the payload IS the message; leave unset for cron-style "
92+
"cues that rely on the stored cue.payload. Fires without payload_override are rejected "
93+
"with HTTP 400 payload_override_required."
94+
),
95+
)
96+
@click.option(
97+
"--required-keys",
98+
"required_keys",
99+
default=None,
100+
help=(
101+
"Comma-separated keys that must be present in the resolved override on fire (post-merge). "
102+
"Missing keys yield HTTP 400 missing_required_payload_keys. Implies --require-payload-override "
103+
"in spirit but doesn't set it; pass both for full enforcement. Empty string sends an empty list."
104+
),
105+
)
85106
@click.pass_context
86107
def create(
87108
ctx: click.Context,
@@ -95,6 +116,8 @@ def create(
95116
description: Optional[str],
96117
worker: bool,
97118
on_failure: Optional[str],
119+
require_payload_override: Optional[bool],
120+
required_keys: Optional[str],
98121
) -> None:
99122
"""Create a new cue."""
100123
if cron and at_time:
@@ -137,6 +160,18 @@ def create(
137160
except json.JSONDecodeError:
138161
raise click.UsageError("--on-failure must be valid JSON")
139162

163+
# Hosted PR #590: per-cue opt-in enforcement of payload_override on /fire.
164+
# `require_payload_override=None` means "not specified" — omit from body so
165+
# the server's default (false) applies on create.
166+
if require_payload_override is not None:
167+
body["require_payload_override"] = require_payload_override
168+
169+
# `required_keys=None` → omit. Empty string → send `[]` (explicit clear).
170+
# Non-empty string → split, trim, drop empties.
171+
if required_keys is not None:
172+
parsed_keys = [k.strip() for k in required_keys.split(",") if k.strip()]
173+
body["required_payload_keys"] = parsed_keys
174+
140175
try:
141176
with CueAPIClient(api_key=ctx.obj.get("api_key"), profile=ctx.obj.get("profile")) as client:
142177
resp = client.post("/cues", json=body)
@@ -406,10 +441,31 @@ def fire(ctx: click.Context, cue_id: str, payload_override: Optional[str], merge
406441
@click.option("--payload", default=None, help="New JSON payload")
407442
@click.option("--description", default=None, help="New description")
408443
@click.option("--on-failure", "on_failure", default=None, help="JSON on_failure config")
444+
@click.option(
445+
"--require-payload-override/--no-require-payload-override",
446+
"require_payload_override",
447+
default=None,
448+
help=(
449+
"Toggle server-side enforcement of payload_override on fire (hosted PR #590). "
450+
"--require-payload-override turns it on; --no-require-payload-override turns it off. "
451+
"Omit to leave unchanged."
452+
),
453+
)
454+
@click.option(
455+
"--required-keys",
456+
"required_keys",
457+
default=None,
458+
help=(
459+
"Comma-separated keys that must be present in the resolved override on fire. "
460+
"Empty string sends `[]` (explicit clear). Omit to leave unchanged."
461+
),
462+
)
409463
@click.pass_context
410464
def update(ctx: click.Context, cue_id: str, name: Optional[str], cron: Optional[str],
411465
url: Optional[str], payload: Optional[str], description: Optional[str],
412-
on_failure: Optional[str]) -> None:
466+
on_failure: Optional[str],
467+
require_payload_override: Optional[bool],
468+
required_keys: Optional[str]) -> None:
413469
"""Update an existing cue."""
414470
body: dict = {}
415471
if name:
@@ -431,6 +487,15 @@ def update(ctx: click.Context, cue_id: str, name: Optional[str], cron: Optional[
431487
except json.JSONDecodeError:
432488
raise click.UsageError("--on-failure must be valid JSON")
433489

490+
# Hosted PR #590: tri-state on update — None omits, True/False sends.
491+
if require_payload_override is not None:
492+
body["require_payload_override"] = require_payload_override
493+
494+
# required_keys: None omits; empty string sends []; non-empty splits.
495+
if required_keys is not None:
496+
parsed_keys = [k.strip() for k in required_keys.split(",") if k.strip()]
497+
body["required_payload_keys"] = parsed_keys
498+
434499
if not body:
435500
raise click.UsageError("Must specify at least one field to update.")
436501

@@ -672,6 +737,13 @@ def executions_get(ctx: click.Context, execution_id: str) -> None:
672737
echo_info("HTTP status:", str(ex["http_status"]))
673738
if ex.get("error_message"):
674739
echo_info("Error:", ex["error_message"])
740+
# Effective payload (hosted PR #589): the JSON the handler /
741+
# webhook actually saw at delivery time. Falls back to the
742+
# parent cue's stored payload when no per-fire override was
743+
# set. Surfaced for forensics — what was delivered, not what
744+
# the cue's stored default looks like at query time.
745+
if ex.get("payload") is not None:
746+
echo_info("Payload:", json.dumps(ex["payload"], indent=2, sort_keys=True))
675747
click.echo()
676748
except click.ClickException as e:
677749
click.echo(str(e))

tests/test_cli.py

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -738,3 +738,270 @@ def test_top_level_help_lists_agents():
738738
result = runner.invoke(main, ["--help"])
739739
assert result.exit_code == 0
740740
assert "agents" in result.output
741+
742+
743+
# --- payload_override enforcement flags (hosted PR #590) ---
744+
745+
746+
def test_create_require_payload_override_in_help():
747+
result = runner.invoke(main, ["create", "--help"])
748+
assert result.exit_code == 0
749+
assert "--require-payload-override" in result.output
750+
assert "--no-require-payload-override" in result.output
751+
752+
753+
def test_create_required_keys_in_help():
754+
result = runner.invoke(main, ["create", "--help"])
755+
assert result.exit_code == 0
756+
assert "--required-keys" in result.output
757+
758+
759+
def test_update_require_payload_override_in_help():
760+
result = runner.invoke(main, ["update", "--help"])
761+
assert result.exit_code == 0
762+
assert "--require-payload-override" in result.output
763+
assert "--no-require-payload-override" in result.output
764+
765+
766+
def test_update_required_keys_in_help():
767+
result = runner.invoke(main, ["update", "--help"])
768+
assert result.exit_code == 0
769+
assert "--required-keys" in result.output
770+
771+
772+
# --- body-construction unit tests for new flags ---
773+
#
774+
# These mock the HTTP layer (CueAPIClient.post / .patch) and assert that the
775+
# CLI body matches the hosted-API spec. Cheap insurance against flag-wiring
776+
# regressions when refactoring create/update body builders.
777+
778+
779+
class _FakeResp:
780+
def __init__(self, status_code: int, payload: dict):
781+
self.status_code = status_code
782+
self._payload = payload
783+
784+
def json(self):
785+
return self._payload
786+
787+
788+
class _FakeClient:
789+
def __init__(self):
790+
self.posted: list = []
791+
self.patched: list = []
792+
793+
def __enter__(self):
794+
return self
795+
796+
def __exit__(self, *_):
797+
pass
798+
799+
def post(self, path, json=None, **_):
800+
self.posted.append((path, json))
801+
return _FakeResp(201, {"id": "cue_test", "status": "active", "next_run": None})
802+
803+
def patch(self, path, json=None, **_):
804+
self.patched.append((path, json))
805+
return _FakeResp(200, {"id": "cue_test", "name": json.get("name", "x") if json else "x"})
806+
807+
def get(self, *_, **__):
808+
# Not used by these tests but defined so the context-manager surface
809+
# matches CueAPIClient.
810+
return _FakeResp(200, {})
811+
812+
813+
def _patched_client(monkeypatch, client_holder):
814+
"""Patch CueAPIClient in cueapi.cli to return a captured FakeClient."""
815+
import cueapi.cli as cli_mod
816+
817+
def fake_factory(*_, **__):
818+
client_holder["client"] = _FakeClient()
819+
return client_holder["client"]
820+
821+
monkeypatch.setattr(cli_mod, "CueAPIClient", fake_factory)
822+
823+
824+
def test_create_require_payload_override_true_sends_field(monkeypatch):
825+
holder: dict = {}
826+
_patched_client(monkeypatch, holder)
827+
result = runner.invoke(
828+
main,
829+
[
830+
"create",
831+
"--name", "team-comm",
832+
"--cron", "0 9 * * *",
833+
"--worker",
834+
"--require-payload-override",
835+
],
836+
)
837+
assert result.exit_code == 0, result.output
838+
body = holder["client"].posted[-1][1]
839+
assert body.get("require_payload_override") is True
840+
841+
842+
def test_create_no_require_payload_override_sends_false(monkeypatch):
843+
holder: dict = {}
844+
_patched_client(monkeypatch, holder)
845+
result = runner.invoke(
846+
main,
847+
[
848+
"create",
849+
"--name", "cron-cue",
850+
"--cron", "0 9 * * *",
851+
"--worker",
852+
"--no-require-payload-override",
853+
],
854+
)
855+
assert result.exit_code == 0, result.output
856+
body = holder["client"].posted[-1][1]
857+
assert body.get("require_payload_override") is False
858+
859+
860+
def test_create_omits_require_payload_override_when_unset(monkeypatch):
861+
# Default None must NOT appear in the body — server-side default is the
862+
# source of truth for "did the caller specify this?" Pinning this
863+
# behavior so a refactor can't silently start sending false.
864+
holder: dict = {}
865+
_patched_client(monkeypatch, holder)
866+
result = runner.invoke(
867+
main,
868+
["create", "--name", "x", "--cron", "0 9 * * *", "--worker"],
869+
)
870+
assert result.exit_code == 0, result.output
871+
body = holder["client"].posted[-1][1]
872+
assert "require_payload_override" not in body
873+
874+
875+
def test_create_required_keys_splits_and_trims(monkeypatch):
876+
holder: dict = {}
877+
_patched_client(monkeypatch, holder)
878+
result = runner.invoke(
879+
main,
880+
[
881+
"create",
882+
"--name", "team-comm",
883+
"--cron", "0 9 * * *",
884+
"--worker",
885+
"--required-keys", " task ,message, token",
886+
],
887+
)
888+
assert result.exit_code == 0, result.output
889+
body = holder["client"].posted[-1][1]
890+
assert body.get("required_payload_keys") == ["task", "message", "token"]
891+
892+
893+
def test_create_required_keys_empty_string_sends_empty_list(monkeypatch):
894+
# Empty string is the "explicit clear" path. Pin so a future refactor
895+
# doesn't drop the body field entirely (which would mean "leave
896+
# unchanged" not "clear").
897+
holder: dict = {}
898+
_patched_client(monkeypatch, holder)
899+
result = runner.invoke(
900+
main,
901+
[
902+
"create",
903+
"--name", "x",
904+
"--cron", "0 9 * * *",
905+
"--worker",
906+
"--required-keys", "",
907+
],
908+
)
909+
assert result.exit_code == 0, result.output
910+
body = holder["client"].posted[-1][1]
911+
assert body.get("required_payload_keys") == []
912+
913+
914+
def test_update_require_payload_override_tri_state(monkeypatch):
915+
holder: dict = {}
916+
_patched_client(monkeypatch, holder)
917+
result = runner.invoke(
918+
main,
919+
["update", "cue_test", "--require-payload-override"],
920+
)
921+
assert result.exit_code == 0, result.output
922+
body = holder["client"].patched[-1][1]
923+
assert body.get("require_payload_override") is True
924+
925+
926+
def test_update_no_require_payload_override_sends_false(monkeypatch):
927+
holder: dict = {}
928+
_patched_client(monkeypatch, holder)
929+
result = runner.invoke(
930+
main,
931+
["update", "cue_test", "--no-require-payload-override"],
932+
)
933+
assert result.exit_code == 0, result.output
934+
body = holder["client"].patched[-1][1]
935+
assert body.get("require_payload_override") is False
936+
937+
938+
def test_update_required_keys_works(monkeypatch):
939+
holder: dict = {}
940+
_patched_client(monkeypatch, holder)
941+
result = runner.invoke(
942+
main,
943+
["update", "cue_test", "--required-keys", "a,b"],
944+
)
945+
assert result.exit_code == 0, result.output
946+
body = holder["client"].patched[-1][1]
947+
assert body.get("required_payload_keys") == ["a", "b"]
948+
949+
950+
# --- effective payload display on `executions get` (hosted PR #589) ---
951+
952+
953+
def test_executions_get_displays_payload_when_present(monkeypatch):
954+
import cueapi.cli as cli_mod
955+
956+
fake_response = {
957+
"id": "exec_abc",
958+
"cue_id": "cue_xyz",
959+
"status": "success",
960+
"scheduled_for": "2026-05-04T10:00:00Z",
961+
"attempts": 1,
962+
"payload": {"task": "demo", "message": "hello"},
963+
}
964+
965+
class _GetClient:
966+
def __enter__(self):
967+
return self
968+
969+
def __exit__(self, *_):
970+
pass
971+
972+
def get(self, path, **_):
973+
return _FakeResp(200, fake_response)
974+
975+
monkeypatch.setattr(cli_mod, "CueAPIClient", lambda *_, **__: _GetClient())
976+
result = runner.invoke(main, ["executions", "get", "exec_abc"])
977+
assert result.exit_code == 0, result.output
978+
assert "Payload:" in result.output
979+
# Pretty-print with indent=2, sort_keys=True — pin the keys are visible.
980+
assert "task" in result.output
981+
assert "demo" in result.output
982+
983+
984+
def test_executions_get_omits_payload_when_null(monkeypatch):
985+
import cueapi.cli as cli_mod
986+
987+
fake_response = {
988+
"id": "exec_abc",
989+
"cue_id": "cue_xyz",
990+
"status": "pending",
991+
"payload": None,
992+
}
993+
994+
class _GetClient:
995+
def __enter__(self):
996+
return self
997+
998+
def __exit__(self, *_):
999+
pass
1000+
1001+
def get(self, path, **_):
1002+
return _FakeResp(200, fake_response)
1003+
1004+
monkeypatch.setattr(cli_mod, "CueAPIClient", lambda *_, **__: _GetClient())
1005+
result = runner.invoke(main, ["executions", "get", "exec_abc"])
1006+
assert result.exit_code == 0, result.output
1007+
assert "Payload:" not in result.output

0 commit comments

Comments
 (0)