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.
- {memoryhub_cli-0.3.0 → memoryhub_cli-0.4.0}/.gitignore +1 -0
- {memoryhub_cli-0.3.0 → memoryhub_cli-0.4.0}/CHANGELOG.md +7 -0
- {memoryhub_cli-0.3.0 → memoryhub_cli-0.4.0}/PKG-INFO +8 -1
- {memoryhub_cli-0.3.0 → memoryhub_cli-0.4.0}/README.md +6 -0
- {memoryhub_cli-0.3.0 → memoryhub_cli-0.4.0}/pyproject.toml +2 -1
- {memoryhub_cli-0.3.0 → memoryhub_cli-0.4.0}/src/memoryhub_cli/__init__.py +1 -1
- memoryhub_cli-0.4.0/src/memoryhub_cli/admin.py +307 -0
- {memoryhub_cli-0.3.0 → memoryhub_cli-0.4.0}/src/memoryhub_cli/main.py +22 -1
- memoryhub_cli-0.4.0/tests/test_admin.py +329 -0
- {memoryhub_cli-0.3.0 → memoryhub_cli-0.4.0}/src/memoryhub_cli/config.py +0 -0
- {memoryhub_cli-0.3.0 → memoryhub_cli-0.4.0}/src/memoryhub_cli/project_config.py +0 -0
- {memoryhub_cli-0.3.0 → memoryhub_cli-0.4.0}/tests/__init__.py +0 -0
- {memoryhub_cli-0.3.0 → memoryhub_cli-0.4.0}/tests/test_project_config.py +0 -0
|
@@ -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
|
+
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.
|
|
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",
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|