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
ermes_cli/mcp_server.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""FastMCP server for Ermes — exposes read-only API tools.
|
|
2
|
+
|
|
3
|
+
Tools are loaded from ``routes.yaml`` at import time, so adding a new
|
|
4
|
+
read-only MCP tool is a YAML edit, not a Python change. The CLI surface
|
|
5
|
+
(``ermes_cli/commands/*.py``) is hand-coded and richer (CRUD + prompts);
|
|
6
|
+
the manifest drives only the MCP tool list.
|
|
7
|
+
|
|
8
|
+
Profile-based auth via the existing ``ermes_cli`` config. Write tools
|
|
9
|
+
(create/update/delete) are deliberately omitted from the default surface;
|
|
10
|
+
enable them later when there's a clear use case and a per-tool review.
|
|
11
|
+
|
|
12
|
+
Set ``ERMES_MCP_DEBUG=1`` to print the loaded tool count to stderr at
|
|
13
|
+
startup.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
from typing import Any, Callable
|
|
21
|
+
|
|
22
|
+
from fastmcp import FastMCP
|
|
23
|
+
|
|
24
|
+
from ermes_cli.client import ErmesClient
|
|
25
|
+
from ermes_cli.config import resolve_profile
|
|
26
|
+
from ermes_cli.manifest import (
|
|
27
|
+
QueryArg,
|
|
28
|
+
Route,
|
|
29
|
+
exposed_routes,
|
|
30
|
+
load_manifest,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
mcp = FastMCP("ermes")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# Auth helpers
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _client() -> ErmesClient:
|
|
42
|
+
profile = resolve_profile()
|
|
43
|
+
return ErmesClient(profile["base_url"], profile["api_key"])
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# Route execution
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _execute_route(route: Route, args: dict[str, Any]) -> Any:
|
|
52
|
+
"""Run one manifest-defined route against the Ermes API."""
|
|
53
|
+
path_values: dict[str, str] = {}
|
|
54
|
+
for arg in route.path_args:
|
|
55
|
+
if arg.name not in args or args[arg.name] in (None, ""):
|
|
56
|
+
raise ValueError(f"missing required argument '{arg.name}'")
|
|
57
|
+
path_values[arg.name] = str(args[arg.name])
|
|
58
|
+
|
|
59
|
+
query_params: dict[str, Any] = {}
|
|
60
|
+
limit: int | None = None
|
|
61
|
+
for q in route.query_args:
|
|
62
|
+
value = args.get(q.name)
|
|
63
|
+
if value is None:
|
|
64
|
+
continue
|
|
65
|
+
if q.name == "limit" and route.paginated:
|
|
66
|
+
limit = int(value)
|
|
67
|
+
continue
|
|
68
|
+
query_params[q.name] = value
|
|
69
|
+
|
|
70
|
+
path = route.render_path(path_values)
|
|
71
|
+
client = _client()
|
|
72
|
+
try:
|
|
73
|
+
if route.paginated:
|
|
74
|
+
return client.get_paginated(
|
|
75
|
+
path,
|
|
76
|
+
params=query_params or None,
|
|
77
|
+
limit=limit if limit is not None else 100,
|
|
78
|
+
)
|
|
79
|
+
return client.get(path, params=query_params or None)
|
|
80
|
+
finally:
|
|
81
|
+
client.close()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
# Dynamic tool handler factory
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
_QUERY_TYPE_MAP = {"str": "str", "int": "int", "bool": "bool"}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _query_annotation(q: QueryArg) -> str:
|
|
93
|
+
base = _QUERY_TYPE_MAP[q.type]
|
|
94
|
+
return f"{base} | None" if q.optional else base
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _build_handler(route: Route) -> Callable[..., Any]:
|
|
98
|
+
"""Generate a typed handler function for one route.
|
|
99
|
+
|
|
100
|
+
Builds the function via ``exec`` so the generated signature carries
|
|
101
|
+
real annotations and defaults — fastmcp introspects these to produce
|
|
102
|
+
the tool's JSON Schema.
|
|
103
|
+
"""
|
|
104
|
+
sig_parts: list[str] = []
|
|
105
|
+
for arg in route.path_args:
|
|
106
|
+
sig_parts.append(f"{arg.name}: str")
|
|
107
|
+
for q in route.query_args:
|
|
108
|
+
ann = _query_annotation(q)
|
|
109
|
+
if q.default is not None:
|
|
110
|
+
sig_parts.append(f"{q.name}: {ann} = {q.default!r}")
|
|
111
|
+
elif q.optional:
|
|
112
|
+
sig_parts.append(f"{q.name}: {ann} = None")
|
|
113
|
+
else:
|
|
114
|
+
sig_parts.append(f"{q.name}: {ann}")
|
|
115
|
+
|
|
116
|
+
sig = ", ".join(sig_parts)
|
|
117
|
+
src = (
|
|
118
|
+
f"def handler({sig}) -> object:\n"
|
|
119
|
+
f" return _execute_route(_ROUTE, locals())\n"
|
|
120
|
+
)
|
|
121
|
+
ns: dict[str, Any] = {"_execute_route": _execute_route, "_ROUTE": route}
|
|
122
|
+
exec(src, ns)
|
|
123
|
+
fn = ns["handler"]
|
|
124
|
+
fn.__name__ = route.mcp.tool_name or route.id
|
|
125
|
+
fn.__doc__ = route.mcp.description
|
|
126
|
+
return fn
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
# Hand-coded tools (no HTTP call)
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@mcp.tool
|
|
135
|
+
def whoami() -> dict[str, Any]:
|
|
136
|
+
"""Return the current Ermes profile + base URL (no API call — local only)."""
|
|
137
|
+
profile = resolve_profile()
|
|
138
|
+
return {
|
|
139
|
+
"base_url": profile["base_url"],
|
|
140
|
+
"api_key_masked": profile["api_key"][:3] + "***" + profile["api_key"][-3:],
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
# Manifest-driven tool registration (at import time)
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
_ROUTES = load_manifest()
|
|
150
|
+
for _route in exposed_routes(_ROUTES):
|
|
151
|
+
mcp.tool(
|
|
152
|
+
_build_handler(_route),
|
|
153
|
+
name=_route.mcp.tool_name,
|
|
154
|
+
description=_route.mcp.description,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def main() -> None:
|
|
159
|
+
if os.environ.get("ERMES_MCP_DEBUG"):
|
|
160
|
+
# +1 for the hand-coded whoami tool.
|
|
161
|
+
count = len(exposed_routes(_ROUTES)) + 1
|
|
162
|
+
print(f"ermes-mcp starting ({count} tools)", file=sys.stderr)
|
|
163
|
+
mcp.run(transport="stdio", show_banner=False)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
if __name__ == "__main__":
|
|
167
|
+
main()
|
ermes_cli/output.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import json
|
|
3
|
+
from typing import Any
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.table import Table
|
|
6
|
+
|
|
7
|
+
console = Console()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def render(
|
|
11
|
+
data: list[dict],
|
|
12
|
+
columns: list[str],
|
|
13
|
+
use_json: bool,
|
|
14
|
+
title: str | None = None,
|
|
15
|
+
column_labels: dict[str, str] | None = None,
|
|
16
|
+
) -> None:
|
|
17
|
+
if use_json:
|
|
18
|
+
print(json.dumps(data, indent=2, default=str))
|
|
19
|
+
return
|
|
20
|
+
if not data:
|
|
21
|
+
console.print("[dim]No results[/dim]")
|
|
22
|
+
return
|
|
23
|
+
labels = column_labels or {}
|
|
24
|
+
table = Table(title=title, show_lines=False)
|
|
25
|
+
for col in columns:
|
|
26
|
+
table.add_column(labels.get(col, col))
|
|
27
|
+
for row in data:
|
|
28
|
+
table.add_row(*[_format_value(row.get(col)) for col in columns])
|
|
29
|
+
console.print(table)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def render_detail(data: dict, use_json: bool) -> None:
|
|
33
|
+
if use_json:
|
|
34
|
+
print(json.dumps(data, indent=2, default=str))
|
|
35
|
+
return
|
|
36
|
+
table = Table(show_header=False, show_lines=False)
|
|
37
|
+
table.add_column("Key", style="bold")
|
|
38
|
+
table.add_column("Value")
|
|
39
|
+
for key, value in data.items():
|
|
40
|
+
table.add_row(key, _format_value(value))
|
|
41
|
+
console.print(table)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def render_message(message: str, use_json: bool) -> None:
|
|
45
|
+
if use_json:
|
|
46
|
+
print(json.dumps({"message": message}))
|
|
47
|
+
return
|
|
48
|
+
console.print(message)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _format_value(value: Any) -> str:
|
|
52
|
+
if value is None:
|
|
53
|
+
return ""
|
|
54
|
+
if isinstance(value, bool):
|
|
55
|
+
return "yes" if value else "no"
|
|
56
|
+
if isinstance(value, (list, dict)):
|
|
57
|
+
return json.dumps(value, default=str)
|
|
58
|
+
return str(value)
|
ermes_cli/routes.yaml
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# ermes-cli MCP route manifest (v0.3 — read-only surface).
|
|
2
|
+
#
|
|
3
|
+
# Each entry describes one Ermes API route exposed through the MCP server.
|
|
4
|
+
# The MCP dispatch builds its tool list from entries where `mcp.expose` is
|
|
5
|
+
# true, using `mcp.tool_name` as the tool id and `mcp.description` as the
|
|
6
|
+
# tool description shown to the model.
|
|
7
|
+
#
|
|
8
|
+
# Conventions:
|
|
9
|
+
# - `risk: read` — safe GETs only. Mutating tiers ("write", "delete") are
|
|
10
|
+
# reserved for v0.4+ when the confirmation UX lands.
|
|
11
|
+
# - `mcp.tool_name` — concept-style snake_case (e.g., `get_campaign`).
|
|
12
|
+
# - `paginated: true` — dispatch through `ErmesClient.get_paginated` so
|
|
13
|
+
# `limit` is honored across cursor pages.
|
|
14
|
+
#
|
|
15
|
+
# Adding a route:
|
|
16
|
+
# 1. Add an entry here.
|
|
17
|
+
# 2. Make sure `id` and `mcp.tool_name` are globally unique.
|
|
18
|
+
# 3. Run `pytest cli/tests/test_manifest.py` to validate.
|
|
19
|
+
#
|
|
20
|
+
# The hand-coded CLI (`ermes_cli/commands/*.py`) is NOT generated from this
|
|
21
|
+
# file. The CLI surface is richer (CRUD + prompts + validation) and lives
|
|
22
|
+
# separately. The manifest exists to drive the MCP tool set so adding a
|
|
23
|
+
# new MCP tool is a YAML edit, not a Python change.
|
|
24
|
+
|
|
25
|
+
- id: list_campaigns
|
|
26
|
+
path: /campaigns
|
|
27
|
+
method: GET
|
|
28
|
+
risk: read
|
|
29
|
+
paginated: true
|
|
30
|
+
mcp:
|
|
31
|
+
expose: true
|
|
32
|
+
tool_name: list_campaigns
|
|
33
|
+
description: List campaigns. Optional `status` filter (e.g. 'sent', 'draft', 'running').
|
|
34
|
+
query_args:
|
|
35
|
+
- name: limit
|
|
36
|
+
type: int
|
|
37
|
+
default: 50
|
|
38
|
+
help: Max number of campaigns to return.
|
|
39
|
+
- name: status
|
|
40
|
+
type: str
|
|
41
|
+
optional: true
|
|
42
|
+
help: Optional status filter.
|
|
43
|
+
|
|
44
|
+
- id: get_campaign
|
|
45
|
+
path: /campaigns/{campaign_id}
|
|
46
|
+
method: GET
|
|
47
|
+
risk: read
|
|
48
|
+
paginated: false
|
|
49
|
+
mcp:
|
|
50
|
+
expose: true
|
|
51
|
+
tool_name: get_campaign
|
|
52
|
+
description: Fetch one campaign by id.
|
|
53
|
+
args:
|
|
54
|
+
- name: campaign_id
|
|
55
|
+
help: The campaign id.
|
|
56
|
+
|
|
57
|
+
- id: list_templates
|
|
58
|
+
path: /templates
|
|
59
|
+
method: GET
|
|
60
|
+
risk: read
|
|
61
|
+
paginated: true
|
|
62
|
+
mcp:
|
|
63
|
+
expose: true
|
|
64
|
+
tool_name: list_templates
|
|
65
|
+
description: List email templates.
|
|
66
|
+
query_args:
|
|
67
|
+
- name: limit
|
|
68
|
+
type: int
|
|
69
|
+
default: 50
|
|
70
|
+
help: Max number of templates to return.
|
|
71
|
+
|
|
72
|
+
- id: get_template
|
|
73
|
+
path: /templates/{template_id}
|
|
74
|
+
method: GET
|
|
75
|
+
risk: read
|
|
76
|
+
paginated: false
|
|
77
|
+
mcp:
|
|
78
|
+
expose: true
|
|
79
|
+
tool_name: get_template
|
|
80
|
+
description: Fetch one template by id.
|
|
81
|
+
args:
|
|
82
|
+
- name: template_id
|
|
83
|
+
help: The template id.
|
|
84
|
+
|
|
85
|
+
- id: list_lists
|
|
86
|
+
path: /lists
|
|
87
|
+
method: GET
|
|
88
|
+
risk: read
|
|
89
|
+
paginated: true
|
|
90
|
+
mcp:
|
|
91
|
+
expose: true
|
|
92
|
+
tool_name: list_lists
|
|
93
|
+
description: List contact lists.
|
|
94
|
+
query_args:
|
|
95
|
+
- name: limit
|
|
96
|
+
type: int
|
|
97
|
+
default: 50
|
|
98
|
+
help: Max number of lists to return.
|
|
99
|
+
|
|
100
|
+
- id: list_journeys
|
|
101
|
+
path: /journeys
|
|
102
|
+
method: GET
|
|
103
|
+
risk: read
|
|
104
|
+
paginated: true
|
|
105
|
+
mcp:
|
|
106
|
+
expose: true
|
|
107
|
+
tool_name: list_journeys
|
|
108
|
+
description: List automation journeys.
|
|
109
|
+
query_args:
|
|
110
|
+
- name: limit
|
|
111
|
+
type: int
|
|
112
|
+
default: 50
|
|
113
|
+
help: Max number of journeys to return.
|
|
114
|
+
|
|
115
|
+
- id: list_users
|
|
116
|
+
path: /users
|
|
117
|
+
method: GET
|
|
118
|
+
risk: read
|
|
119
|
+
paginated: true
|
|
120
|
+
mcp:
|
|
121
|
+
expose: true
|
|
122
|
+
tool_name: list_users
|
|
123
|
+
description: List users (contacts). Optional exact-email filter.
|
|
124
|
+
query_args:
|
|
125
|
+
- name: limit
|
|
126
|
+
type: int
|
|
127
|
+
default: 100
|
|
128
|
+
help: Max number of users to return.
|
|
129
|
+
- name: email
|
|
130
|
+
type: str
|
|
131
|
+
optional: true
|
|
132
|
+
help: Optional exact-email filter.
|
|
133
|
+
|
|
134
|
+
- id: get_user
|
|
135
|
+
path: /users/{user_id}
|
|
136
|
+
method: GET
|
|
137
|
+
risk: read
|
|
138
|
+
paginated: false
|
|
139
|
+
mcp:
|
|
140
|
+
expose: true
|
|
141
|
+
tool_name: get_user
|
|
142
|
+
description: Fetch one user by id.
|
|
143
|
+
args:
|
|
144
|
+
- name: user_id
|
|
145
|
+
help: The user id.
|
|
146
|
+
|
|
147
|
+
- id: get_user_events
|
|
148
|
+
path: /users/{user_id}/events
|
|
149
|
+
method: GET
|
|
150
|
+
risk: read
|
|
151
|
+
paginated: true
|
|
152
|
+
mcp:
|
|
153
|
+
expose: true
|
|
154
|
+
tool_name: get_user_events
|
|
155
|
+
description: Recent events for one user (sends, opens, clicks).
|
|
156
|
+
args:
|
|
157
|
+
- name: user_id
|
|
158
|
+
help: The user id.
|
|
159
|
+
query_args:
|
|
160
|
+
- name: limit
|
|
161
|
+
type: int
|
|
162
|
+
default: 100
|
|
163
|
+
help: Max number of events to return.
|
ermes_cli/rules.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Normalization for rule trees sent to the API.
|
|
2
|
+
|
|
3
|
+
The platform accepts arbitrary JSON scalars as rule ``value``s and the rule
|
|
4
|
+
engine coerces them at evaluation time (``NumberRule`` does ``Number(value)``,
|
|
5
|
+
``BooleanRule`` never reads ``rule.value``), but the admin UI builder always
|
|
6
|
+
stores strings. Hand-authored payloads naturally write ``"value": 156004``,
|
|
7
|
+
which crashed the journey editor's rule renderer (DEV-1374). Canonicalize
|
|
8
|
+
CLI-authored payloads to the string form the UI produces so stored rules stay
|
|
9
|
+
uniform regardless of who wrote them.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
RULE_TYPES = {"wrapper", "string", "number", "boolean", "date", "array"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _is_rule_node(node: dict) -> bool:
|
|
20
|
+
return node.get("type") in RULE_TYPES and "path" in node and "operator" in node
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _to_string(value: Any) -> Any:
|
|
24
|
+
# json.dumps matches JS literal semantics: True -> "true", 156004 -> "156004",
|
|
25
|
+
# 1.5 -> "1.5". Strings and null pass through untouched.
|
|
26
|
+
if isinstance(value, (bool, int, float)):
|
|
27
|
+
return json.dumps(value)
|
|
28
|
+
return value
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def normalize_rule_values(payload: Any) -> Any:
|
|
32
|
+
"""Recursively stringify scalar ``value``s on every rule node in ``payload``.
|
|
33
|
+
|
|
34
|
+
Works on any JSON shape (a bare rule, a journey step map, a list body) by
|
|
35
|
+
detecting rule nodes structurally (``type``/``path``/``operator``). Mutates
|
|
36
|
+
dicts and lists in place and returns ``payload`` for convenience.
|
|
37
|
+
"""
|
|
38
|
+
if isinstance(payload, dict):
|
|
39
|
+
# Date rules are exempt: DateRule casts with `new Date(value)`, and a
|
|
40
|
+
# numeric epoch is a valid Date while its string form is not
|
|
41
|
+
# ("Invalid Date" -> rule evaluates false). The UI tolerates
|
|
42
|
+
# non-string values since DEV-1374, so numeric dates stay numeric.
|
|
43
|
+
if _is_rule_node(payload) and payload.get("type") != "date":
|
|
44
|
+
value = payload.get("value")
|
|
45
|
+
if isinstance(value, list):
|
|
46
|
+
payload["value"] = [_to_string(item) for item in value]
|
|
47
|
+
elif value is not None:
|
|
48
|
+
payload["value"] = _to_string(value)
|
|
49
|
+
for child in payload.values():
|
|
50
|
+
normalize_rule_values(child)
|
|
51
|
+
elif isinstance(payload, list):
|
|
52
|
+
for item in payload:
|
|
53
|
+
normalize_rule_values(item)
|
|
54
|
+
return payload
|
ermes_cli/write_mode.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Write-mode guard: block mutating CLI/MCP calls by default.
|
|
2
|
+
|
|
3
|
+
Why: most CLI sessions are read-only investigation ("what campaigns ran
|
|
4
|
+
last week?"). The mutating commands (`campaigns create`, `lists delete`,
|
|
5
|
+
`templates update`) only exist for the maintenance use case, and a
|
|
6
|
+
typo'd flag or copy-pasted-from-docs command shouldn't be one Enter-press
|
|
7
|
+
away from deleting production data.
|
|
8
|
+
|
|
9
|
+
Behavior:
|
|
10
|
+
- Default: writes BLOCKED. POST/PATCH/PUT/DELETE through `client.py` raise
|
|
11
|
+
WriteBlockedError before hitting the network.
|
|
12
|
+
- `ermes write allow` → unblocks for 30 min (default).
|
|
13
|
+
`ermes write allow --minutes 5` for shorter windows.
|
|
14
|
+
`ermes write allow --persistent` for permanent (until `write deny`).
|
|
15
|
+
- `ermes write deny` → revoke immediately.
|
|
16
|
+
- `ermes write status` → show current state.
|
|
17
|
+
- `ERMES_ALLOW_WRITES=1` env override (for CI/scripts that consciously
|
|
18
|
+
want unguarded behavior).
|
|
19
|
+
|
|
20
|
+
State lives in `~/.ermes/state.yml` (chmod 600). It's deliberately
|
|
21
|
+
separate from projects.yml so refreshing projects doesn't accidentally
|
|
22
|
+
clobber the write window.
|
|
23
|
+
|
|
24
|
+
The MCP server's tool set is read-only by design (see PR #395 TODO), so
|
|
25
|
+
this guard primarily protects the `ermes` CLI surface. When MCP gains
|
|
26
|
+
mutating tools (v0.4), they'll be gated through the same helper.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import os
|
|
32
|
+
from datetime import datetime, timezone
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
import yaml
|
|
37
|
+
|
|
38
|
+
from ermes_cli.config import _config_dir
|
|
39
|
+
from ermes_cli.errors import WriteBlockedError
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _state_path() -> Path:
|
|
43
|
+
return _config_dir() / "state.yml"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _load_state() -> dict[str, Any]:
|
|
47
|
+
p = _state_path()
|
|
48
|
+
if not p.exists():
|
|
49
|
+
return {}
|
|
50
|
+
try:
|
|
51
|
+
with open(p) as f:
|
|
52
|
+
data = yaml.safe_load(f)
|
|
53
|
+
return data if isinstance(data, dict) else {}
|
|
54
|
+
except (OSError, yaml.YAMLError):
|
|
55
|
+
return {}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _save_state(state: dict[str, Any]) -> None:
|
|
59
|
+
p = _state_path()
|
|
60
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
with open(p, "w") as f:
|
|
62
|
+
yaml.dump(state, f, default_flow_style=False, sort_keys=False)
|
|
63
|
+
p.chmod(0o600)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _now() -> datetime:
|
|
67
|
+
return datetime.now(timezone.utc)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _parse_iso(value: Any) -> datetime | None:
|
|
71
|
+
if not isinstance(value, str):
|
|
72
|
+
return None
|
|
73
|
+
try:
|
|
74
|
+
# Tolerate the trailing "Z" form.
|
|
75
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
76
|
+
except ValueError:
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# WriteBlockedError is defined in ermes_cli.errors so both client.py and
|
|
81
|
+
# this module can import it without a circular dependency. Re-exporting
|
|
82
|
+
# here keeps the old import path (`from ermes_cli.write_mode import
|
|
83
|
+
# WriteBlockedError`) working for any caller that already used it.
|
|
84
|
+
__all__ = [
|
|
85
|
+
"WriteBlockedError",
|
|
86
|
+
"env_override_active",
|
|
87
|
+
"status",
|
|
88
|
+
"is_writes_allowed",
|
|
89
|
+
"allow",
|
|
90
|
+
"deny",
|
|
91
|
+
"assert_writes_allowed",
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def env_override_active() -> bool:
|
|
96
|
+
"""True if the operator opted in via env var (CI / automation path)."""
|
|
97
|
+
return os.environ.get("ERMES_ALLOW_WRITES", "").lower() in {"1", "true", "yes"}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def status() -> dict[str, Any]:
|
|
101
|
+
"""Structured status: {allowed, mode, until, source}."""
|
|
102
|
+
if env_override_active():
|
|
103
|
+
return {
|
|
104
|
+
"allowed": True,
|
|
105
|
+
"mode": "env",
|
|
106
|
+
"until": None,
|
|
107
|
+
"source": "ERMES_ALLOW_WRITES env",
|
|
108
|
+
}
|
|
109
|
+
state = _load_state()
|
|
110
|
+
if state.get("write_mode_persistent"):
|
|
111
|
+
return {
|
|
112
|
+
"allowed": True,
|
|
113
|
+
"mode": "persistent",
|
|
114
|
+
"until": None,
|
|
115
|
+
"source": "state.yml",
|
|
116
|
+
}
|
|
117
|
+
until = _parse_iso(state.get("write_mode_until"))
|
|
118
|
+
if until and until > _now():
|
|
119
|
+
return {
|
|
120
|
+
"allowed": True,
|
|
121
|
+
"mode": "timed",
|
|
122
|
+
"until": until.isoformat(),
|
|
123
|
+
"source": "state.yml",
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
"allowed": False,
|
|
127
|
+
"mode": "denied",
|
|
128
|
+
"until": until.isoformat() if until else None,
|
|
129
|
+
"source": "default" if not state else "state.yml",
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def is_writes_allowed() -> bool:
|
|
134
|
+
return bool(status()["allowed"])
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def allow(minutes: int = 30, persistent: bool = False) -> dict[str, Any]:
|
|
138
|
+
"""Open the write window.
|
|
139
|
+
|
|
140
|
+
`persistent=True` ignores `minutes` and unblocks indefinitely. Otherwise
|
|
141
|
+
the window opens for `minutes` and closes automatically when it expires.
|
|
142
|
+
"""
|
|
143
|
+
state = _load_state()
|
|
144
|
+
if persistent:
|
|
145
|
+
state["write_mode_persistent"] = True
|
|
146
|
+
state.pop("write_mode_until", None)
|
|
147
|
+
else:
|
|
148
|
+
until = _now() + _delta(minutes)
|
|
149
|
+
state["write_mode_persistent"] = False
|
|
150
|
+
state["write_mode_until"] = until.isoformat()
|
|
151
|
+
_save_state(state)
|
|
152
|
+
return status()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def deny() -> dict[str, Any]:
|
|
156
|
+
"""Close the write window immediately."""
|
|
157
|
+
state = _load_state()
|
|
158
|
+
state["write_mode_persistent"] = False
|
|
159
|
+
state.pop("write_mode_until", None)
|
|
160
|
+
_save_state(state)
|
|
161
|
+
return status()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _delta(minutes: int):
|
|
165
|
+
from datetime import timedelta
|
|
166
|
+
|
|
167
|
+
return timedelta(minutes=int(minutes))
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def assert_writes_allowed(method: str, path: str) -> None:
|
|
171
|
+
"""Guard called from `client.py` before sending mutating requests."""
|
|
172
|
+
if is_writes_allowed():
|
|
173
|
+
return
|
|
174
|
+
raise WriteBlockedError(
|
|
175
|
+
f"Write blocked: {method} {path}. Writes are disabled by default. "
|
|
176
|
+
"Run `ermes write allow` (30 min) or `ermes write allow --persistent`, "
|
|
177
|
+
"or set ERMES_ALLOW_WRITES=1 in your environment."
|
|
178
|
+
)
|