memoryhub-cli 0.3.0__tar.gz → 0.4.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.
@@ -34,3 +34,4 @@ seed-clients.json
34
34
 
35
35
  NEXT_SESSION_old.md
36
36
  NEXT_SESSION.md
37
+ planning/rfe_draft.md
@@ -2,6 +2,13 @@
2
2
 
3
3
  All notable changes to the `memoryhub-cli` package.
4
4
 
5
+ ## [0.4.0] — 2026-04-14
6
+
7
+ - **Admin subcommands (#186)**: Added `memoryhub admin` command group with
8
+ `create-agent`, `list-agents`, `rotate-secret`, and `disable-agent` for
9
+ self-serve agent provisioning via the auth service REST API.
10
+ - **`--version` flag**: `memoryhub --version` now prints the installed version.
11
+
5
12
  ## [0.3.0] — 2026-04-09
6
13
 
7
14
  - **Campaign & domain parameter support (#164)**: Added `--project-id` flag to
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memoryhub-cli
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: CLI client for MemoryHub — centralized, governed memory for AI agents
5
5
  Project-URL: Homepage, https://github.com/redhat-ai-americas/memory-hub
6
6
  Project-URL: Repository, https://github.com/redhat-ai-americas/memory-hub
@@ -18,6 +18,7 @@ Classifier: Programming Language :: Python :: 3.11
18
18
  Classifier: Programming Language :: Python :: 3.12
19
19
  Classifier: Programming Language :: Python :: 3.13
20
20
  Requires-Python: >=3.10
21
+ Requires-Dist: httpx>=0.27
21
22
  Requires-Dist: memoryhub>=0.3.0
22
23
  Requires-Dist: pyyaml>=6.0
23
24
  Requires-Dist: rich>=13.0
@@ -59,6 +60,12 @@ memoryhub write "Use vLLM for embeddings" --project-id my-project --domain ML
59
60
  # Set up project-level memory loading
60
61
  memoryhub config init
61
62
  memoryhub config regenerate
63
+
64
+ # Admin: provision and manage agents
65
+ memoryhub admin create-agent my-agent --scopes user,project
66
+ memoryhub admin list-agents
67
+ memoryhub admin rotate-secret my-agent
68
+ memoryhub admin disable-agent my-agent
62
69
  ```
63
70
 
64
71
  The `--project-id` flag enables campaign-scoped memory access. When your project is enrolled in campaigns via `.memoryhub.yaml`, the CLI auto-loads the project identifier from config, so you can omit the flag in most cases. Use `--domain` to tag writes or boost domain-matching results in search.
@@ -30,6 +30,12 @@ memoryhub write "Use vLLM for embeddings" --project-id my-project --domain ML
30
30
  # Set up project-level memory loading
31
31
  memoryhub config init
32
32
  memoryhub config regenerate
33
+
34
+ # Admin: provision and manage agents
35
+ memoryhub admin create-agent my-agent --scopes user,project
36
+ memoryhub admin list-agents
37
+ memoryhub admin rotate-secret my-agent
38
+ memoryhub admin disable-agent my-agent
33
39
  ```
34
40
 
35
41
  The `--project-id` flag enables campaign-scoped memory access. When your project is enrolled in campaigns via `.memoryhub.yaml`, the CLI auto-loads the project identifier from config, so you can omit the flag in most cases. Use `--domain` to tag writes or boost domain-matching results in search.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "memoryhub-cli"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  description = "CLI client for MemoryHub — centralized, governed memory for AI agents"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -18,6 +18,7 @@ dependencies = [
18
18
  "typer[all]>=0.15",
19
19
  "rich>=13.0",
20
20
  "pyyaml>=6.0",
21
+ "httpx>=0.27",
21
22
  ]
22
23
  classifiers = [
23
24
  "Development Status :: 3 - Alpha",
@@ -1,3 +1,3 @@
1
1
  """MemoryHub CLI client."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.4.0"
@@ -0,0 +1,307 @@
1
+ """Admin subcommands for managing agents and OAuth clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import os
8
+
9
+ import httpx
10
+ import typer
11
+ from rich.console import Console
12
+ from rich.table import Table
13
+
14
+ from memoryhub_cli.config import CONFIG_DIR, load_config
15
+
16
+ admin_app = typer.Typer(
17
+ name="admin",
18
+ help="Manage agents and OAuth clients.",
19
+ no_args_is_help=True,
20
+ )
21
+ console = Console()
22
+ err_console = Console(stderr=True)
23
+
24
+
25
+ def _run(coro):
26
+ """Run an async coroutine."""
27
+ return asyncio.run(coro)
28
+
29
+
30
+ def _get_admin_key() -> str:
31
+ """Resolve the admin key from env var or config file."""
32
+ key = os.environ.get("MEMORYHUB_ADMIN_KEY")
33
+ if key:
34
+ return key
35
+
36
+ config = load_config()
37
+ key = config.get("admin_key")
38
+ if key:
39
+ return key
40
+
41
+ err_console.print(
42
+ "[red]No admin key found.[/red]\n"
43
+ "Set MEMORYHUB_ADMIN_KEY or add 'admin_key' to "
44
+ "~/.config/memoryhub/config.json."
45
+ )
46
+ raise typer.Exit(1)
47
+
48
+
49
+ def _get_auth_url() -> str:
50
+ """Resolve the auth service base URL (without trailing slash)."""
51
+ url = os.environ.get("MEMORYHUB_AUTH_URL")
52
+ if url:
53
+ return url.rstrip("/")
54
+
55
+ config = load_config()
56
+ url = config.get("auth_url")
57
+ if url:
58
+ return url.rstrip("/")
59
+
60
+ url = config.get("url")
61
+ if url:
62
+ # Fall back to base MCP URL with /admin stripped — assume auth
63
+ # is co-located at the base URL.
64
+ return url.rstrip("/")
65
+
66
+ err_console.print(
67
+ "[red]No auth URL found.[/red]\n"
68
+ "Set MEMORYHUB_AUTH_URL, or add 'auth_url' to "
69
+ "~/.config/memoryhub/config.json."
70
+ )
71
+ raise typer.Exit(1)
72
+
73
+
74
+ def _admin_headers(admin_key: str) -> dict[str, str]:
75
+ return {"X-Admin-Key": admin_key}
76
+
77
+
78
+ # ── Commands ─────────────────────────────────────────────────────────────────
79
+
80
+
81
+ @admin_app.command("create-agent")
82
+ def create_agent(
83
+ name: str = typer.Argument(..., help="Agent name (used as client_id and client_name)"),
84
+ scopes: str = typer.Option(
85
+ "user,project",
86
+ "--scopes",
87
+ help="Comma-separated default scopes",
88
+ ),
89
+ project_id: str | None = typer.Option(
90
+ None, "--project-id", "-p", help="Project ID to associate with the agent",
91
+ ),
92
+ tenant_id: str = typer.Option(
93
+ "default", "--tenant-id", "-t", help="Tenant ID",
94
+ ),
95
+ write_config: bool = typer.Option(
96
+ False,
97
+ "--write-config",
98
+ help="Write the client_secret to ~/.config/memoryhub/api-key",
99
+ ),
100
+ json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
101
+ ):
102
+ """Create a new agent (OAuth client).
103
+
104
+ The client_secret is shown only once. Save it immediately.
105
+ """
106
+ admin_key = _get_admin_key()
107
+ auth_url = _get_auth_url()
108
+ scope_list = [s.strip() for s in scopes.split(",") if s.strip()]
109
+
110
+ body = {
111
+ "client_id": name,
112
+ "client_name": name,
113
+ "default_scopes": scope_list,
114
+ "tenant_id": tenant_id,
115
+ }
116
+
117
+ async def _do():
118
+ async with httpx.AsyncClient() as client:
119
+ resp = await client.post(
120
+ f"{auth_url}/admin/clients",
121
+ json=body,
122
+ headers=_admin_headers(admin_key),
123
+ timeout=30,
124
+ )
125
+ resp.raise_for_status()
126
+ return resp.json()
127
+
128
+ try:
129
+ data = _run(_do())
130
+ except httpx.HTTPStatusError as exc:
131
+ _handle_http_error(exc)
132
+ return
133
+
134
+ if json_output:
135
+ console.print_json(json.dumps(data))
136
+ return
137
+
138
+ console.print("[green]Agent created successfully.[/green]\n")
139
+ console.print(f" Client ID: [bold]{data['client_id']}[/bold]")
140
+ console.print(f" Client Name: {data['client_name']}")
141
+ console.print(f" Tenant: {data['tenant_id']}")
142
+ console.print(f" Scopes: {', '.join(data['default_scopes'])}")
143
+ console.print(f" Active: {data['active']}")
144
+ console.print()
145
+ console.print(
146
+ f" [yellow bold]Client Secret: {data['client_secret']}[/yellow bold]"
147
+ )
148
+ console.print(
149
+ "\n [dim]Save this secret now. It will not be shown again.[/dim]"
150
+ )
151
+
152
+ if write_config:
153
+ api_key_path = CONFIG_DIR / "api-key"
154
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
155
+ api_key_path.write_text(data["client_secret"])
156
+ api_key_path.chmod(0o600)
157
+ console.print(f"\n [green]Secret written to {api_key_path}[/green]")
158
+
159
+
160
+ @admin_app.command("list-agents")
161
+ def list_agents(
162
+ json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
163
+ ):
164
+ """List all registered agents (OAuth clients)."""
165
+ admin_key = _get_admin_key()
166
+ auth_url = _get_auth_url()
167
+
168
+ async def _do():
169
+ async with httpx.AsyncClient() as client:
170
+ resp = await client.get(
171
+ f"{auth_url}/admin/clients",
172
+ headers=_admin_headers(admin_key),
173
+ timeout=30,
174
+ )
175
+ resp.raise_for_status()
176
+ return resp.json()
177
+
178
+ try:
179
+ data = _run(_do())
180
+ except httpx.HTTPStatusError as exc:
181
+ _handle_http_error(exc)
182
+ return
183
+
184
+ if json_output:
185
+ console.print_json(json.dumps(data))
186
+ return
187
+
188
+ if not data:
189
+ console.print("[dim]No agents registered.[/dim]")
190
+ return
191
+
192
+ table = Table(title="Registered Agents")
193
+ table.add_column("Client ID", style="bold")
194
+ table.add_column("Name")
195
+ table.add_column("Active", justify="center")
196
+ table.add_column("Scopes")
197
+ table.add_column("Created", style="dim")
198
+
199
+ for agent in data:
200
+ active = "[green]Yes[/green]" if agent.get("active") else "[red]No[/red]"
201
+ scopes = ", ".join(agent.get("default_scopes", []))
202
+ created = str(agent.get("created_at", "-"))[:19]
203
+ table.add_row(
204
+ agent["client_id"],
205
+ agent.get("client_name", "-"),
206
+ active,
207
+ scopes,
208
+ created,
209
+ )
210
+
211
+ console.print(table)
212
+
213
+
214
+ @admin_app.command("rotate-secret")
215
+ def rotate_secret(
216
+ client_id: str = typer.Argument(..., help="Client ID of the agent"),
217
+ json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
218
+ ):
219
+ """Rotate the client secret for an agent.
220
+
221
+ The new secret is shown only once. Save it immediately.
222
+ """
223
+ admin_key = _get_admin_key()
224
+ auth_url = _get_auth_url()
225
+
226
+ async def _do():
227
+ async with httpx.AsyncClient() as client:
228
+ resp = await client.post(
229
+ f"{auth_url}/admin/clients/{client_id}/rotate-secret",
230
+ headers=_admin_headers(admin_key),
231
+ timeout=30,
232
+ )
233
+ resp.raise_for_status()
234
+ return resp.json()
235
+
236
+ try:
237
+ data = _run(_do())
238
+ except httpx.HTTPStatusError as exc:
239
+ _handle_http_error(exc)
240
+ return
241
+
242
+ if json_output:
243
+ console.print_json(json.dumps(data))
244
+ return
245
+
246
+ console.print(f"[green]Secret rotated for {data['client_id']}.[/green]\n")
247
+ console.print(
248
+ f" [yellow bold]New Secret: {data['client_secret']}[/yellow bold]"
249
+ )
250
+ console.print(
251
+ "\n [dim]Save this secret now. It will not be shown again.[/dim]"
252
+ )
253
+
254
+
255
+ @admin_app.command("disable-agent")
256
+ def disable_agent(
257
+ client_id: str = typer.Argument(..., help="Client ID of the agent to disable"),
258
+ ):
259
+ """Disable an agent (set active=false)."""
260
+ admin_key = _get_admin_key()
261
+ auth_url = _get_auth_url()
262
+
263
+ async def _do():
264
+ async with httpx.AsyncClient() as client:
265
+ resp = await client.patch(
266
+ f"{auth_url}/admin/clients/{client_id}",
267
+ json={"active": False},
268
+ headers=_admin_headers(admin_key),
269
+ timeout=30,
270
+ )
271
+ resp.raise_for_status()
272
+ return resp.json()
273
+
274
+ try:
275
+ data = _run(_do())
276
+ except httpx.HTTPStatusError as exc:
277
+ _handle_http_error(exc)
278
+ return
279
+
280
+ console.print(
281
+ f"[green]Agent '{data['client_id']}' has been disabled.[/green]"
282
+ )
283
+
284
+
285
+ # ── Helpers ──────────────────────────────────────────────────────────────────
286
+
287
+
288
+ def _handle_http_error(exc: httpx.HTTPStatusError) -> None:
289
+ """Print a user-friendly error for HTTP failures."""
290
+ status = exc.response.status_code
291
+ try:
292
+ detail = exc.response.json().get("detail", exc.response.text)
293
+ except Exception:
294
+ detail = exc.response.text
295
+
296
+ if status == 401:
297
+ err_console.print(
298
+ "[red]Authentication failed.[/red] Check your admin key."
299
+ )
300
+ elif status == 404:
301
+ err_console.print(f"[red]Not found:[/red] {detail}")
302
+ elif status == 409:
303
+ err_console.print(f"[red]Conflict:[/red] {detail}")
304
+ else:
305
+ err_console.print(f"[red]HTTP {status}:[/red] {detail}")
306
+
307
+ raise typer.Exit(1)
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import asyncio
6
6
  import sys
7
+ from importlib.metadata import version as pkg_version
7
8
  from pathlib import Path
8
9
 
9
10
  import typer
@@ -11,6 +12,7 @@ from memoryhub import CONFIG_FILENAME, ConfigError, load_project_config
11
12
  from rich.console import Console
12
13
  from rich.table import Table
13
14
 
15
+ from memoryhub_cli.admin import admin_app
14
16
  from memoryhub_cli.config import get_connection_params, save_config
15
17
  from memoryhub_cli.project_config import (
16
18
  InitChoices,
@@ -22,17 +24,36 @@ from memoryhub_cli.project_config import (
22
24
  write_init_files,
23
25
  )
24
26
 
27
+
28
+ def _version_callback(value: bool) -> None:
29
+ if value:
30
+ print(f"memoryhub {pkg_version('memoryhub-cli')}")
31
+ raise typer.Exit()
32
+
33
+
25
34
  app = typer.Typer(
26
35
  name="memoryhub",
27
- help="CLI client for MemoryHub — centralized, governed memory for AI agents.",
28
36
  no_args_is_help=True,
29
37
  )
38
+
39
+
40
+ @app.callback()
41
+ def main(
42
+ version: bool = typer.Option(
43
+ False, "--version", "-V", callback=_version_callback, is_eager=True,
44
+ help="Show version and exit.",
45
+ ),
46
+ ) -> None:
47
+ """CLI client for MemoryHub — centralized, governed memory for AI agents."""
48
+
49
+
30
50
  config_app = typer.Typer(
31
51
  name="config",
32
52
  help="Manage project-level MemoryHub configuration (.memoryhub.yaml).",
33
53
  no_args_is_help=True,
34
54
  )
35
55
  app.add_typer(config_app, name="config")
56
+ app.add_typer(admin_app, name="admin", help="Manage agents and OAuth clients")
36
57
  console = Console()
37
58
  err_console = Console(stderr=True)
38
59
 
@@ -0,0 +1,329 @@
1
+ """Tests for memoryhub_cli.admin — admin subcommands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from unittest.mock import AsyncMock, patch
7
+
8
+ import httpx
9
+ from typer.testing import CliRunner
10
+
11
+ from memoryhub_cli.main import app
12
+
13
+ runner = CliRunner()
14
+
15
+ # ── Fixtures ─────────────────────────────────────────────────────────────────
16
+
17
+ SAMPLE_CLIENT = {
18
+ "client_id": "test-agent",
19
+ "client_name": "test-agent",
20
+ "identity_type": "user",
21
+ "tenant_id": "default",
22
+ "default_scopes": ["user", "project"],
23
+ "redirect_uris": None,
24
+ "public": False,
25
+ "active": True,
26
+ "created_at": "2026-04-14T12:00:00",
27
+ "updated_at": "2026-04-14T12:00:00",
28
+ }
29
+
30
+ SAMPLE_CREATED = {**SAMPLE_CLIENT, "client_secret": "super-secret-value"}
31
+
32
+ SAMPLE_ROTATED = {
33
+ "client_id": "test-agent",
34
+ "client_secret": "new-secret-value",
35
+ }
36
+
37
+
38
+ def _mock_response(data, status_code=200):
39
+ """Build a mock httpx.Response."""
40
+ resp = httpx.Response(
41
+ status_code=status_code,
42
+ json=data,
43
+ request=httpx.Request("GET", "http://test"),
44
+ )
45
+ return resp
46
+
47
+
48
+ def _mock_error_response(status_code, detail="error"):
49
+ """Build a mock httpx.Response that triggers raise_for_status."""
50
+ resp = httpx.Response(
51
+ status_code=status_code,
52
+ json={"detail": detail},
53
+ request=httpx.Request("GET", "http://test"),
54
+ )
55
+ return resp
56
+
57
+
58
+ def _env_with_admin_key(**extra):
59
+ """Base env vars for tests that need an admin key and auth URL."""
60
+ env = {
61
+ "MEMORYHUB_ADMIN_KEY": "test-admin-key",
62
+ "MEMORYHUB_AUTH_URL": "http://auth.test",
63
+ }
64
+ env.update(extra)
65
+ return env
66
+
67
+
68
+ # ── create-agent ─────────────────────────────────────────────────────────────
69
+
70
+
71
+ class TestCreateAgent:
72
+ def test_happy_path(self):
73
+ mock_client = AsyncMock()
74
+ mock_client.__aenter__ = AsyncMock(return_value=mock_client)
75
+ mock_client.__aexit__ = AsyncMock(return_value=False)
76
+ mock_client.post = AsyncMock(return_value=_mock_response(SAMPLE_CREATED, 201))
77
+
78
+ with patch.dict("os.environ", _env_with_admin_key(), clear=False):
79
+ with patch("memoryhub_cli.admin.httpx.AsyncClient", return_value=mock_client):
80
+ result = runner.invoke(app, ["admin", "create-agent", "test-agent"])
81
+
82
+ assert result.exit_code == 0
83
+ assert "test-agent" in result.output
84
+ assert "super-secret-value" in result.output
85
+ assert "Save this secret" in result.output
86
+
87
+ def test_json_output(self):
88
+ mock_client = AsyncMock()
89
+ mock_client.__aenter__ = AsyncMock(return_value=mock_client)
90
+ mock_client.__aexit__ = AsyncMock(return_value=False)
91
+ mock_client.post = AsyncMock(return_value=_mock_response(SAMPLE_CREATED, 201))
92
+
93
+ with patch.dict("os.environ", _env_with_admin_key(), clear=False):
94
+ with patch("memoryhub_cli.admin.httpx.AsyncClient", return_value=mock_client):
95
+ result = runner.invoke(
96
+ app, ["admin", "create-agent", "test-agent", "--json"]
97
+ )
98
+
99
+ assert result.exit_code == 0
100
+ parsed = json.loads(result.output)
101
+ assert parsed["client_id"] == "test-agent"
102
+ assert parsed["client_secret"] == "super-secret-value"
103
+
104
+ def test_write_config(self, tmp_path):
105
+ mock_client = AsyncMock()
106
+ mock_client.__aenter__ = AsyncMock(return_value=mock_client)
107
+ mock_client.__aexit__ = AsyncMock(return_value=False)
108
+ mock_client.post = AsyncMock(return_value=_mock_response(SAMPLE_CREATED, 201))
109
+
110
+ with patch.dict("os.environ", _env_with_admin_key(), clear=False):
111
+ with patch("memoryhub_cli.admin.httpx.AsyncClient", return_value=mock_client):
112
+ with patch("memoryhub_cli.admin.CONFIG_DIR", tmp_path):
113
+ result = runner.invoke(
114
+ app,
115
+ ["admin", "create-agent", "test-agent", "--write-config"],
116
+ )
117
+
118
+ assert result.exit_code == 0
119
+ api_key_file = tmp_path / "api-key"
120
+ assert api_key_file.exists()
121
+ assert api_key_file.read_text() == "super-secret-value"
122
+
123
+ def test_conflict_409(self):
124
+ error_resp = _mock_error_response(
125
+ 409, "Client with client_id 'test-agent' already exists"
126
+ )
127
+ mock_client = AsyncMock()
128
+ mock_client.__aenter__ = AsyncMock(return_value=mock_client)
129
+ mock_client.__aexit__ = AsyncMock(return_value=False)
130
+ mock_client.post = AsyncMock(
131
+ side_effect=httpx.HTTPStatusError(
132
+ "conflict", request=error_resp.request, response=error_resp
133
+ )
134
+ )
135
+
136
+ with patch.dict("os.environ", _env_with_admin_key(), clear=False):
137
+ with patch("memoryhub_cli.admin.httpx.AsyncClient", return_value=mock_client):
138
+ result = runner.invoke(app, ["admin", "create-agent", "test-agent"])
139
+
140
+ assert result.exit_code == 1
141
+ assert "Conflict" in result.output
142
+
143
+
144
+ # ── list-agents ──────────────────────────────────────────────────────────────
145
+
146
+
147
+ class TestListAgents:
148
+ def test_happy_path(self):
149
+ mock_client = AsyncMock()
150
+ mock_client.__aenter__ = AsyncMock(return_value=mock_client)
151
+ mock_client.__aexit__ = AsyncMock(return_value=False)
152
+ mock_client.get = AsyncMock(
153
+ return_value=_mock_response([SAMPLE_CLIENT])
154
+ )
155
+
156
+ with patch.dict("os.environ", _env_with_admin_key(), clear=False):
157
+ with patch("memoryhub_cli.admin.httpx.AsyncClient", return_value=mock_client):
158
+ result = runner.invoke(app, ["admin", "list-agents"])
159
+
160
+ assert result.exit_code == 0
161
+ assert "test-agent" in result.output
162
+ assert "Yes" in result.output # active
163
+
164
+ def test_json_output(self):
165
+ mock_client = AsyncMock()
166
+ mock_client.__aenter__ = AsyncMock(return_value=mock_client)
167
+ mock_client.__aexit__ = AsyncMock(return_value=False)
168
+ mock_client.get = AsyncMock(
169
+ return_value=_mock_response([SAMPLE_CLIENT])
170
+ )
171
+
172
+ with patch.dict("os.environ", _env_with_admin_key(), clear=False):
173
+ with patch("memoryhub_cli.admin.httpx.AsyncClient", return_value=mock_client):
174
+ result = runner.invoke(app, ["admin", "list-agents", "--json"])
175
+
176
+ assert result.exit_code == 0
177
+ parsed = json.loads(result.output)
178
+ assert len(parsed) == 1
179
+ assert parsed[0]["client_id"] == "test-agent"
180
+
181
+ def test_empty_list(self):
182
+ mock_client = AsyncMock()
183
+ mock_client.__aenter__ = AsyncMock(return_value=mock_client)
184
+ mock_client.__aexit__ = AsyncMock(return_value=False)
185
+ mock_client.get = AsyncMock(return_value=_mock_response([]))
186
+
187
+ with patch.dict("os.environ", _env_with_admin_key(), clear=False):
188
+ with patch("memoryhub_cli.admin.httpx.AsyncClient", return_value=mock_client):
189
+ result = runner.invoke(app, ["admin", "list-agents"])
190
+
191
+ assert result.exit_code == 0
192
+ assert "No agents registered" in result.output
193
+
194
+
195
+ # ── rotate-secret ────────────────────────────────────────────────────────────
196
+
197
+
198
+ class TestRotateSecret:
199
+ def test_happy_path(self):
200
+ mock_client = AsyncMock()
201
+ mock_client.__aenter__ = AsyncMock(return_value=mock_client)
202
+ mock_client.__aexit__ = AsyncMock(return_value=False)
203
+ mock_client.post = AsyncMock(
204
+ return_value=_mock_response(SAMPLE_ROTATED)
205
+ )
206
+
207
+ with patch.dict("os.environ", _env_with_admin_key(), clear=False):
208
+ with patch("memoryhub_cli.admin.httpx.AsyncClient", return_value=mock_client):
209
+ result = runner.invoke(
210
+ app, ["admin", "rotate-secret", "test-agent"]
211
+ )
212
+
213
+ assert result.exit_code == 0
214
+ assert "new-secret-value" in result.output
215
+ assert "rotated" in result.output.lower()
216
+
217
+ def test_json_output(self):
218
+ mock_client = AsyncMock()
219
+ mock_client.__aenter__ = AsyncMock(return_value=mock_client)
220
+ mock_client.__aexit__ = AsyncMock(return_value=False)
221
+ mock_client.post = AsyncMock(
222
+ return_value=_mock_response(SAMPLE_ROTATED)
223
+ )
224
+
225
+ with patch.dict("os.environ", _env_with_admin_key(), clear=False):
226
+ with patch("memoryhub_cli.admin.httpx.AsyncClient", return_value=mock_client):
227
+ result = runner.invoke(
228
+ app, ["admin", "rotate-secret", "test-agent", "--json"]
229
+ )
230
+
231
+ assert result.exit_code == 0
232
+ parsed = json.loads(result.output)
233
+ assert parsed["client_secret"] == "new-secret-value"
234
+
235
+ def test_not_found(self):
236
+ error_resp = _mock_error_response(404, "Client 'ghost' not found")
237
+ mock_client = AsyncMock()
238
+ mock_client.__aenter__ = AsyncMock(return_value=mock_client)
239
+ mock_client.__aexit__ = AsyncMock(return_value=False)
240
+ mock_client.post = AsyncMock(
241
+ side_effect=httpx.HTTPStatusError(
242
+ "not found", request=error_resp.request, response=error_resp
243
+ )
244
+ )
245
+
246
+ with patch.dict("os.environ", _env_with_admin_key(), clear=False):
247
+ with patch("memoryhub_cli.admin.httpx.AsyncClient", return_value=mock_client):
248
+ result = runner.invoke(app, ["admin", "rotate-secret", "ghost"])
249
+
250
+ assert result.exit_code == 1
251
+ assert "Not found" in result.output
252
+
253
+
254
+ # ── disable-agent ────────────────────────────────────────────────────────────
255
+
256
+
257
+ class TestDisableAgent:
258
+ def test_happy_path(self):
259
+ disabled = {**SAMPLE_CLIENT, "active": False}
260
+ mock_client = AsyncMock()
261
+ mock_client.__aenter__ = AsyncMock(return_value=mock_client)
262
+ mock_client.__aexit__ = AsyncMock(return_value=False)
263
+ mock_client.patch = AsyncMock(return_value=_mock_response(disabled))
264
+
265
+ with patch.dict("os.environ", _env_with_admin_key(), clear=False):
266
+ with patch("memoryhub_cli.admin.httpx.AsyncClient", return_value=mock_client):
267
+ result = runner.invoke(
268
+ app, ["admin", "disable-agent", "test-agent"]
269
+ )
270
+
271
+ assert result.exit_code == 0
272
+ assert "disabled" in result.output.lower()
273
+
274
+
275
+ # ── Missing admin key ────────────────────────────────────────────────────────
276
+
277
+
278
+ class TestMissingAdminKey:
279
+ def test_no_admin_key_errors(self):
280
+ """When no admin key is available, commands should fail with a clear message."""
281
+ env = {"MEMORYHUB_AUTH_URL": "http://auth.test"}
282
+ with patch.dict("os.environ", env, clear=False):
283
+ # Ensure config file returns no admin_key either
284
+ with patch("memoryhub_cli.admin.load_config", return_value={}):
285
+ result = runner.invoke(app, ["admin", "list-agents"])
286
+
287
+ assert result.exit_code == 1
288
+ assert "admin key" in result.output.lower()
289
+
290
+
291
+ # ── HTTP error handling ──────────────────────────────────────────────────────
292
+
293
+
294
+ class TestHttpErrors:
295
+ def test_401_auth_failed(self):
296
+ error_resp = _mock_error_response(401, "Invalid or missing admin key")
297
+ mock_client = AsyncMock()
298
+ mock_client.__aenter__ = AsyncMock(return_value=mock_client)
299
+ mock_client.__aexit__ = AsyncMock(return_value=False)
300
+ mock_client.get = AsyncMock(
301
+ side_effect=httpx.HTTPStatusError(
302
+ "unauthorized", request=error_resp.request, response=error_resp
303
+ )
304
+ )
305
+
306
+ with patch.dict("os.environ", _env_with_admin_key(), clear=False):
307
+ with patch("memoryhub_cli.admin.httpx.AsyncClient", return_value=mock_client):
308
+ result = runner.invoke(app, ["admin", "list-agents"])
309
+
310
+ assert result.exit_code == 1
311
+ assert "Authentication failed" in result.output
312
+
313
+ def test_generic_http_error(self):
314
+ error_resp = _mock_error_response(500, "Internal server error")
315
+ mock_client = AsyncMock()
316
+ mock_client.__aenter__ = AsyncMock(return_value=mock_client)
317
+ mock_client.__aexit__ = AsyncMock(return_value=False)
318
+ mock_client.get = AsyncMock(
319
+ side_effect=httpx.HTTPStatusError(
320
+ "server error", request=error_resp.request, response=error_resp
321
+ )
322
+ )
323
+
324
+ with patch.dict("os.environ", _env_with_admin_key(), clear=False):
325
+ with patch("memoryhub_cli.admin.httpx.AsyncClient", return_value=mock_client):
326
+ result = runner.invoke(app, ["admin", "list-agents"])
327
+
328
+ assert result.exit_code == 1
329
+ assert "HTTP 500" in result.output