Skip to content

Commit 10f866c

Browse files
authored
feat: add cueapi workers list/delete + cueapi key webhook-secret get/regenerate (#33)
Drains 3 entries from cueapi-cli #25's `endpoints_missing` plus a bonus DELETE that wasn't in the manifest: - GET /v1/workers → `cueapi workers list` - DELETE /v1/workers/{id} → `cueapi workers delete <id>` - GET /v1/auth/webhook-secret → `cueapi key webhook-secret get` - POST /v1/auth/webhook-secret/regenerate → `cueapi key webhook-secret regenerate` Two new / extended groups: `cueapi workers` (top-level) + `cueapi key webhook-secret` (nested under existing `key`). `regenerate` automatically sends `X-Confirm-Destructive: true` header (server-required); pinned by test_key_webhook_secret_regenerate_sends_destructive_header. 404 on `key webhook-secret get` produces a helpful error pointing messaging-primitive-only users at the per-agent path. Tests: 11 new (60 → 71 total). Reuses _FakeResp from agents tests added in #28; adds _WSClient capture class for header-sensitive tests. Skipped from manifest: POST /v1/worker/heartbeat (manifest itself notes it's redundant with cueapi-worker package). No hosted-PR dependency. Rebased against main 2026-05-04 (post-#28 agents-group merge). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent 5640f77 commit 10f866c

2 files changed

Lines changed: 348 additions & 0 deletions

File tree

cueapi/cli.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -858,6 +858,85 @@ def regenerate(ctx: click.Context, yes: bool) -> None:
858858
)
859859

860860

861+
@key.group(name="webhook-secret")
862+
def key_webhook_secret() -> None:
863+
"""Manage the user-level webhook signing secret (legacy /v1/auth/webhook-secret).
864+
865+
For per-agent webhook secrets (Phase 12.1 messaging primitive), use
866+
`cueapi agents webhook-secret get/regenerate` instead.
867+
"""
868+
pass
869+
870+
871+
@key_webhook_secret.command(name="get")
872+
@click.pass_context
873+
def key_webhook_secret_get(ctx: click.Context) -> None:
874+
"""Reveal the current user-level webhook signing secret."""
875+
try:
876+
with CueAPIClient(api_key=ctx.obj.get("api_key"), profile=ctx.obj.get("profile")) as client:
877+
resp = client.get("/auth/webhook-secret")
878+
if resp.status_code == 200:
879+
data = resp.json()
880+
click.echo()
881+
echo_info("Webhook secret:", data.get("webhook_secret", "?"))
882+
click.echo()
883+
elif resp.status_code == 404:
884+
echo_error(
885+
"No webhook secret found. The user-level webhook secret is "
886+
"auto-provisioned for accounts using the legacy webhook signing "
887+
"path; if you only use the messaging primitive (per-agent secrets), "
888+
"this is expected. Use `cueapi agents webhook-secret get <ref>` "
889+
"for an agent's secret instead."
890+
)
891+
else:
892+
error = resp.json().get("detail", {}).get("error", {})
893+
echo_error(error.get("message", f"Failed (HTTP {resp.status_code})"))
894+
except click.ClickException as e:
895+
click.echo(str(e))
896+
897+
898+
@key_webhook_secret.command(name="regenerate")
899+
@click.option("--yes", "-y", is_flag=True, default=False, help="Skip confirmation")
900+
@click.pass_context
901+
def key_webhook_secret_regenerate(ctx: click.Context, yes: bool) -> None:
902+
"""Rotate the user-level webhook signing secret. Old secret is revoked immediately.
903+
904+
Server requires the X-Confirm-Destructive: true header (same pattern as
905+
api-key regenerate). The CLI sends this header automatically when the user
906+
confirms the prompt (or passes --yes).
907+
"""
908+
if not yes:
909+
if not click.confirm(
910+
"Rotate user-level webhook secret? Current secret will be revoked immediately."
911+
):
912+
click.echo("Aborted.")
913+
return
914+
try:
915+
with CueAPIClient(api_key=ctx.obj.get("api_key"), profile=ctx.obj.get("profile")) as client:
916+
resp = client.post(
917+
"/auth/webhook-secret/regenerate",
918+
json={},
919+
headers={"X-Confirm-Destructive": "true"},
920+
)
921+
if resp.status_code == 200:
922+
data = resp.json()
923+
click.echo()
924+
echo_success("Rotated user-level webhook secret")
925+
echo_info("New webhook secret (save now — only shown once):", data.get("webhook_secret", "?"))
926+
click.echo()
927+
elif resp.status_code == 400:
928+
# Should be unreachable since the CLI sends the confirmation
929+
# header automatically; but if the server's contract changes,
930+
# surface the error rather than swallow it.
931+
error = resp.json().get("detail", {}).get("error", {})
932+
echo_error(error.get("message", "Bad request"))
933+
else:
934+
error = resp.json().get("detail", {}).get("error", {})
935+
echo_error(error.get("message", f"Failed (HTTP {resp.status_code})"))
936+
except click.ClickException as e:
937+
click.echo(str(e))
938+
939+
861940
main.add_command(key)
862941

