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,321 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import json as json_lib
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from typing import Optional
|
|
5
|
+
import typer
|
|
6
|
+
from ermes_cli.client import ErmesAPIError
|
|
7
|
+
|
|
8
|
+
campaigns_app = typer.Typer(help="Manage campaigns", no_args_is_help=True)
|
|
9
|
+
COLUMNS = ["id", "name", "type", "channel", "state", "send_at", "tags"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _parse_send_at(value: str) -> str:
|
|
13
|
+
"""Validate an ISO 8601 timestamp and normalize it to UTC.
|
|
14
|
+
|
|
15
|
+
Rejects tz-naive input: a naive timestamp interpreted in the wrong
|
|
16
|
+
zone mis-fires a blast, so the operator must state the offset.
|
|
17
|
+
"""
|
|
18
|
+
try:
|
|
19
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
20
|
+
except ValueError:
|
|
21
|
+
typer.echo(
|
|
22
|
+
f"Invalid --send-at '{value}': expected ISO 8601, e.g. 2026-07-02T14:00:00-03:00",
|
|
23
|
+
err=True,
|
|
24
|
+
)
|
|
25
|
+
raise typer.Exit(code=1)
|
|
26
|
+
if parsed.tzinfo is None:
|
|
27
|
+
typer.echo(
|
|
28
|
+
f"--send-at '{value}' must include a UTC offset, e.g. 2026-07-02T14:00:00-03:00 or 2026-07-02T17:00:00Z",
|
|
29
|
+
err=True,
|
|
30
|
+
)
|
|
31
|
+
raise typer.Exit(code=1)
|
|
32
|
+
return parsed.astimezone(timezone.utc).isoformat()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _parse_id_list(value: str, option: str) -> list[int]:
|
|
36
|
+
try:
|
|
37
|
+
ids = [int(part) for part in value.split(",") if part.strip()]
|
|
38
|
+
except ValueError:
|
|
39
|
+
typer.echo(
|
|
40
|
+
f"Invalid {option} '{value}': expected comma-separated integers, e.g. 3,7",
|
|
41
|
+
err=True,
|
|
42
|
+
)
|
|
43
|
+
raise typer.Exit(code=1)
|
|
44
|
+
if not ids:
|
|
45
|
+
typer.echo(
|
|
46
|
+
f"Invalid {option} '{value}': expected at least one integer ID", err=True
|
|
47
|
+
)
|
|
48
|
+
raise typer.Exit(code=1)
|
|
49
|
+
return ids
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _get_deps():
|
|
53
|
+
from ermes_cli.main import get_client, state
|
|
54
|
+
from ermes_cli.output import render, render_detail, render_message
|
|
55
|
+
|
|
56
|
+
return get_client(), state, render, render_detail, render_message
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@campaigns_app.command("list")
|
|
60
|
+
def campaigns_list(
|
|
61
|
+
status: Optional[str] = typer.Option(None, "--status", help="Filter by state"),
|
|
62
|
+
campaign_type: Optional[str] = typer.Option(None, "--type", help="Filter by type"),
|
|
63
|
+
limit: int = typer.Option(100, "--limit", help="Max results (0 = all)"),
|
|
64
|
+
):
|
|
65
|
+
"""List campaigns."""
|
|
66
|
+
client, st, render, _, _ = _get_deps()
|
|
67
|
+
params = {}
|
|
68
|
+
filter_obj = {}
|
|
69
|
+
if status:
|
|
70
|
+
filter_obj["state"] = status
|
|
71
|
+
if campaign_type:
|
|
72
|
+
filter_obj["type"] = campaign_type
|
|
73
|
+
if filter_obj:
|
|
74
|
+
params["filter"] = json_lib.dumps(filter_obj)
|
|
75
|
+
try:
|
|
76
|
+
data = client.get_paginated("/campaigns", params=params, limit=limit)
|
|
77
|
+
render(data, columns=COLUMNS, use_json=st.use_json)
|
|
78
|
+
except ErmesAPIError as e:
|
|
79
|
+
typer.echo(str(e), err=True)
|
|
80
|
+
raise typer.Exit(code=1)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@campaigns_app.command("get")
|
|
84
|
+
def campaigns_get(campaign_id: int = typer.Argument(..., help="Campaign ID")):
|
|
85
|
+
"""Get campaign details."""
|
|
86
|
+
client, st, _, render_detail, _ = _get_deps()
|
|
87
|
+
try:
|
|
88
|
+
data = client.get(f"/campaigns/{campaign_id}")
|
|
89
|
+
render_detail(data, use_json=st.use_json)
|
|
90
|
+
except ErmesAPIError as e:
|
|
91
|
+
typer.echo(str(e), err=True)
|
|
92
|
+
raise typer.Exit(code=1)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@campaigns_app.command("create")
|
|
96
|
+
def campaigns_create(
|
|
97
|
+
campaign_type: str = typer.Option(..., "--type", help="blast or trigger"),
|
|
98
|
+
subscription: int = typer.Option(..., "--subscription", help="Subscription ID"),
|
|
99
|
+
provider: int = typer.Option(..., "--provider", help="Provider ID"),
|
|
100
|
+
name: Optional[str] = typer.Option(None, "--name", help="Campaign name"),
|
|
101
|
+
channel: Optional[str] = typer.Option(
|
|
102
|
+
None, "--channel", help="email|text|push|webhook"
|
|
103
|
+
),
|
|
104
|
+
list_ids: Optional[str] = typer.Option(
|
|
105
|
+
None, "--list-ids", help="Comma-separated list IDs, e.g. 3,7"
|
|
106
|
+
),
|
|
107
|
+
exclusion_list_ids: Optional[str] = typer.Option(
|
|
108
|
+
None, "--exclusion-list-ids", help="Comma-separated list IDs to exclude"
|
|
109
|
+
),
|
|
110
|
+
send_at: Optional[str] = typer.Option(
|
|
111
|
+
None,
|
|
112
|
+
"--send-at",
|
|
113
|
+
help="ISO 8601 with UTC offset, e.g. 2026-07-02T14:00:00-03:00",
|
|
114
|
+
),
|
|
115
|
+
):
|
|
116
|
+
"""Create a campaign."""
|
|
117
|
+
client, st, _, render_detail, _ = _get_deps()
|
|
118
|
+
body: dict = {
|
|
119
|
+
"type": campaign_type,
|
|
120
|
+
"subscription_id": subscription,
|
|
121
|
+
"provider_id": provider,
|
|
122
|
+
}
|
|
123
|
+
if name:
|
|
124
|
+
body["name"] = name
|
|
125
|
+
if channel:
|
|
126
|
+
body["channel"] = channel
|
|
127
|
+
if list_ids:
|
|
128
|
+
body["list_ids"] = _parse_id_list(list_ids, "--list-ids")
|
|
129
|
+
if exclusion_list_ids:
|
|
130
|
+
body["exclusion_list_ids"] = _parse_id_list(
|
|
131
|
+
exclusion_list_ids, "--exclusion-list-ids"
|
|
132
|
+
)
|
|
133
|
+
if send_at:
|
|
134
|
+
body["send_at"] = _parse_send_at(send_at)
|
|
135
|
+
try:
|
|
136
|
+
data = client.post("/campaigns", json=body)
|
|
137
|
+
render_detail(data, use_json=st.use_json)
|
|
138
|
+
except ErmesAPIError as e:
|
|
139
|
+
typer.echo(str(e), err=True)
|
|
140
|
+
raise typer.Exit(code=1)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@campaigns_app.command("update")
|
|
144
|
+
def campaigns_update(
|
|
145
|
+
campaign_id: int = typer.Argument(..., help="Campaign ID"),
|
|
146
|
+
name: Optional[str] = typer.Option(None, "--name"),
|
|
147
|
+
status: Optional[str] = typer.Option(None, "--status"),
|
|
148
|
+
send_at: Optional[str] = typer.Option(
|
|
149
|
+
None,
|
|
150
|
+
"--send-at",
|
|
151
|
+
help="ISO 8601 with UTC offset, e.g. 2026-07-02T14:00:00-03:00",
|
|
152
|
+
),
|
|
153
|
+
list_ids: Optional[str] = typer.Option(
|
|
154
|
+
None, "--list-ids", help="Comma-separated list IDs, e.g. 3,7"
|
|
155
|
+
),
|
|
156
|
+
exclusion_list_ids: Optional[str] = typer.Option(
|
|
157
|
+
None, "--exclusion-list-ids", help="Comma-separated list IDs to exclude"
|
|
158
|
+
),
|
|
159
|
+
):
|
|
160
|
+
"""Update a campaign."""
|
|
161
|
+
client, st, _, render_detail, _ = _get_deps()
|
|
162
|
+
body: dict = {}
|
|
163
|
+
if name:
|
|
164
|
+
body["name"] = name
|
|
165
|
+
if status:
|
|
166
|
+
body["state"] = status
|
|
167
|
+
if send_at:
|
|
168
|
+
body["send_at"] = _parse_send_at(send_at)
|
|
169
|
+
if list_ids:
|
|
170
|
+
body["list_ids"] = _parse_id_list(list_ids, "--list-ids")
|
|
171
|
+
if exclusion_list_ids:
|
|
172
|
+
body["exclusion_list_ids"] = _parse_id_list(
|
|
173
|
+
exclusion_list_ids, "--exclusion-list-ids"
|
|
174
|
+
)
|
|
175
|
+
if not body:
|
|
176
|
+
typer.echo("Nothing to update. Provide at least one option.", err=True)
|
|
177
|
+
raise typer.Exit(code=1)
|
|
178
|
+
try:
|
|
179
|
+
data = client.patch(f"/campaigns/{campaign_id}", json=body)
|
|
180
|
+
render_detail(data, use_json=st.use_json)
|
|
181
|
+
except ErmesAPIError as e:
|
|
182
|
+
typer.echo(str(e), err=True)
|
|
183
|
+
raise typer.Exit(code=1)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@campaigns_app.command("schedule")
|
|
187
|
+
def campaigns_schedule(
|
|
188
|
+
campaign_id: int = typer.Argument(..., help="Campaign ID"),
|
|
189
|
+
send_at: str = typer.Option(
|
|
190
|
+
...,
|
|
191
|
+
"--send-at",
|
|
192
|
+
help="ISO 8601 with UTC offset, e.g. 2026-07-02T14:00:00-03:00",
|
|
193
|
+
),
|
|
194
|
+
list_ids: Optional[str] = typer.Option(
|
|
195
|
+
None, "--list-ids", help="Comma-separated list IDs, e.g. 3,7"
|
|
196
|
+
),
|
|
197
|
+
exclusion_list_ids: Optional[str] = typer.Option(
|
|
198
|
+
None, "--exclusion-list-ids", help="Comma-separated list IDs to exclude"
|
|
199
|
+
),
|
|
200
|
+
):
|
|
201
|
+
"""Schedule a campaign: set send_at and move it to state=scheduled."""
|
|
202
|
+
client, st, _, render_detail, _ = _get_deps()
|
|
203
|
+
body: dict = {"send_at": _parse_send_at(send_at), "state": "scheduled"}
|
|
204
|
+
if list_ids:
|
|
205
|
+
body["list_ids"] = _parse_id_list(list_ids, "--list-ids")
|
|
206
|
+
if exclusion_list_ids:
|
|
207
|
+
body["exclusion_list_ids"] = _parse_id_list(
|
|
208
|
+
exclusion_list_ids, "--exclusion-list-ids"
|
|
209
|
+
)
|
|
210
|
+
try:
|
|
211
|
+
data = client.patch(f"/campaigns/{campaign_id}", json=body)
|
|
212
|
+
render_detail(data, use_json=st.use_json)
|
|
213
|
+
except ErmesAPIError as e:
|
|
214
|
+
typer.echo(str(e), err=True)
|
|
215
|
+
raise typer.Exit(code=1)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@campaigns_app.command("delete")
|
|
219
|
+
def campaigns_delete(campaign_id: int = typer.Argument(..., help="Campaign ID")):
|
|
220
|
+
"""Delete (archive) a campaign."""
|
|
221
|
+
client, st, _, _, render_message = _get_deps()
|
|
222
|
+
try:
|
|
223
|
+
client.delete(f"/campaigns/{campaign_id}")
|
|
224
|
+
render_message(f"Campaign {campaign_id} deleted.", use_json=st.use_json)
|
|
225
|
+
except ErmesAPIError as e:
|
|
226
|
+
typer.echo(str(e), err=True)
|
|
227
|
+
raise typer.Exit(code=1)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@campaigns_app.command("duplicate")
|
|
231
|
+
def campaigns_duplicate(campaign_id: int = typer.Argument(..., help="Campaign ID")):
|
|
232
|
+
"""Duplicate a campaign."""
|
|
233
|
+
client, st, _, render_detail, _ = _get_deps()
|
|
234
|
+
try:
|
|
235
|
+
data = client.post(f"/campaigns/{campaign_id}/duplicate")
|
|
236
|
+
render_detail(data, use_json=st.use_json)
|
|
237
|
+
except ErmesAPIError as e:
|
|
238
|
+
typer.echo(str(e), err=True)
|
|
239
|
+
raise typer.Exit(code=1)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@campaigns_app.command("restore")
|
|
243
|
+
def campaigns_restore(campaign_id: int = typer.Argument(..., help="Campaign ID")):
|
|
244
|
+
"""Restore a deleted campaign."""
|
|
245
|
+
client, st, _, _, render_message = _get_deps()
|
|
246
|
+
try:
|
|
247
|
+
client.post(f"/campaigns/{campaign_id}/restore")
|
|
248
|
+
render_message(f"Campaign {campaign_id} restored.", use_json=st.use_json)
|
|
249
|
+
except ErmesAPIError as e:
|
|
250
|
+
typer.echo(str(e), err=True)
|
|
251
|
+
raise typer.Exit(code=1)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@campaigns_app.command("preview")
|
|
255
|
+
def campaigns_preview(campaign_id: int = typer.Argument(..., help="Campaign ID")):
|
|
256
|
+
"""Preview campaign content."""
|
|
257
|
+
client, st, _, render_detail, _ = _get_deps()
|
|
258
|
+
try:
|
|
259
|
+
data = client.get(f"/campaigns/{campaign_id}/preview")
|
|
260
|
+
render_detail(data, use_json=st.use_json)
|
|
261
|
+
except ErmesAPIError as e:
|
|
262
|
+
typer.echo(str(e), err=True)
|
|
263
|
+
raise typer.Exit(code=1)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@campaigns_app.command("users")
|
|
267
|
+
def campaigns_users(
|
|
268
|
+
campaign_id: int = typer.Argument(..., help="Campaign ID"),
|
|
269
|
+
limit: int = typer.Option(100, "--limit"),
|
|
270
|
+
):
|
|
271
|
+
"""List users targeted by a campaign."""
|
|
272
|
+
client, st, render, _, _ = _get_deps()
|
|
273
|
+
try:
|
|
274
|
+
data = client.get_paginated(f"/campaigns/{campaign_id}/users", limit=limit)
|
|
275
|
+
render(data, columns=["id", "email", "external_id"], use_json=st.use_json)
|
|
276
|
+
except ErmesAPIError as e:
|
|
277
|
+
typer.echo(str(e), err=True)
|
|
278
|
+
raise typer.Exit(code=1)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
@campaigns_app.command("trigger")
|
|
282
|
+
def campaigns_trigger(
|
|
283
|
+
campaign_id: int = typer.Argument(..., help="Campaign ID"),
|
|
284
|
+
external_id: str = typer.Option(..., "--external-id", help="User external ID"),
|
|
285
|
+
event: str = typer.Option(..., "--event", help="Event data as JSON"),
|
|
286
|
+
email: Optional[str] = typer.Option(None, "--email", help="User email"),
|
|
287
|
+
phone: Optional[str] = typer.Option(None, "--phone", help="User phone"),
|
|
288
|
+
device_token: Optional[str] = typer.Option(
|
|
289
|
+
None, "--device-token", help="Push device token to register before the send"
|
|
290
|
+
),
|
|
291
|
+
locale: Optional[str] = typer.Option(None, "--locale", help="User locale, e.g. pt-BR"),
|
|
292
|
+
tz: Optional[str] = typer.Option(
|
|
293
|
+
None, "--timezone", help="User timezone, e.g. America/Sao_Paulo"
|
|
294
|
+
),
|
|
295
|
+
):
|
|
296
|
+
"""Trigger a campaign send for a user, identified by external ID."""
|
|
297
|
+
client, st, _, render_detail, _ = _get_deps()
|
|
298
|
+
try:
|
|
299
|
+
event_data = json_lib.loads(event)
|
|
300
|
+
except json_lib.JSONDecodeError as e:
|
|
301
|
+
typer.echo(f"Invalid JSON for --event: {e}", err=True)
|
|
302
|
+
raise typer.Exit(code=1)
|
|
303
|
+
user = {"external_id": external_id}
|
|
304
|
+
for key, value in (
|
|
305
|
+
("email", email),
|
|
306
|
+
("phone", phone),
|
|
307
|
+
("device_token", device_token),
|
|
308
|
+
("locale", locale),
|
|
309
|
+
("timezone", tz),
|
|
310
|
+
):
|
|
311
|
+
if value is not None:
|
|
312
|
+
user[key] = value
|
|
313
|
+
try:
|
|
314
|
+
data = client.post(
|
|
315
|
+
f"/campaigns/{campaign_id}/trigger",
|
|
316
|
+
json={"user": user, "event": event_data},
|
|
317
|
+
)
|
|
318
|
+
render_detail(data, use_json=st.use_json)
|
|
319
|
+
except ErmesAPIError as e:
|
|
320
|
+
typer.echo(str(e), err=True)
|
|
321
|
+
raise typer.Exit(code=1)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Optional
|
|
3
|
+
import typer
|
|
4
|
+
from ermes_cli.config import (
|
|
5
|
+
add_profile,
|
|
6
|
+
remove_profile,
|
|
7
|
+
set_default_profile,
|
|
8
|
+
list_profiles,
|
|
9
|
+
ConfigError,
|
|
10
|
+
DEFAULT_BASE_URL,
|
|
11
|
+
)
|
|
12
|
+
from ermes_cli.output import render, render_message
|
|
13
|
+
|
|
14
|
+
config_app = typer.Typer(help="Manage connection profiles", no_args_is_help=True)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@config_app.command("init")
|
|
18
|
+
def config_init(
|
|
19
|
+
name: str = typer.Option(..., prompt="Profile name"),
|
|
20
|
+
api_key: str = typer.Option(..., prompt="API key (sk_...)"),
|
|
21
|
+
base_url: str = typer.Option(
|
|
22
|
+
DEFAULT_BASE_URL, prompt="Base URL", show_default=True
|
|
23
|
+
),
|
|
24
|
+
):
|
|
25
|
+
"""[Legacy] Single-project profile setup.
|
|
26
|
+
|
|
27
|
+
Prefer `ermes login` — runs an Auth0 device flow, auto-discovers every
|
|
28
|
+
project you have access to, and auto-creates per-project sk_ keys. One
|
|
29
|
+
auth event unlocks everything; this command requires a manual paste per
|
|
30
|
+
project.
|
|
31
|
+
"""
|
|
32
|
+
from ermes_cli.main import state
|
|
33
|
+
|
|
34
|
+
typer.secho(
|
|
35
|
+
"DEPRECATED: `ermes config init` is single-project + manual. "
|
|
36
|
+
"Use `ermes login` for Auth0-based multi-project auto-discovery.",
|
|
37
|
+
fg=typer.colors.YELLOW,
|
|
38
|
+
err=True,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
add_profile(name, api_key, base_url)
|
|
43
|
+
render_message(f"Profile '{name}' created.", use_json=state.use_json)
|
|
44
|
+
except ConfigError as e:
|
|
45
|
+
typer.echo(str(e), err=True)
|
|
46
|
+
raise typer.Exit(code=1)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@config_app.command("list")
|
|
50
|
+
def config_list():
|
|
51
|
+
"""Show all configured profiles."""
|
|
52
|
+
from ermes_cli.main import state
|
|
53
|
+
|
|
54
|
+
profiles = list_profiles()
|
|
55
|
+
render(
|
|
56
|
+
profiles,
|
|
57
|
+
columns=["name", "base_url", "api_key", "is_default"],
|
|
58
|
+
use_json=state.use_json,
|
|
59
|
+
title="Profiles",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@config_app.command("add")
|
|
64
|
+
def config_add(
|
|
65
|
+
name: str = typer.Argument(..., help="Profile name"),
|
|
66
|
+
api_key: str = typer.Option(..., "--api-key", help="API key (sk_...)"),
|
|
67
|
+
base_url: str = typer.Option(
|
|
68
|
+
DEFAULT_BASE_URL, "--base-url", help="API base URL"
|
|
69
|
+
),
|
|
70
|
+
):
|
|
71
|
+
"""Add a new profile."""
|
|
72
|
+
from ermes_cli.main import state
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
add_profile(name, api_key, base_url)
|
|
76
|
+
render_message(f"Profile '{name}' added.", use_json=state.use_json)
|
|
77
|
+
except ConfigError as e:
|
|
78
|
+
typer.echo(str(e), err=True)
|
|
79
|
+
raise typer.Exit(code=1)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@config_app.command("remove")
|
|
83
|
+
def config_remove(name: str = typer.Argument(..., help="Profile name")):
|
|
84
|
+
"""Remove a profile."""
|
|
85
|
+
from ermes_cli.main import state
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
remove_profile(name)
|
|
89
|
+
render_message(f"Profile '{name}' removed.", use_json=state.use_json)
|
|
90
|
+
except ConfigError as e:
|
|
91
|
+
typer.echo(str(e), err=True)
|
|
92
|
+
raise typer.Exit(code=1)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@config_app.command("set-default")
|
|
96
|
+
def config_set_default(name: str = typer.Argument(..., help="Profile name")):
|
|
97
|
+
"""Set the default profile."""
|
|
98
|
+
from ermes_cli.main import state
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
set_default_profile(name)
|
|
102
|
+
render_message(f"Default profile set to '{name}'.", use_json=state.use_json)
|
|
103
|
+
except ConfigError as e:
|
|
104
|
+
typer.echo(str(e), err=True)
|
|
105
|
+
raise typer.Exit(code=1)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""`ermes data-paths` — read the platform's schema introspection.
|
|
2
|
+
|
|
3
|
+
Output of /api/client/data/paths:
|
|
4
|
+
{
|
|
5
|
+
"userPaths": ["$.name", "$.email", ...], # flat list of JSON paths
|
|
6
|
+
"eventPaths": {"signup": ["$.utm_source", ...], # keyed by event name
|
|
7
|
+
"purchase": [...]}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
We flatten that into `{type, name, path}` rows where:
|
|
11
|
+
type=user → name="" (user paths have no event)
|
|
12
|
+
type=event, name=signup, path=$.utm_source
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import typer
|
|
18
|
+
|
|
19
|
+
from ermes_cli.client import ErmesAPIError
|
|
20
|
+
|
|
21
|
+
data_paths_app = typer.Typer(help="Manage data paths", no_args_is_help=True)
|
|
22
|
+
COLUMNS = ["type", "name", "path"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _get_deps():
|
|
26
|
+
from ermes_cli.main import get_client, state
|
|
27
|
+
from ermes_cli.output import render, render_message
|
|
28
|
+
|
|
29
|
+
return get_client(), state, render, render_message
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _flatten_paths(data: dict) -> list[dict]:
|
|
33
|
+
"""Normalize /data/paths response into a flat row list.
|
|
34
|
+
|
|
35
|
+
Handles three shapes for forward-compatibility:
|
|
36
|
+
1. {userPaths: [str], eventPaths: {name: [str]}} ← current platform
|
|
37
|
+
2. {user: [str], event: {name: [str]}} ← short-key variant
|
|
38
|
+
3. {user: [{path, name}], event: [{path, name}]} ← hypothetical dict-row variant
|
|
39
|
+
"""
|
|
40
|
+
rows: list[dict] = []
|
|
41
|
+
|
|
42
|
+
for user_key in ("userPaths", "user"):
|
|
43
|
+
user_paths = data.get(user_key)
|
|
44
|
+
if isinstance(user_paths, list):
|
|
45
|
+
for entry in user_paths:
|
|
46
|
+
if isinstance(entry, str):
|
|
47
|
+
rows.append({"type": "user", "name": "", "path": entry})
|
|
48
|
+
elif isinstance(entry, dict):
|
|
49
|
+
rows.append({"type": "user", "name": entry.get("name", ""),
|
|
50
|
+
"path": entry.get("path", "")})
|
|
51
|
+
break
|
|
52
|
+
|
|
53
|
+
for event_key in ("eventPaths", "event"):
|
|
54
|
+
event_paths = data.get(event_key)
|
|
55
|
+
if isinstance(event_paths, dict):
|
|
56
|
+
for event_name, paths in sorted(event_paths.items()):
|
|
57
|
+
if not isinstance(paths, list):
|
|
58
|
+
continue
|
|
59
|
+
for entry in paths:
|
|
60
|
+
if isinstance(entry, str):
|
|
61
|
+
rows.append({"type": "event", "name": event_name, "path": entry})
|
|
62
|
+
elif isinstance(entry, dict):
|
|
63
|
+
rows.append({
|
|
64
|
+
"type": "event",
|
|
65
|
+
"name": entry.get("name", event_name),
|
|
66
|
+
"path": entry.get("path", ""),
|
|
67
|
+
})
|
|
68
|
+
break
|
|
69
|
+
if isinstance(event_paths, list):
|
|
70
|
+
for entry in event_paths:
|
|
71
|
+
if isinstance(entry, str):
|
|
72
|
+
rows.append({"type": "event", "name": "", "path": entry})
|
|
73
|
+
elif isinstance(entry, dict):
|
|
74
|
+
rows.append({"type": "event", "name": entry.get("name", ""),
|
|
75
|
+
"path": entry.get("path", "")})
|
|
76
|
+
break
|
|
77
|
+
|
|
78
|
+
return rows
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@data_paths_app.command("list")
|
|
82
|
+
def data_paths_list():
|
|
83
|
+
"""List known user/event data field paths."""
|
|
84
|
+
client, st, render, _ = _get_deps()
|
|
85
|
+
try:
|
|
86
|
+
data = client.get("/data/paths")
|
|
87
|
+
rows = _flatten_paths(data) if isinstance(data, dict) else []
|
|
88
|
+
render(rows, columns=COLUMNS, use_json=st.use_json)
|
|
89
|
+
except ErmesAPIError as e:
|
|
90
|
+
typer.echo(str(e), err=True)
|
|
91
|
+
raise typer.Exit(code=1)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@data_paths_app.command("sync")
|
|
95
|
+
def data_paths_sync():
|
|
96
|
+
"""Trigger a re-sync of data paths from recent users + events."""
|
|
97
|
+
client, st, _, render_message = _get_deps()
|
|
98
|
+
try:
|
|
99
|
+
client.post("/data/paths/sync")
|
|
100
|
+
render_message("Data paths sync triggered.", use_json=st.use_json)
|
|
101
|
+
except ErmesAPIError as e:
|
|
102
|
+
typer.echo(str(e), err=True)
|
|
103
|
+
raise typer.Exit(code=1)
|