ermes-cli 1.0.0__tar.gz → 1.1.0__tar.gz

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.
Files changed (77) hide show
  1. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/PKG-INFO +40 -43
  2. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/README.md +38 -40
  3. ermes_cli-1.1.0/ermes_cli/__init__.py +1 -0
  4. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/ermes_cli/commands/auth_cmds.py +1 -1
  5. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/ermes_cli/commands/projects.py +1 -1
  6. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/ermes_cli/config.py +1 -1
  7. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/ermes_cli/main.py +48 -0
  8. ermes_cli-1.1.0/ermes_cli/update.py +225 -0
  9. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/ermes_cli/write_mode.py +1 -5
  10. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/pyproject.toml +2 -7
  11. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/scripts/install-cli.sh +1 -11
  12. ermes_cli-1.1.0/tests/test_update.py +366 -0
  13. ermes_cli-1.0.0/ermes_cli/__init__.py +0 -1
  14. ermes_cli-1.0.0/ermes_cli/manifest.py +0 -218
  15. ermes_cli-1.0.0/ermes_cli/mcp_server.py +0 -167
  16. ermes_cli-1.0.0/ermes_cli/routes.yaml +0 -163
  17. ermes_cli-1.0.0/scripts/install-mcp.sh +0 -348
  18. ermes_cli-1.0.0/tests/test_install_mcp_script.py +0 -256
  19. ermes_cli-1.0.0/tests/test_install_mcp_uninstall.py +0 -168
  20. ermes_cli-1.0.0/tests/test_manifest.py +0 -229
  21. ermes_cli-1.0.0/tests/test_mcp_server.py +0 -160
  22. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/.gitignore +0 -0
  23. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/ermes_cli/admin_api.py +0 -0
  24. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/ermes_cli/auth0.py +0 -0
  25. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/ermes_cli/client.py +0 -0
  26. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/ermes_cli/commands/__init__.py +0 -0
  27. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/ermes_cli/commands/campaigns.py +0 -0
  28. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/ermes_cli/commands/config_cmd.py +0 -0
  29. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/ermes_cli/commands/data_paths.py +0 -0
  30. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/ermes_cli/commands/export.py +0 -0
  31. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/ermes_cli/commands/ingest.py +0 -0
  32. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/ermes_cli/commands/journeys.py +0 -0
  33. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/ermes_cli/commands/keys.py +0 -0
  34. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/ermes_cli/commands/lists.py +0 -0
  35. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/ermes_cli/commands/metrics.py +0 -0
  36. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/ermes_cli/commands/providers.py +0 -0
  37. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/ermes_cli/commands/subscriptions.py +0 -0
  38. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/ermes_cli/commands/tags.py +0 -0
  39. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/ermes_cli/commands/templates.py +0 -0
  40. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/ermes_cli/commands/users.py +0 -0
  41. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/ermes_cli/commands/write_cmds.py +0 -0
  42. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/ermes_cli/errors.py +0 -0
  43. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/ermes_cli/export_utils.py +0 -0
  44. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/ermes_cli/keyring_store.py +0 -0
  45. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/ermes_cli/output.py +0 -0
  46. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/ermes_cli/rules.py +0 -0
  47. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/tests/__init__.py +0 -0
  48. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/tests/conftest.py +0 -0
  49. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/tests/test_admin_api.py +0 -0
  50. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/tests/test_auth0.py +0 -0
  51. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/tests/test_auth_cmds.py +0 -0
  52. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/tests/test_client.py +0 -0
  53. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/tests/test_commands/__init__.py +0 -0
  54. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/tests/test_commands/test_campaigns.py +0 -0
  55. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/tests/test_commands/test_data_paths.py +0 -0
  56. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/tests/test_commands/test_export.py +0 -0
  57. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/tests/test_commands/test_ingest.py +0 -0
  58. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/tests/test_commands/test_journeys.py +0 -0
  59. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/tests/test_commands/test_keys.py +0 -0
  60. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/tests/test_commands/test_lists.py +0 -0
  61. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/tests/test_commands/test_metrics.py +0 -0
  62. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/tests/test_commands/test_projects.py +0 -0
  63. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/tests/test_commands/test_providers.py +0 -0
  64. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/tests/test_commands/test_subscriptions.py +0 -0
  65. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/tests/test_commands/test_tags.py +0 -0
  66. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/tests/test_commands/test_templates.py +0 -0
  67. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/tests/test_commands/test_users.py +0 -0
  68. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/tests/test_config.py +0 -0
  69. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/tests/test_export_utils.py +0 -0
  70. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/tests/test_keyring_store.py +0 -0
  71. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/tests/test_output.py +0 -0
  72. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/tests/test_projects_config.py +0 -0
  73. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/tests/test_rules.py +0 -0
  74. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/tests/test_version.py +0 -0
  75. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/tests/test_whoami.py +0 -0
  76. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/tests/test_write_cmds.py +0 -0
  77. {ermes_cli-1.0.0 → ermes_cli-1.1.0}/tests/test_write_mode.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ermes-cli
