ermes-cli 1.0.0__py3-none-any.whl

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.
@@ -0,0 +1,238 @@
1
+ from __future__ import annotations
2
+
3
+ import json as json_lib
4
+ from typing import Any, Optional
5
+
6
+ import typer
7
+
8
+ from ermes_cli.client import ErmesAPIError, ErmesClient
9
+ from ermes_cli.export_utils import (
10
+ extract_campaign_record,
11
+ is_within_date_range,
12
+ )
13
+
14
+ export_app = typer.Typer(
15
+ help="Export campaign data for analysis",
16
+ no_args_is_help=True,
17
+ )
18
+
19
+
20
+ def _get_deps():
21
+ from ermes_cli.main import get_client, state
22
+ from ermes_cli.output import render_message
23
+ return get_client(), state, render_message
24
+
25
+
26
+ def _fetch_campaigns(
27
+ client: ErmesClient,
28
+ campaign_type: str | None,
29
+ status: str | None,
30
+ ) -> list[dict]:
31
+ """Fetch campaigns with optional server-side filters."""
32
+ params: dict = {}
33
+ filter_obj: dict = {}
34
+ if campaign_type:
35
+ filter_obj["type"] = campaign_type
36
+ if status:
37
+ filter_obj["state"] = status
38
+ if filter_obj:
39
+ params["filter"] = json_lib.dumps(filter_obj)
40
+ return client.get_paginated("/campaigns", params=params, limit=0)
41
+
42
+
43
+ def _apply_client_filters(
44
+ campaigns: list[dict],
45
+ *,
46
+ days: int | None,
47
+ after: str | None,
48
+ before: str | None,
49
+ limit: int,
50
+ ) -> list[dict]:
51
+ """Apply date filtering, zero-send removal, and limit."""
52
+ if days is not None or after is not None or before is not None:
53
+ campaigns = [
54
+ c for c in campaigns
55
+ if is_within_date_range(
56
+ c.get("send_at"),
57
+ days=days, after=after, before=before,
58
+ )
59
+ ]
60
+ campaigns = [
61
+ c for c in campaigns
62
+ if (c.get("delivery") or {}).get("sent", 0) > 0
63
+ ]
64
+ if limit > 0:
65
+ campaigns = campaigns[:limit]
66
+ return campaigns
67
+
68
+
69
+ def _enrich_campaigns(
70
+ client: ErmesClient,
71
+ campaigns: list[dict],
72
+ project_timezone: str | None,
73
+ ) -> list[dict]:
74
+ """Fetch details and enrich each campaign."""
75
+ enriched = []
76
+ skipped = 0
77
+ for c in campaigns:
78
+ try:
79
+ detail = client.get(f"/campaigns/{c['id']}")
80
+ record = extract_campaign_record(detail, project_timezone)
81
+ if record:
82
+ enriched.append(record)
83
+ except ErmesAPIError:
84
+ skipped += 1
85
+ continue
86
+ if skipped:
87
+ import sys
88
+ print(f"Warning: {skipped}/{len(campaigns)} campaigns skipped due to API errors", file=sys.stderr)
89
+ return enriched
90
+
91
+
92
+ def _build_metadata(
93
+ enriched: list[dict],
94
+ project_timezone: str | None,
95
+ ) -> dict[str, Any]:
96
+ """Build summary metadata for the export."""
97
+ metadata: dict[str, Any] = {
98
+ "count": len(enriched),
99
+ "project_timezone": project_timezone,
100
+ }
101
+ if not enriched:
102
+ return metadata
103
+
104
+ open_rates = [r["analytics"]["open_rate"] for r in enriched]
105
+ click_rates = [r["analytics"]["click_rate"] for r in enriched]
106
+ metadata["avg_open_rate"] = round(
107
+ sum(open_rates) / len(open_rates), 2,
108
+ )
109
+ metadata["avg_click_rate"] = round(
110
+ sum(click_rates) / len(click_rates), 2,
111
+ )
112
+ send_dates = [
113
+ r["send_at"] for r in enriched if r.get("send_at")
114
+ ]
115
+ if send_dates:
116
+ metadata["date_range"] = {
117
+ "from": min(send_dates), "to": max(send_dates),
118
+ }
119
+ return metadata
120
+
121
+
122
+ def _write_or_print(
123
+ json_str: str,
124
+ output: str | None,
125
+ count: int,
126
+ render_message,
127
+ use_json: bool,
128
+ ):
129
+ """Write JSON to file or print to stdout."""
130
+ if output:
131
+ with open(output, "w", encoding="utf-8") as f:
132
+ f.write(json_str)
133
+ render_message(
134
+ f"Exported {count} campaigns to {output}",
135
+ use_json=use_json,
136
+ )
137
+ else:
138
+ print(json_str)
139
+
140
+
141
+ @export_app.command("campaigns")
142
+ def export_campaigns(
143
+ campaign_type: Optional[str] = typer.Option(
144
+ None, "--type", help="Filter: blast|trigger",
145
+ ),
146
+ status: Optional[str] = typer.Option(
147
+ None, "--status", help="Filter by state",
148
+ ),
149
+ days: Optional[int] = typer.Option(
150
+ None, "--days", help="Only last N days",
151
+ ),
152
+ after: Optional[str] = typer.Option(
153
+ None, "--after", help="Sent after (ISO date)",
154
+ ),
155
+ before: Optional[str] = typer.Option(
156
+ None, "--before", help="Sent before (ISO date)",
157
+ ),
158
+ limit: int = typer.Option(
159
+ 0, "--limit", help="Max campaigns (0 = all)",
160
+ ),
161
+ output: Optional[str] = typer.Option(
162
+ None, "--output", "-o", help="Write JSON to file",
163
+ ),
164
+ ):
165
+ """Export enriched campaign JSON with analytics."""
166
+ client, st, render_message = _get_deps()
167
+ try:
168
+ project = client.get("/")
169
+ tz = (
170
+ project.get("timezone")
171
+ if isinstance(project, dict) else None
172
+ )
173
+ campaigns = _fetch_campaigns(client, campaign_type, status)
174
+ campaigns = _apply_client_filters(
175
+ campaigns,
176
+ days=days, after=after, before=before, limit=limit,
177
+ )
178
+ enriched = _enrich_campaigns(client, campaigns, tz)
179
+ result = {
180
+ "metadata": _build_metadata(enriched, tz),
181
+ "campaigns": enriched,
182
+ }
183
+ json_str = json_lib.dumps(
184
+ result, indent=2, ensure_ascii=False, default=str,
185
+ )
186
+ _write_or_print(
187
+ json_str, output, len(enriched),
188
+ render_message, st.use_json,
189
+ )
190
+ except ErmesAPIError as e:
191
+ typer.echo(str(e), err=True)
192
+ raise typer.Exit(code=1)
193
+
194
+
195
+ @export_app.command("html")
196
+ def export_html(
197
+ campaign_id: int = typer.Argument(
198
+ ..., help="Campaign ID to extract HTML from",
199
+ ),
200
+ output: Optional[str] = typer.Option(
201
+ None, "--output", "-o", help="Write HTML to file",
202
+ ),
203
+ ):
204
+ """Extract the HTML template from a single campaign."""
205
+ client, st, render_message = _get_deps()
206
+ try:
207
+ detail = client.get(f"/campaigns/{campaign_id}")
208
+ templates = detail.get("templates", [])
209
+ if not templates:
210
+ typer.echo("Campaign has no templates.", err=True)
211
+ raise typer.Exit(code=1)
212
+
213
+ tmpl_data = templates[0].get("data", {})
214
+ html = (
215
+ tmpl_data.get("html", "") or tmpl_data.get("mjml", "")
216
+ )
217
+ if not html:
218
+ typer.echo("No HTML or MJML content.", err=True)
219
+ raise typer.Exit(code=1)
220
+
221
+ if output:
222
+ with open(output, "w", encoding="utf-8") as f:
223
+ f.write(html)
224
+ render_message(
225
+ f"HTML saved to {output}", use_json=st.use_json,
226
+ )
227
+ elif st.use_json:
228
+ print(json_lib.dumps({
229
+ "campaign_id": campaign_id,
230
+ "subject": tmpl_data.get("subject", ""),
231
+ "html": html,
232
+ }, indent=2, ensure_ascii=False))
233
+ else:
234
+ print(html)
235
+
236
+ except ErmesAPIError as e:
237
+ typer.echo(str(e), err=True)
238
+ raise typer.Exit(code=1)
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+ import json as json_lib
3
+ from typing import Optional
4
+ import typer
5
+ from ermes_cli.client import ErmesAPIError
6
+
7
+ ingest_app = typer.Typer(help="Ingest data (identify users, post events, alias)", no_args_is_help=True)
8
+
9
+
10
+ def _get_deps():
11
+ from ermes_cli.main import get_client, state
12
+ from ermes_cli.output import render_message
13
+ return get_client(), state, render_message
14
+
15
+
16
+ def _require_identity(external_id: str | None, anonymous_id: str | None) -> None:
17
+ """Exit with error if neither identity field is provided."""
18
+ if not external_id and not anonymous_id:
19
+ typer.echo("Error: at least one of --external-id or --anonymous-id is required.", err=True)
20
+ raise typer.Exit(code=1)
21
+
22
+
23
+ def _build_body(fields: dict) -> dict:
24
+ """Build a request body from optional fields, parsing 'data' as JSON."""
25
+ body: dict = {}
26
+ for key, value in fields.items():
27
+ if value is None:
28
+ continue
29
+ if key == "data":
30
+ try:
31
+ body["data"] = json_lib.loads(value)
32
+ except json_lib.JSONDecodeError as e:
33
+ typer.echo(f"Invalid JSON for --data: {e}", err=True)
34
+ raise typer.Exit(code=1)
35
+ else:
36
+ body[key] = value
37
+ return body
38
+
39
+
40
+ @ingest_app.command("identify")
41
+ def ingest_identify(
42
+ external_id: Optional[str] = typer.Option(None, "--external-id", help="User external ID"),
43
+ anonymous_id: Optional[str] = typer.Option(None, "--anonymous-id", help="Anonymous tracking ID"),
44
+ email: Optional[str] = typer.Option(None, "--email", help="Email address"),
45
+ phone: Optional[str] = typer.Option(None, "--phone", help="Phone number"),
46
+ timezone: Optional[str] = typer.Option(None, "--timezone", help="IANA timezone"),
47
+ locale: Optional[str] = typer.Option(None, "--locale", help="Locale code"),
48
+ data: Optional[str] = typer.Option(None, "--data", help="Custom attributes as JSON string"),
49
+ ):
50
+ """Identify or update a user."""
51
+ _require_identity(external_id, anonymous_id)
52
+ client, st, render_message = _get_deps()
53
+ body = _build_body({
54
+ "external_id": external_id, "anonymous_id": anonymous_id,
55
+ "email": email, "phone": phone, "timezone": timezone,
56
+ "locale": locale, "data": data,
57
+ })
58
+ try:
59
+ client.post("/identify", json=body)
60
+ render_message("User identified.", use_json=st.use_json)
61
+ except ErmesAPIError as e:
62
+ typer.echo(str(e), err=True)
63
+ raise typer.Exit(code=1)
64
+
65
+
66
+ @ingest_app.command("event")
67
+ def ingest_event(
68
+ external_id: Optional[str] = typer.Option(None, "--external-id", help="User external ID"),
69
+ anonymous_id: Optional[str] = typer.Option(None, "--anonymous-id", help="Anonymous tracking ID"),
70
+ name: str = typer.Option(..., "--name", help="Event name"),
71
+ data: Optional[str] = typer.Option(None, "--data", help="Event properties as JSON string"),
72
+ ):
73
+ """Post a user event."""
74
+ _require_identity(external_id, anonymous_id)
75
+ client, st, render_message = _get_deps()
76
+ event = _build_body({
77
+ "external_id": external_id, "anonymous_id": anonymous_id,
78
+ "name": name, "data": data,
79
+ })
80
+ try:
81
+ client.post("/events", json=[event])
82
+ render_message("Event recorded.", use_json=st.use_json)
83
+ except ErmesAPIError as e:
84
+ typer.echo(str(e), err=True)
85
+ raise typer.Exit(code=1)
86
+
87
+
88
+ @ingest_app.command("alias")
89
+ def ingest_alias(
90
+ external_id: str = typer.Option(..., "--external-id", help="User external ID"),
91
+ anonymous_id: str = typer.Option(..., "--anonymous-id", help="Anonymous tracking ID"),
92
+ ):
93
+ """Link an anonymous ID to an external ID."""
94
+ client, st, render_message = _get_deps()
95
+ body = {"external_id": external_id, "anonymous_id": anonymous_id}
96
+ try:
97
+ client.post("/alias", json=body)
98
+ render_message("Alias created.", use_json=st.use_json)
99
+ except ErmesAPIError as e:
100
+ typer.echo(str(e), err=True)
101
+ raise typer.Exit(code=1)
@@ -0,0 +1,182 @@
1
+ from __future__ import annotations
2
+ import json as json_lib
3
+ from typing import Optional
4
+ import typer
5
+ from ermes_cli.client import ErmesAPIError
6
+ from ermes_cli.rules import normalize_rule_values
7
+
8
+ journeys_app = typer.Typer(help="Manage journeys", no_args_is_help=True)
9
+ COLUMNS = ["id", "name", "published", "tags"]
10
+
11
+
12
+ def _get_deps():
13
+ from ermes_cli.main import get_client, state
14
+ from ermes_cli.output import render, render_detail, render_message
15
+ return get_client(), state, render, render_detail, render_message
16
+
17
+
18
+ @journeys_app.command("list")
19
+ def journeys_list(
20
+ limit: int = typer.Option(100, "--limit", help="Max results (0 = all)"),
21
+ ):
22
+ """List journeys."""
23
+ client, st, render, _, _ = _get_deps()
24
+ try:
25
+ data = client.get_paginated("/journeys", limit=limit)
26
+ render(data, columns=COLUMNS, use_json=st.use_json)
27
+ except ErmesAPIError as e:
28
+ typer.echo(str(e), err=True)
29
+ raise typer.Exit(code=1)
30
+
31
+
32
+ @journeys_app.command("get")
33
+ def journeys_get(journey_id: int = typer.Argument(..., help="Journey ID")):
34
+ """Get journey details."""
35
+ client, st, _, render_detail, _ = _get_deps()
36
+ try:
37
+ data = client.get(f"/journeys/{journey_id}")
38
+ render_detail(data, use_json=st.use_json)
39
+ except ErmesAPIError as e:
40
+ typer.echo(str(e), err=True)
41
+ raise typer.Exit(code=1)
42
+
43
+
44
+ @journeys_app.command("create")
45
+ def journeys_create(
46
+ name: str = typer.Option(..., "--name", help="Journey name"),
47
+ published: bool = typer.Option(False, "--published", help="Publish the journey"),
48
+ description: Optional[str] = typer.Option(None, "--description", help="Journey description"),
49
+ ):
50
+ """Create a journey."""
51
+ client, st, _, render_detail, _ = _get_deps()
52
+ body: dict = {"name": name, "published": published}
53
+ if description is not None:
54
+ body["description"] = description
55
+ try:
56
+ data = client.post("/journeys", json=body)
57
+ render_detail(data, use_json=st.use_json)
58
+ except ErmesAPIError as e:
59
+ typer.echo(str(e), err=True)
60
+ raise typer.Exit(code=1)
61
+
62
+
63
+ @journeys_app.command("update")
64
+ def journeys_update(
65
+ journey_id: int = typer.Argument(..., help="Journey ID"),
66
+ name: str = typer.Option(..., "--name", help="Journey name (required by the API on update)"),
67
+ published: Optional[bool] = typer.Option(None, "--published", help="Publish or unpublish"),
68
+ description: Optional[str] = typer.Option(None, "--description", help="Journey description"),
69
+ ):
70
+ """Update a journey."""
71
+ client, st, _, render_detail, _ = _get_deps()
72
+ body: dict = {"name": name}
73
+ if published is not None:
74
+ body["published"] = published
75
+ if description is not None:
76
+ body["description"] = description
77
+ try:
78
+ data = client.patch(f"/journeys/{journey_id}", json=body)
79
+ render_detail(data, use_json=st.use_json)
80
+ except ErmesAPIError as e:
81
+ typer.echo(str(e), err=True)
82
+ raise typer.Exit(code=1)
83
+
84
+
85
+ @journeys_app.command("delete")
86
+ def journeys_delete(journey_id: int = typer.Argument(..., help="Journey ID")):
87
+ """Delete a journey."""
88
+ client, st, _, _, render_message = _get_deps()
89
+ try:
90
+ client.delete(f"/journeys/{journey_id}")
91
+ render_message(f"Journey {journey_id} deleted.", use_json=st.use_json)
92
+ except ErmesAPIError as e:
93
+ typer.echo(str(e), err=True)
94
+ raise typer.Exit(code=1)
95
+
96
+
97
+ @journeys_app.command("duplicate")
98
+ def journeys_duplicate(journey_id: int = typer.Argument(..., help="Journey ID")):
99
+ """Duplicate a journey."""
100
+ client, st, _, render_detail, _ = _get_deps()
101
+ try:
102
+ data = client.post(f"/journeys/{journey_id}/duplicate")
103
+ render_detail(data, use_json=st.use_json)
104
+ except ErmesAPIError as e:
105
+ typer.echo(str(e), err=True)
106
+ raise typer.Exit(code=1)
107
+
108
+
109
+ @journeys_app.command("steps")
110
+ def journeys_steps(journey_id: int = typer.Argument(..., help="Journey ID")):
111
+ """Get journey steps."""
112
+ client, st, _, render_detail, _ = _get_deps()
113
+ try:
114
+ data = client.get(f"/journeys/{journey_id}/steps")
115
+ render_detail(data, use_json=st.use_json)
116
+ except ErmesAPIError as e:
117
+ typer.echo(str(e), err=True)
118
+ raise typer.Exit(code=1)
119
+
120
+
121
+ @journeys_app.command("trigger")
122
+ def journeys_trigger(
123
+ journey_id: int = typer.Argument(..., help="Journey ID"),
124
+ entrance: int = typer.Option(..., "--entrance", help="Entrance step ID"),
125
+ external_id: str = typer.Option(..., "--external-id", help="User external ID"),
126
+ event: Optional[str] = typer.Option(None, "--event", help="Event data as JSON"),
127
+ ):
128
+ """Trigger a journey for a user."""
129
+ client, st, _, render_detail, _ = _get_deps()
130
+ body: dict = {"entrance_id": entrance, "user": {"external_id": external_id}}
131
+ if event:
132
+ try:
133
+ body["event"] = json_lib.loads(event)
134
+ except json_lib.JSONDecodeError as e:
135
+ typer.echo(f"Invalid JSON for --event: {e}", err=True)
136
+ raise typer.Exit(code=1)
137
+ try:
138
+ data = client.post(f"/journeys/{journey_id}/trigger", json=body)
139
+ render_detail(data, use_json=st.use_json)
140
+ except ErmesAPIError as e:
141
+ typer.echo(str(e), err=True)
142
+ raise typer.Exit(code=1)
143
+
144
+
145
+ @journeys_app.command("exit-user")
146
+ def journeys_exit_user(
147
+ journey_id: int = typer.Argument(..., help="Journey ID"),
148
+ user: str = typer.Option(..., "--user", help="User external ID"),
149
+ entrance: Optional[int] = typer.Option(None, "--entrance", help="Entrance ID"),
150
+ ):
151
+ """Exit a user from a journey (or a specific entrance)."""
152
+ client, st, _, _, render_message = _get_deps()
153
+ if entrance is not None:
154
+ path = f"/journeys/{journey_id}/entrances/{entrance}/users/{user}"
155
+ else:
156
+ path = f"/journeys/{journey_id}/users/{user}"
157
+ try:
158
+ client.delete(path)
159
+ render_message(f"User {user} exited from journey {journey_id}.", use_json=st.use_json)
160
+ except ErmesAPIError as e:
161
+ typer.echo(str(e), err=True)
162
+ raise typer.Exit(code=1)
163
+
164
+
165
+ @journeys_app.command("steps-update")
166
+ def journeys_steps_update(
167
+ journey_id: int = typer.Argument(..., help="Journey ID"),
168
+ steps: str = typer.Option(..., "--steps", help="Steps JSON"),
169
+ ):
170
+ """Update journey steps."""
171
+ client, st, _, render_detail, _ = _get_deps()
172
+ try:
173
+ body = normalize_rule_values(json_lib.loads(steps))
174
+ except json_lib.JSONDecodeError as e:
175
+ typer.echo(f"Invalid JSON for --steps: {e}", err=True)
176
+ raise typer.Exit(code=1)
177
+ try:
178
+ data = client.put(f"/journeys/{journey_id}/steps", json=body)
179
+ render_detail(data, use_json=st.use_json)
180
+ except ErmesAPIError as e:
181
+ typer.echo(str(e), err=True)
182
+ raise typer.Exit(code=1)
@@ -0,0 +1,113 @@
1
+ """`ermes keys` — manage a project's API keys.
2
+
3
+ Routed through the admin JWT at `/api/admin/projects/:id/keys`: since DEV-2260
4
+ `/api/client/keys` rejects `sk_` keys, so a leaked CLI key cannot mint more keys.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Optional
10
+
11
+ import typer
12
+
13
+ from ermes_cli import admin_api, write_mode
14
+ from ermes_cli.errors import WriteBlockedError
15
+
16
+ keys_app = typer.Typer(help="Manage API keys (requires `ermes login`)", no_args_is_help=True)
17
+ COLUMNS = ["id", "name", "scope", "role", "created_at"]
18
+
19
+
20
+ def _call(method: str, path: str = "", json: dict | None = None, params: dict | None = None):
21
+ """One admin-JWT request against the effective project's keys. Exits 1 on any failure."""
22
+ from ermes_cli.commands.auth_cmds import _ensure_admin_session
23
+ from ermes_cli.config import get_effective_project
24
+ from ermes_cli.main import state
25
+
26
+ pid, _, source = get_effective_project(state.profile_name)
27
+ if pid is None:
28
+ typer.echo(
29
+ f"keys: no project identity ({source}). Use `ermes use <project>` or ERMES_PROJECT.",
30
+ err=True,
31
+ )
32
+ raise typer.Exit(code=1)
33
+ if method != "GET":
34
+ try:
35
+ write_mode.assert_writes_allowed(method, f"/api/admin/projects/{pid}/keys{path}")
36
+ except WriteBlockedError as exc:
37
+ typer.echo(str(exc), err=True)
38
+ raise typer.Exit(code=1)
39
+ base, session = _ensure_admin_session()
40
+ url = f"{base.rstrip('/')}/api/admin/projects/{pid}/keys{path}"
41
+ try:
42
+ return admin_api.request(method, url, session.ermes_access_token, json=json, params=params)
43
+ except admin_api.AdminAPIError as exc:
44
+ typer.echo(str(exc), err=True)
45
+ raise typer.Exit(code=1)
46
+
47
+
48
+ def _out():
49
+ from ermes_cli.main import state
50
+ from ermes_cli.output import render, render_detail, render_message
51
+
52
+ return state, render, render_detail, render_message
53
+
54
+
55
+ @keys_app.command("list")
56
+ def keys_list(limit: int = typer.Option(100, "--limit", help="Max results")):
57
+ """List API keys of the active project."""
58
+ st, render, _, _ = _out()
59
+ data = _call("GET", params={"limit": limit})
60
+ rows = data.get("results", data) if isinstance(data, dict) else data
61
+ render(rows or [], columns=COLUMNS, use_json=st.use_json)
62
+
63
+
64
+ @keys_app.command("create")
65
+ def keys_create(
66
+ name: str = typer.Option(..., "--name", help="Key name"),
67
+ scope: str = typer.Option(..., "--scope", help="Key scope: public or secret"),
68
+ description: Optional[str] = typer.Option(None, "--description", help="Key description"),
69
+ role: Optional[str] = typer.Option(None, "--role", help="Project role: support, editor, or admin"),
70
+ ):
71
+ """Create an API key."""
72
+ st, _, render_detail, _ = _out()
73
+ body = {"name": name, "scope": scope}
74
+ if description is not None:
75
+ body["description"] = description
76
+ if role is not None:
77
+ body["role"] = role
78
+ render_detail(_call("POST", json=body), use_json=st.use_json)
79
+
80
+
81
+ @keys_app.command("get")
82
+ def keys_get(key_id: int = typer.Argument(..., help="Key ID")):
83
+ """Get API key details."""
84
+ st, _, render_detail, _ = _out()
85
+ render_detail(_call("GET", f"/{key_id}"), use_json=st.use_json)
86
+
87
+
88
+ @keys_app.command("update")
89
+ def keys_update(
90
+ key_id: int = typer.Argument(..., help="Key ID"),
91
+ name: str = typer.Option(..., "--name", help="Key name"),
92
+ scope: Optional[str] = typer.Option(None, "--scope", help="Key scope: public or secret"),
93
+ description: Optional[str] = typer.Option(None, "--description", help="Key description"),
94
+ role: Optional[str] = typer.Option(None, "--role", help="Project role: support, editor, or admin"),
95
+ ):
96
+ """Update an API key."""
97
+ st, _, render_detail, _ = _out()
98
+ body = {"name": name}
99
+ if scope is not None:
100
+ body["scope"] = scope
101
+ if description is not None:
102
+ body["description"] = description
103
+ if role is not None:
104
+ body["role"] = role
105
+ render_detail(_call("PATCH", f"/{key_id}", json=body), use_json=st.use_json)
106
+
107
+
108
+ @keys_app.command("delete")
109
+ def keys_delete(key_id: int = typer.Argument(..., help="Key ID")):
110
+ """Delete (revoke) an API key."""
111
+ st, _, _, render_message = _out()
112
+ _call("DELETE", f"/{key_id}")
113
+ render_message(f"Key {key_id} deleted.", use_json=st.use_json)