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,145 @@
1
+ """Cross-platform OS-vault token storage via `keyring`.
2
+
3
+ macOS → Keychain
4
+ Linux → secret-service (GNOME Keyring, KWallet), falls back to encrypted
5
+ file via `keyring`'s file backend
6
+ Windows → Credential Manager
7
+
8
+ The whole token bundle (access + refresh + id + expires_at) is stored as a
9
+ single JSON blob under one keyring item. One read/write per operation —
10
+ simpler than splitting across three items, and atomic from the OS vault's
11
+ perspective.
12
+
13
+ This module ONLY deals with Auth0 tokens. Per-project Ermes API keys live
14
+ in `~/.ermes/projects.yml` (managed by `config.py`); they're project-scoped
15
+ non-bearer secrets that the user can already regenerate from the Ermes UI,
16
+ so the marginal value of keychain storage there is low.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import sys
23
+ import time
24
+ from dataclasses import asdict, dataclass
25
+ from typing import Optional
26
+
27
+ import keyring
28
+ import keyring.errors
29
+
30
+ from ermes_cli.config import _config_dir, _open_private
31
+
32
+ KEYRING_SERVICE = "ermes-cli"
33
+ KEYRING_ACCOUNT = "default"
34
+
35
+
36
+ def _fallback_path():
37
+ """Headless Linux / containers have no OS vault: keyring raises
38
+ `NoKeyringError`. Fall back to a 0600 file next to projects.yml (which
39
+ already holds sk_ keys with the same protection)."""
40
+ return _config_dir() / "auth0-tokens.json"
41
+
42
+
43
+ @dataclass
44
+ class TokenBundle:
45
+ access_token: str
46
+ refresh_token: str
47
+ id_token: str
48
+ expires_at: int # unix epoch seconds
49
+ base_url: str = "" # the Ermes host these tokens were obtained for (DEV-2261)
50
+
51
+ @classmethod
52
+ def from_token_response(
53
+ cls, body: dict, now: Optional[int] = None
54
+ ) -> "TokenBundle":
55
+ """Build a bundle from Auth0's `/oauth/token` response."""
56
+ now = now if now is not None else int(time.time())
57
+ return cls(
58
+ access_token=body["access_token"],
59
+ refresh_token=body.get("refresh_token", ""),
60
+ id_token=body.get("id_token", ""),
61
+ expires_at=now + int(body.get("expires_in", 0)),
62
+ )
63
+
64
+ def is_expired(self, now: Optional[int] = None, leeway: int = 60) -> bool:
65
+ """True if the access token has expired (with `leeway` seconds buffer)."""
66
+ now = now if now is not None else int(time.time())
67
+ return now + leeway >= self.expires_at
68
+
69
+
70
+ # keyring's no-op / always-failing stand-ins and the `keyrings.alt` plaintext
71
+ # backends: a token "saved" there is silently lost or written world-readable.
72
+ _NOT_A_VAULT = ("keyring.backends.null", "keyring.backends.fail", "keyrings.alt")
73
+ _warned_fallback = False
74
+
75
+
76
+ def _vault_is_real() -> bool:
77
+ return not type(keyring.get_keyring()).__module__.startswith(_NOT_A_VAULT)
78
+
79
+
80
+ def save(bundle: TokenBundle) -> None:
81
+ """Exactly one store holds the bundle afterwards: the vault, or (when it is
82
+ missing, fake or refusing) the 0600 fallback file."""
83
+ raw = json.dumps(asdict(bundle))
84
+ if _vault_is_real():
85
+ try:
86
+ keyring.set_password(KEYRING_SERVICE, KEYRING_ACCOUNT, raw)
87
+ _fallback_path().unlink(missing_ok=True)
88
+ return
89
+ except keyring.errors.KeyringError:
90
+ pass
91
+ global _warned_fallback
92
+ if not _warned_fallback:
93
+ _warned_fallback = True
94
+ print(
95
+ f"warning: no usable OS keyring; Auth0 tokens stored in {_fallback_path()} (mode 0600)",
96
+ file=sys.stderr,
97
+ )
98
+ path = _fallback_path()
99
+ path.parent.mkdir(parents=True, exist_ok=True)
100
+ with _open_private(path) as f:
101
+ f.write(raw)
102
+ try: # never leave an older bundle behind in the vault
103
+ keyring.delete_password(KEYRING_SERVICE, KEYRING_ACCOUNT)
104
+ except keyring.errors.KeyringError:
105
+ pass
106
+
107
+
108
+ def load() -> Optional[TokenBundle]:
109
+ # The fallback file exists only when the last save() could not use the vault,
110
+ # so when both are present the file is the newer copy.
111
+ path = _fallback_path()
112
+ raw = path.read_text() if path.exists() else None
113
+ if not raw:
114
+ try:
115
+ raw = keyring.get_password(KEYRING_SERVICE, KEYRING_ACCOUNT)
116
+ except keyring.errors.KeyringError:
117
+ raw = None
118
+ if not raw:
119
+ return None
120
+ try:
121
+ data = json.loads(raw)
122
+ return TokenBundle(**data)
123
+ except (json.JSONDecodeError, TypeError, KeyError):
124
+ # Corrupted entry — treat as logged out. The next login overwrites it.
125
+ return None
126
+
127
+
128
+ def clear() -> None:
129
+ """Remove the stored tokens. Raises `keyring.errors.KeyringError` when the
130
+ backend refuses the delete, so `logout` cannot report success while the
131
+ token is still in the vault. Absent entry / no vault = already cleared."""
132
+ try:
133
+ keyring.delete_password(KEYRING_SERVICE, KEYRING_ACCOUNT)
134
+ except keyring.errors.NoKeyringError:
135
+ pass
136
+ except keyring.errors.PasswordDeleteError as exc:
137
+ # macOS wraps EVERY SecItemDelete failure (locked vault included) in this,
138
+ # not just "no such item": only a confirmed-absent entry counts as cleared.
139
+ try:
140
+ still_there = keyring.get_password(KEYRING_SERVICE, KEYRING_ACCOUNT)
141
+ except keyring.errors.NoKeyringError:
142
+ still_there = None
143
+ if still_there is not None:
144
+ raise keyring.errors.KeyringError(f"token still in the vault after delete: {exc}") from exc
145
+ _fallback_path().unlink(missing_ok=True)
ermes_cli/main.py ADDED
@@ -0,0 +1,193 @@
1
+ from __future__ import annotations
2
+ import atexit
3
+ from typing import Optional
4
+ import typer
5
+ from ermes_cli import __version__
6
+ from ermes_cli.config import resolve_profile, ConfigError
7
+ from ermes_cli.client import ErmesClient
8
+
9
+ app = typer.Typer(
10
+ name="ermes",
11
+ help="CLI for managing Ermes marketing automation APIs",
12
+ no_args_is_help=True,
13
+ )
14
+
15
+
16
+ class State:
17
+ profile_name: str | None = None
18
+ use_json: bool = False
19
+ client: ErmesClient | None = None
20
+
21
+
22
+ state = State()
23
+
24
+
25
+ def _close_client():
26
+ if state.client is not None:
27
+ state.client.close()
28
+
29
+
30
+ atexit.register(_close_client)
31
+
32
+
33
+ @app.callback()
34
+ def main(
35
+ profile: Optional[str] = typer.Option(None, "--profile", "-p", help="Profile name"),
36
+ use_json: bool = typer.Option(False, "--json", help="Output as JSON"),
37
+ ):
38
+ state.profile_name = profile
39
+ state.use_json = use_json
40
+
41
+
42
+ def get_client() -> ErmesClient:
43
+ if state.client is None:
44
+ try:
45
+ profile = resolve_profile(state.profile_name)
46
+ state.client = ErmesClient(base_url=profile["base_url"], api_key=profile["api_key"])
47
+ except ConfigError as e: # also a malformed key from projects.yml / config.yml
48
+ typer.echo(str(e), err=True)
49
+ raise typer.Exit(code=1) from e
50
+ return state.client
51
+
52
+
53
+ @app.command()
54
+ def version():
55
+ """Show CLI version."""
56
+ if state.use_json:
57
+ import json
58
+
59
+ print(json.dumps({"version": __version__}))
60
+ else:
61
+ typer.echo(f"ermes-cli {__version__}")
62
+
63
+
64
+ @app.command()
65
+ def whoami(
66
+ check: bool = typer.Option(
67
+ False, "--check", help="Also make one authenticated API read to prove the key works."
68
+ ),
69
+ ):
70
+ """Show the Auth0 identity, active project, API base URL, and write-mode state."""
71
+ from ermes_cli.output import render_detail
72
+ from ermes_cli.config import (
73
+ get_auth0_email,
74
+ get_base_url,
75
+ get_effective_project,
76
+ mask_key,
77
+ )
78
+ from ermes_cli import write_mode
79
+
80
+ email = get_auth0_email()
81
+ # Must match what `resolve_profile()` will pick across ALL of its tiers — `--profile` and
82
+ # ERMES_PROFILE included, not just ERMES_PROJECT — otherwise whoami reports one project
83
+ # while every data command queries another.
84
+ active_id, active, project_source = get_effective_project(state.profile_name)
85
+ ws = write_mode.status()
86
+
87
+ # `project_source` belongs in BOTH branches: the whole point is that the origin of the
88
+ # project is always visible, and the legacy/unresolved paths are exactly where it is
89
+ # least obvious.
90
+ result: dict = {
91
+ "base_url": get_base_url(),
92
+ "auth0_email": email,
93
+ "project_source": project_source,
94
+ "writes_allowed": ws["allowed"],
95
+ "writes_mode": ws["mode"],
96
+ "writes_until": ws["until"] or "(n/a)",
97
+ }
98
+
99
+ if active and active_id is not None:
100
+ result.update({
101
+ "active_project_id": active_id,
102
+ "active_project_name": active.get("name"),
103
+ "active_project_role": active.get("role"),
104
+ "api_key": mask_key(active["api_key"]) if active.get("api_key") else "(provisioned on first use)",
105
+ })
106
+ if check:
107
+ result["api_check"] = _api_check()
108
+ render_detail(result, use_json=state.use_json)
109
+ return
110
+
111
+ # An unresolvable override is a config error, not a legacy-profile setup. Say what whoami
112
+ # resolved before resolve_profile() raises, so the diagnosis and the fix hint both land.
113
+ if "NOT FOUND" in project_source:
114
+ typer.echo(f"whoami: {project_source}", err=True)
115
+
116
+ # Legacy profile path — hit the API to confirm liveness.
117
+ try:
118
+ profile = resolve_profile(state.profile_name)
119
+ except ConfigError as e:
120
+ typer.echo(str(e), err=True)
121
+ raise typer.Exit(code=1)
122
+ client = ErmesClient(base_url=profile["base_url"], api_key=profile["api_key"])
123
+ try:
124
+ project_info = client.get("/")
125
+ except Exception as e:
126
+ # Never echo the exception text: an httpx header error repeats the key verbatim.
127
+ typer.echo(f"Connection failed: {type(e).__name__}", err=True)
128
+ raise typer.Exit(code=1)
129
+ finally:
130
+ client.close()
131
+ result["profile"] = state.profile_name or "(default)"
132
+ result["base_url"] = profile["base_url"]
133
+ result["api_key"] = mask_key(profile["api_key"])
134
+ if isinstance(project_info, dict):
135
+ result.update(project_info)
136
+ if check:
137
+ result["api_check"] = _api_check()
138
+ render_detail(result, use_json=state.use_json)
139
+
140
+
141
+ def _api_check() -> str:
142
+ """One bounded read with the resolved key; exit 1 so scripts can gate on it.
143
+
144
+ Probes `/` (the project info route every role can read) rather than
145
+ `/campaigns`, which is editor-gated and fails for support-role keys.
146
+ """
147
+ client = get_client() # a ConfigError already printed its own message + Exit(1)
148
+ try:
149
+ client.get("/")
150
+ except Exception as e: # noqa: BLE001 — any failure is a failed check; never echo the key
151
+ typer.echo(f"api_check failed: {type(e).__name__}", err=True)
152
+ raise typer.Exit(code=1)
153
+ return "ok"
154
+
155
+
156
+ # Register subcommands
157
+ from ermes_cli.commands.campaigns import campaigns_app
158
+ from ermes_cli.commands.templates import templates_app
159
+ from ermes_cli.commands.users import users_app
160
+ from ermes_cli.commands.config_cmd import config_app
161
+ from ermes_cli.commands.lists import lists_app
162
+ from ermes_cli.commands.journeys import journeys_app
163
+ from ermes_cli.commands.subscriptions import subscriptions_app
164
+ from ermes_cli.commands.providers import providers_app
165
+ from ermes_cli.commands.tags import tags_app
166
+ from ermes_cli.commands.ingest import ingest_app
167
+ from ermes_cli.commands.data_paths import data_paths_app
168
+ from ermes_cli.commands.keys import keys_app
169
+ from ermes_cli.commands.export import export_app
170
+ from ermes_cli.commands.auth_cmds import login as _login, logout as _logout
171
+ from ermes_cli.commands.projects import projects_app, use as _use
172
+ from ermes_cli.commands.write_cmds import write_app
173
+ from ermes_cli.commands.metrics import metrics_app
174
+
175
+ app.add_typer(campaigns_app, name="campaigns")
176
+ app.add_typer(templates_app, name="templates")
177
+ app.add_typer(users_app, name="users")
178
+ app.add_typer(config_app, name="config")
179
+ app.add_typer(lists_app, name="lists")
180
+ app.add_typer(journeys_app, name="journeys")
181
+ app.add_typer(subscriptions_app, name="subscriptions")
182
+ app.add_typer(providers_app, name="providers")
183
+ app.add_typer(tags_app, name="tags")
184
+ app.add_typer(ingest_app, name="ingest")
185
+ app.add_typer(data_paths_app, name="data-paths")
186
+ app.add_typer(keys_app, name="keys")
187
+ app.add_typer(export_app, name="export")
188
+ app.add_typer(projects_app, name="projects")
189
+ app.add_typer(write_app, name="write")
190
+ app.add_typer(metrics_app, name="metrics")
191
+ app.command(name="login", help="Auth0 device login + auto-discover projects")(_login)
192
+ app.command(name="logout", help="Forget Auth0 tokens + cached projects")(_logout)
193
+ app.command(name="use", help="Switch active project (by name or id)")(_use)
ermes_cli/manifest.py ADDED
@@ -0,0 +1,218 @@
1
+ """Route manifest loader for ermes-cli.
2
+
3
+ Loads ``routes.yaml`` (next to this module) into a list of typed ``Route``
4
+ records. The MCP server builds its tool list from the manifest, so adding
5
+ a new read-only MCP tool is a YAML edit rather than a Python change.
6
+
7
+ The hand-coded CLI in ``ermes_cli/commands/*.py`` is NOT generated from
8
+ this file — the CLI surface is richer (CRUD + interactive prompts) and
9
+ managed independently. If the manifest later grows to drive CLI commands,
10
+ add a ``cli:`` block per route mirroring the publinker-cli pattern.
11
+
12
+ Uses ``dataclasses`` + ``yaml`` to keep the runtime dependency surface
13
+ small (every megabyte costs at ``pipx install`` time).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+ from typing import Optional
22
+ from urllib.parse import quote
23
+
24
+ import yaml
25
+
26
+ DEFAULT_ROUTES_FILE = Path(__file__).parent / "routes.yaml"
27
+
28
+ # Allowed risk tiers. v0.3 only ships GET routes ("read"); mutating tiers
29
+ # ("write", "delete") are reserved for v0.4+ when the confirmation UX lands.
30
+ RISK_TIERS = {"read", "write", "delete", "admin"}
31
+
32
+ # Allowed HTTP methods.
33
+ METHODS = {"GET", "POST", "PUT", "PATCH", "DELETE"}
34
+
35
+ # Allowed query-arg primitive types.
36
+ QUERY_TYPES = {"str", "int", "bool"}
37
+
38
+ # Matches `{name}` placeholders in API paths.
39
+ _PATH_PLACEHOLDER = re.compile(r"\{([a-zA-Z_][a-zA-Z0-9_]*)\}")
40
+
41
+
42
+ class ManifestError(ValueError):
43
+ """Raised when the manifest is malformed."""
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class MCPSpec:
48
+ expose: bool
49
+ tool_name: Optional[str]
50
+ description: str
51
+
52
+ @classmethod
53
+ def from_dict(cls, route_id: str, raw: dict) -> "MCPSpec":
54
+ if not isinstance(raw, dict):
55
+ raise ManifestError(f"{route_id}: mcp must be a dict")
56
+ expose = bool(raw.get("expose", False))
57
+ tool_name = raw.get("tool_name")
58
+ description = (raw.get("description") or "").strip()
59
+ if expose:
60
+ if not tool_name:
61
+ raise ManifestError(
62
+ f"{route_id}: mcp.tool_name required when mcp.expose is true"
63
+ )
64
+ if not description:
65
+ raise ManifestError(
66
+ f"{route_id}: mcp.description required when mcp.expose is true"
67
+ )
68
+ return cls(expose=expose, tool_name=tool_name, description=description)
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class PathArg:
73
+ """A positional argument required by the path (e.g., {campaign_id})."""
74
+
75
+ name: str
76
+ help: str
77
+
78
+ @classmethod
79
+ def from_path_placeholder(cls, name: str, helps: dict) -> "PathArg":
80
+ return cls(name=name, help=helps.get(name, f"The {name} value."))
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class QueryArg:
85
+ """An optional query-string argument (e.g., --limit)."""
86
+
87
+ name: str
88
+ help: str
89
+ type: str
90
+ default: Optional[object]
91
+ optional: bool
92
+
93
+ @classmethod
94
+ def from_dict(cls, route_id: str, raw: dict) -> "QueryArg":
95
+ if not isinstance(raw, dict) or "name" not in raw:
96
+ raise ManifestError(f"{route_id}: query_args entries need a 'name' key")
97
+ qtype = raw.get("type", "str")
98
+ if qtype not in QUERY_TYPES:
99
+ raise ManifestError(
100
+ f"{route_id}: query_arg {raw['name']!r} type must be one of "
101
+ f"{sorted(QUERY_TYPES)}, got {qtype!r}"
102
+ )
103
+ default = raw.get("default")
104
+ optional = bool(raw.get("optional", False))
105
+ # Optional + default-None is the common combination; default makes
106
+ # `optional` implicit only when no default is set.
107
+ if default is None and not optional:
108
+ optional = "default" not in raw
109
+ return cls(
110
+ name=raw["name"],
111
+ help=raw.get("help", ""),
112
+ type=qtype,
113
+ default=default,
114
+ optional=optional,
115
+ )
116
+
117
+
118
+ @dataclass(frozen=True)
119
+ class Route:
120
+ id: str
121
+ path: str
122
+ method: str
123
+ mcp: MCPSpec
124
+ risk: str
125
+ paginated: bool = False
126
+ path_args: tuple[PathArg, ...] = field(default_factory=tuple)
127
+ query_args: tuple[QueryArg, ...] = field(default_factory=tuple)
128
+
129
+ def render_path(self, values: dict[str, str]) -> str:
130
+ """Substitute ``{var}`` placeholders, URL-encoding each value as a
131
+ path segment. Missing keys raise ManifestError.
132
+ """
133
+ try:
134
+ encoded = {k: quote(str(v), safe="") for k, v in values.items()}
135
+ return self.path.format(**encoded)
136
+ except KeyError as exc:
137
+ raise ManifestError(
138
+ f"{self.id}: missing value for path arg {exc} when rendering {self.path}"
139
+ ) from exc
140
+
141
+ @classmethod
142
+ def from_dict(cls, raw: dict) -> "Route":
143
+ if not isinstance(raw, dict):
144
+ raise ManifestError(
145
+ f"route entry must be a dict, got {type(raw).__name__}"
146
+ )
147
+ try:
148
+ route_id = raw["id"]
149
+ path = raw["path"]
150
+ method = raw["method"].upper()
151
+ except KeyError as exc:
152
+ raise ManifestError(f"route missing required key {exc}") from exc
153
+ if method not in METHODS:
154
+ raise ManifestError(f"{route_id}: unknown HTTP method {method!r}")
155
+ risk = raw.get("risk", "read")
156
+ if risk not in RISK_TIERS:
157
+ raise ManifestError(
158
+ f"{route_id}: risk must be one of {sorted(RISK_TIERS)}, got {risk!r}"
159
+ )
160
+
161
+ path_arg_helps: dict[str, str] = {}
162
+ for entry in raw.get("args", []) or []:
163
+ if not isinstance(entry, dict) or "name" not in entry:
164
+ raise ManifestError(f"{route_id}: args entries need a 'name' key")
165
+ path_arg_helps[entry["name"]] = entry.get("help", "")
166
+
167
+ placeholders = _PATH_PLACEHOLDER.findall(path)
168
+ path_args = tuple(
169
+ PathArg.from_path_placeholder(name, path_arg_helps)
170
+ for name in placeholders
171
+ )
172
+
173
+ query_args = tuple(
174
+ QueryArg.from_dict(route_id, q)
175
+ for q in (raw.get("query_args") or [])
176
+ )
177
+
178
+ return cls(
179
+ id=route_id,
180
+ path=path,
181
+ method=method,
182
+ mcp=MCPSpec.from_dict(route_id, raw["mcp"]),
183
+ risk=risk,
184
+ paginated=bool(raw.get("paginated", False)),
185
+ path_args=path_args,
186
+ query_args=query_args,
187
+ )
188
+
189
+
190
+ def load_manifest(path: Path = DEFAULT_ROUTES_FILE) -> list[Route]:
191
+ """Load + validate the manifest. Raises ManifestError on any structural issue."""
192
+ raw = yaml.safe_load(path.read_text())
193
+ if not isinstance(raw, list):
194
+ raise ManifestError(
195
+ f"{path}: top-level must be a YAML list, got {type(raw).__name__}"
196
+ )
197
+
198
+ routes = [Route.from_dict(entry) for entry in raw]
199
+
200
+ ids: set[str] = set()
201
+ mcp_tool_names: set[str] = set()
202
+ for route in routes:
203
+ if route.id in ids:
204
+ raise ManifestError(f"duplicate route id: {route.id}")
205
+ ids.add(route.id)
206
+ if route.mcp.expose and route.mcp.tool_name in mcp_tool_names:
207
+ raise ManifestError(
208
+ f"{route.id}: duplicate MCP tool name {route.mcp.tool_name!r}"
209
+ )
210
+ if route.mcp.expose and route.mcp.tool_name:
211
+ mcp_tool_names.add(route.mcp.tool_name)
212
+
213
+ return routes
214
+
215
+
216
+ def exposed_routes(routes: list[Route]) -> list[Route]:
217
+ """Return the subset of routes where ``mcp.expose`` is true."""
218
+ return [r for r in routes if r.mcp.expose]