3
- Version: 1.0.0
4
- Summary: CLI-first access to Ermes marketing automation, with Auth0 multi-project auth and an optional compatibility MCP.
3
+ Version: 1.1.0
4
+ Summary: CLI-first access to Ermes marketing automation with Auth0 multi-project auth.
5
5
  Project-URL: Homepage, https://ermes.publinker.com
6
6
  Project-URL: Repository, https://github.com/Martech-DM/ermes
7
7
  Author: Martech-DM
@@ -14,7 +14,6 @@ Classifier: Programming Language :: Python :: 3
14
14
  Classifier: Programming Language :: Python :: 3 :: Only
15
15
  Classifier: Topic :: Communications :: Email
16
16
  Requires-Python: >=3.10
17
- Requires-Dist: fastmcp>=2.0
18
17
  Requires-Dist: httpx>=0.28
19
18
  Requires-Dist: keyring>=24.0
20
19
  Requires-Dist: pyyaml>=6.0
@@ -28,13 +27,12 @@ Requires-Dist: pytest>=8.0; extra == 'dev'
28
27
  Requires-Dist: responses>=0.25; extra == 'dev'
29
28
  Description-Content-Type: text/markdown
30
29
 
31
- # Ermes CLI and optional compatibility MCP
30
+ # Ermes CLI
32
31
 
33
- This package gives Ermes operators a CLI-first surface for explicit-project API reads and guarded
34
- writes. It also retains a 10-tool read-only MCP for temporary compatibility, but normal agent
35
- sessions leave that server unregistered.
32
+ This package gives Ermes operators a CLI surface for explicit-project API reads and guarded writes.
36
33
 
37
- Version 1.0.0 (DEV-2261). Verified 2026-10-20.
34
+ Version 1.1.0. It removed the `ermes-mcp` compatibility server that 1.0.0 shipped (DEV-2326); a
35
+ machine that registered it must drop that registration (e.g. `claude mcp remove ermes`).
38
36
 
39
37
  ## Install the CLI
40
38
 
@@ -55,22 +53,44 @@ Releases are cut by pushing a tag `ermes-cli-vX.Y.Z` equal to `cli/pyproject.tom
55
53
  (`.github/workflows/publish-cli.yml` runs the tests, builds the wheel, publishes to PyPI and attaches
56
54
  it to a GitHub Release). `install-cli.sh` is idempotent and safe to re-run for upgrades.
57
55
 
58
- If a temporary rollback needs fixed typed schemas, register the compatibility MCP only for one
59
- explicit project:
56
+ ## Update the CLI
60
57
 
61
- ```bash
62
- ./cli/scripts/install-mcp.sh --project=<project-name-or-id>
58
+ **Automatic prompt.** In an interactive terminal, the CLI checks PyPI for a newer `ermes-cli` at
59
+ most once every 24 hours (2-second timeout, cached in `~/.ermes/update-check.json`; failed checks
60
+ are cached too, so an offline laptop is not slowed down). When one exists it asks before running
61
+ your command:
62
+
63
+ ```
64
+ ermes-cli 1.2.0 is available (you have 1.1.0). Update now? [Y]es / [n]ot now / [s]kip this version
63
65
  ```
64
66
 
65
- Remove it after the rollback window. Removal preserves Auth0 tokens, project keys, and every file
66
- under `~/.ermes`:
67
+ - **Yes** (Enter): runs `pipx upgrade ermes-cli`, checks the new version in a fresh process, then
68
+ re-runs your command on it.
69
+ - **Not now**: runs your command; asks again on the next run.
70
+ - **Skip this version**: never asks about that version again; a newer release asks once more.
71
+
72
+ A failed upgrade or Ctrl-C at the prompt prints one line and runs your command on the current
73
+ version. The check never runs with `--json`, when `CI` is set, when stdin/stdout/stderr is not a
74
+ TTY, before `ermes version` / `ermes update`, or with `ERMES_NO_UPDATE_CHECK=1`.
75
+
76
+ **Manual.**
67
77
 
68
78
  ```bash
69
- ./cli/scripts/install-mcp.sh --uninstall
79
+ ermes update # check, then ask before upgrading
80
+ ermes update --check # report only, never install
81
+ ermes update --yes # upgrade without asking
82
+ ermes --json update [--yes] # --json installs only with --yes
70
83
  ```
71
84
 
72
- Never run `ermes logout` as part of registration removal; logout deletes the Keychain session and
73
- `projects.yml`.
85
+ **Status values** (`--json` `status`): `up_to_date`, `ahead`, `update_available`, `updated`,
86
+ `lookup_failed` (exit 1), `update_failed` (exit 1). Installer output goes to stderr; stdout stays
87
+ one JSON document.
88
+
89
+ **Supported install.** Only the pipx install of the PyPI package (`install-cli.sh`, or
90
+ `pipx install ermes-cli`) upgrades itself. `pipx upgrade` keeps the old venv if pip fails, so a
91
+ failed upgrade never leaves you without a CLI. Editable checkouts (`git pull`), git or local-dir
92
+ installs, non-pipx environments and an `ermes` that is not the venv pipx manages get the manual
93
+ command instead of a prompt. Keychain tokens and `~/.ermes` are untouched.
74
94
 
