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.
@@ -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
@@ -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.2.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.2.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.2.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
 
@@ -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(memory_id, max_versions=max_versions)
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}"
@@ -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.