863942

@@ -1261,5 +1340,87 @@ def agents_sent(
12611340
main.add_command(agents)
12621341

12631342

1343+
# --- Workers (fleet visibility for worker-transport users) ---
1344+
1345+
1346+
@main.group()
1347+
def workers() -> None:
1348+
"""Manage worker fleet (registered workers + heartbeat status)."""
1349+
pass
1350+
1351+
1352+
@workers.command(name="list")
1353+
@click.pass_context
1354+
def workers_list(ctx: click.Context) -> None:
1355+
"""List all registered workers with heartbeat status."""
1356+
try:
1357+
with CueAPIClient(api_key=ctx.obj.get("api_key"), profile=ctx.obj.get("profile")) as client:
1358+
resp = client.get("/workers")
1359+
if resp.status_code != 200:
1360+
echo_error(f"Failed (HTTP {resp.status_code})")
1361+
return
1362+
data = resp.json()
1363+
workers_list_data = data.get("workers", [])
1364+
if not workers_list_data:
1365+
click.echo(
1366+
"\nNo workers registered yet. Workers register automatically by "
1367+
"sending heartbeats; install cueapi-worker to get started.\n"
1368+
)
1369+
return
1370+
click.echo()
1371+
rows = []
1372+
for w in workers_list_data:
1373+
rows.append([
1374+
w.get("worker_id", "?"),
1375+
format_status(w.get("heartbeat_status", "?")),
1376+
str(w.get("seconds_since_heartbeat", "?")),
1377+
(w.get("last_heartbeat") or "—")[:19].replace("T", " "),
1378+
])
1379+
echo_table(
1380+
["WORKER ID", "STATUS", "SECONDS AGO", "LAST HEARTBEAT"],
1381+
rows,
1382+
widths=[28, 14, 14, 22],
1383+
)
1384+
total = data.get("total", len(workers_list_data))
1385+
click.echo(f"\n{total} workers\n")
1386+
except click.ClickException as e:
1387+
click.echo(str(e))
1388+
1389+
1390+
@workers.command(name="delete")
1391+
@click.argument("worker_id")
1392+
@click.option("--yes", "-y", is_flag=True, default=False, help="Skip confirmation")
1393+
@click.pass_context
1394+
def workers_delete(ctx: click.Context, worker_id: str, yes: bool) -> None:
1395+
"""Delete a registered worker.
1396+
1397+
Removes the worker row; in-flight executions claimed by this worker
1398+
will be picked up by the stale-recovery loop. Useful for cleaning up
1399+
workers that have been decommissioned.
1400+
"""
1401+
if not yes:
1402+
if not click.confirm(f"Delete worker {worker_id}?"):
1403+
click.echo("Aborted.")
1404+
return
1405+
try:
1406+
with CueAPIClient(api_key=ctx.obj.get("api_key"), profile=ctx.obj.get("profile")) as client:
1407+
resp = client.delete(f"/workers/{worker_id}")
1408+
if resp.status_code == 204:
1409+
echo_success(f"Deleted worker: {worker_id}")
1410+
elif resp.status_code == 404:
1411+
echo_error(f"Worker not found: {worker_id}")
1412+
else:
1413+
try:
1414+
error = resp.json().get("detail", {}).get("error", {})
1415+
echo_error(error.get("message", f"Failed (HTTP {resp.status_code})"))
1416+
except Exception:
1417+
echo_error(f"Failed (HTTP {resp.status_code})")
1418+
except click.ClickException as e:
1419+
click.echo(str(e))
1420+
1421+
1422+
main.add_command(workers)
1423+
1424+
12641425
if __name__ == "__main__":
12651426
main()

tests/test_cli.py

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -738,3 +738,190 @@ 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+
# --- workers + key webhook-secret ---
744+
#
745+
# Reuses _FakeResp from the agents tests above. _WSClient is a separate
746+
# capture client because it tracks the headers kwarg (needed for the
747+
# X-Confirm-Destructive pin on key webhook-secret regenerate).
748+
749+
750+
class _WSClient:
751+
def __init__(self, responses: Optional[dict] = None):
752+
self.calls: list = []
753+
self._responses = responses or {}
754+
755+
def __enter__(self):
756+
return self
757+
758+
def __exit__(self, *_):
759+
pass
760+
761+
def _resolve(self, method: str, path: str):
762+
for (m, p), factory in sorted(self._responses.items(), key=lambda kv: -len(kv[0][1])):
763+
if m == method and path.startswith(p):
764+
return factory()
765+
return _FakeResp(200, {})
766+
767+
def get(self, path, params=None, **_):
768+
self.calls.append(("GET", path, params, None))
769+
return self._resolve("GET", path)
770+
771+
def post(self, path, json=None, headers=None, **_):
772+
self.calls.append(("POST", path, json, headers))
773+
return self._resolve("POST", path)
774+
775+
def delete(self, path, **_):
776+
self.calls.append(("DELETE", path, None, None))
777+
return self._resolve("DELETE", path)
778+
779+
780+
def _patch_ws_client(monkeypatch, holder, responses=None):
781+
import cueapi.cli as cli_mod
782+
783+
def fake_factory(*_, **__):
784+
holder["client"] = _WSClient(responses=responses)
785+
return holder["client"]
786+
787+
monkeypatch.setattr(cli_mod, "CueAPIClient", fake_factory)
788+
789+
790+
def test_workers_group_help():
791+
result = runner.invoke(main, ["workers", "--help"])
792+
assert result.exit_code == 0
793+
for sub in ("list", "delete"):
794+
assert sub in result.output
795+
796+
797+
def test_workers_list_renders(monkeypatch):
798+
holder: dict = {}
799+
_patch_ws_client(
800+
monkeypatch,
801+
holder,
802+
responses={
803+
("GET", "/workers"): lambda: _FakeResp(
804+
200,
805+
{
806+
"workers": [
807+
{
808+
"worker_id": "worker-1",
809+
"heartbeat_status": "online",
810+
"seconds_since_heartbeat": 5,
811+
"last_heartbeat": "2026-05-04T17:30:00Z",
812+
}
813+
],
814+
"total": 1,
815+
},
816+
)
817+
},
818+
)
819+
result = runner.invoke(main, ["workers", "list"])
820+
assert result.exit_code == 0, result.output
821+
assert "worker-1" in result.output
822+
assert "online" in result.output
823+
824+
825+
def test_workers_list_empty_renders_install_hint(monkeypatch):
826+
holder: dict = {}
827+
_patch_ws_client(
828+
monkeypatch,
829+
holder,
830+
responses={
831+
("GET", "/workers"): lambda: _FakeResp(200, {"workers": [], "total": 0})
832+
},
833+
)
834+
result = runner.invoke(main, ["workers", "list"])
835+
assert result.exit_code == 0
836+
assert "no workers" in result.output.lower() or "register" in result.output.lower()
837+
838+
839+
def test_workers_delete_with_yes(monkeypatch):
840+
holder: dict = {}
841+
_patch_ws_client(
842+
monkeypatch,
843+
holder,
844+
responses={
845+
("DELETE", "/workers/worker-1"): lambda: _FakeResp(204, {})
846+
},
847+
)
848+
result = runner.invoke(main, ["workers", "delete", "worker-1", "--yes"])
849+
assert result.exit_code == 0, result.output
850+
assert holder["client"].calls[-1][:2] == ("DELETE", "/workers/worker-1")
851+
852+
853+
def test_workers_delete_without_yes_aborts():
854+
result = runner.invoke(main, ["workers", "delete", "worker-1"], input="n\n")
855+
assert "Aborted" in result.output or "aborted" in result.output.lower()
856+
857+
858+
def test_key_webhook_secret_get_help():
859+
result = runner.invoke(main, ["key", "webhook-secret", "get", "--help"])
860+
assert result.exit_code == 0
861+
862+
863+
def test_key_webhook_secret_regenerate_help():
864+
result = runner.invoke(main, ["key", "webhook-secret", "regenerate", "--help"])
865+
assert result.exit_code == 0
866+
assert "--yes" in result.output or "-y" in result.output
867+
868+
869+
def test_key_webhook_secret_get_renders(monkeypatch):
870+
holder: dict = {}
871+
_patch_ws_client(
872+
monkeypatch,
873+
holder,
874+
responses={
875+
("GET", "/auth/webhook-secret"): lambda: _FakeResp(
876+
200, {"webhook_secret": "wsec_user_revealed"}
877+
)
878+
},
879+
)
880+
result = runner.invoke(main, ["key", "webhook-secret", "get"])
881+
assert result.exit_code == 0
882+
assert "wsec_user_revealed" in result.output
883+
884+
885+
def test_key_webhook_secret_get_404_helpful_error(monkeypatch):
886+
holder: dict = {}
887+
_patch_ws_client(
888+
monkeypatch,
889+
holder,
890+
responses={
891+
("GET", "/auth/webhook-secret"): lambda: _FakeResp(404, {})
892+
},
893+
)
894+
result = runner.invoke(main, ["key", "webhook-secret", "get"])
895+
assert "agents webhook-secret" in result.output
896+
897+
898+
def test_key_webhook_secret_regenerate_sends_destructive_header(monkeypatch):
899+
holder: dict = {}
900+
_patch_ws_client(
901+
monkeypatch,
902+
holder,
903+
responses={
904+
("POST", "/auth/webhook-secret/regenerate"): lambda: _FakeResp(
905+
200, {"webhook_secret": "wsec_new"}
906+
)
907+
},
908+
)
909+
result = runner.invoke(
910+
main,
911+
["key", "webhook-secret", "regenerate", "--yes"],
912+
)
913+
assert result.exit_code == 0, result.output
914+
method, path, body, headers = holder["client"].calls[-1]
915+
assert method == "POST"
916+
assert path == "/auth/webhook-secret/regenerate"
917+
assert headers == {"X-Confirm-Destructive": "true"}
918+
assert "wsec_new" in result.output
919+
920+
921+
def test_key_webhook_secret_regenerate_aborts_without_yes():
922+
result = runner.invoke(
923+
main,
924+
["key", "webhook-secret", "regenerate"],
925+
input="n\n",
926+
)
927+
assert "Aborted" in result.output or "aborted" in result.output.lower()

0 commit comments

Comments
 (0)