diff --git a/cueapi/cli.py b/cueapi/cli.py index 07eb492..b010a8d 100644 --- a/cueapi/cli.py +++ b/cueapi/cli.py @@ -858,6 +858,85 @@ def regenerate(ctx: click.Context, yes: bool) -> None: ) +@key.group(name="webhook-secret") +def key_webhook_secret() -> None: + """Manage the user-level webhook signing secret (legacy /v1/auth/webhook-secret). + + For per-agent webhook secrets (Phase 12.1 messaging primitive), use + `cueapi agents webhook-secret get/regenerate` instead. + """ + pass + + +@key_webhook_secret.command(name="get") +@click.pass_context +def key_webhook_secret_get(ctx: click.Context) -> None: + """Reveal the current user-level webhook signing secret.""" + try: + with CueAPIClient(api_key=ctx.obj.get("api_key"), profile=ctx.obj.get("profile")) as client: + resp = client.get("/auth/webhook-secret") + if resp.status_code == 200: + data = resp.json() + click.echo() + echo_info("Webhook secret:", data.get("webhook_secret", "?")) + click.echo() + elif resp.status_code == 404: + echo_error( + "No webhook secret found. The user-level webhook secret is " + "auto-provisioned for accounts using the legacy webhook signing " + "path; if you only use the messaging primitive (per-agent secrets), " + "this is expected. Use `cueapi agents webhook-secret get ` " + "for an agent's secret instead." + ) + else: + error = resp.json().get("detail", {}).get("error", {}) + echo_error(error.get("message", f"Failed (HTTP {resp.status_code})")) + except click.ClickException as e: + click.echo(str(e)) + + +@key_webhook_secret.command(name="regenerate") +@click.option("--yes", "-y", is_flag=True, default=False, help="Skip confirmation") +@click.pass_context +def key_webhook_secret_regenerate(ctx: click.Context, yes: bool) -> None: + """Rotate the user-level webhook signing secret. Old secret is revoked immediately. + + Server requires the X-Confirm-Destructive: true header (same pattern as + api-key regenerate). The CLI sends this header automatically when the user + confirms the prompt (or passes --yes). + """ + if not yes: + if not click.confirm( + "Rotate user-level webhook secret? Current secret will be revoked immediately." + ): + click.echo("Aborted.") + return + try: + with CueAPIClient(api_key=ctx.obj.get("api_key"), profile=ctx.obj.get("profile")) as client: + resp = client.post( + "/auth/webhook-secret/regenerate", + json={}, + headers={"X-Confirm-Destructive": "true"}, + ) + if resp.status_code == 200: + data = resp.json() + click.echo() + echo_success("Rotated user-level webhook secret") + echo_info("New webhook secret (save now — only shown once):", data.get("webhook_secret", "?")) + click.echo() + elif resp.status_code == 400: + # Should be unreachable since the CLI sends the confirmation + # header automatically; but if the server's contract changes, + # surface the error rather than swallow it. + error = resp.json().get("detail", {}).get("error", {}) + echo_error(error.get("message", "Bad request")) + else: + error = resp.json().get("detail", {}).get("error", {}) + echo_error(error.get("message", f"Failed (HTTP {resp.status_code})")) + except click.ClickException as e: + click.echo(str(e)) + + main.add_command(key) @@ -1261,5 +1340,87 @@ def agents_sent( main.add_command(agents) +# --- Workers (fleet visibility for worker-transport users) --- + + +@main.group() +def workers() -> None: + """Manage worker fleet (registered workers + heartbeat status).""" + pass + + +@workers.command(name="list") +@click.pass_context +def workers_list(ctx: click.Context) -> None: + """List all registered workers with heartbeat status.""" + try: + with CueAPIClient(api_key=ctx.obj.get("api_key"), profile=ctx.obj.get("profile")) as client: + resp = client.get("/workers") + if resp.status_code != 200: + echo_error(f"Failed (HTTP {resp.status_code})") + return + data = resp.json() + workers_list_data = data.get("workers", []) + if not workers_list_data: + click.echo( + "\nNo workers registered yet. Workers register automatically by " + "sending heartbeats; install cueapi-worker to get started.\n" + ) + return + click.echo() + rows = [] + for w in workers_list_data: + rows.append([ + w.get("worker_id", "?"), + format_status(w.get("heartbeat_status", "?")), + str(w.get("seconds_since_heartbeat", "?")), + (w.get("last_heartbeat") or "—")[:19].replace("T", " "), + ]) + echo_table( + ["WORKER ID", "STATUS", "SECONDS AGO", "LAST HEARTBEAT"], + rows, + widths=[28, 14, 14, 22], + ) + total = data.get("total", len(workers_list_data)) + click.echo(f"\n{total} workers\n") + except click.ClickException as e: + click.echo(str(e)) + + +@workers.command(name="delete") +@click.argument("worker_id") +@click.option("--yes", "-y", is_flag=True, default=False, help="Skip confirmation") +@click.pass_context +def workers_delete(ctx: click.Context, worker_id: str, yes: bool) -> None: + """Delete a registered worker. + + Removes the worker row; in-flight executions claimed by this worker + will be picked up by the stale-recovery loop. Useful for cleaning up + workers that have been decommissioned. + """ + if not yes: + if not click.confirm(f"Delete worker {worker_id}?"): + click.echo("Aborted.") + return + try: + with CueAPIClient(api_key=ctx.obj.get("api_key"), profile=ctx.obj.get("profile")) as client: + resp = client.delete(f"/workers/{worker_id}") + if resp.status_code == 204: + echo_success(f"Deleted worker: {worker_id}") + elif resp.status_code == 404: + echo_error(f"Worker not found: {worker_id}") + else: + try: + error = resp.json().get("detail", {}).get("error", {}) + echo_error(error.get("message", f"Failed (HTTP {resp.status_code})")) + except Exception: + echo_error(f"Failed (HTTP {resp.status_code})") + except click.ClickException as e: + click.echo(str(e)) + + +main.add_command(workers) + + if __name__ == "__main__": main() diff --git a/tests/test_cli.py b/tests/test_cli.py index 0689811..32c4497 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -738,3 +738,190 @@ def test_top_level_help_lists_agents(): result = runner.invoke(main, ["--help"]) assert result.exit_code == 0 assert "agents" in result.output + + +# --- workers + key webhook-secret --- +# +# Reuses _FakeResp from the agents tests above. _WSClient is a separate +# capture client because it tracks the headers kwarg (needed for the +# X-Confirm-Destructive pin on key webhook-secret regenerate). + + +class _WSClient: + def __init__(self, responses: Optional[dict] = None): + self.calls: list = [] + self._responses = responses or {} + + def __enter__(self): + return self + + def __exit__(self, *_): + pass + + def _resolve(self, method: str, path: str): + for (m, p), factory in sorted(self._responses.items(), key=lambda kv: -len(kv[0][1])): + if m == method and path.startswith(p): + return factory() + return _FakeResp(200, {}) + + def get(self, path, params=None, **_): + self.calls.append(("GET", path, params, None)) + return self._resolve("GET", path) + + def post(self, path, json=None, headers=None, **_): + self.calls.append(("POST", path, json, headers)) + return self._resolve("POST", path) + + def delete(self, path, **_): + self.calls.append(("DELETE", path, None, None)) + return self._resolve("DELETE", path) + + +def _patch_ws_client(monkeypatch, holder, responses=None): + import cueapi.cli as cli_mod + + def fake_factory(*_, **__): + holder["client"] = _WSClient(responses=responses) + return holder["client"] + + monkeypatch.setattr(cli_mod, "CueAPIClient", fake_factory) + + +def test_workers_group_help(): + result = runner.invoke(main, ["workers", "--help"]) + assert result.exit_code == 0 + for sub in ("list", "delete"): + assert sub in result.output + + +def test_workers_list_renders(monkeypatch): + holder: dict = {} + _patch_ws_client( + monkeypatch, + holder, + responses={ + ("GET", "/workers"): lambda: _FakeResp( + 200, + { + "workers": [ + { + "worker_id": "worker-1", + "heartbeat_status": "online", + "seconds_since_heartbeat": 5, + "last_heartbeat": "2026-05-04T17:30:00Z", + } + ], + "total": 1, + }, + ) + }, + ) + result = runner.invoke(main, ["workers", "list"]) + assert result.exit_code == 0, result.output + assert "worker-1" in result.output + assert "online" in result.output + + +def test_workers_list_empty_renders_install_hint(monkeypatch): + holder: dict = {} + _patch_ws_client( + monkeypatch, + holder, + responses={ + ("GET", "/workers"): lambda: _FakeResp(200, {"workers": [], "total": 0}) + }, + ) + result = runner.invoke(main, ["workers", "list"]) + assert result.exit_code == 0 + assert "no workers" in result.output.lower() or "register" in result.output.lower() + + +def test_workers_delete_with_yes(monkeypatch): + holder: dict = {} + _patch_ws_client( + monkeypatch, + holder, + responses={ + ("DELETE", "/workers/worker-1"): lambda: _FakeResp(204, {}) + }, + ) + result = runner.invoke(main, ["workers", "delete", "worker-1", "--yes"]) + assert result.exit_code == 0, result.output + assert holder["client"].calls[-1][:2] == ("DELETE", "/workers/worker-1") + + +def test_workers_delete_without_yes_aborts(): + result = runner.invoke(main, ["workers", "delete", "worker-1"], input="n\n") + assert "Aborted" in result.output or "aborted" in result.output.lower() + + +def test_key_webhook_secret_get_help(): + result = runner.invoke(main, ["key", "webhook-secret", "get", "--help"]) + assert result.exit_code == 0 + + +def test_key_webhook_secret_regenerate_help(): + result = runner.invoke(main, ["key", "webhook-secret", "regenerate", "--help"]) + assert result.exit_code == 0 + assert "--yes" in result.output or "-y" in result.output + + +def test_key_webhook_secret_get_renders(monkeypatch): + holder: dict = {} + _patch_ws_client( + monkeypatch, + holder, + responses={ + ("GET", "/auth/webhook-secret"): lambda: _FakeResp( + 200, {"webhook_secret": "wsec_user_revealed"} + ) + }, + ) + result = runner.invoke(main, ["key", "webhook-secret", "get"]) + assert result.exit_code == 0 + assert "wsec_user_revealed" in result.output + + +def test_key_webhook_secret_get_404_helpful_error(monkeypatch): + holder: dict = {} + _patch_ws_client( + monkeypatch, + holder, + responses={ + ("GET", "/auth/webhook-secret"): lambda: _FakeResp(404, {}) + }, + ) + result = runner.invoke(main, ["key", "webhook-secret", "get"]) + assert "agents webhook-secret" in result.output + + +def test_key_webhook_secret_regenerate_sends_destructive_header(monkeypatch): + holder: dict = {} + _patch_ws_client( + monkeypatch, + holder, + responses={ + ("POST", "/auth/webhook-secret/regenerate"): lambda: _FakeResp( + 200, {"webhook_secret": "wsec_new"} + ) + }, + ) + result = runner.invoke( + main, + ["key", "webhook-secret", "regenerate", "--yes"], + ) + assert result.exit_code == 0, result.output + method, path, body, headers = holder["client"].calls[-1] + assert method == "POST" + assert path == "/auth/webhook-secret/regenerate" + assert headers == {"X-Confirm-Destructive": "true"} + assert "wsec_new" in result.output + + +def test_key_webhook_secret_regenerate_aborts_without_yes(): + result = runner.invoke( + main, + ["key", "webhook-secret", "regenerate"], + input="n\n", + ) + assert "Aborted" in result.output or "aborted" in result.output.lower()