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,286 @@
1
+ """`ermes projects` / `ermes use` — manage the projects.yml cache."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import TYPE_CHECKING, Optional
7
+ from zoneinfo import available_timezones
8
+
9
+ import typer
10
+ from rich.console import Console
11
+ from rich.table import Table
12
+
13
+ from ermes_cli import write_mode
14
+ from ermes_cli.config import (
15
+ ConfigError,
16
+ get_active_project_id,
17
+ get_auth0_email,
18
+ get_projects,
19
+ mask_key,
20
+ set_active_project_id,
21
+ )
22
+ from ermes_cli.errors import WriteBlockedError
23
+
24
+ if TYPE_CHECKING:
25
+ from ermes_cli import admin_api
26
+
27
+ console = Console()
28
+
29
+ # BCP-47 language tag: 2-3 letter primary subtag + optional alnum subtags.
30
+ # Deliberately permissive — the server's Intl.DisplayNames is the real
31
+ # authority. This only catches the common `en_US` (underscore) footgun and
32
+ # obviously-malformed input before it reaches the API and 500s.
33
+ _LOCALE_RE = re.compile(r"^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$")
34
+
35
+
36
+ def _validate_locale(locale: str) -> None:
37
+ """Reject malformed locales client-side (a bad tag 500s server-side)."""
38
+ if not _LOCALE_RE.match(locale):
39
+ console.print(
40
+ f"[red]Invalid --locale {locale!r}.[/red] Use a BCP-47 tag like "
41
+ "`en`, `pt-BR`, or `pl-PL` (hyphen, not underscore)."
42
+ )
43
+ raise typer.Exit(code=1)
44
+
45
+
46
+ def _validate_timezone(timezone: str) -> None:
47
+ """Reject non-IANA timezones client-side (the server stores them unchecked)."""
48
+ if timezone not in available_timezones():
49
+ console.print(
50
+ f"[red]Invalid --timezone {timezone!r}.[/red] Use an IANA name like "
51
+ "`America/Sao_Paulo` or `UTC`."
52
+ )
53
+ raise typer.Exit(code=1)
54
+
55
+
56
+ projects_app = typer.Typer(help="Manage cached Ermes projects", no_args_is_help=True)
57
+
58
+
59
+ @projects_app.command("refresh")
60
+ def projects_refresh(
61
+ base_url: str = typer.Option(
62
+ None,
63
+ "--base-url",
64
+ help="Override the API base URL (defaults to projects.yml's value).",
65
+ ),
66
+ ) -> None:
67
+ """Re-sync projects.yml from the Ermes API. Auto-refreshes Auth0 token if expired."""
68
+ from ermes_cli.commands.auth_cmds import refresh_projects as _refresh
69
+
70
+ _refresh(base_url=base_url)
71
+
72
+
73
+ def _run_create_project(
74
+ api_base: str,
75
+ result: admin_api.ExchangeResult,
76
+ name: str,
77
+ timezone: str,
78
+ locale: str,
79
+ description: str | None,
80
+ activate: bool,
81
+ ) -> None:
82
+ """Core logic for `projects create`: call the API, re-sync, write projects.yml."""
83
+ from ermes_cli import admin_api
84
+ from ermes_cli.commands.auth_cmds import _bootstrap_projects, _hostname, _write_projects_file
85
+
86
+ try:
87
+ new_project = admin_api.create_project(
88
+ api_base,
89
+ result.ermes_access_token,
90
+ name=name,
91
+ timezone=timezone,
92
+ locale=locale,
93
+ description=description,
94
+ )
95
+ except admin_api.AdminAPIError as exc:
96
+ console.print(f"[red]Project creation failed:[/red] {exc}")
97
+ raise typer.Exit(code=1)
98
+
99
+ console.print(
100
+ f"[green]+[/green] Created project [bold]{new_project.name}[/bold] "
101
+ f"(id={new_project.id})."
102
+ )
103
+ console.print("[bold]Re-syncing projects + minting CLI key…[/bold]")
104
+
105
+ try:
106
+ projects, keys = _bootstrap_projects(api_base, result.ermes_access_token, result.admin_email)
107
+ except admin_api.AdminAPIError as exc:
108
+ console.print(f"[red]Project discovery failed:[/red] {exc}")
109
+ raise typer.Exit(code=1)
110
+
111
+ # Key provisioning is best-effort. If it fails for the project we just
112
+ # created, DON'T set it active — a keyless active project makes the next
113
+ # `ermes <cmd>` fail with a confusing auth error. Cache what we have, tell
114
+ # the user to refresh, and exit non-zero so scripts notice.
115
+ new_key = None
116
+ try:
117
+ new_key = admin_api.create_cli_key(
118
+ api_base, result.ermes_access_token, new_project.id, _hostname()
119
+ )
120
+ keys[new_project.id] = new_key
121
+ except admin_api.AdminAPIError as exc:
122
+ console.print(f"[yellow]![/yellow] Key provisioning failed: {exc}")
123
+ if new_key is None or not new_key.value:
124
+ _write_projects_file(
125
+ base_url=api_base,
126
+ auth0_email=result.admin_email,
127
+ organization_id=result.organization_id,
128
+ projects=projects,
129
+ keys=keys,
130
+ active_project_id=get_active_project_id(), # keep prior active (may be None)
131
+ )
132
+ console.print(
133
+ f"[yellow]![/yellow] Project [bold]{new_project.name}[/bold] "
134
+ f"(id={new_project.id}) was created, but minting its CLI key failed. "
135
+ f"Run `ermes projects refresh`, then `ermes use {new_project.id}`."
136
+ )
137
+ raise typer.Exit(code=1)
138
+
139
+ # Default: make the new project active. With --no-activate keep the current
140
+ # active project, but fall back to the new one if nothing is active yet.
141
+ active_id = new_project.id if activate else get_active_project_id()
142
+ if active_id not in {p.id for p in projects}:
143
+ active_id = new_project.id
144
+
145
+ _write_projects_file(
146
+ base_url=api_base,
147
+ auth0_email=result.admin_email,
148
+ organization_id=result.organization_id,
149
+ projects=projects,
150
+ keys=keys,
151
+ active_project_id=active_id,
152
+ )
153
+ suffix = " Now active." if active_id == new_project.id else ""
154
+ console.print(
155
+ f"[green]✓[/green] Project [bold]{new_project.name}[/bold] "
156
+ f"(id={new_project.id}) cached.{suffix}"
157
+ )
158
+
159
+
160
+ @projects_app.command("create")
161
+ def projects_create(
162
+ name: str = typer.Option(
163
+ ..., "--name", help="Project name (shown in the UI + CLI)."
164
+ ),
165
+ timezone: str = typer.Option(
166
+ ..., "--timezone", help="IANA timezone, e.g. America/Sao_Paulo."
167
+ ),
168
+ locale: str = typer.Option(
169
+ ..., "--locale", help="BCP 47 locale, e.g. pt-BR or en."
170
+ ),
171
+ description: Optional[str] = typer.Option(
172
+ None, "--description", help="Optional project description."
173
+ ),
174
+ activate: bool = typer.Option(
175
+ True,
176
+ "--activate/--no-activate",
177
+ help="Switch the active project to the new one (default). With "
178
+ "--no-activate the current active project is kept, unless none is set.",
179
+ ),
180
+ base_url: Optional[str] = typer.Option(
181
+ None,
182
+ "--base-url",
183
+ help="Override the API base URL (defaults to projects.yml's value).",
184
+ ),
185
+ ) -> None:
186
+ """Create a new Ermes project in your organization, then cache it + mint a CLI key.
187
+
188
+ Requires `ermes login` (uses your Auth0 session → Ermes admin JWT) and org
189
+ role `admin` or `owner`. After creating the project server-side, projects.yml
190
+ is re-synced and a per-admin `sk_` key is minted for the new project,
191
+ ready for `ermes campaigns list` etc.
192
+ """
193
+ from ermes_cli.commands.auth_cmds import _ensure_admin_session
194
+
195
+ # Validate input before spending a network round-trip on auth.
196
+ _validate_locale(locale)
197
+ _validate_timezone(timezone)
198
+
199
+ # Creating a project is a write. The admin_api path bypasses client.py's
200
+ # guard, so enforce it here to stay consistent with `campaigns create` etc.
201
+ try:
202
+ write_mode.assert_writes_allowed("POST", "/api/admin/projects")
203
+ except WriteBlockedError as exc:
204
+ console.print(f"[red]{exc}[/red]")
205
+ raise typer.Exit(code=1)
206
+
207
+ api_base, result = _ensure_admin_session(base_url)
208
+ _run_create_project(api_base, result, name, timezone, locale, description, activate)
209
+
210
+
211
+ @projects_app.command("list")
212
+ def projects_list() -> None:
213
+ """Show all projects cached in ~/.ermes/projects.yml."""
214
+ projects = get_projects()
215
+ if not projects:
216
+ console.print(
217
+ "[yellow]No cached projects.[/yellow] Run `ermes login` to discover them."
218
+ )
219
+ raise typer.Exit(code=0)
220
+
221
+ active = get_active_project_id()
222
+ email = get_auth0_email()
223
+
224
+ table = Table(title=f"Projects ({email or 'unknown user'})")
225
+ table.add_column("ID", justify="right")
226
+ table.add_column("Name")
227
+ table.add_column("Role")
228
+ table.add_column("Key", overflow="fold")
229
+ table.add_column("Active")
230
+ for pid in sorted(projects.keys()):
231
+ p = projects[pid]
232
+ key = p.get("api_key") or ""
233
+ key_str = mask_key(key) if key else "[dim](no key)[/dim]"
234
+ table.add_row(
235
+ str(pid),
236
+ str(p.get("name", "")),
237
+ str(p.get("role", "")),
238
+ key_str,
239
+ "★" if pid == active else "",
240
+ )
241
+ console.print(table)
242
+
243
+
244
+ def use(
245
+ name_or_id: str = typer.Argument(
246
+ ..., help="Project name or numeric id (from `ermes projects list`)"
247
+ ),
248
+ ) -> None:
249
+ """Switch the active project. Affects every subsequent CLI + MCP call."""
250
+ projects = get_projects()
251
+ if not projects:
252
+ console.print("[yellow]No cached projects.[/yellow] Run `ermes login` first.")
253
+ raise typer.Exit(code=1)
254
+
255
+ target_id: int | None = None
256
+ if name_or_id.isdigit():
257
+ candidate = int(name_or_id)
258
+ if candidate in projects:
259
+ target_id = candidate
260
+ if target_id is None:
261
+ for pid, proj in projects.items():
262
+ if str(proj.get("name", "")).lower() == name_or_id.lower():
263
+ target_id = pid
264
+ break
265
+
266
+ if target_id is None:
267
+ console.print(
268
+ f"[red]No project matching {name_or_id!r}[/red] in projects.yml. "
269
+ "Run `ermes projects list` to see what's cached."
270
+ )
271
+ raise typer.Exit(code=1)
272
+
273
+ from ermes_cli.commands.auth_cmds import ensure_project_key
274
+
275
+ try:
276
+ set_active_project_id(target_id)
277
+ ensure_project_key(target_id)
278
+ except ConfigError as exc:
279
+ console.print(f"[red]{exc}[/red]")
280
+ raise typer.Exit(code=1)
281
+
282
+ proj = projects[target_id]
283
+ console.print(
284
+ f"[green]✓[/green] Active project: [bold]{proj.get('name')}[/bold] "
285
+ f"(id={target_id})"
286
+ )
@@ -0,0 +1,184 @@
1
+ from __future__ import annotations
2
+ import json as json_lib
3
+ from typing import Optional
4
+
5
+ import typer
6
+ from ermes_cli.client import ErmesAPIError
7
+
8
+ providers_app = typer.Typer(help="Manage providers", no_args_is_help=True)
9
+ COLUMNS = ["id", "name", "group", "type", "is_default"]
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
+
16
+ return get_client(), state, render, render_detail, render_message
17
+
18
+
19
+ @providers_app.command("list")
20
+ def providers_list(
21
+ limit: int = typer.Option(100, "--limit", help="Max results (0 = all)"),
22
+ ):
23
+ """List providers."""
24
+ client, st, render, _, _ = _get_deps()
25
+ try:
26
+ data = client.get_paginated("/providers", limit=limit)
27
+ render(data, columns=COLUMNS, use_json=st.use_json)
28
+ except ErmesAPIError as e:
29
+ typer.echo(str(e), err=True)
30
+ raise typer.Exit(code=1)
31
+
32
+
33
+ @providers_app.command("all")
34
+ def providers_all():
35
+ """List all providers without pagination."""
36
+ client, st, render, _, _ = _get_deps()
37
+ try:
38
+ data = client.get("/providers/all")
39
+ render(data, columns=COLUMNS, use_json=st.use_json)
40
+ except ErmesAPIError as e:
41
+ typer.echo(str(e), err=True)
42
+ raise typer.Exit(code=1)
43
+
44
+
45
+ @providers_app.command("meta")
46
+ def providers_meta():
47
+ """List available provider types."""
48
+ client, st, _, render_detail, _ = _get_deps()
49
+ try:
50
+ data = client.get("/providers/meta")
51
+ if st.use_json:
52
+ import json
53
+
54
+ print(json.dumps(data, indent=2))
55
+ else:
56
+ render_detail(data, use_json=False)
57
+ except ErmesAPIError as e:
58
+ typer.echo(str(e), err=True)
59
+ raise typer.Exit(code=1)
60
+
61
+
62
+ def _provider_body(
63
+ data: str,
64
+ name: Optional[str],
65
+ is_default: Optional[bool],
66
+ rate_limit: Optional[float],
67
+ rate_interval: Optional[str],
68
+ ) -> dict:
69
+ try:
70
+ body = {"data": json_lib.loads(data)}
71
+ except json_lib.JSONDecodeError as e:
72
+ typer.echo(f"Invalid JSON for --data: {e}", err=True)
73
+ raise typer.Exit(code=1)
74
+ if name is not None:
75
+ body["name"] = name
76
+ if is_default is not None:
77
+ body["is_default"] = is_default
78
+ if rate_limit is not None:
79
+ body["rate_limit"] = rate_limit
80
+ if rate_interval is not None:
81
+ body["rate_interval"] = rate_interval
82
+ return body
83
+
84
+
85
+ @providers_app.command("create")
86
+ def providers_create(
87
+ group: str = typer.Option(
88
+ ..., "--group", help="Provider group: email, text, push, webhook, analytics"
89
+ ),
90
+ provider_type: str = typer.Option(..., "--type", help="Provider type"),
91
+ data: str = typer.Option(..., "--data", help="Provider-specific data JSON"),
92
+ name: Optional[str] = typer.Option(None, "--name", help="Provider name"),
93
+ is_default: Optional[bool] = typer.Option(
94
+ None, "--is-default", help="Set as default provider"
95
+ ),
96
+ rate_limit: Optional[float] = typer.Option(
97
+ None, "--rate-limit", help="Per-interval send limit"
98
+ ),
99
+ rate_interval: Optional[str] = typer.Option(
100
+ None, "--rate-interval", help="second, minute, hour, or day"
101
+ ),
102
+ ):
103
+ """Create a provider."""
104
+ client, st, _, render_detail, _ = _get_deps()
105
+ body = _provider_body(data, name, is_default, rate_limit, rate_interval)
106
+ try:
107
+ result = client.post(f"/providers/{group}/{provider_type}", json=body)
108
+ render_detail(result, use_json=st.use_json)
109
+ except ErmesAPIError as e:
110
+ typer.echo(str(e), err=True)
111
+ raise typer.Exit(code=1)
112
+
113
+
114
+ @providers_app.command("get")
115
+ def providers_get(provider_id: int = typer.Argument(..., help="Provider ID")):
116
+ """Get provider details (fetches list first to resolve group/type)."""
117
+ client, st, _, render_detail, _ = _get_deps()
118
+ try:
119
+ providers = client.get_paginated("/providers", limit=0)
120
+ provider = None
121
+ for p in providers:
122
+ if p.get("id") == provider_id:
123
+ provider = p
124
+ break
125
+ if not provider:
126
+ typer.echo(f"Provider {provider_id} not found.", err=True)
127
+ raise typer.Exit(code=1)
128
+ data = client.get(
129
+ f"/providers/{provider['group']}/{provider['type']}/{provider_id}"
130
+ )
131
+ render_detail(data, use_json=st.use_json)
132
+ except ErmesAPIError as e:
133
+ typer.echo(str(e), err=True)
134
+ raise typer.Exit(code=1)
135
+
136
+
137
+ @providers_app.command("update")
138
+ def providers_update(
139
+ provider_id: int = typer.Argument(..., help="Provider ID"),
140
+ group: str = typer.Option(..., "--group", help="Provider group"),
141
+ provider_type: str = typer.Option(..., "--type", help="Provider type"),
142
+ data: str = typer.Option(
143
+ ..., "--data", help="Provider-specific data JSON (replaces the full config)"
144
+ ),
145
+ name: Optional[str] = typer.Option(None, "--name", help="Provider name"),
146
+ is_default: Optional[bool] = typer.Option(
147
+ None, "--is-default", help="Set as default provider"
148
+ ),
149
+ rate_limit: Optional[float] = typer.Option(
150
+ None, "--rate-limit", help="Per-interval send limit"
151
+ ),
152
+ rate_interval: Optional[str] = typer.Option(
153
+ None, "--rate-interval", help="second, minute, hour, or day"
154
+ ),
155
+ ):
156
+ """Update a provider."""
157
+ client, st, _, render_detail, render_message = _get_deps()
158
+ body = _provider_body(data, name, is_default, rate_limit, rate_interval)
159
+ try:
160
+ result = client.patch(
161
+ f"/providers/{group}/{provider_type}/{provider_id}", json=body
162
+ )
163
+ # The provider PATCH endpoint returns an empty body ({}), which would
164
+ # render as an empty table. Fall back to a confirmation message so the
165
+ # user gets feedback; still render the object if the API ever returns one.
166
+ if result:
167
+ render_detail(result, use_json=st.use_json)
168
+ else:
169
+ render_message(f"Provider {provider_id} updated.", use_json=st.use_json)
170
+ except ErmesAPIError as e:
171
+ typer.echo(str(e), err=True)
172
+ raise typer.Exit(code=1)
173
+
174
+
175
+ @providers_app.command("delete")
176
+ def providers_delete(provider_id: int = typer.Argument(..., help="Provider ID")):
177
+ """Delete a provider."""
178
+ client, st, _, _, render_message = _get_deps()
179
+ try:
180
+ client.delete(f"/providers/{provider_id}")
181
+ render_message(f"Provider {provider_id} deleted.", use_json=st.use_json)
182
+ except ErmesAPIError as e:
183
+ typer.echo(str(e), err=True)
184
+ raise typer.Exit(code=1)
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+ from typing import Optional
3
+ import typer
4
+ from ermes_cli.client import ErmesAPIError
5
+
6
+ subscriptions_app = typer.Typer(help="Manage subscriptions", no_args_is_help=True)
7
+ COLUMNS = ["id", "name", "channel"]
8
+
9
+
10
+ def _get_deps():
11
+ from ermes_cli.main import get_client, state
12
+ from ermes_cli.output import render, render_detail, render_message
13
+ return get_client(), state, render, render_detail, render_message
14
+
15
+
16
+ @subscriptions_app.command("list")
17
+ def subscriptions_list(
18
+ limit: int = typer.Option(100, "--limit", help="Max results (0 = all)"),
19
+ ):
20
+ """List subscriptions."""
21
+ client, st, render, _, _ = _get_deps()
22
+ try:
23
+ data = client.get_paginated("/subscriptions", limit=limit)
24
+ render(data, columns=COLUMNS, use_json=st.use_json)
25
+ except ErmesAPIError as e:
26
+ typer.echo(str(e), err=True)
27
+ raise typer.Exit(code=1)
28
+
29
+
30
+ @subscriptions_app.command("get")
31
+ def subscriptions_get(subscription_id: int = typer.Argument(..., help="Subscription ID")):
32
+ """Get subscription details."""
33
+ client, st, _, render_detail, _ = _get_deps()
34
+ try:
35
+ data = client.get(f"/subscriptions/{subscription_id}")
36
+ render_detail(data, use_json=st.use_json)
37
+ except ErmesAPIError as e:
38
+ typer.echo(str(e), err=True)
39
+ raise typer.Exit(code=1)
40
+
41
+
42
+ @subscriptions_app.command("create")
43
+ def subscriptions_create(
44
+ name: str = typer.Option(..., "--name", help="Subscription name"),
45
+ channel: str = typer.Option(..., "--channel", help="Channel (email, text, push, webhook)"),
46
+ ):
47
+ """Create a subscription."""
48
+ client, st, _, render_detail, _ = _get_deps()
49
+ try:
50
+ data = client.post("/subscriptions", json={"name": name, "channel": channel})
51
+ render_detail(data, use_json=st.use_json)
52
+ except ErmesAPIError as e:
53
+ typer.echo(str(e), err=True)
54
+ raise typer.Exit(code=1)
55
+
56
+
57
+ @subscriptions_app.command("update")
58
+ def subscriptions_update(
59
+ subscription_id: int = typer.Argument(..., help="Subscription ID"),
60
+ name: Optional[str] = typer.Option(None, "--name"),
61
+ ):
62
+ """Update a subscription."""
63
+ client, st, _, render_detail, _ = _get_deps()
64
+ body = {}
65
+ if name:
66
+ body["name"] = name
67
+ if not body:
68
+ typer.echo("Nothing to update. Provide at least one option.", err=True)
69
+ raise typer.Exit(code=1)
70
+ try:
71
+ data = client.patch(f"/subscriptions/{subscription_id}", json=body)
72
+ render_detail(data, use_json=st.use_json)
73
+ except ErmesAPIError as e:
74
+ typer.echo(str(e), err=True)
75
+ raise typer.Exit(code=1)
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+ from typing import Optional
3
+ import typer
4
+ from ermes_cli.client import ErmesAPIError
5
+
6
+ tags_app = typer.Typer(help="Manage tags", no_args_is_help=True)
7
+ COLUMNS = ["id", "name"]
8
+
9
+
10
+ def _get_deps():
11
+ from ermes_cli.main import get_client, state
12
+ from ermes_cli.output import render, render_detail, render_message
13
+ return get_client(), state, render, render_detail, render_message
14
+
15
+
16
+ @tags_app.command("list")
17
+ def tags_list(
18
+ limit: int = typer.Option(100, "--limit", help="Max results (0 = all)"),
19
+ ):
20
+ """List tags."""
21
+ client, st, render, _, _ = _get_deps()
22
+ try:
23
+ data = client.get_paginated("/tags", limit=limit)
24
+ render(data, columns=COLUMNS, use_json=st.use_json)
25
+ except ErmesAPIError as e:
26
+ typer.echo(str(e), err=True)
27
+ raise typer.Exit(code=1)
28
+
29
+
30
+ @tags_app.command("get")
31
+ def tags_get(tag_id: int = typer.Argument(..., help="Tag ID")):
32
+ """Get tag details."""
33
+ client, st, _, render_detail, _ = _get_deps()
34
+ try:
35
+ data = client.get(f"/tags/{tag_id}")
36
+ render_detail(data, use_json=st.use_json)
37
+ except ErmesAPIError as e:
38
+ typer.echo(str(e), err=True)
39
+ raise typer.Exit(code=1)
40
+
41
+
42
+ @tags_app.command("create")
43
+ def tags_create(
44
+ name: str = typer.Option(..., "--name", help="Tag name"),
45
+ ):
46
+ """Create a tag."""
47
+ client, st, _, render_detail, _ = _get_deps()
48
+ try:
49
+ data = client.post("/tags", json={"name": name})
50
+ render_detail(data, use_json=st.use_json)
51
+ except ErmesAPIError as e:
52
+ typer.echo(str(e), err=True)
53
+ raise typer.Exit(code=1)
54
+
55
+
56
+ @tags_app.command("update")
57
+ def tags_update(
58
+ tag_id: int = typer.Argument(..., help="Tag ID"),
59
+ name: Optional[str] = typer.Option(None, "--name"),
60
+ ):
61
+ """Update a tag."""
62
+ client, st, _, render_detail, _ = _get_deps()
63
+ body = {}
64
+ if name:
65
+ body["name"] = name
66
+ if not body:
67
+ typer.echo("Nothing to update. Provide at least one option.", err=True)
68
+ raise typer.Exit(code=1)
69
+ try:
70
+ data = client.patch(f"/tags/{tag_id}", json=body)
71
+ render_detail(data, use_json=st.use_json)
72
+ except ErmesAPIError as e:
73
+ typer.echo(str(e), err=True)
74
+ raise typer.Exit(code=1)
75
+
76
+
77
+ @tags_app.command("delete")
78
+ def tags_delete(tag_id: int = typer.Argument(..., help="Tag ID")):
79
+ """Delete a tag."""
80
+ client, st, _, _, render_message = _get_deps()
81
+ try:
82
+ client.delete(f"/tags/{tag_id}")
83
+ render_message(f"Tag {tag_id} deleted.", use_json=st.use_json)
84
+ except ErmesAPIError as e:
85
+ typer.echo(str(e), err=True)
86
+ raise typer.Exit(code=1)