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.
- ermes_cli/__init__.py +1 -0
- ermes_cli/admin_api.py +285 -0
- ermes_cli/auth0.py +220 -0
- ermes_cli/client.py +100 -0
- ermes_cli/commands/__init__.py +0 -0
- ermes_cli/commands/auth_cmds.py +386 -0
- ermes_cli/commands/campaigns.py +321 -0
- ermes_cli/commands/config_cmd.py +105 -0
- ermes_cli/commands/data_paths.py +103 -0
- ermes_cli/commands/export.py +238 -0
- ermes_cli/commands/ingest.py +101 -0
- ermes_cli/commands/journeys.py +182 -0
- ermes_cli/commands/keys.py +113 -0
- ermes_cli/commands/lists.py +137 -0
- ermes_cli/commands/metrics.py +398 -0
- ermes_cli/commands/projects.py +286 -0
- ermes_cli/commands/providers.py +184 -0
- ermes_cli/commands/subscriptions.py +75 -0
- ermes_cli/commands/tags.py +86 -0
- ermes_cli/commands/templates.py +133 -0
- ermes_cli/commands/users.py +133 -0
- ermes_cli/commands/write_cmds.py +87 -0
- ermes_cli/config.py +393 -0
- ermes_cli/errors.py +34 -0
- ermes_cli/export_utils.py +263 -0
- ermes_cli/keyring_store.py +145 -0
- ermes_cli/main.py +193 -0
- ermes_cli/manifest.py +218 -0
- ermes_cli/mcp_server.py +167 -0
- ermes_cli/output.py +58 -0
- ermes_cli/routes.yaml +163 -0
- ermes_cli/rules.py +54 -0
- ermes_cli/write_mode.py +178 -0
- ermes_cli-1.0.0.dist-info/METADATA +281 -0
- ermes_cli-1.0.0.dist-info/RECORD +37 -0
- ermes_cli-1.0.0.dist-info/WHEEL +4 -0
- ermes_cli-1.0.0.dist-info/entry_points.txt +3 -0
|
@@ -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)
|