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,386 @@
|
|
|
1
|
+
"""`ermes login` / `ermes logout` — Auth0 device-flow auth + project discovery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import socket
|
|
7
|
+
|
|
8
|
+
import keyring
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
from ermes_cli import admin_api, auth0, keyring_store
|
|
14
|
+
from ermes_cli.config import (
|
|
15
|
+
DEFAULT_BASE_URL,
|
|
16
|
+
ConfigError,
|
|
17
|
+
clear_projects_file,
|
|
18
|
+
get_active_project_id,
|
|
19
|
+
get_base_url,
|
|
20
|
+
load_projects_file,
|
|
21
|
+
save_projects_file,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
console = Console(stderr=True)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _on_user_code(code: auth0.DeviceCode) -> None:
|
|
28
|
+
"""Show the Auth0 device code prompt before opening the browser."""
|
|
29
|
+
console.print()
|
|
30
|
+
console.print("[bold cyan]Sign in to Ermes via Auth0[/bold cyan]")
|
|
31
|
+
console.print(f" user code: [bold]{code.user_code}[/bold]")
|
|
32
|
+
console.print(f" verification URL: {code.verification_uri_complete}")
|
|
33
|
+
console.print(
|
|
34
|
+
" [dim]Opening your browser… if it doesn't open, paste the URL above.[/dim]"
|
|
35
|
+
)
|
|
36
|
+
console.print()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _bootstrap_projects(
|
|
40
|
+
base_url: str, ermes_jwt: str, admin_email: str | None = None
|
|
41
|
+
) -> tuple[list[admin_api.ProjectSummary], dict[int, admin_api.ProjectKey]]:
|
|
42
|
+
"""Enumerate all projects; keep already-cached per-admin keys.
|
|
43
|
+
|
|
44
|
+
Login no longer mints a key on every project (that left 9 hosts holding
|
|
45
|
+
admin keys to all 240 prod projects — DEV-1136). Keys are provisioned
|
|
46
|
+
lazily by `ensure_project_key()` on first use. Cached keys survive a
|
|
47
|
+
re-sync, except legacy shared `cli-managed-<host>` keys, which are
|
|
48
|
+
dropped so the next use mints a per-admin key. Cached keys are also
|
|
49
|
+
dropped wholesale when the identity or host changed: a key minted for
|
|
50
|
+
another admin, or against another Ermes host, must not survive a login.
|
|
51
|
+
"""
|
|
52
|
+
projects = admin_api.list_all_projects(base_url, ermes_jwt)
|
|
53
|
+
stored = load_projects_file()
|
|
54
|
+
same_identity = (
|
|
55
|
+
(admin_email is None or stored.get("auth0_email") in (None, admin_email))
|
|
56
|
+
and str(stored.get("base_url") or base_url).rstrip("/") == base_url.rstrip("/")
|
|
57
|
+
)
|
|
58
|
+
# Read the cache from the same dict the identity check used, not from disk again.
|
|
59
|
+
cached = {int(k): v for k, v in (stored.get("projects") or {}).items()} if same_identity else {}
|
|
60
|
+
keys: dict[int, admin_api.ProjectKey] = {}
|
|
61
|
+
for project in projects:
|
|
62
|
+
prev = cached.get(project.id) or {}
|
|
63
|
+
name = str(prev.get("api_key_name") or "")
|
|
64
|
+
if prev.get("api_key") and not name.startswith("cli-managed-"):
|
|
65
|
+
keys[project.id] = admin_api.ProjectKey(
|
|
66
|
+
id=int(prev.get("api_key_id") or 0),
|
|
67
|
+
name=name,
|
|
68
|
+
value=str(prev["api_key"]),
|
|
69
|
+
scope="secret",
|
|
70
|
+
role=str(prev.get("role") or ""),
|
|
71
|
+
)
|
|
72
|
+
return projects, keys
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _hostname() -> str:
|
|
76
|
+
"""Server accepts ^[A-Za-z0-9._-]{1,64}$ for the key name suffix."""
|
|
77
|
+
return re.sub(r"[^A-Za-z0-9._-]", "-", socket.gethostname())[:64] or "unknown-host"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def ensure_project_key(project_id: int) -> str:
|
|
81
|
+
"""Return the cached CLI key for `project_id`, minting it on first use.
|
|
82
|
+
|
|
83
|
+
Raises `ConfigError` (never SystemExit — the MCP server calls this too)
|
|
84
|
+
when there is no Auth0 session or the server refuses.
|
|
85
|
+
"""
|
|
86
|
+
payload = load_projects_file()
|
|
87
|
+
projects = payload.get("projects") or {}
|
|
88
|
+
# pop both spellings so a str-keyed YAML entry never leaves a duplicate
|
|
89
|
+
proj = projects.pop(project_id, None) or projects.pop(str(project_id), None) or {}
|
|
90
|
+
if proj.get("api_key"):
|
|
91
|
+
return str(proj["api_key"])
|
|
92
|
+
try:
|
|
93
|
+
api_base, result = _ensure_admin_session()
|
|
94
|
+
key = admin_api.create_cli_key(
|
|
95
|
+
api_base, result.ermes_access_token, project_id, _hostname()
|
|
96
|
+
)
|
|
97
|
+
except typer.Exit:
|
|
98
|
+
raise ConfigError("Not logged in. Run `ermes login` first.")
|
|
99
|
+
except admin_api.AdminAPIError as exc:
|
|
100
|
+
raise ConfigError(f"Could not provision a CLI key for project {project_id}: {exc}")
|
|
101
|
+
proj.update({"api_key": key.value, "api_key_id": key.id, "api_key_name": key.name})
|
|
102
|
+
projects[project_id] = proj
|
|
103
|
+
payload["projects"] = projects
|
|
104
|
+
try:
|
|
105
|
+
save_projects_file(payload)
|
|
106
|
+
except OSError as exc:
|
|
107
|
+
raise ConfigError(f"Could not write {exc.filename or 'projects.yml'}: {exc.strerror}")
|
|
108
|
+
console.print(f"[green]+[/green] Provisioned CLI key `{key.name}` for project {project_id}.")
|
|
109
|
+
return key.value
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _write_projects_file(
|
|
113
|
+
base_url: str,
|
|
114
|
+
auth0_email: str | None,
|
|
115
|
+
organization_id: int,
|
|
116
|
+
projects: list[admin_api.ProjectSummary],
|
|
117
|
+
keys: dict[int, admin_api.ProjectKey],
|
|
118
|
+
active_project_id: int | None,
|
|
119
|
+
) -> None:
|
|
120
|
+
"""Persist projects.yml. Preserve the prior active project if it still exists."""
|
|
121
|
+
payload: dict = {
|
|
122
|
+
"auth0_email": auth0_email,
|
|
123
|
+
"base_url": base_url,
|
|
124
|
+
"organization_id": organization_id,
|
|
125
|
+
"active_project": active_project_id,
|
|
126
|
+
"projects": {},
|
|
127
|
+
}
|
|
128
|
+
for project in projects:
|
|
129
|
+
key = keys.get(project.id)
|
|
130
|
+
payload["projects"][project.id] = {
|
|
131
|
+
"name": project.name,
|
|
132
|
+
"role": project.role,
|
|
133
|
+
"organization_id": organization_id,
|
|
134
|
+
"api_key": key.value if key else None,
|
|
135
|
+
"api_key_id": key.id if key else None,
|
|
136
|
+
"api_key_name": key.name if key else None,
|
|
137
|
+
}
|
|
138
|
+
save_projects_file(payload)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _ensure_admin_session(
|
|
142
|
+
base_url: str | None = None,
|
|
143
|
+
) -> tuple[str, admin_api.ExchangeResult]:
|
|
144
|
+
"""Return `(api_base, exchange_result)` using cached Auth0 tokens.
|
|
145
|
+
|
|
146
|
+
Loads the keychain bundle, transparently refreshes the Auth0 access token
|
|
147
|
+
via the cached refresh_token when it has expired, then exchanges it for an
|
|
148
|
+
Ermes admin JWT via `/api/cli/exchange`. Exits (code 1) with an actionable
|
|
149
|
+
message when the user isn't logged in, the refresh token is dead, or the
|
|
150
|
+
exchange fails. `base_url` overrides projects.yml's cached base URL.
|
|
151
|
+
"""
|
|
152
|
+
api_base = base_url or get_base_url()
|
|
153
|
+
_require_https(api_base)
|
|
154
|
+
bundle = keyring_store.load()
|
|
155
|
+
if bundle is None:
|
|
156
|
+
console.print("[red]Not logged in.[/red] Run `ermes login` first.")
|
|
157
|
+
raise typer.Exit(code=1)
|
|
158
|
+
if bundle.base_url and bundle.base_url.rstrip("/") != api_base.rstrip("/"):
|
|
159
|
+
# The id_token would otherwise be handed to whichever host --base-url names.
|
|
160
|
+
console.print(
|
|
161
|
+
f"[red]Logged in to {bundle.base_url}, not {api_base}.[/red] "
|
|
162
|
+
f"Run `ermes login --base-url {api_base}` to use this host."
|
|
163
|
+
)
|
|
164
|
+
raise typer.Exit(code=1)
|
|
165
|
+
|
|
166
|
+
if bundle.is_expired():
|
|
167
|
+
if not bundle.refresh_token:
|
|
168
|
+
console.print(
|
|
169
|
+
"[red]Auth0 token expired and no refresh token cached.[/red] "
|
|
170
|
+
"Run `ermes login` again."
|
|
171
|
+
)
|
|
172
|
+
raise typer.Exit(code=1)
|
|
173
|
+
try:
|
|
174
|
+
refreshed = auth0.refresh_access_token(bundle.refresh_token)
|
|
175
|
+
refreshed.base_url = bundle.base_url
|
|
176
|
+
bundle = refreshed
|
|
177
|
+
keyring_store.save(bundle)
|
|
178
|
+
console.print("[green]✓[/green] Refreshed Auth0 access token.")
|
|
179
|
+
except auth0.AuthError as exc:
|
|
180
|
+
console.print(
|
|
181
|
+
f"[red]Refresh failed:[/red] {exc}. "
|
|
182
|
+
"Run `ermes login` to re-authenticate."
|
|
183
|
+
)
|
|
184
|
+
raise typer.Exit(code=1)
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
result = admin_api.exchange_auth0_token(
|
|
188
|
+
api_base, bundle.access_token, id_token=bundle.id_token or None
|
|
189
|
+
)
|
|
190
|
+
except admin_api.AdminAPIError as exc:
|
|
191
|
+
console.print(f"[red]Ermes exchange failed:[/red] {exc}")
|
|
192
|
+
raise typer.Exit(code=1)
|
|
193
|
+
|
|
194
|
+
return api_base, result
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _require_https(base_url: str) -> None:
|
|
198
|
+
"""Auth0 id_tokens and the minted sk_ keys travel to this host; refuse plaintext
|
|
199
|
+
except for a local dev stack."""
|
|
200
|
+
from urllib.parse import urlparse
|
|
201
|
+
|
|
202
|
+
parsed = urlparse(base_url)
|
|
203
|
+
# urllib3 reads `http://evil.example\\@localhost` as host evil.example while
|
|
204
|
+
# urlparse says localhost; no Ermes URL carries userinfo, so refuse both outright.
|
|
205
|
+
if "@" in parsed.netloc or "\\" in base_url:
|
|
206
|
+
console.print(f"[red]--base-url must not contain credentials or backslashes:[/red] {base_url}")
|
|
207
|
+
raise typer.Exit(code=1)
|
|
208
|
+
if parsed.scheme == "https" or (
|
|
209
|
+
parsed.scheme == "http" and parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
|
210
|
+
):
|
|
211
|
+
return
|
|
212
|
+
console.print(f"[red]--base-url must use https:[/red] {base_url}")
|
|
213
|
+
raise typer.Exit(code=1)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def login(
|
|
217
|
+
base_url: str = typer.Option(
|
|
218
|
+
DEFAULT_BASE_URL,
|
|
219
|
+
"--base-url",
|
|
220
|
+
help="Ermes API base URL. Defaults to production.",
|
|
221
|
+
show_default=True,
|
|
222
|
+
),
|
|
223
|
+
open_browser: bool = typer.Option(
|
|
224
|
+
True,
|
|
225
|
+
"--open-browser/--no-open-browser",
|
|
226
|
+
help="Open the Auth0 verification URL in the default browser.",
|
|
227
|
+
),
|
|
228
|
+
) -> None:
|
|
229
|
+
"""Authenticate via Auth0 device flow + auto-discover your Ermes projects.
|
|
230
|
+
|
|
231
|
+
What this does:
|
|
232
|
+
1. Starts an Auth0 device flow against the shared `dev-hqiwr-re` tenant.
|
|
233
|
+
2. Stores the resulting Auth0 tokens in your OS keychain (`ermes-cli` service).
|
|
234
|
+
3. Trades the Auth0 access token for an Ermes admin JWT via /api/cli/exchange.
|
|
235
|
+
4. Calls /api/admin/projects/all to enumerate every project you can see.
|
|
236
|
+
5. Caches the list in `~/.ermes/projects.yml` (chmod 600).
|
|
237
|
+
|
|
238
|
+
A per-admin, per-host `sk_` key is provisioned for a project the first
|
|
239
|
+
time you use it (`ermes use`, `ERMES_PROJECT=…`) — never for every project.
|
|
240
|
+
"""
|
|
241
|
+
_require_https(base_url)
|
|
242
|
+
try:
|
|
243
|
+
bundle = auth0.device_login(
|
|
244
|
+
open_browser=open_browser,
|
|
245
|
+
on_user_code=_on_user_code,
|
|
246
|
+
)
|
|
247
|
+
except auth0.AuthError as exc:
|
|
248
|
+
console.print(f"[red]Auth0 login failed:[/red] {exc}")
|
|
249
|
+
raise typer.Exit(code=1)
|
|
250
|
+
|
|
251
|
+
bundle.base_url = base_url
|
|
252
|
+
keyring_store.save(bundle)
|
|
253
|
+
console.print("[green]✓[/green] Auth0 tokens stored in OS keychain.")
|
|
254
|
+
|
|
255
|
+
try:
|
|
256
|
+
result = admin_api.exchange_auth0_token(
|
|
257
|
+
base_url, bundle.access_token, id_token=bundle.id_token or None
|
|
258
|
+
)
|
|
259
|
+
except admin_api.AdminAPIError as exc:
|
|
260
|
+
console.print(f"[red]Ermes exchange failed:[/red] {exc}")
|
|
261
|
+
raise typer.Exit(code=1)
|
|
262
|
+
|
|
263
|
+
console.print(
|
|
264
|
+
f"[green]✓[/green] Logged in as [bold]{result.admin_email}[/bold] "
|
|
265
|
+
f"(org {result.organization_id}, role {result.role})."
|
|
266
|
+
)
|
|
267
|
+
stored = load_projects_file()
|
|
268
|
+
if stored and (
|
|
269
|
+
stored.get("auth0_email") != result.admin_email
|
|
270
|
+
or str(stored.get("base_url") or "").rstrip("/") != base_url.rstrip("/")
|
|
271
|
+
):
|
|
272
|
+
# The keyring now holds this admin's tokens; the previous admin's cached
|
|
273
|
+
# keys must not outlive the login even if discovery stops early.
|
|
274
|
+
clear_projects_file()
|
|
275
|
+
console.print()
|
|
276
|
+
console.print("[bold]Discovering projects…[/bold]")
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
projects, keys = _bootstrap_projects(base_url, result.ermes_access_token, result.admin_email)
|
|
280
|
+
except admin_api.AdminAPIError as exc:
|
|
281
|
+
console.print(f"[red]Project discovery failed:[/red] {exc}")
|
|
282
|
+
raise typer.Exit(code=1)
|
|
283
|
+
|
|
284
|
+
prior_active = get_active_project_id()
|
|
285
|
+
active_id = (
|
|
286
|
+
prior_active if prior_active in {p.id for p in projects} else (projects[0].id if projects else None)
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
# Written even when empty: a stale projects.yml must not outlive a login
|
|
290
|
+
# that no longer grants those projects.
|
|
291
|
+
_write_projects_file(
|
|
292
|
+
base_url=base_url,
|
|
293
|
+
auth0_email=result.admin_email,
|
|
294
|
+
organization_id=result.organization_id,
|
|
295
|
+
projects=projects,
|
|
296
|
+
keys=keys,
|
|
297
|
+
active_project_id=active_id,
|
|
298
|
+
)
|
|
299
|
+
if not projects:
|
|
300
|
+
console.print(
|
|
301
|
+
"[yellow]No projects found for this admin.[/yellow] "
|
|
302
|
+
"Visit the Ermes UI to create one, then re-run `ermes login`."
|
|
303
|
+
)
|
|
304
|
+
raise typer.Exit(code=0)
|
|
305
|
+
|
|
306
|
+
console.print()
|
|
307
|
+
table = Table(title=f"{len(projects)} project(s) cached at ~/.ermes/projects.yml")
|
|
308
|
+
table.add_column("ID", justify="right")
|
|
309
|
+
table.add_column("Name")
|
|
310
|
+
table.add_column("Role")
|
|
311
|
+
table.add_column("Active")
|
|
312
|
+
for p in projects:
|
|
313
|
+
table.add_row(str(p.id), p.name, p.role, "★" if p.id == active_id else "")
|
|
314
|
+
console.print(table)
|
|
315
|
+
console.print(
|
|
316
|
+
f"\nActive project: [bold]{next(p.name for p in projects if p.id == active_id)}[/bold] "
|
|
317
|
+
f"(id={active_id}). Switch with `ermes use <name-or-id>`. "
|
|
318
|
+
"Keys are provisioned per project on first use."
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def logout() -> None:
|
|
323
|
+
"""Forget Auth0 tokens + cached projects. Legacy ~/.ermes/config.yml is preserved."""
|
|
324
|
+
bundle = keyring_store.load()
|
|
325
|
+
if bundle is not None and bundle.refresh_token and not auth0.revoke_refresh_token(bundle.refresh_token):
|
|
326
|
+
console.print("[yellow]Could not revoke the refresh token at Auth0; it expires on its own.[/yellow]")
|
|
327
|
+
try:
|
|
328
|
+
keyring_store.clear()
|
|
329
|
+
except keyring.errors.KeyringError as exc:
|
|
330
|
+
console.print(f"[red]Could not remove the Auth0 tokens from the keyring:[/red] {exc}")
|
|
331
|
+
raise typer.Exit(code=1)
|
|
332
|
+
finally:
|
|
333
|
+
clear_projects_file()
|
|
334
|
+
console.print("[green]✓[/green] Cleared Auth0 tokens + projects.yml.")
|
|
335
|
+
console.print(
|
|
336
|
+
"[dim]Legacy ~/.ermes/config.yml profiles are unchanged. "
|
|
337
|
+
"Remove them with `ermes config remove <name>` if desired.[/dim]"
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def refresh_projects(
|
|
342
|
+
base_url: str = typer.Option(
|
|
343
|
+
None,
|
|
344
|
+
"--base-url",
|
|
345
|
+
help="Override the API base URL (defaults to projects.yml's value).",
|
|
346
|
+
),
|
|
347
|
+
) -> None:
|
|
348
|
+
"""Re-sync projects.yml from the API using the cached Auth0 token.
|
|
349
|
+
|
|
350
|
+
Useful when an admin has added you to a new project or revoked an
|
|
351
|
+
existing key. If the Auth0 access token has expired, this command
|
|
352
|
+
automatically refreshes it via the cached refresh_token. If the
|
|
353
|
+
refresh_token itself has expired or been revoked, you'll be told to
|
|
354
|
+
re-run `ermes login`.
|
|
355
|
+
"""
|
|
356
|
+
api_base, result = _ensure_admin_session(base_url)
|
|
357
|
+
|
|
358
|
+
console.print(f"[green]✓[/green] Authenticated as {result.admin_email}.")
|
|
359
|
+
console.print("[bold]Re-syncing projects…[/bold]")
|
|
360
|
+
|
|
361
|
+
try:
|
|
362
|
+
projects, keys = _bootstrap_projects(api_base, result.ermes_access_token, result.admin_email)
|
|
363
|
+
except admin_api.AdminAPIError as exc:
|
|
364
|
+
console.print(f"[red]Project discovery failed:[/red] {exc}")
|
|
365
|
+
raise typer.Exit(code=1)
|
|
366
|
+
|
|
367
|
+
prior_active = get_active_project_id()
|
|
368
|
+
active_id = (
|
|
369
|
+
prior_active if prior_active in {p.id for p in projects} else (projects[0].id if projects else None)
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
_write_projects_file(
|
|
373
|
+
base_url=api_base,
|
|
374
|
+
auth0_email=result.admin_email,
|
|
375
|
+
organization_id=result.organization_id,
|
|
376
|
+
projects=projects,
|
|
377
|
+
keys=keys,
|
|
378
|
+
active_project_id=active_id,
|
|
379
|
+
)
|
|
380
|
+
if not projects:
|
|
381
|
+
console.print("[yellow]No projects found for this admin.[/yellow]")
|
|
382
|
+
raise typer.Exit(code=0)
|
|
383
|
+
console.print(
|
|
384
|
+
f"[green]✓[/green] Cached {len(projects)} project(s). Active: "
|
|
385
|
+
f"{next(p.name for p in projects if p.id == active_id)} (id={active_id})."
|
|
386
|
+
)
|