75
95
  ## Auth — Auth0 device login (recommended)
76
96
 
@@ -161,8 +181,8 @@ State lives in `~/.ermes/state.yml` (chmod 600), separate from `projects.yml`
161
181
  so refreshing projects doesn't clobber the write window. `ermes whoami` shows
162
182
  the current write status.
163
183
 
164
- The optional compatibility MCP remains fixed at 10 read-only tools. Mutations stay CLI-only so every
165
- agent write must carry explicit project scope and one-process `ERMES_ALLOW_WRITES=1` authorization.
184
+ Every agent write must carry explicit project scope and one-process `ERMES_ALLOW_WRITES=1`
185
+ authorization.
166
186
 
167
187
  ## Metrics
168
188
 
@@ -243,35 +263,12 @@ is legible.
243
263
 
244
264
  Full surface: `ermes --help`.
245
265
 
246
- ### Optional compatibility MCP (`ermes-mcp`)
247
-
248
- The 10-tool read-only MCP remains for temporary Claude Code rollback only. Normal sessions use the
249
- CLI so they can pin project scope per command without loading fixed schemas at startup.
250
- `install-mcp.sh --project=<name-or-id>` writes an explicit `ERMES_PROJECT`; `ermes use` doesn't
251
- retarget that process.
252
-
253
- The CLI covers every compatibility read and is the preferred path:
254
-
255
- | Tool | Purpose |
256
- |---|---|
257
- | `whoami` | Current profile + base URL (no API call) |
258
- | `list_campaigns(limit?, status?)` | Campaigns; use CLI `campaigns list --status` when filtering |
259
- | `get_campaign(campaign_id)` | One campaign |
260
- | `list_templates(limit?)` | Templates |
261
- | `get_template(template_id)` | One template |
262
- | `list_lists(limit?)` | Contact lists |
263
- | `list_journeys(limit?)` | Automation journeys |
264
- | `list_users(limit?, email?)` | Users; use CLI `users list --q` for exact search |
265
- | `get_user(user_id)` | One user; the CLI argument is the external ID |
266
- | `get_user_events(user_id, limit?)` | Recent events; the CLI argument is the external ID |
267
-
268
266
  ## Where in the wider stack
269
267
 
270
268
  Ermes is a CLI-first integration in the
