memoryhub-cli 0.3.0__tar.gz → 0.8.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,17 @@ seed-clients.json
34
34
 
35
35
  NEXT_SESSION_old.md
36
36
  NEXT_SESSION.md
37
+ planning/rfe_draft.md
38
+
39
+
40
+ # Research drafts not ready for public commit
41
+ research/agent-memory-benchmarks/
42
+
43
+ # Claude Code worktrees
44
+ .claude/worktrees/
45
+
46
+ # Local DB backups (created by scripts/backup-db.sh)
47
+ backups/
48
+
49
+ medium_article_1.pdf
50
+
@@ -0,0 +1,80 @@
1
+ # Changelog — memoryhub-cli
2
+
3
+ All notable changes to the `memoryhub-cli` package.
4
+
5
+ ## [0.8.0] — 2026-06-03
6
+
7
+ - **New commands (#256)**: `memoryhub promote`, `memoryhub graduate`,
8
+ `memoryhub checkpoint`, and `memoryhub describe` for memory lifecycle
9
+ management.
10
+ - **Obsidian export (#245)**: `memoryhub export --format obsidian` generates
11
+ Obsidian-compatible markdown with wikilinks and frontmatter.
12
+ - **Hook-aware rule templates**: `memoryhub config init` and
13
+ `memoryhub config regenerate` now generate rule files that check for a
14
+ `<memoryhub-context>` block from SessionStart hooks before falling back
15
+ to the manual `register_session` + `search_memory` flow.
16
+
17
+ ## [0.7.0] — 2026-05-19
18
+
19
+ - **Content type support (#237)**: Write and search commands accept
20
+ `--content-type` for behavioral memory classification.
21
+
22
+ ## [0.6.0] — 2026-05-07
23
+
24
+ - **API key authentication (#256)**: The CLI now supports API key auth
25
+ via `MEMORYHUB_API_KEY` env var, `--api-key` flag, or the file at
26
+ `~/.config/memoryhub/api-key`. This enables non-interactive use cases
27
+ like SessionStart hooks.
28
+ - **Compact output (#255)**: `--output compact` on search and list commands
29
+ produces content-only text wrapped in `<memoryhub-context>` tags for
30
+ zero-overhead LLM injection.
31
+ - **List command**: `memoryhub list` enumerates memories without semantic
32
+ ranking, with cursor-based pagination.
33
+ - **Server URL prompt**: `memoryhub config init` now prompts for the
34
+ server URL and saves it to `~/.config/memoryhub/config.json`.
35
+
36
+ ## [0.5.0] — 2026-04-22
37
+
38
+ - **Structured output (#200)**: Replaced `--json` flag with `--output`
39
+ accepting `table`, `json`, `quiet`, and `compact` formats. All commands
40
+ now return structured JSON envelopes with `--output json`.
41
+ - **New sub-apps**: `memoryhub graph` (relate, list, similar),
42
+ `memoryhub curation` (report, resolve), `memoryhub session` (status,
43
+ focus), `memoryhub project` (list, describe, join, leave).
44
+ - **Update command**: `memoryhub update` for modifying existing memories
45
+ with version history preservation.
46
+ - **Config enhancements**: `--project` and `--non-interactive` flags on
47
+ `memoryhub config init`.
48
+
49
+ ## [0.4.0] — 2026-04-14
50
+
51
+ - **Admin subcommands (#186)**: Added `memoryhub admin` command group with
52
+ `create-agent`, `list-agents`, `rotate-secret`, and `disable-agent` for
53
+ self-serve agent provisioning via the auth service REST API.
54
+ - **`--version` flag**: `memoryhub --version` now prints the installed version.
55
+
56
+ ## [0.3.0] — 2026-04-09
57
+
58
+ - **Campaign & domain parameter support (#164)**: Added `--project-id` flag to
59
+ search, read, write, delete, and history commands. Added `--domain` flag to
60
+ search and write. When `.memoryhub.yaml` has campaigns configured, `project_id`
61
+ is auto-loaded from the config so the flag can be omitted.
62
+
63
+ ## [0.2.0] — 2026-04-09
64
+
65
+ - Added campaign enrollment prompt to `memoryhub config init` (#160).
66
+ - API key check after config init (#153).
67
+
68
+ ## [0.1.1] — 2026-04-09
69
+
70
+ - Fix ruff lint errors (import sorting, `Optional` → `X | Y` annotations,
71
+ line length). No functional changes from 0.1.0.
72
+
73
+ ## [0.1.0] — 2026-04-09
74
+
75
+ - Initial release. Terminal client for MemoryHub with search, read, write,
76
+ delete, and history commands.
77
+ - `memoryhub config init` — interactive wizard for generating
78
+ `.memoryhub.yaml` and `.claude/rules/memoryhub-loading.md`.
79
+ - `memoryhub config regenerate` — re-render rule file after editing YAML.
80
+ - `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.0
3
+ Version: 0.8.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
@@ -37,15 +38,54 @@ Command-line client for MemoryHub — centralized, governed memory for AI agents
37
38
  pip install memoryhub-cli
38
39
  ```
39
40
 
40
- ## Usage
41
+ ## Authentication
42
+
43
+ The CLI supports two authentication modes:
44
+
45
+ **API key (recommended for non-interactive use):**
46
+
47
+ ```bash
48
+ # Via environment variable
49
+ export MEMORYHUB_API_KEY=mh-dev-abc123
50
+
51
+ # Or place your key at ~/.config/memoryhub/api-key (mode 0600)
52
+ echo "mh-dev-abc123" > ~/.config/memoryhub/api-key
53
+ ```
54
+
55
+ API key resolution order: `MEMORYHUB_API_KEY` env var > `~/.config/memoryhub/api-key` file > `api_key` in config.json.
56
+
57
+ **Server URL:**
58
+
59
+ The CLI needs to know where the MemoryHub server is. The SessionStart hook for Claude Code also reads from this config.
60
+
61
+ ```bash
62
+ # Via environment variable
63
+ export MEMORYHUB_URL=https://memoryhub.example.com/mcp/
64
+
65
+ # Or save to ~/.config/memoryhub/config.json (also set by memoryhub config init)
66
+ echo '{"url": "https://memoryhub.example.com/mcp/"}' > ~/.config/memoryhub/config.json
67
+ chmod 600 ~/.config/memoryhub/config.json
68
+ ```
69
+
70
+ URL resolution: `MEMORYHUB_URL` env var > `url` in `~/.config/memoryhub/config.json`.
71
+
72
+ **OAuth (interactive setup):**
41
73
 
42
74
  ```bash
43
- # Authenticate to a MemoryHub instance
44
75
  memoryhub login
76
+ ```
77
+
78
+ When both are available, API key takes precedence.
45
79
 
80
+ ## Usage
81
+
82
+ ```bash
46
83
  # Search for memories
47
84
  memoryhub search "deployment patterns"
48
85
 
86
+ # List memories by creation time (no semantic search)
87
+ memoryhub list --project-id my-project --max 20
88
+
49
89
  # Read a specific memory
50
90
  memoryhub read <memory-id>
51
91
 
@@ -56,13 +96,31 @@ memoryhub write "Use Podman, not Docker" --scope user --weight 0.9
56
96
  memoryhub search "shared patterns" --project-id my-project --domain React
57
97
  memoryhub write "Use vLLM for embeddings" --project-id my-project --domain ML
58
98
 
99
+ # Compact output for LLM context injection (content only, no metadata)
100
+ memoryhub search "project conventions" --output compact
101
+
59
102
  # Set up project-level memory loading
60
103
  memoryhub config init
61
104
  memoryhub config regenerate
105
+
106
+ # Admin: provision and manage agents
107
+ memoryhub admin create-agent my-agent --scopes user,project
108
+ memoryhub admin list-agents
109
+ memoryhub admin rotate-secret my-agent
110
+ memoryhub admin disable-agent my-agent
62
111
  ```
63
112
 
64
113
  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.
65
114
 
115
+ ## Output formats
116
+
117
+ The `--output` / `-o` flag controls output format on most commands:
118
+
119
+ - `table` (default) -- Rich-formatted tables for interactive use
120
+ - `json` -- Machine-readable JSON envelope (`{"status": "ok", "data": {...}}`)
121
+ - `quiet` -- No output (exit code only)
122
+ - `compact` -- Content-only text for LLM context injection, no IDs or metadata
123
+
66
124
  ## Project configuration
67
125
 
68
126
  `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.
@@ -8,15 +8,54 @@ Command-line client for MemoryHub — centralized, governed memory for AI agents
8
8
  pip install memoryhub-cli
9
9
  ```
10
10
 
11
- ## Usage
11
+ ## Authentication
12
+
13
+ The CLI supports two authentication modes:
14
+
15
+ **API key (recommended for non-interactive use):**
16
+
17
+ ```bash
18
+ # Via environment variable
19
+ export MEMORYHUB_API_KEY=mh-dev-abc123
20
+
21
+ # Or place your key at ~/.config/memoryhub/api-key (mode 0600)
22
+ echo "mh-dev-abc123" > ~/.config/memoryhub/api-key
23
+ ```
24
+
25
+ API key resolution order: `MEMORYHUB_API_KEY` env var > `~/.config/memoryhub/api-key` file > `api_key` in config.json.
26
+
27
+ **Server URL:**
28
+
29
+ The CLI needs to know where the MemoryHub server is. The SessionStart hook for Claude Code also reads from this config.
30
+
31
+ ```bash
32
+ # Via environment variable
33
+ export MEMORYHUB_URL=https://memoryhub.example.com/mcp/
34
+
35
+ # Or save to ~/.config/memoryhub/config.json (also set by memoryhub config init)
36
+ echo '{"url": "https://memoryhub.example.com/mcp/"}' > ~/.config/memoryhub/config.json
37
+ chmod 600 ~/.config/memoryhub/config.json
38
+ ```
39
+
40
+ URL resolution: `MEMORYHUB_URL` env var > `url` in `~/.config/memoryhub/config.json`.
41
+
42
+ **OAuth (interactive setup):**
12
43
 
13
44
  ```bash
14
- # Authenticate to a MemoryHub instance
15
45
  memoryhub login
46
+ ```
47
+
48
+ When both are available, API key takes precedence.
16
49
 
50
+ ## Usage
51
+
52
+ ```bash
17
53
  # Search for memories
18
54
  memoryhub search "deployment patterns"
19
55
 
56
+ # List memories by creation time (no semantic search)
57
+ memoryhub list --project-id my-project --max 20
58
+
20
59
  # Read a specific memory
21
60
  memoryhub read <memory-id>
22
61
 
@@ -27,13 +66,31 @@ memoryhub write "Use Podman, not Docker" --scope user --weight 0.9
27
66
  memoryhub search "shared patterns" --project-id my-project --domain React
28
67
  memoryhub write "Use vLLM for embeddings" --project-id my-project --domain ML
29
68
 
69
+ # Compact output for LLM context injection (content only, no metadata)
70
+ memoryhub search "project conventions" --output compact
71
+
30
72
  # Set up project-level memory loading
31
73
  memoryhub config init
32
74
  memoryhub config regenerate
75
+
76
+ # Admin: provision and manage agents
77
+ memoryhub admin create-agent my-agent --scopes user,project
78
+ memoryhub admin list-agents
79
+ memoryhub admin rotate-secret my-agent
80
+ memoryhub admin disable-agent my-agent
33
81
  ```
34
82
 
35
83
  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.
36
84
 
85
+ ## Output formats
86
+
87
+ The `--output` / `-o` flag controls output format on most commands:
88
+
89
+ - `table` (default) -- Rich-formatted tables for interactive use
90
+ - `json` -- Machine-readable JSON envelope (`{"status": "ok", "data": {...}}`)
91
+ - `quiet` -- No output (exit code only)
92
+ - `compact` -- Content-only text for LLM context injection, no IDs or metadata
93
+
37
94
  ## Project configuration
38
95
 
39
96
  `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.3.0"
7
+ version = "0.8.0"
8
8
  description = "CLI client for MemoryHub — centralized, governed memory for AI agents"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -18,6 +18,7 @@ dependencies = [
18
18
  "typer[all]>=0.15",
19
19
  "rich>=13.0",
20
20
  "pyyaml>=6.0",
21
+ "httpx>=0.27",
21
22
  ]
22
23
  classifiers = [
23
24
  "Development Status :: 3 - Alpha",
@@ -1,3 +1,3 @@
1
1
  """MemoryHub CLI client."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.8.0"
@@ -0,0 +1,336 @@
1
+ """Admin subcommands for managing agents and OAuth clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+
8
+ import httpx
9
+ import typer
10
+ from rich.table import Table
11
+
12
+ from memoryhub_cli.config import CONFIG_DIR, load_config
13
+ from memoryhub_cli.output import (
14
+ EXIT_AUTH_ERROR,
15
+ EXIT_CLIENT_ERROR,
16
+ EXIT_SERVER_ERROR,
17
+ OutputFormat,
18
+ console,
19
+ handle_error,
20
+ json_success,
21
+ )
22
+
23
+ admin_app = typer.Typer(
24
+ name="admin",
25
+ help="Manage agents and OAuth clients.",
26
+ no_args_is_help=True,
27
+ )
28
+
29
+
30
+ def _run(coro):
31
+ """Run an async coroutine."""
32
+ return asyncio.run(coro)
33
+
34
+
35
+ def _get_admin_key(output: OutputFormat) -> str:
36
+ """Resolve the admin key from env var or config file."""
37
+ key = os.environ.get("MEMORYHUB_ADMIN_KEY")
38
+ if key:
39
+ return key
40
+
41
+ config = load_config()
42
+ key = config.get("admin_key")
43
+ if key:
44
+ return key
45
+
46
+ handle_error(
47
+ "missing_config",
48
+ "No admin key found. Set MEMORYHUB_ADMIN_KEY or add 'admin_key' to "
49
+ "~/.config/memoryhub/config.json.",
50
+ output,
51
+ EXIT_CLIENT_ERROR,
52
+ )
53
+
54
+
55
+ def _get_auth_url(output: OutputFormat) -> str:
56
+ """Resolve the auth service base URL (without trailing slash)."""
57
+ url = os.environ.get("MEMORYHUB_AUTH_URL")
58
+ if url:
59
+ return url.rstrip("/")
60
+
61
+ config = load_config()
62
+ url = config.get("auth_url")
63
+ if url:
64
+ return url.rstrip("/")
65
+
66
+ url = config.get("url")
67
+ if url:
68
+ return url.rstrip("/")
69
+
70
+ handle_error(
71
+ "missing_config",
72
+ "No auth URL found. Set MEMORYHUB_AUTH_URL, or add 'auth_url' to "
73
+ "~/.config/memoryhub/config.json.",
74
+ output,
75
+ EXIT_CLIENT_ERROR,
76
+ )
77
+
78
+
79
+ def _admin_headers(admin_key: str) -> dict[str, str]:
80
+ return {"X-Admin-Key": admin_key}
81
+
82
+
83
+ # ── Commands ─────────────────────────────────────────────────────────────────
84
+
85
+
86
+ @admin_app.command("create-agent")
87
+ def create_agent(
88
+ name: str = typer.Argument(..., help="Agent name (used as client_id and client_name)"),
89
+ scopes: str = typer.Option(
90
+ "user,project",
91
+ "--scopes",
92
+ help="Comma-separated default scopes",
93
+ ),
94
+ project_id: str | None = typer.Option(
95
+ None, "--project-id", "-p", help="Project ID to associate with the agent",
96
+ ),
97
+ tenant_id: str = typer.Option(
98
+ "default", "--tenant-id", "-t", help="Tenant ID",
99
+ ),
100
+ write_config: bool = typer.Option(
101
+ False,
102
+ "--write-config",
103
+ help="Write the client_secret to ~/.config/memoryhub/api-key",
104
+ ),
105
+ output: OutputFormat = typer.Option(
106
+ OutputFormat.table, "--output", "-o", help="Output format: table, json, quiet",
107
+ ),
108
+ ):
109
+ """Create a new agent (OAuth client).
110
+
111
+ The client_secret is shown only once. Save it immediately.
112
+ """
113
+ admin_key = _get_admin_key(output)
114
+ auth_url = _get_auth_url(output)
115
+ scope_list = [s.strip() for s in scopes.split(",") if s.strip()]
116
+
117
+ body = {
118
+ "client_id": name,
119
+ "client_name": name,
120
+ "default_scopes": scope_list,
121
+ "tenant_id": tenant_id,
122
+ }
123
+
124
+ async def _do():
125
+ async with httpx.AsyncClient() as client:
126
+ resp = await client.post(
127
+ f"{auth_url}/admin/clients",
128
+ json=body,
129
+ headers=_admin_headers(admin_key),
130
+ timeout=30,
131
+ )
132
+ resp.raise_for_status()
133
+ return resp.json()
134
+
135
+ try:
136
+ data = _run(_do())
137
+ except httpx.HTTPStatusError as exc:
138
+ _handle_http_error(exc, output)
139
+ return
140
+
141
+ if output == OutputFormat.json:
142
+ json_success(data)
143
+ return
144
+ if output == OutputFormat.quiet:
145
+ return
146
+
147
+ console.print("[green]Agent created successfully.[/green]\n")
148
+ console.print(f" Client ID: [bold]{data['client_id']}[/bold]")
149
+ console.print(f" Client Name: {data['client_name']}")
150
+ console.print(f" Tenant: {data['tenant_id']}")
151
+ console.print(f" Scopes: {', '.join(data['default_scopes'])}")
152
+ console.print(f" Active: {data['active']}")
153
+ console.print()
154
+ console.print(
155
+ f" [yellow bold]Client Secret: {data['client_secret']}[/yellow bold]"
156
+ )
157
+ console.print(
158
+ "\n [dim]Save this secret now. It will not be shown again.[/dim]"
159
+ )
160
+
161
+ if write_config:
162
+ api_key_path = CONFIG_DIR / "api-key"
163
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
164
+ api_key_path.write_text(data["client_secret"])
165
+ api_key_path.chmod(0o600)
166
+ console.print(f"\n [green]Secret written to {api_key_path}[/green]")
167
+
168
+
169
+ @admin_app.command("list-agents")
170
+ def list_agents(
171
+ output: OutputFormat = typer.Option(
172
+ OutputFormat.table, "--output", "-o", help="Output format: table, json, quiet",
173
+ ),
174
+ ):
175
+ """List all registered agents (OAuth clients)."""
176
+ admin_key = _get_admin_key(output)
177
+ auth_url = _get_auth_url(output)
178
+
179
+ async def _do():
180
+ async with httpx.AsyncClient() as client:
181
+ resp = await client.get(
182
+ f"{auth_url}/admin/clients",
183
+ headers=_admin_headers(admin_key),
184
+ timeout=30,
185
+ )
186
+ resp.raise_for_status()
187
+ return resp.json()
188
+
189
+ try:
190
+ data = _run(_do())
191
+ except httpx.HTTPStatusError as exc:
192
+ _handle_http_error(exc, output)
193
+ return
194
+
195
+ if output == OutputFormat.json:
196
+ json_success(data)
197
+ return
198
+ if output == OutputFormat.quiet:
199
+ return
200
+
201
+ if not data:
202
+ console.print("[dim]No agents registered.[/dim]")
203
+ return
204
+
205
+ table = Table(title="Registered Agents")
206
+ table.add_column("Client ID", style="bold")
207
+ table.add_column("Name")
208
+ table.add_column("Active", justify="center")
209
+ table.add_column("Scopes")
210
+ table.add_column("Created", style="dim")
211
+
212
+ for agent in data:
213
+ active = "[green]Yes[/green]" if agent.get("active") else "[red]No[/red]"
214
+ scopes = ", ".join(agent.get("default_scopes", []))
215
+ created = str(agent.get("created_at", "-"))[:19]
216
+ table.add_row(
217
+ agent["client_id"],
218
+ agent.get("client_name", "-"),
219
+ active,
220
+ scopes,
221
+ created,
222
+ )
223
+
224
+ console.print(table)
225
+
226
+
227
+ @admin_app.command("rotate-secret")
228
+ def rotate_secret(
229
+ client_id: str = typer.Argument(..., help="Client ID of the agent"),
230
+ output: OutputFormat = typer.Option(
231
+ OutputFormat.table, "--output", "-o", help="Output format: table, json, quiet",
232
+ ),
233
+ ):
234
+ """Rotate the client secret for an agent.
235
+
236
+ The new secret is shown only once. Save it immediately.
237
+ """
238
+ admin_key = _get_admin_key(output)
239
+ auth_url = _get_auth_url(output)
240
+
241
+ async def _do():
242
+ async with httpx.AsyncClient() as client:
243
+ resp = await client.post(
244
+ f"{auth_url}/admin/clients/{client_id}/rotate-secret",
245
+ headers=_admin_headers(admin_key),
246
+ timeout=30,
247
+ )
248
+ resp.raise_for_status()
249
+ return resp.json()
250
+
251
+ try:
252
+ data = _run(_do())
253
+ except httpx.HTTPStatusError as exc:
254
+ _handle_http_error(exc, output)
255
+ return
256
+
257
+ if output == OutputFormat.json:
258
+ json_success(data)
259
+ return
260
+ if output == OutputFormat.quiet:
261
+ return
262
+
263
+ console.print(f"[green]Secret rotated for {data['client_id']}.[/green]\n")
264
+ console.print(
265
+ f" [yellow bold]New Secret: {data['client_secret']}[/yellow bold]"
266
+ )
267
+ console.print(
268
+ "\n [dim]Save this secret now. It will not be shown again.[/dim]"
269
+ )
270
+
271
+
272
+ @admin_app.command("disable-agent")
273
+ def disable_agent(
274
+ client_id: str = typer.Argument(..., help="Client ID of the agent to disable"),
275
+ output: OutputFormat = typer.Option(
276
+ OutputFormat.table, "--output", "-o", help="Output format: table, json, quiet",
277
+ ),
278
+ ):
279
+ """Disable an agent (set active=false)."""
280
+ admin_key = _get_admin_key(output)
281
+ auth_url = _get_auth_url(output)
282
+
283
+ async def _do():
284
+ async with httpx.AsyncClient() as client:
285
+ resp = await client.patch(
286
+ f"{auth_url}/admin/clients/{client_id}",
287
+ json={"active": False},
288
+ headers=_admin_headers(admin_key),
289
+ timeout=30,
290
+ )
291
+ resp.raise_for_status()
292
+ return resp.json()
293
+
294
+ try:
295
+ data = _run(_do())
296
+ except httpx.HTTPStatusError as exc:
297
+ _handle_http_error(exc, output)
298
+ return
299
+
300
+ if output == OutputFormat.json:
301
+ json_success(data)
302
+ return
303
+ if output == OutputFormat.quiet:
304
+ return
305
+
306
+ console.print(
307
+ f"[green]Agent '{data['client_id']}' has been disabled.[/green]"
308
+ )
309
+
310
+
311
+ # ── Helpers ──────────────────────────────────────────────────────────────────
312
+
313
+
314
+ def _handle_http_error(exc: httpx.HTTPStatusError, output: OutputFormat) -> None:
315
+ """Emit a structured error for HTTP failures and exit."""
316
+ status = exc.response.status_code
317
+ try:
318
+ detail = exc.response.json().get("detail", exc.response.text)
319
+ except Exception:
320
+ detail = exc.response.text
321
+
322
+ if status == 401:
323
+ handle_error(
324
+ "auth_failed",
325
+ "Authentication failed. Check your admin key.",
326
+ output,
327
+ EXIT_AUTH_ERROR,
328
+ )
329
+ elif status == 404:
330
+ handle_error("not_found", str(detail), output, EXIT_CLIENT_ERROR)
331
+ elif status == 409:
332
+ handle_error("conflict", str(detail), output, EXIT_CLIENT_ERROR)
333
+ elif status >= 500:
334
+ handle_error("server_error", f"HTTP {status}: {detail}", output, EXIT_SERVER_ERROR)
335
+ else:
336
+ handle_error("http_error", f"HTTP {status}: {detail}", output, EXIT_CLIENT_ERROR)