memoryhub-cli 0.2.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.2.0 → memoryhub_cli-0.4.0}/.gitignore +1 -0
- memoryhub_cli-0.4.0/CHANGELOG.md +36 -0
- {memoryhub_cli-0.2.0 → memoryhub_cli-0.4.0}/PKG-INFO +14 -1
- {memoryhub_cli-0.2.0 → memoryhub_cli-0.4.0}/README.md +12 -0
- {memoryhub_cli-0.2.0 → memoryhub_cli-0.4.0}/pyproject.toml +2 -1
- {memoryhub_cli-0.2.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.2.0 → memoryhub_cli-0.4.0}/src/memoryhub_cli/main.py +71 -4
- memoryhub_cli-0.4.0/tests/test_admin.py +329 -0
- {memoryhub_cli-0.2.0 → memoryhub_cli-0.4.0}/tests/test_project_config.py +87 -2
- memoryhub_cli-0.2.0/CHANGELOG.md +0 -17
- {memoryhub_cli-0.2.0 → memoryhub_cli-0.4.0}/src/memoryhub_cli/config.py +0 -0
- {memoryhub_cli-0.2.0 → memoryhub_cli-0.4.0}/src/memoryhub_cli/project_config.py +0 -0
- {memoryhub_cli-0.2.0 → memoryhub_cli-0.4.0}/tests/__init__.py +0 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Changelog — memoryhub-cli
|
|
2
|
+
|
|
3
|
+
All notable changes to the `memoryhub-cli` package.
|
|
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
|
+
|
|
12
|
+
## [0.3.0] — 2026-04-09
|
|
13
|
+
|
|
14
|
+
- **Campaign & domain parameter support (#164)**: Added `--project-id` flag to
|
|
15
|
+
search, read, write, delete, and history commands. Added `--domain` flag to
|
|
16
|
+
search and write. When `.memoryhub.yaml` has campaigns configured, `project_id`
|
|
17
|
+
is auto-loaded from the config so the flag can be omitted.
|
|
18
|
+
|
|
19
|
+
## [0.2.0] — 2026-04-09
|
|
20
|
+
|
|
21
|
+
- Added campaign enrollment prompt to `memoryhub config init` (#160).
|
|
22
|
+
- API key check after config init (#153).
|
|
23
|
+
|
|
24
|
+
## [0.1.1] — 2026-04-09
|
|
25
|
+
|
|
26
|
+
- Fix ruff lint errors (import sorting, `Optional` → `X | Y` annotations,
|
|
27
|
+
line length). No functional changes from 0.1.0.
|
|
28
|
+
|
|
29
|
+
## [0.1.0] — 2026-04-09
|
|
30
|
+
|
|
31
|
+
- Initial release. Terminal client for MemoryHub with search, read, write,
|
|
32
|
+
delete, and history commands.
|
|
33
|
+
- `memoryhub config init` — interactive wizard for generating
|
|
34
|
+
`.memoryhub.yaml` and `.claude/rules/memoryhub-loading.md`.
|
|
35
|
+
- `memoryhub config regenerate` — re-render rule file after editing YAML.
|
|
36
|
+
- `memoryhub login` — one-time credential setup.
|
|
@@ -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
|
|
@@ -52,11 +53,23 @@ memoryhub read <memory-id>
|
|
|
52
53
|
# Write a new memory
|
|
53
54
|
memoryhub write "Use Podman, not Docker" --scope user --weight 0.9
|
|
54
55
|
|
|
56
|
+
# Campaign-scoped operations (requires project enrollment)
|
|
57
|
+
memoryhub search "shared patterns" --project-id my-project --domain React
|
|
58
|
+
memoryhub write "Use vLLM for embeddings" --project-id my-project --domain ML
|
|
59
|
+
|
|
55
60
|
# Set up project-level memory loading
|
|
56
61
|
memoryhub config init
|
|
57
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
|
|
58
69
|
```
|
|
59
70
|
|
|
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.
|
|
72
|
+
|
|
60
73
|
## Project configuration
|
|
61
74
|
|
|
62
75
|
`memoryhub config` generates a project-local `.memoryhub.yaml` and a companion `.claude/rules/memoryhub-loading.md` rule file. Both files are meant to be committed so every contributor's agent inherits the same loading policy.
|
|
@@ -23,11 +23,23 @@ memoryhub read <memory-id>
|
|
|
23
23
|
# Write a new memory
|
|
24
24
|
memoryhub write "Use Podman, not Docker" --scope user --weight 0.9
|
|
25
25
|
|
|
26
|
+
# Campaign-scoped operations (requires project enrollment)
|
|
27
|
+
memoryhub search "shared patterns" --project-id my-project --domain React
|
|
28
|
+
memoryhub write "Use vLLM for embeddings" --project-id my-project --domain ML
|
|
29
|
+
|
|
26
30
|
# Set up project-level memory loading
|
|
27
31
|
memoryhub config init
|
|
28
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
|
|
29
39
|
```
|
|
30
40
|
|
|
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.
|
|
42
|
+
|
|
31
43
|
## Project configuration
|
|
32
44
|
|
|
33
45
|
`memoryhub config` generates a project-local `.memoryhub.yaml` and a companion `.claude/rules/memoryhub-loading.md` rule file. Both files are meant to be committed so every contributor's agent inherits the same loading policy.
|
|
@@ -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
|
|
|
@@ -58,6 +79,21 @@ def _get_client():
|
|
|
58
79
|
)
|
|
59
80
|
|
|
60
81
|
|
|
82
|
+
def _get_project_id_default() -> str | None:
|
|
83
|
+
"""Try to load project_id from .memoryhub.yaml campaigns config.
|
|
84
|
+
|
|
85
|
+
Returns the project directory name as the project identifier when
|
|
86
|
+
campaigns are configured, or None when no config/campaigns exist.
|
|
87
|
+
"""
|
|
88
|
+
try:
|
|
89
|
+
config = load_project_config() # auto-discovers .memoryhub.yaml
|
|
90
|
+
if config.memory_loading.campaigns:
|
|
91
|
+
return Path.cwd().name
|
|
92
|
+
except Exception:
|
|
93
|
+
pass
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
61
97
|
def _run(coro):
|
|
62
98
|
"""Run an async coroutine."""
|
|
63
99
|
return asyncio.run(coro)
|
|
@@ -110,15 +146,23 @@ def search(
|
|
|
110
146
|
query: str = typer.Argument(..., help="Search query"),
|
|
111
147
|
scope: str | None = typer.Option(None, "--scope", "-s", help="Filter by scope"),
|
|
112
148
|
max_results: int = typer.Option(10, "--max", "-n", help="Maximum results"),
|
|
149
|
+
project_id: str | None = typer.Option(
|
|
150
|
+
None, "--project-id", "-p", help="Project ID for campaign access",
|
|
151
|
+
),
|
|
152
|
+
domains: list[str] | None = typer.Option(
|
|
153
|
+
None, "--domain", help="Domain tags to boost",
|
|
154
|
+
),
|
|
113
155
|
json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
|
|
114
156
|
):
|
|
115
157
|
"""Search memories using semantic similarity."""
|
|
116
158
|
client = _get_client()
|
|
159
|
+
_project_id = project_id or _get_project_id_default()
|
|
117
160
|
|
|
118
161
|
async def _do():
|
|
119
162
|
async with client:
|
|
120
163
|
return await client.search(
|
|
121
164
|
query, scope=scope, max_results=max_results,
|
|
165
|
+
project_id=_project_id, domains=domains or None,
|
|
122
166
|
)
|
|
123
167
|
|
|
124
168
|
result = _run(_do())
|
|
@@ -158,14 +202,18 @@ def search(
|
|
|
158
202
|
@app.command()
|
|
159
203
|
def read(
|
|
160
204
|
memory_id: str = typer.Argument(..., help="Memory UUID"),
|
|
205
|
+
project_id: str | None = typer.Option(
|
|
206
|
+
None, "--project-id", "-p", help="Project ID for campaign access",
|
|
207
|
+
),
|
|
161
208
|
json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
|
|
162
209
|
):
|
|
163
210
|
"""Read a memory by ID."""
|
|
164
211
|
client = _get_client()
|
|
212
|
+
_project_id = project_id or _get_project_id_default()
|
|
165
213
|
|
|
166
214
|
async def _do():
|
|
167
215
|
async with client:
|
|
168
|
-
return await client.read(memory_id)
|
|
216
|
+
return await client.read(memory_id, project_id=_project_id)
|
|
169
217
|
|
|
170
218
|
memory = _run(_do())
|
|
171
219
|
|
|
@@ -193,6 +241,12 @@ def write(
|
|
|
193
241
|
weight: float = typer.Option(0.7, "--weight", "-w", help="Priority weight 0.0-1.0"),
|
|
194
242
|
parent_id: str | None = typer.Option(None, "--parent", help="Parent memory ID"),
|
|
195
243
|
branch_type: str | None = typer.Option(None, "--branch-type", help="Branch type"),
|
|
244
|
+
project_id: str | None = typer.Option(
|
|
245
|
+
None, "--project-id", "-p", help="Project ID for campaign access",
|
|
246
|
+
),
|
|
247
|
+
domains: list[str] | None = typer.Option(
|
|
248
|
+
None, "--domain", help="Domain tags",
|
|
249
|
+
),
|
|
196
250
|
json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
|
|
197
251
|
):
|
|
198
252
|
"""Write a new memory.
|
|
@@ -210,12 +264,14 @@ def write(
|
|
|
210
264
|
raise typer.Exit(1)
|
|
211
265
|
|
|
212
266
|
client = _get_client()
|
|
267
|
+
_project_id = project_id or _get_project_id_default()
|
|
213
268
|
|
|
214
269
|
async def _do():
|
|
215
270
|
async with client:
|
|
216
271
|
return await client.write(
|
|
217
272
|
content, scope=scope, weight=weight,
|
|
218
273
|
parent_id=parent_id, branch_type=branch_type,
|
|
274
|
+
project_id=_project_id, domains=domains or None,
|
|
219
275
|
)
|
|
220
276
|
|
|
221
277
|
result = _run(_do())
|
|
@@ -240,6 +296,9 @@ def write(
|
|
|
240
296
|
def delete(
|
|
241
297
|
memory_id: str = typer.Argument(..., help="Memory UUID to delete"),
|
|
242
298
|
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"),
|
|
299
|
+
project_id: str | None = typer.Option(
|
|
300
|
+
None, "--project-id", "-p", help="Project ID for campaign access",
|
|
301
|
+
),
|
|
243
302
|
json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
|
|
244
303
|
):
|
|
245
304
|
"""Soft-delete a memory and its version chain."""
|
|
@@ -249,10 +308,11 @@ def delete(
|
|
|
249
308
|
raise typer.Abort()
|
|
250
309
|
|
|
251
310
|
client = _get_client()
|
|
311
|
+
_project_id = project_id or _get_project_id_default()
|
|
252
312
|
|
|
253
313
|
async def _do():
|
|
254
314
|
async with client:
|
|
255
|
-
return await client.delete(memory_id)
|
|
315
|
+
return await client.delete(memory_id, project_id=_project_id)
|
|
256
316
|
|
|
257
317
|
result = _run(_do())
|
|
258
318
|
|
|
@@ -270,14 +330,21 @@ def delete(
|
|
|
270
330
|
def history(
|
|
271
331
|
memory_id: str = typer.Argument(..., help="Memory UUID"),
|
|
272
332
|
max_versions: int = typer.Option(20, "--max", "-n", help="Maximum versions to show"),
|
|
333
|
+
project_id: str | None = typer.Option(
|
|
334
|
+
None, "--project-id", "-p", help="Project ID for campaign access",
|
|
335
|
+
),
|
|
273
336
|
json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
|
|
274
337
|
):
|
|
275
338
|
"""Show version history for a memory."""
|
|
276
339
|
client = _get_client()
|
|
340
|
+
_project_id = project_id or _get_project_id_default()
|
|
277
341
|
|
|
278
342
|
async def _do():
|
|
279
343
|
async with client:
|
|
280
|
-
return await client.get_history(
|
|
344
|
+
return await client.get_history(
|
|
345
|
+
memory_id, max_versions=max_versions,
|
|
346
|
+
project_id=_project_id,
|
|
347
|
+
)
|
|
281
348
|
|
|
282
349
|
result = _run(_do())
|
|
283
350
|
|
|
@@ -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
|
|
@@ -6,12 +6,11 @@ from pathlib import Path
|
|
|
6
6
|
|
|
7
7
|
import pytest
|
|
8
8
|
import yaml
|
|
9
|
-
|
|
10
9
|
from memoryhub import (
|
|
11
10
|
CONFIG_FILENAME,
|
|
12
|
-
ProjectConfig,
|
|
13
11
|
load_project_config,
|
|
14
12
|
)
|
|
13
|
+
|
|
15
14
|
from memoryhub_cli.project_config import (
|
|
16
15
|
GENERATED_RULE_NAME,
|
|
17
16
|
LEGACY_RULE_NAME,
|
|
@@ -425,3 +424,89 @@ def test_rewrite_rule_file_does_not_touch_yaml(tmp_path: Path):
|
|
|
425
424
|
# YAML untouched.
|
|
426
425
|
assert yaml_path.stat().st_mtime == yaml_mtime_before
|
|
427
426
|
assert "pattern: eager" in yaml_path.read_text()
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
# ── CLI option wiring: project_id and domains ────────────────────────────
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _strip_ansi(text: str) -> str:
|
|
433
|
+
"""Remove ANSI escape codes from text for reliable assertions."""
|
|
434
|
+
import re
|
|
435
|
+
|
|
436
|
+
return re.sub(r"\x1b\[[0-9;]*m", "", text)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def test_search_accepts_project_id_option():
|
|
440
|
+
"""--project-id is recognized by the search command."""
|
|
441
|
+
from typer.testing import CliRunner
|
|
442
|
+
|
|
443
|
+
from memoryhub_cli.main import app
|
|
444
|
+
|
|
445
|
+
runner = CliRunner()
|
|
446
|
+
result = runner.invoke(app, ["search", "--help"])
|
|
447
|
+
assert result.exit_code == 0
|
|
448
|
+
text = _strip_ansi(result.stdout)
|
|
449
|
+
assert "--project-id" in text, f"--project-id not in: {text}"
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def test_search_accepts_domain_option():
|
|
453
|
+
"""--domain is recognized by the search command."""
|
|
454
|
+
from typer.testing import CliRunner
|
|
455
|
+
|
|
456
|
+
from memoryhub_cli.main import app
|
|
457
|
+
|
|
458
|
+
runner = CliRunner()
|
|
459
|
+
result = runner.invoke(app, ["search", "--help"])
|
|
460
|
+
assert result.exit_code == 0
|
|
461
|
+
text = _strip_ansi(result.stdout)
|
|
462
|
+
assert "--domain" in text, f"--domain not in: {text}"
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def test_write_accepts_project_id_and_domain_options():
|
|
466
|
+
"""--project-id and --domain are recognized by the write command."""
|
|
467
|
+
from typer.testing import CliRunner
|
|
468
|
+
|
|
469
|
+
from memoryhub_cli.main import app
|
|
470
|
+
|
|
471
|
+
runner = CliRunner()
|
|
472
|
+
result = runner.invoke(app, ["write", "--help"])
|
|
473
|
+
assert result.exit_code == 0
|
|
474
|
+
text = _strip_ansi(result.stdout)
|
|
475
|
+
assert "--project-id" in text, f"--project-id not in: {text}"
|
|
476
|
+
assert "--domain" in text, f"--domain not in: {text}"
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def test_read_accepts_project_id_option():
|
|
480
|
+
from typer.testing import CliRunner
|
|
481
|
+
|
|
482
|
+
from memoryhub_cli.main import app
|
|
483
|
+
|
|
484
|
+
runner = CliRunner()
|
|
485
|
+
result = runner.invoke(app, ["read", "--help"])
|
|
486
|
+
assert result.exit_code == 0
|
|
487
|
+
text = _strip_ansi(result.stdout)
|
|
488
|
+
assert "--project-id" in text, f"--project-id not in: {text}"
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def test_delete_accepts_project_id_option():
|
|
492
|
+
from typer.testing import CliRunner
|
|
493
|
+
|
|
494
|
+
from memoryhub_cli.main import app
|
|
495
|
+
|
|
496
|
+
runner = CliRunner()
|
|
497
|
+
result = runner.invoke(app, ["delete", "--help"])
|
|
498
|
+
assert result.exit_code == 0
|
|
499
|
+
text = _strip_ansi(result.stdout)
|
|
500
|
+
assert "--project-id" in text, f"--project-id not in: {text}"
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def test_history_accepts_project_id_option():
|
|
504
|
+
from typer.testing import CliRunner
|
|
505
|
+
|
|
506
|
+
from memoryhub_cli.main import app
|
|
507
|
+
|
|
508
|
+
runner = CliRunner()
|
|
509
|
+
result = runner.invoke(app, ["history", "--help"])
|
|
510
|
+
assert result.exit_code == 0
|
|
511
|
+
text = _strip_ansi(result.stdout)
|
|
512
|
+
assert "--project-id" in text, f"--project-id not in: {text}"
|
memoryhub_cli-0.2.0/CHANGELOG.md
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
# Changelog — memoryhub-cli
|
|
2
|
-
|
|
3
|
-
All notable changes to the `memoryhub-cli` package.
|
|
4
|
-
|
|
5
|
-
## [0.1.1] — 2026-04-09
|
|
6
|
-
|
|
7
|
-
- Fix ruff lint errors (import sorting, `Optional` → `X | Y` annotations,
|
|
8
|
-
line length). No functional changes from 0.1.0.
|
|
9
|
-
|
|
10
|
-
## [0.1.0] — 2026-04-09
|
|
11
|
-
|
|
12
|
-
- Initial release. Terminal client for MemoryHub with search, read, write,
|
|
13
|
-
delete, and history commands.
|
|
14
|
-
- `memoryhub config init` — interactive wizard for generating
|
|
15
|
-
`.memoryhub.yaml` and `.claude/rules/memoryhub-loading.md`.
|
|
16
|
-
- `memoryhub config regenerate` — re-render rule file after editing YAML.
|
|
17
|
-
- `memoryhub login` — one-time credential setup.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|