271
269
  [`Martech-DM/martech-mcps`](https://github.com/Martech-DM/martech-mcps) registry.
272
270
  That repo's `ermes/README.md` and `ermes/skills/ermes-ops/SKILL.md` are the
273
- cross-fleet setup and operating runbooks. The compatibility MCP stays
274
- unregistered during normal operation.
271
+ cross-fleet setup and operating runbooks.
275
272
 
276
273
  ## CLI source history
277
274
 
@@ -1,10 +1,9 @@
1
- # Ermes CLI and optional compatibility MCP
1
+ # Ermes CLI
2
2
 
3
- This package gives Ermes operators a CLI-first surface for explicit-project API reads and guarded
4
- writes. It also retains a 10-tool read-only MCP for temporary compatibility, but normal agent
5
- sessions leave that server unregistered.
3
+ This package gives Ermes operators a CLI surface for explicit-project API reads and guarded writes.
6
4
 
7
- Version 1.0.0 (DEV-2261). Verified 2026-10-20.
5
+ Version 1.1.0. It removed the `ermes-mcp` compatibility server that 1.0.0 shipped (DEV-2326); a
6
+ machine that registered it must drop that registration (e.g. `claude mcp remove ermes`).
8
7
 
9
8
  ## Install the CLI
10
9
 
@@ -25,22 +24,44 @@ Releases are cut by pushing a tag `ermes-cli-vX.Y.Z` equal to `cli/pyproject.tom
25
24
  (`.github/workflows/publish-cli.yml` runs the tests, builds the wheel, publishes to PyPI and attaches
26
25
  it to a GitHub Release). `install-cli.sh` is idempotent and safe to re-run for upgrades.
27
26
 
28
- If a temporary rollback needs fixed typed schemas, register the compatibility MCP only for one
29
- explicit project:
27
+ ## Update the CLI
30
28
 
31
- ```bash
32
- ./cli/scripts/install-mcp.sh --project=<project-name-or-id>
29
+ **Automatic prompt.** In an interactive terminal, the CLI checks PyPI for a newer `ermes-cli` at
30
+ most once every 24 hours (2-second timeout, cached in `~/.ermes/update-check.json`; failed checks
31
+ are cached too, so an offline laptop is not slowed down). When one exists it asks before running
32
+ your command:
33
+
34
+ ```
35
+ ermes-cli 1.2.0 is available (you have 1.1.0). Update now? [Y]es / [n]ot now / [s]kip this version
33
36
  ```
34
37
 
35
- Remove it after the rollback window. Removal preserves Auth0 tokens, project keys, and every file
36
- under `~/.ermes`:
38
+ - **Yes** (Enter): runs `pipx upgrade ermes-cli`, checks the new version in a fresh process, then
39
+ re-runs your command on it.
40
+ - **Not now**: runs your command; asks again on the next run.
41
+ - **Skip this version**: never asks about that version again; a newer release asks once more.
42
+
43
+ A failed upgrade or Ctrl-C at the prompt prints one line and runs your command on the current
44
+ version. The check never runs with `--json`, when `CI` is set, when stdin/stdout/stderr is not a
45
+ TTY, before `ermes version` / `ermes update`, or with `ERMES_NO_UPDATE_CHECK=1`.
46
+
47
+ **Manual.**
37
48
 
38
49
  ```bash
39
- ./cli/scripts/install-mcp.sh --uninstall
50
+ ermes update # check, then ask before upgrading
51
+ ermes update --check # report only, never install
52
+ ermes update --yes # upgrade without asking
53
+ ermes --json update [--yes] # --json installs only with --yes
40
54
  ```
41
55
 
42
- Never run `ermes logout` as part of registration removal; logout deletes the Keychain session and
43
- `projects.yml`.
56
+ **Status values** (`--json` `status`): `up_to_date`, `ahead`, `update_available`, `updated`,
57
+ `lookup_failed` (exit 1), `update_failed` (exit 1). Installer output goes to stderr; stdout stays
58
+ one JSON document.
59
+
60
+ **Supported install.** Only the pipx install of the PyPI package (`install-cli.sh`, or
61
+ `pipx install ermes-cli`) upgrades itself. `pipx upgrade` keeps the old venv if pip fails, so a
62
+ failed upgrade never leaves you without a CLI. Editable checkouts (`git pull`), git or local-dir
63
+ installs, non-pipx environments and an `ermes` that is not the venv pipx manages get the manual
64
+ command instead of a prompt. Keychain tokens and `~/.ermes` are untouched.
44
65
 
45
66
  ## Auth — Auth0 device login (recommended)
46
67
 
@@ -131,8 +152,8 @@ State lives in `~/.ermes/state.yml` (chmod 600), separate from `projects.yml`
131
152
  so refreshing projects doesn't clobber the write window. `ermes whoami` shows
132
153
  the current write status.
133
154
 
134
- The optional compatibility MCP remains fixed at 10 read-only tools. Mutations stay CLI-only so every
135
- agent write must carry explicit project scope and one-process `ERMES_ALLOW_WRITES=1` authorization.
155
+ Every agent write must carry explicit project scope and one-process `ERMES_ALLOW_WRITES=1`
156
+ authorization.
136
157
 
137
158
  ## Metrics
138
159
 
@@ -213,35 +234,12 @@ is legible.
213
234
 
214
235
  Full surface: `ermes --help`.
215
236
 
216
- ### Optional compatibility MCP (`ermes-mcp`)
217
-
218
- The 10-tool read-only MCP remains for temporary Claude Code rollback only. Normal sessions use the
219
- CLI so they can pin project scope per command without loading fixed schemas at startup.
220
- `install-mcp.sh --project=<name-or-id>` writes an explicit `ERMES_PROJECT`; `ermes use` doesn't
221
- retarget that process.
222
-
223
- The CLI covers every compatibility read and is the preferred path:
224
-
225
- | Tool | Purpose |
226
- |---|---|
227
- | `whoami` | Current profile + base URL (no API call) |
228
- | `list_campaigns(limit?, status?)` | Campaigns; use CLI `campaigns list --status` when filtering |
229
- | `get_campaign(campaign_id)` | One campaign |
230
- | `list_templates(limit?)` | Templates |
231
- | `get_template(template_id)` | One template |
232
- | `list_lists(limit?)` | Contact lists |
233
- | `list_journeys(limit?)` | Automation journeys |
234
- | `list_users(limit?, email?)` | Users; use CLI `users list --q` for exact search |
235
- | `get_user(user_id)` | One user; the CLI argument is the external ID |
236
- | `get_user_events(user_id, limit?)` | Recent events; the CLI argument is the external ID |
237
-
238
237
  ## Where in the wider stack
239
238
 
240
239
  Ermes is a CLI-first integration in the
241
240
  [`Martech-DM/martech-mcps`](https://github.com/Martech-DM/martech-mcps) registry.
242
241
  That repo's `ermes/README.md` and `ermes/skills/ermes-ops/SKILL.md` are the
243
- cross-fleet setup and operating runbooks. The compatibility MCP stays
244
- unregistered during normal operation.
242
+ cross-fleet setup and operating runbooks.
245
243
 
246
244
  ## CLI source history
247
245
 
@@ -0,0 +1 @@
1
+ __version__ = "1.1.0"
@@ -80,7 +80,7 @@ def _hostname() -> str:
80
80
  def ensure_project_key(project_id: int) -> str:
81
81
  """Return the cached CLI key for `project_id`, minting it on first use.
82
82
 
83
- Raises `ConfigError` (never SystemExit — the MCP server calls this too)
83
+ Raises `ConfigError` (never SystemExit)
84
84
  when there is no Auth0 session or the server refuses.
85
85
  """
86
86
  payload = load_projects_file()
@@ -246,7 +246,7 @@ def use(
246
246
  ..., help="Project name or numeric id (from `ermes projects list`)"
247
247
  ),
248
248
  ) -> None:
249
- """Switch the active project. Affects every subsequent CLI + MCP call."""
249
+ """Switch the active project. Affects every subsequent CLI call."""
250
250
  projects = get_projects()
251
251
  if not projects:
252
252
  console.print("[yellow]No cached projects.[/yellow] Run `ermes login` first.")
@@ -304,7 +304,7 @@ def get_auth0_email() -> str | None:
304
304
 
305
305
 
306
306
  # ---------------------------------------------------------------------------
307
- # Profile resolution — what the CLI / MCP actually consume at runtime
307
+ # Profile resolution — what the CLI actually consumes at runtime
308
308
  # ---------------------------------------------------------------------------
309
309
 
310
310
 
@@ -32,11 +32,15 @@ atexit.register(_close_client)
32
32
 
33
33
  @app.callback()
34
34
  def main(
35
+ ctx: typer.Context,
35
36
  profile: Optional[str] = typer.Option(None, "--profile", "-p", help="Profile name"),
36
37
  use_json: bool = typer.Option(False, "--json", help="Output as JSON"),
37
38
  ):
38
39
  state.profile_name = profile
39
40
  state.use_json = use_json
41
+ from ermes_cli.update import maybe_prompt
42
+
43
+ maybe_prompt(use_json, ctx.invoked_subcommand)
40
44
 
41
45
 
42
46
  def get_client() -> ErmesClient:
@@ -61,6 +65,50 @@ def version():
61
65
  typer.echo(f"ermes-cli {__version__}")
62
66
 
63
67
 
68
+ @app.command()
69
+ def update(
70
+ check: bool = typer.Option(False, "--check", help="Only report whether a newer version exists."),
71
+ yes: bool = typer.Option(False, "--yes", "-y", help="Upgrade without asking."),
72
+ ):
73
+ """Check PyPI for a newer ermes-cli and upgrade the pipx install. Disable the daily prompt with ERMES_NO_UPDATE_CHECK=1."""
74
+ import json
75
+ import time
76
+
77
+ from ermes_cli import update as upd
78
+
79
+ def done(status: str, code: int = 0, **extra) -> None:
80
+ if state.use_json:
81
+ print(json.dumps({"status": status, "installed": __version__, **extra}))
82
+ raise typer.Exit(code=code)
83
+
84
+ try:
85
+ latest = upd.fetch_latest()
86
+ except upd.UpdateError as e:
87
+ typer.echo(f"Update check failed: {e}", err=True)
88
+ done("lookup_failed", 1, error=str(e))
89
+ upd.write_cache(checked_at=time.time(), latest=latest, error=None)
90
+
91
+ if not upd.is_newer(latest, __version__):
92
+ status = "ahead" if upd.is_newer(__version__, latest) else "up_to_date"
93
+ if not state.use_json:
94
+ typer.echo(f"ermes-cli {__version__} is {'newer than PyPI ' + latest if status == 'ahead' else 'up to date'}.")
95
+ done(status, latest=latest)
96
+ if check or (state.use_json and not yes):
97
+ if not state.use_json:
98
+ typer.echo(f"ermes-cli {latest} is available (you have {__version__}). Run `ermes update` to install.")
99
+ done("update_available", latest=latest)
100
+ if not yes and not typer.confirm(f"Upgrade ermes-cli {__version__} -> {latest}?", default=True, err=True):
101
+ done("update_available", latest=latest)
102
+ try:
103
+ installed = upd.upgrade(upd.detect_install(), latest)
104
+ except upd.UpdateError as e:
105
+ typer.echo(f"Update failed: {e}", err=True)
106
+ done("update_failed", 1, latest=latest, error=str(e))
107
+ if not state.use_json:
108
+ typer.echo(f"Updated ermes-cli to {installed}.")
109
+ done("updated", latest=latest)
110
+
111
+
64
112
  @app.command()
65
113
  def whoami(
66
114
  check: bool = typer.Option(
@@ -0,0 +1,225 @@
1
+ """Newer-version check, upgrade prompt and `ermes update` (DEV-1972).
2
+
3
+ ermes-cli ships to PyPI on `ermes-cli-v*` tags (DEV-2262), so "latest" is PyPI's current
4
+ `ermes-cli` release: a public JSON endpoint, no GitHub or Ermes credentials. Interactive runs check
5
+ at most once a day and ask before upgrading, like Codex / Claude Code. Only the pipx install made by
6
+ `install-cli.sh` is upgraded in place (`pipx upgrade`); anything else gets the manual command.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import re
14
+ import shutil
15
+ import subprocess
16
+ import sys
17
+ import time
18
+ from dataclasses import dataclass
19
+ from importlib import metadata
20
+ from pathlib import Path
21
+
22
+ import httpx
23
+ import typer
24
+
25
+ from ermes_cli import __version__
26
+
27
+ PACKAGE = "ermes-cli"
28
+ PYPI_URL = f"https://pypi.org/pypi/{PACKAGE}/json"
29
+
30
+ CACHE_FILE = "update-check.json"
31
+ CHECK_INTERVAL_S = 24 * 3600
32
+ AUTO_TIMEOUT_S = 2.0
33
+ EXPLICIT_TIMEOUT_S = 10.0
34
+ OPT_OUT_ENV = "ERMES_NO_UPDATE_CHECK"
35
+ # Never prompt before these: `version` must stay a pure read, `update` does its own check.
36
+ SKIP_COMMANDS = {"update", "version"}
37
+
38
+ MANUAL_INSTALL = f"Upgrade manually: `pipx upgrade {PACKAGE}`, or re-run cli/scripts/install-cli.sh."
39
+
40
+ _SEMVER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")
41
+
42
+
43
+ class UpdateError(Exception):
44
+ """Lookup, environment or install failure. The message is shown to the operator."""
45
+
46
+
47
+ def parse_version(value: str | None) -> tuple[int, int, int] | None:
48
+ """MAJOR.MINOR.PATCH -> int tuple; anything else (pre-release, junk, None) -> None."""
49
+ m = _SEMVER_RE.match((value or "").strip())
50
+ return (int(m[1]), int(m[2]), int(m[3])) if m else None
51
+
52
+
53
+ def is_newer(latest: str | None, current: str | None) -> bool:
54
+ """True only when both parse and latest > current. Malformed never counts as newer."""
55
+ a, b = parse_version(latest), parse_version(current)
56
+ return a is not None and b is not None and a > b
57
+
58
+
59
+ def fetch_latest(timeout: float = EXPLICIT_TIMEOUT_S) -> str:
60
+ """PyPI's current release. `info.version` already excludes yanked and pre-releases."""
61
+ try:
62
+ r = httpx.get(PYPI_URL, timeout=timeout, follow_redirects=True)
63
+ r.raise_for_status()
64
+ version = r.json()["info"]["version"]
65
+ except httpx.HTTPStatusError as e:
66
+ raise UpdateError(f"PyPI lookup failed: HTTP {e.response.status_code}") from e
67
+ except httpx.HTTPError as e:
68
+ raise UpdateError(f"PyPI lookup failed: {e.__class__.__name__}") from e
69
+ except (ValueError, KeyError, TypeError) as e:
70
+ raise UpdateError("PyPI returned an unexpected response") from e
71
+ if parse_version(version) is None:
72
+ raise UpdateError(f"PyPI's latest {PACKAGE} is not MAJOR.MINOR.PATCH: {version!r}")
73
+ return version
74
+
75
+
76
+ # ---------------------------------------------------------------- cache
77
+
78
+
79
+ def _cache_path() -> Path:
80
+ from ermes_cli.config import _config_dir
81
+
82
+ return _config_dir() / CACHE_FILE
83
+
84
+
85
+ def read_cache() -> dict:
86
+ try:
87
+ data = json.loads(_cache_path().read_text())
88
+ return data if isinstance(data, dict) else {}
89
+ except (OSError, ValueError):
90
+ return {}
91
+
92
+
93
+ def write_cache(**fields) -> None:
94
+ """Merge into the cache. Failed lookups are cached too, so an offline laptop backs off."""
95
+ try:
96
+ path = _cache_path()
97
+ path.parent.mkdir(parents=True, exist_ok=True)
98
+ path.write_text(json.dumps({**read_cache(), **fields}))
99
+ except OSError:
100
+ pass
101
+
102
+
103
+ # ---------------------------------------------------------------- install detection + upgrade
104
+
105
+
106
+ @dataclass
107
+ class Install:
108
+ venv: Path
109
+ pipx: str
110
+
111
+
112
+ def detect_install() -> Install:
113
+ """Prove the RUNNING CLI is the pipx venv `pipx upgrade ermes-cli` would touch, else refuse."""
114
+ try:
115
+ raw = metadata.distribution(PACKAGE).read_text("direct_url.json")
116
+ except metadata.PackageNotFoundError as e:
117
+ raise UpdateError(f"{PACKAGE} is not installed as a package. {MANUAL_INSTALL}") from e
118
+ if raw: # PyPI installs carry no direct_url.json; git, local-dir and editable installs do.
119
+ du = json.loads(raw)
120
+ if du.get("dir_info", {}).get("editable"):
121
+ raise UpdateError(f"this ermes runs from an editable checkout ({du.get('url')}); update it with git pull.")
122
+ raise UpdateError(f"this ermes was installed from {du.get('url')}, not PyPI. {MANUAL_INSTALL}")
123
+
124
+ venv = Path(sys.prefix)
125
+ if not (venv / "pipx_metadata.json").is_file():
126
+ raise UpdateError(f"this ermes is not a pipx install ({venv}). {MANUAL_INSTALL}")
127
+ pipx = shutil.which("pipx")
128
+ if not pipx:
129
+ raise UpdateError(f"pipx is not on PATH. {MANUAL_INSTALL}")
130
+ out = subprocess.run(
131
+ [pipx, "environment", "--value", "PIPX_LOCAL_VENVS"],
132
+ capture_output=True, text=True, timeout=30, check=False,
133
+ )
134
+ expected = Path(out.stdout.strip() or Path.home() / ".local/pipx/venvs") / PACKAGE
135
+ if not expected.exists() or not expected.samefile(venv):
136
+ raise UpdateError(
137
+ f"the running ermes ({venv}) is not the venv pipx manages ({expected}); "
138
+ "refusing to update a different install."
139
+ )
140
+ return Install(venv=venv, pipx=pipx)
141
+
142
+
143
+ def upgrade(inst: Install, target: str) -> str:
144
+ """`pipx upgrade`, then check a FRESH process reports `target`; raise otherwise.
145
+
146
+ `pipx upgrade` keeps the old venv when pip fails, so a failed upgrade never leaves the operator
147
+ without a CLI (the uninstall-first trap DEV-2070 describes for install-cli.sh).
148
+ """
149
+ # Installer output goes to stderr so `--json` stdout stays one parseable document.
150
+ subprocess.run([inst.pipx, "upgrade", PACKAGE], stdout=sys.stderr, stderr=sys.stderr, check=False)
151
+ out = subprocess.run(
152
+ [str(inst.venv / "bin" / "ermes"), "--json", "version"],
153
+ capture_output=True, text=True, timeout=60, check=False,
154
+ env={**os.environ, OPT_OUT_ENV: "1"},
155
+ )
156
+ try:
157
+ installed = json.loads(out.stdout)["version"]
158
+ except (ValueError, KeyError, TypeError) as e:
159
+ raise UpdateError(
160
+ f"the upgraded ermes did not report a version (exit {out.returncode}). {MANUAL_INSTALL}"
161
+ ) from e
162
+ if installed != target:
163
+ raise UpdateError(f"upgrade left {installed} installed, expected {target}. {MANUAL_INSTALL}")
164
+ return installed
165
+
166
+
167
+ # ---------------------------------------------------------------- automatic prompt
168
+
169
+
170
+ def auto_check_enabled(use_json: bool, command: str | None) -> bool:
171
+ """Interactive humans only: never in --json, CI, pipes, scripts, or with the opt-out set."""
172
+ if use_json or command in SKIP_COMMANDS or os.environ.get(OPT_OUT_ENV) or os.environ.get("CI"):
173
+ return False
174
+ return sys.stdin.isatty() and sys.stdout.isatty() and sys.stderr.isatty()
175
+
176
+
177
+ def _cached_latest() -> str | None:
178
+ cache = read_cache()
179
+ if time.time() - float(cache.get("checked_at") or 0) < CHECK_INTERVAL_S:
180
+ return cache.get("latest")
181
+ try:
182
+ latest = fetch_latest(timeout=AUTO_TIMEOUT_S)
183
+ except UpdateError as e:
184
+ write_cache(checked_at=time.time(), latest=None, error=str(e))
185
+ return None
186
+ write_cache(checked_at=time.time(), latest=latest, error=None)
187
+ return latest
188
+
189
+
190
+ def maybe_prompt(use_json: bool, command: str | None) -> None:
191
+ """Ask (at most daily) whether to upgrade; on yes, upgrade and re-run the command on the new code.
192
+
193
+ Never raises: a broken check must not break the command the operator actually asked for.
194
+ """
195
+ try:
196
+ if not auto_check_enabled(use_json, command):
197
+ return
198
+ latest = _cached_latest()
199
+ if not is_newer(latest, __version__) or read_cache().get("skipped") == latest:
200
+ return
201
+ try:
202
+ inst = detect_install()
203
+ except UpdateError as e:
204
+ typer.echo(f"ermes-cli {latest} is available (you have {__version__}). {e}", err=True)
205
+ return
206
+ # typer >= 0.24 vendors click, so there is no Choice type: anything but y/s means "not now".
207
+ choice = typer.prompt(
208
+ f"ermes-cli {latest} is available (you have {__version__}). "
209
+ "Update now? [Y]es / [n]ot now / [s]kip this version",
210
+ default="y", show_default=False, err=True,
211
+ ).strip().lower()[:1]
212
+ if choice == "s":
213
+ write_cache(skipped=latest)
214
+ return
215
+ if choice != "y":
216
+ return
217
+ upgrade(inst, latest)
218
+ typer.echo(f"Updated ermes-cli to {latest}. Re-running your command...", err=True)
219
+ os.execv(str(inst.venv / "bin" / "ermes"), ["ermes", *sys.argv[1:]])
220
+ except typer.Abort: # Ctrl-C / EOF at the prompt: carry on with the command
221
+ typer.echo("", err=True)
222
+ except UpdateError as e:
223
+ typer.echo(f"Update failed, continuing on {__version__}: {e}", err=True)
224
+ except Exception: # noqa: BLE001 - the check must never break the real command
225
+ return
@@ -1,4 +1,4 @@
1
- """Write-mode guard: block mutating CLI/MCP calls by default.
1
+ """Write-mode guard: block mutating CLI calls by default.
2
2
 
3
3
  Why: most CLI sessions are read-only investigation ("what campaigns ran
4
4
  last week?"). The mutating commands (`campaigns create`, `lists delete`,
@@ -20,10 +20,6 @@ Behavior:
20
20
  State lives in `~/.ermes/state.yml` (chmod 600). It's deliberately
21
21
  separate from projects.yml so refreshing projects doesn't accidentally
22
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
23
  """
28
24
 
29
25
  from __future__ import annotations
@@ -5,8 +5,8 @@ build-backend = "hatchling.build"
5
5
  [project]
6
6
  name = "ermes-cli"
7
7
  # Keep in lockstep with ermes_cli/__init__.py::__version__ — tests/test_version.py fails if they drift.
8
- version = "1.0.0"
9
- description = "CLI-first access to Ermes marketing automation, with Auth0 multi-project auth and an optional compatibility MCP."
8
+ version = "1.1.0"
9
+ description = "CLI-first access to Ermes marketing automation with Auth0 multi-project auth."
10
10
  readme = "README.md"
11
11
  requires-python = ">=3.10"
12
12
  license = { text = "Proprietary" }
@@ -25,7 +25,6 @@ dependencies = [
25
25
  "httpx>=0.28",
26
26
  "rich>=14.0",
27
27
  "pyyaml>=6.0",
28
- "fastmcp>=2.0",
29
28
  "keyring>=24.0",
30
29
  "requests>=2.31",
31
30
  # zoneinfo data — guarantees `projects create --timezone` validation works on
@@ -36,7 +35,6 @@ dependencies = [
36
35
 
37
36
  [project.scripts]
38
37
  ermes = "ermes_cli.main:app"
39
- ermes-mcp = "ermes_cli.mcp_server:main"
40
38
 
41
39
  [project.urls]
42
40
  Homepage = "https://ermes.publinker.com"
@@ -55,6 +53,3 @@ exclude = ["uv.lock"]
55
53
 
56
54
  [tool.hatch.build.targets.wheel]
57
55
  packages = ["ermes_cli"]
58
-
59
- [tool.hatch.build.targets.wheel.force-include]
60
- "ermes_cli/routes.yaml" = "ermes_cli/routes.yaml"
@@ -1,7 +1,5 @@
1
1
  #!/usr/bin/env bash
2
- # One-shot installer for the CLI-first Ermes package.
3
- # The package includes an optional compatibility MCP, but this script never
4
- # registers it.
2
+ # One-shot installer for the Ermes CLI.
5
3
  #
6
4
  # Usage:
7
5
  # ./cli/scripts/install-cli.sh # install latest release from PyPI + verify
@@ -121,15 +119,12 @@ fi
121
119
  step "Verifying"
122
120
  ERMES_VENV="$(ermes_venv_dir)"
123
121
  ERMES_BIN="$ERMES_VENV/bin/ermes"
124
- ERMES_MCP_BIN="$ERMES_VENV/bin/ermes-mcp"
125
122
 
126
123
  [ -x "$ERMES_BIN" ] || die "ermes binary missing from pipx venv $ERMES_VENV."
127
- [ -x "$ERMES_MCP_BIN" ] || die "ermes-mcp binary missing from pipx venv $ERMES_VENV."
128
124
 
129
125
  # Typer apps expose a `version` subcommand (no --version flag).
130
126
  VERSION_OUTPUT="$("$ERMES_BIN" version 2>&1)" || die "ermes version failed: $VERSION_OUTPUT"
131
127
  ok "$VERSION_OUTPUT"
132
- ok "ermes-mcp at $ERMES_MCP_BIN"
133
128
 
134
129
  PATH_ERMES="$(command -v ermes || true)"
135
130
  if [ -z "$PATH_ERMES" ]; then
@@ -162,10 +157,5 @@ else
162
157
  ermes whoami --check proves the selected key against the API. CI or a box
163
158
  without a browser: ERMES_API_KEY=sk_... ermes --json campaigns list.
164
159
 
165
- The optional compatibility MCP is not registered by default. If a temporary
166
- rollback needs its 10 fixed schemas, pin its project at registration:
167
-
168
- ./cli/scripts/install-mcp.sh --project=<project-name-or-id>
169
-
170
160
  EOF
171
161
  fi