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,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]
|