cortexdb-cli 0.5.3__tar.gz → 0.5.4__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.
Files changed (41) hide show
  1. {cortexdb_cli-0.5.3 → cortexdb_cli-0.5.4}/.gitignore +109 -101
  2. {cortexdb_cli-0.5.3 → cortexdb_cli-0.5.4}/PKG-INFO +1 -1
  3. {cortexdb_cli-0.5.3 → cortexdb_cli-0.5.4}/cortexdb_cli/__init__.py +3 -3
  4. {cortexdb_cli-0.5.3 → cortexdb_cli-0.5.4}/cortexdb_cli/client.py +8 -0
  5. cortexdb_cli-0.5.4/cortexdb_cli/commands/admin.py +612 -0
  6. {cortexdb_cli-0.5.3 → cortexdb_cli-0.5.4}/cortexdb_cli/commands/code.py +60 -4
  7. {cortexdb_cli-0.5.3 → cortexdb_cli-0.5.4}/pyproject.toml +56 -56
  8. cortexdb_cli-0.5.3/cortexdb_cli/commands/admin.py +0 -83
  9. {cortexdb_cli-0.5.3 → cortexdb_cli-0.5.4}/README.md +0 -0
  10. {cortexdb_cli-0.5.3 → cortexdb_cli-0.5.4}/cortexdb_cli/commands/__init__.py +0 -0
  11. {cortexdb_cli-0.5.3 → cortexdb_cli-0.5.4}/cortexdb_cli/commands/answer.py +0 -0
  12. {cortexdb_cli-0.5.3 → cortexdb_cli-0.5.4}/cortexdb_cli/commands/audit.py +0 -0
  13. {cortexdb_cli-0.5.3 → cortexdb_cli-0.5.4}/cortexdb_cli/commands/auth.py +0 -0
  14. {cortexdb_cli-0.5.3 → cortexdb_cli-0.5.4}/cortexdb_cli/commands/beliefs.py +0 -0
  15. {cortexdb_cli-0.5.3 → cortexdb_cli-0.5.4}/cortexdb_cli/commands/claims.py +0 -0
  16. {cortexdb_cli-0.5.3 → cortexdb_cli-0.5.4}/cortexdb_cli/commands/compose.py +0 -0
  17. {cortexdb_cli-0.5.3 → cortexdb_cli-0.5.4}/cortexdb_cli/commands/config_cmd.py +0 -0
  18. {cortexdb_cli-0.5.3 → cortexdb_cli-0.5.4}/cortexdb_cli/commands/conflicts.py +0 -0
  19. {cortexdb_cli-0.5.3 → cortexdb_cli-0.5.4}/cortexdb_cli/commands/entities.py +0 -0
  20. {cortexdb_cli-0.5.3 → cortexdb_cli-0.5.4}/cortexdb_cli/commands/episodes.py +0 -0
  21. {cortexdb_cli-0.5.3 → cortexdb_cli-0.5.4}/cortexdb_cli/commands/experience.py +0 -0
  22. {cortexdb_cli-0.5.3 → cortexdb_cli-0.5.4}/cortexdb_cli/commands/export_cmd.py +0 -0
  23. {cortexdb_cli-0.5.3 → cortexdb_cli-0.5.4}/cortexdb_cli/commands/facts.py +0 -0
  24. {cortexdb_cli-0.5.3 → cortexdb_cli-0.5.4}/cortexdb_cli/commands/forget.py +0 -0
  25. {cortexdb_cli-0.5.3 → cortexdb_cli-0.5.4}/cortexdb_cli/commands/import_cmd.py +0 -0
  26. {cortexdb_cli-0.5.3 → cortexdb_cli-0.5.4}/cortexdb_cli/commands/init.py +0 -0
  27. {cortexdb_cli-0.5.3 → cortexdb_cli-0.5.4}/cortexdb_cli/commands/interactive.py +0 -0
  28. {cortexdb_cli-0.5.3 → cortexdb_cli-0.5.4}/cortexdb_cli/commands/onboard.py +0 -0
  29. {cortexdb_cli-0.5.3 → cortexdb_cli-0.5.4}/cortexdb_cli/commands/policy.py +0 -0
  30. {cortexdb_cli-0.5.3 → cortexdb_cli-0.5.4}/cortexdb_cli/commands/recall.py +0 -0
  31. {cortexdb_cli-0.5.3 → cortexdb_cli-0.5.4}/cortexdb_cli/commands/scopes.py +0 -0
  32. {cortexdb_cli-0.5.3 → cortexdb_cli-0.5.4}/cortexdb_cli/commands/search.py +0 -0
  33. {cortexdb_cli-0.5.3 → cortexdb_cli-0.5.4}/cortexdb_cli/commands/understanding.py +0 -0
  34. {cortexdb_cli-0.5.3 → cortexdb_cli-0.5.4}/cortexdb_cli/commands/wire.py +0 -0
  35. {cortexdb_cli-0.5.3 → cortexdb_cli-0.5.4}/cortexdb_cli/config.py +0 -0
  36. {cortexdb_cli-0.5.3 → cortexdb_cli-0.5.4}/cortexdb_cli/errors.py +0 -0
  37. {cortexdb_cli-0.5.3 → cortexdb_cli-0.5.4}/cortexdb_cli/harness_packs_data.py +0 -0
  38. {cortexdb_cli-0.5.3 → cortexdb_cli-0.5.4}/cortexdb_cli/main.py +0 -0
  39. {cortexdb_cli-0.5.3 → cortexdb_cli-0.5.4}/cortexdb_cli/onboarding.py +0 -0
  40. {cortexdb_cli-0.5.3 → cortexdb_cli-0.5.4}/cortexdb_cli/output.py +0 -0
  41. {cortexdb_cli-0.5.3 → cortexdb_cli-0.5.4}/cortexdb_cli/state.py +0 -0
@@ -1,101 +1,109 @@
1
- # Rust
2
- /target
3
- **/*.rs.bk
4
-
5
- # Environment / secrets
6
- .env
7
- .env.local
8
- .env*.local
9
- *.pem
10
- *.key
11
- .npmrc
12
-
13
- # SQLite database
14
- *.sqlite
15
- *.sqlite-wal
16
- *.sqlite-shm
17
-
18
- # OS
19
- .DS_Store
20
- Thumbs.db
21
- desktop.ini
22
-
23
- # IDE
24
- .idea/
25
- .vscode/
26
- *.swp
27
- *.swo
28
-
29
- # Data directories
30
- cortexdb_data*/
31
- /data/
32
- # Per-bench tenant stores (RocksDB + Tantivy + HNSW state; regeneratable per run)
33
- /data_*/
34
- # Experimental per-branch stores (not tracked on this branch but left gitignored
35
- # so checkout from other branches doesn't surface them in git status)
36
- /event_memory_store/
37
- /llm_cache/
38
-
39
- # Benchmark inputs and per-run outputs (kept local, regenerated each run)
40
- benchmarks/longmemeval/data/
41
- benchmarks/longmemeval/server_results/
42
- benchmarks/longmemeval/fast_results/
43
- benchmarks/longmemeval/micro_results/
44
- benchmarks/longmemeval/server_logs/
45
- benchmarks/longmemeval/*.log
46
- benchmarks/locomo/locomo_results*.json
47
- benchmarks/locomo/server_results/
48
- benchmarks/locomo/*.log
49
- /answer_out.json
50
-
51
- # Local Claude Code state
52
- .claude/
53
- .tmp/
54
-
55
- # Python
56
- __pycache__/
57
- *.pyc
58
- .venv/
59
- venv/
60
-
61
- # Node
62
- node_modules/
63
- dist/
64
- .next/
65
-
66
- # Egg info
67
- *.egg-info/
68
-
69
- # Scratch/debug text files at root
70
- /*.txt
71
- /*.log
72
-
73
- # Local debug / marketing / private content (not for repo)
74
- harness/.reports/
75
- harness_data_*/
76
- blog/
77
- sales/
78
- videos/
79
- local-instance/
80
-
81
- # doc-claims verifier scratch output
82
- tools/verifier_out*/
83
-
84
- # Generated benchmark / runtime data (audit M-10: hundreds of untracked
85
- # data dirs at the workspace root slow every git status/search and risk
86
- # being packaged; results JSONs are artifacts, never source)
87
- benchmarks/data_*/
88
- benchmarks/**/fast_results/
89
- data_*/
90
- *_data/
91
- *_data_20*/
92
- cortexdb_data*/
93
- server_results/
94
- *.log
95
- docker_build.log
96
-
97
- # local gate/bench data stores
98
- gate*_data_*/
99
-
100
- # Code-plane benchmark local run outputs (official results are committed deliberately)
101
- benchmarks/codeplane/runs/
1
+ # Rust
2
+ /target
3
+ **/*.rs.bk
4
+
5
+ # Environment / secrets
6
+ .env
7
+ .env.local
8
+ .env*.local
9
+ *.pem
10
+ *.key
11
+ .npmrc
12
+
13
+ # SQLite database
14
+ *.sqlite
15
+ *.sqlite-wal
16
+ *.sqlite-shm
17
+
18
+ # OS
19
+ .DS_Store
20
+ Thumbs.db
21
+ desktop.ini
22
+
23
+ # IDE
24
+ .idea/
25
+ .vscode/
26
+ *.swp
27
+ *.swo
28
+
29
+ # Data directories
30
+ cortexdb_data*/
31
+ /data/
32
+ # Per-bench tenant stores (RocksDB + Tantivy + HNSW state; regeneratable per run)
33
+ /data_*/
34
+ # Experimental per-branch stores (not tracked on this branch but left gitignored
35
+ # so checkout from other branches doesn't surface them in git status)
36
+ /event_memory_store/
37
+ /llm_cache/
38
+
39
+ # Benchmark inputs and per-run outputs (kept local, regenerated each run)
40
+ benchmarks/longmemeval/data/
41
+ benchmarks/longmemeval/server_results/
42
+ benchmarks/longmemeval/fast_results/
43
+ benchmarks/longmemeval/micro_results/
44
+ benchmarks/longmemeval/server_logs/
45
+ benchmarks/longmemeval/*.log
46
+ benchmarks/locomo/locomo_results*.json
47
+ benchmarks/locomo/server_results/
48
+ benchmarks/locomo/*.log
49
+ /answer_out.json
50
+
51
+ # Local Claude Code state
52
+ .claude/
53
+ .tmp/
54
+
55
+ # Python
56
+ __pycache__/
57
+ *.pyc
58
+ .venv/
59
+ venv/
60
+
61
+ # Node
62
+ node_modules/
63
+ dist/
64
+ .next/
65
+
66
+ # Egg info
67
+ *.egg-info/
68
+
69
+ # Scratch/debug text files at root
70
+ /*.txt
71
+ /*.log
72
+
73
+ # Local debug / marketing / private content (not for repo)
74
+ harness/.reports/
75
+ harness_data_*/
76
+ blog/
77
+ sales/
78
+ videos/
79
+ local-instance/
80
+
81
+ # doc-claims verifier scratch output
82
+ tools/verifier_out*/
83
+
84
+ # Generated benchmark / runtime data (audit M-10: hundreds of untracked
85
+ # data dirs at the workspace root slow every git status/search and risk
86
+ # being packaged; results JSONs are artifacts, never source)
87
+ benchmarks/data_*/
88
+ benchmarks/**/fast_results/
89
+ data_*/
90
+ *_data/
91
+ *_data_20*/
92
+ cortexdb_data*/
93
+ server_results/
94
+ *.log
95
+ docker_build.log
96
+
97
+ # local gate/bench data stores
98
+ gate*_data_*/
99
+
100
+ # Code-plane benchmark local run outputs (official results are committed deliberately)
101
+ benchmarks/codeplane/runs/
102
+
103
+ # Deliberately untracked, four times over: `git add docs/` keeps sweeping the
104
+ # investor deck sources and the 2.7 MB OpenClaw design PDF back in (7dda2a51,
105
+ # 3a778b5e, then again in the 2026-09-09 checkpoint). They live on disk; they do
106
+ # not belong in the repo. Stage explicit paths, never a directory.
107
+ docs/investor_deck/
108
+ docs/CORTEX_OPENCLAW_AGENT_OS_DESIGN.md
109
+ docs/CORTEX_OPENCLAW_AGENT_OS_DESIGN.pdf
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cortexdb-cli
3
- Version: 0.5.3
3
+ Version: 0.5.4
4
4
  Summary: Command-line interface for CortexDB — the long-term memory layer for AI.
5
5
  Project-URL: Homepage, https://cortexdb.ai
6
6
  Project-URL: Documentation, https://cortexdb.ai/docs
@@ -1,3 +1,3 @@
1
- """CortexDB CLI - command-line interface for the v1 memory API."""
2
-
3
- __version__ = "0.5.3"
1
+ """CortexDB CLI - command-line interface for the v1 memory API."""
2
+
3
+ __version__ = "0.5.4"
@@ -63,6 +63,14 @@ class CortexClient:
63
63
  ) -> httpx.Response:
64
64
  return self._http.post(path, json=json, params=params)
65
65
 
66
+ def put(
67
+ self,
68
+ path: str,
69
+ json: dict[str, Any] | None = None,
70
+ params: dict[str, Any] | None = None,
71
+ ) -> httpx.Response:
72
+ return self._http.put(path, json=json, params=params)
73
+
66
74
  def delete(self, path: str, params: dict[str, Any] | None = None) -> httpx.Response:
67
75
  return self._http.delete(path, params=params)
68
76
 
@@ -0,0 +1,612 @@
1
+ """cortexdb admin - health, usage, layer stats, retention, unified WAL."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import click
8
+
9
+ from cortexdb_cli.client import get_client
10
+ from cortexdb_cli.errors import handle_errors
11
+ from cortexdb_cli.output import (
12
+ console,
13
+ escape,
14
+ is_json_mode,
15
+ print_health,
16
+ print_json,
17
+ print_ontology,
18
+ print_success,
19
+ print_usage,
20
+ spinner,
21
+ )
22
+
23
+
24
+ @click.group("admin")
25
+ def admin_group() -> None:
26
+ """Diagnostics - reachability, identity, rate-limit, layer status."""
27
+
28
+
29
+ @admin_group.command("health")
30
+ @click.pass_context
31
+ @handle_errors
32
+ def admin_health(ctx: click.Context) -> None:
33
+ """Auth + reachability check (GET /v1/auth/whoami)."""
34
+ cfg = ctx.obj
35
+ with get_client(cfg) as client:
36
+ resp = client.get("/v1/auth/whoami")
37
+ resp.raise_for_status()
38
+ body = resp.json()
39
+ # Normalise into the shape print_health expects.
40
+ body.setdefault("status", "ok")
41
+ print_health(body, cfg)
42
+
43
+
44
+ @admin_group.command("usage")
45
+ @click.pass_context
46
+ @handle_errors
47
+ def admin_usage(ctx: click.Context) -> None:
48
+ """Tier, capabilities, rate-limit headroom, token expiry."""
49
+ cfg = ctx.obj
50
+ with get_client(cfg) as client:
51
+ if not is_json_mode(cfg):
52
+ with spinner("Loading usage..."):
53
+ resp = client.get("/v1/auth/whoami")
54
+ else:
55
+ resp = client.get("/v1/auth/whoami")
56
+ resp.raise_for_status()
57
+ body = resp.json()
58
+ # Fold rate-limit + expiry response headers into the body so the
59
+ # output formatter can render them in one block.
60
+ for k in (
61
+ "X-RateLimit-Limit",
62
+ "X-RateLimit-Reset",
63
+ "X-RateLimit-Remaining",
64
+ "X-Cortex-Token-Expires-In",
65
+ "X-Cortex-Token-Expires-At",
66
+ ):
67
+ if k in resp.headers:
68
+ body[k] = resp.headers[k]
69
+ print_usage(body, cfg)
70
+
71
+
72
+ @admin_group.command("layers")
73
+ @click.pass_context
74
+ @handle_errors
75
+ def admin_layers(ctx: click.Context) -> None:
76
+ """Per-layer counts and synthesis lag (GET /v1/admin/layers/stats).
77
+
78
+ Stability: experimental - the endpoint is gated by the
79
+ `admin.layers.stats` capability and may not be present on the free tier.
80
+ """
81
+ cfg = ctx.obj
82
+ with get_client(cfg) as client:
83
+ if not is_json_mode(cfg):
84
+ with spinner("Loading layer stats..."):
85
+ resp = client.get("/v1/admin/layers/stats")
86
+ else:
87
+ resp = client.get("/v1/admin/layers/stats")
88
+ resp.raise_for_status()
89
+ print_ontology(resp.json(), cfg)
90
+
91
+
92
+ # -- retention (unified WAL) -------------------------------------------------
93
+ #
94
+ # The operator surface over the D1 retention plane. Every write here is
95
+ # irreversible in effect - an applied sweep ERASES expired captured events
96
+ # through the ordinary erasure plane - so reporting is the default on both
97
+ # mutating commands and `--apply` is the only thing that sends
98
+ # `dry_run: false`.
99
+
100
+
101
+ def _parse_source_days(pairs: tuple[str, ...]) -> dict[str, int]:
102
+ """Parse repeated ``--source-days KEY=N`` options into the wire map.
103
+
104
+ Only the option's own grammar is checked here (KEY=VALUE, N a positive
105
+ integer). The ``source:`` prefix rule and the 1..=36500 ceiling belong to
106
+ the server's governance validator, which answers 422
107
+ INVALID_RETENTION_POLICY - duplicating them here would let the CLI and
108
+ the server disagree about what a valid rule is.
109
+ """
110
+ out: dict[str, int] = {}
111
+ for pair in pairs:
112
+ if "=" not in pair:
113
+ raise click.UsageError(f"--source-days must be KEY=DAYS, got {pair!r}")
114
+ key, raw = pair.split("=", 1)
115
+ key = key.strip()
116
+ if not key:
117
+ raise click.UsageError(f"--source-days needs a key, got {pair!r}")
118
+ try:
119
+ days = int(raw)
120
+ except ValueError:
121
+ raise click.UsageError(
122
+ f"--source-days value must be a whole number of days, got {raw!r}"
123
+ ) from None
124
+ if days < 1:
125
+ raise click.UsageError(
126
+ f"--source-days must be at least 1 day, got {days} for {key!r}"
127
+ )
128
+ if key in out:
129
+ raise click.UsageError(f"--source-days {key!r} given twice")
130
+ out[key] = days
131
+ return out
132
+
133
+
134
+ def _print_retention_status(data: dict[str, Any], cfg: dict[str, Any]) -> None:
135
+ """Render GET /v1/admin/retention."""
136
+ if is_json_mode(cfg):
137
+ print_json(data)
138
+ return
139
+
140
+ from rich.table import Table
141
+
142
+ config = Table(title="[header]Retention[/header]", border_style="cyan")
143
+ config.add_column("Setting", style="bold")
144
+ config.add_column("Value")
145
+ interval = data.get("sweep_interval_secs", 0)
146
+ if data.get("enabled"):
147
+ scheduled = "yes"
148
+ elif data.get("scheduler_disabled"):
149
+ scheduled = "[warning]no (CORTEX_SCHEDULER_DISABLE)[/warning]"
150
+ else:
151
+ scheduled = "no (operator-triggered only)"
152
+ config.add_row("scheduled", scheduled)
153
+ config.add_row("sweep interval", f"{interval}s" if interval else "-")
154
+ default_days = data.get("deployment_default_days")
155
+ config.add_row("deployment default", f"{default_days}d" if default_days else "keep")
156
+ config.add_row("max events/tick", str(data.get("max_events_per_tick", "")))
157
+ config.add_row("tick budget", str(data.get("tick_budget_secs", "")) + "s")
158
+ config.add_row(
159
+ "compact after sweep", "yes" if data.get("compact_after_sweep") else "no"
160
+ )
161
+ config.add_row("cursor", str(data.get("cursor", "")))
162
+ config.add_row("watermark", str(data.get("watermark", "")))
163
+ config.add_row(
164
+ "cursor trustworthy",
165
+ "yes" if data.get("fingerprint_ok") else "no (next applied tick rescans)",
166
+ )
167
+ config.add_row("running", "yes" if data.get("running") else "no")
168
+ if data.get("native_profile_blocked"):
169
+ config.add_row("native profile", "[warning]retention refused[/warning]")
170
+ console.print(config)
171
+
172
+ policies = data.get("policies") or []
173
+ if policies:
174
+ table = Table(title="[header]Policies[/header]", border_style="cyan")
175
+ table.add_column("Scope", style="bold")
176
+ table.add_column("Default", justify="right")
177
+ table.add_column("Per-source", style="dim")
178
+ for entry in policies:
179
+ policy = entry.get("policy") or {}
180
+ days = policy.get("default_retention_days")
181
+ per_source = policy.get("per_source_days") or {}
182
+ table.add_row(
183
+ escape(str(entry.get("scope", ""))),
184
+ f"{days}d" if days else "keep",
185
+ escape(", ".join(f"{k}={v}d" for k, v in sorted(per_source.items()))),
186
+ )
187
+ console.print(table)
188
+ else:
189
+ console.print("[dim]No per-scope retention rules stored.[/dim]")
190
+
191
+ last = data.get("last")
192
+ if isinstance(last, dict):
193
+ note = " [warning](dry run)[/warning]" if last.get("dry_run") else ""
194
+ console.print(
195
+ "[dim]Last tick:[/dim] "
196
+ f"scanned {last.get('scanned', 0)}, "
197
+ f"expired {last.get('expired', 0)}, "
198
+ f"erased {last.get('erased', 0)}, "
199
+ f"stopped {escape(str(last.get('stopped', '')))}" + note
200
+ )
201
+
202
+
203
+ def _print_sweep_report(data: dict[str, Any], cfg: dict[str, Any]) -> None:
204
+ """Render POST /v1/admin/retention/sweep (or its 202 still-running body)."""
205
+ if is_json_mode(cfg):
206
+ print_json(data)
207
+ return
208
+
209
+ from rich.panel import Panel
210
+
211
+ if "erased" not in data:
212
+ # 202: the tick outlived the blocking-lane deadline and is still going.
213
+ poll = escape(str(data.get("poll", "/v1/admin/retention")))
214
+ console.print(
215
+ Panel(
216
+ f"Sweep still running; poll [bold]{poll}[/bold]",
217
+ title="[header]Retention sweep[/header]",
218
+ border_style="yellow",
219
+ )
220
+ )
221
+ return
222
+
223
+ dry = data.get("dry_run", True)
224
+ lines = [
225
+ f"scanned [bold]{data.get('scanned', 0)}[/bold] captured row(s)",
226
+ f"expired [bold]{data.get('expired', 0)}[/bold], "
227
+ f"erased [bold]{data.get('erased', 0)}[/bold], "
228
+ f"kept forever [bold]{data.get('kept_forever', 0)}[/bold]",
229
+ f"cursor {data.get('cursor_before', 0)} -> {data.get('cursor_after', 0)} "
230
+ f"(watermark {data.get('watermark', 0)})",
231
+ f"stopped: {escape(str(data.get('stopped', '')))} "
232
+ f"in {data.get('elapsed_ms', 0)}ms",
233
+ ]
234
+ held = data.get("held") or {}
235
+ if isinstance(held, dict) and any(held.values()):
236
+ lines.append(f"[dim]held: {escape(str(held))}[/dim]")
237
+ for error in data.get("errors") or []:
238
+ lines.append(f"[error]{escape(str(error))}[/error]")
239
+ title = "[header]Retention sweep" + (" (dry run)" if dry else " (APPLIED)")
240
+ console.print(
241
+ Panel(
242
+ "\n".join(lines),
243
+ title=title + "[/header]",
244
+ border_style="yellow" if dry else "red",
245
+ )
246
+ )
247
+
248
+
249
+ def _print_wal_status(data: dict[str, Any], cfg: dict[str, Any]) -> None:
250
+ """Render GET /v1/admin/wal."""
251
+ if is_json_mode(cfg):
252
+ print_json(data)
253
+ return
254
+
255
+ from rich.table import Table
256
+
257
+ table = Table(title="[header]Unified WAL[/header]", border_style="cyan")
258
+ table.add_column("Field", style="bold")
259
+ table.add_column("Value", justify="right")
260
+ for label, key in (
261
+ ("live rows", "live_count"),
262
+ ("next offset", "next_offset"),
263
+ ("head offset", "head_offset"),
264
+ ("captured head", "captured_head_offset"),
265
+ ("partitions", "partitions"),
266
+ ("retired markers", "retired_marker_count"),
267
+ ):
268
+ value = data.get(key)
269
+ table.add_row(label, "-" if value is None else str(value))
270
+ if data.get("native_profile_blocked"):
271
+ table.add_row("native profile", "[warning]mutations refused[/warning]")
272
+ console.print(table)
273
+
274
+ consumers = data.get("consumer_offsets") or {}
275
+ if consumers:
276
+ floors = Table(title="[header]Consumer floors[/header]", border_style="cyan")
277
+ floors.add_column("Consumer", style="bold")
278
+ floors.add_column("Offset", justify="right")
279
+ for name, offset in sorted(consumers.items()):
280
+ floors.add_row(escape(str(name)), str(offset))
281
+ console.print(floors)
282
+
283
+ verify = data.get("verify")
284
+ if isinstance(verify, dict):
285
+ ok = verify.get("ok")
286
+ if ok is None:
287
+ # Not a verdict: the self-check is bounded and a production-sized
288
+ # log is past that bound, so nothing was checked. Rendering this
289
+ # as a failure is how a healthy store gets restored from backup.
290
+ skipped = escape(
291
+ str(verify.get("skipped", "log exceeds the bounded self-check"))
292
+ )
293
+ console.print(f"[warning]Integrity check not run:[/warning] {skipped}")
294
+ elif ok:
295
+ console.print("[success]Integrity check passed.[/success]")
296
+ else:
297
+ reason = escape(str(verify.get("reason", "unknown")))
298
+ console.print(f"[error]Integrity check failed:[/error] {reason}")
299
+
300
+
301
+ def _print_compaction(data: dict[str, Any], cfg: dict[str, Any]) -> None:
302
+ """Render POST /v1/admin/wal/compact (or its 202 still-running body)."""
303
+ if is_json_mode(cfg):
304
+ print_json(data)
305
+ return
306
+
307
+ from rich.panel import Panel
308
+
309
+ if "removed_tombstones" not in data:
310
+ poll = escape(str(data.get("poll", "/v1/admin/wal")))
311
+ console.print(
312
+ Panel(
313
+ f"Compaction still running; poll [bold]{poll}[/bold]",
314
+ title="[header]WAL compaction[/header]",
315
+ border_style="yellow",
316
+ )
317
+ )
318
+ return
319
+
320
+ dry = data.get("dry_run", True)
321
+ lines = [
322
+ f"scanned [bold]{data.get('scanned', 0)}[/bold] row(s)",
323
+ f"removed {data.get('removed_tombstones', 0)} tombstone(s), "
324
+ f"{data.get('removed_orphaned_twins', 0)} orphaned twin(s)",
325
+ f"skipped {data.get('skipped_in_use', 0)} in use, "
326
+ f"{data.get('skipped_above_bound', 0)} above the bound",
327
+ f"live rows {data.get('live_count_before', 0)} -> "
328
+ f"{data.get('live_count_after', 0)}, "
329
+ f"{data.get('bytes_removed', 0)} byte(s) reclaimed",
330
+ f"ranges compacted: {data.get('ranges_compacted', 0)} "
331
+ f"in {data.get('elapsed_ms', 0)}ms",
332
+ ]
333
+ if data.get("truncated_by_budget"):
334
+ lines.append("[warning]Budget reached - rerun to continue.[/warning]")
335
+ title = "[header]WAL compaction" + (" (dry run)" if dry else " (APPLIED)")
336
+ console.print(
337
+ Panel(
338
+ "\n".join(lines),
339
+ title=title + "[/header]",
340
+ border_style="yellow" if dry else "red",
341
+ )
342
+ )
343
+
344
+
345
+ @admin_group.group("retention")
346
+ def retention_group() -> None:
347
+ """Unified-WAL retention - per-scope rules and the expiry sweep.
348
+
349
+ Requires trusted-operator access. Reporting needs `diagnostics.read`;
350
+ storing a rule or applying a sweep needs `admin.compact`.
351
+ """
352
+
353
+
354
+ @retention_group.command("show")
355
+ @click.pass_context
356
+ @handle_errors
357
+ def retention_show(ctx: click.Context) -> None:
358
+ """Config, stored rules, cursor and last tick (GET /v1/admin/retention).
359
+
360
+ 503 NOT_AVAILABLE when this process runs no retention sweeper (legacy
361
+ WAL, no coordinator, or a configuration the sweeper refused).
362
+ """
363
+ cfg = ctx.obj
364
+ with get_client(cfg) as client:
365
+ if not is_json_mode(cfg):
366
+ with spinner("Loading retention status..."):
367
+ resp = client.get("/v1/admin/retention")
368
+ else:
369
+ resp = client.get("/v1/admin/retention")
370
+ resp.raise_for_status()
371
+ _print_retention_status(resp.json(), cfg)
372
+
373
+
374
+ @retention_group.command("set")
375
+ @click.option("--scope", "-S", required=True, help="Scope path the rule is keyed on.")
376
+ @click.option(
377
+ "--days",
378
+ type=click.IntRange(min=1),
379
+ default=None,
380
+ help="Expire captured events after this many days. Omit for an explicit KEEP.",
381
+ )
382
+ @click.option(
383
+ "--source-days",
384
+ "source_days",
385
+ multiple=True,
386
+ metavar="KEY=DAYS",
387
+ help="Per-source override, e.g. source:slack=30. Repeatable.",
388
+ )
389
+ @click.pass_context
390
+ @handle_errors
391
+ def retention_set(
392
+ ctx: click.Context,
393
+ scope: str,
394
+ days: int | None,
395
+ source_days: tuple[str, ...],
396
+ ) -> None:
397
+ """Store the retention rule for one scope (PUT /v1/admin/retention/policies).
398
+
399
+ Irreversible in effect: an applied sweep under this rule ERASES expired
400
+ events from the scope and every scope beneath it. Omitting `--days`
401
+ stores an explicit KEEP, which overrides the deployment default for the
402
+ subtree but never a shorter rule inherited from an ancestor scope.
403
+ Storing a rule always resets the durable sweep cursor.
404
+ """
405
+ cfg = ctx.obj
406
+ body: dict[str, Any] = {"scope": scope, "default_retention_days": days}
407
+ per_source = _parse_source_days(source_days)
408
+ if per_source:
409
+ body["per_source_days"] = per_source
410
+ with get_client(cfg) as client:
411
+ resp = client.put("/v1/admin/retention/policies", json=body)
412
+ resp.raise_for_status()
413
+ data = resp.json()
414
+ if is_json_mode(cfg):
415
+ print_json(data)
416
+ return
417
+ policy = data.get("policy") or {}
418
+ stored = policy.get("default_retention_days")
419
+ reset = " (sweep cursor reset)" if data.get("cursor_reset") else ""
420
+ print_success(
421
+ f"Retention rule stored for {scope}: "
422
+ + (f"{stored} day(s)" if stored else "keep")
423
+ + reset
424
+ )
425
+
426
+
427
+ @retention_group.command("delete")
428
+ @click.option("--scope", "-S", required=True, help="Scope path whose rule to remove.")
429
+ @click.pass_context
430
+ @handle_errors
431
+ def retention_delete(ctx: click.Context, scope: str) -> None:
432
+ """Remove one scope's rule (DELETE /v1/admin/retention/policies).
433
+
434
+ 404 when a well-formed store held no rule for exactly this scope -
435
+ inheritance is resolution, not storage, so a descendant that expires by
436
+ an ancestor's rule has no rule of its own to delete. Resets the durable
437
+ sweep cursor, like `set`.
438
+ """
439
+ cfg = ctx.obj
440
+ with get_client(cfg) as client:
441
+ resp = client.delete("/v1/admin/retention/policies", params={"scope": scope})
442
+ resp.raise_for_status()
443
+ if is_json_mode(cfg):
444
+ print_json({"scope": scope, "deleted": True, "cursor_reset": True})
445
+ return
446
+ print_success(f"Retention rule removed for {scope} (sweep cursor reset)")
447
+
448
+
449
+ @retention_group.command("sweep")
450
+ @click.option(
451
+ "--apply",
452
+ "apply_",
453
+ is_flag=True,
454
+ default=False,
455
+ help="ERASE expired events. Without it the tick only reports.",
456
+ )
457
+ @click.option("--scope", "-S", default=None, help="Confine the scan to this subtree.")
458
+ @click.option(
459
+ "--older-than-secs",
460
+ type=click.IntRange(min=1),
461
+ default=None,
462
+ help="Request-only TTL override for --scope. Never persisted.",
463
+ )
464
+ @click.option(
465
+ "--max-events",
466
+ type=click.IntRange(min=1),
467
+ default=None,
468
+ help="Override the per-tick expiry budget.",
469
+ )
470
+ @click.option(
471
+ "--rescan",
472
+ is_flag=True,
473
+ default=False,
474
+ help="Ignore the durable cursor and scan from the start of the log.",
475
+ )
476
+ @click.pass_context
477
+ @handle_errors
478
+ def retention_sweep(
479
+ ctx: click.Context,
480
+ apply_: bool,
481
+ scope: str | None,
482
+ older_than_secs: int | None,
483
+ max_events: int | None,
484
+ rescan: bool,
485
+ ) -> None:
486
+ """Run one retention tick (POST /v1/admin/retention/sweep).
487
+
488
+ DRY RUN IS THE DEFAULT - the tick reports what would be erased and
489
+ writes nothing. `--apply` is what sends `dry_run: false`, which erases
490
+ expired captured events irreversibly and needs `admin.compact`.
491
+
492
+ Single-flight: a second concurrent tick is refused with 409
493
+ RETENTION_SWEEP_RUNNING rather than queued.
494
+ """
495
+ cfg = ctx.obj
496
+ if older_than_secs is not None and not scope:
497
+ raise click.UsageError("--older-than-secs requires --scope.")
498
+ body: dict[str, Any] = {"dry_run": not apply_}
499
+ if scope:
500
+ body["scope"] = scope
501
+ if older_than_secs is not None:
502
+ body["older_than_secs"] = older_than_secs
503
+ if max_events is not None:
504
+ body["max_events"] = max_events
505
+ if rescan:
506
+ body["rescan"] = True
507
+ with get_client(cfg) as client:
508
+ if not is_json_mode(cfg):
509
+ with spinner("Sweeping..."):
510
+ resp = client.post("/v1/admin/retention/sweep", json=body)
511
+ else:
512
+ resp = client.post("/v1/admin/retention/sweep", json=body)
513
+ resp.raise_for_status()
514
+ _print_sweep_report(resp.json(), cfg)
515
+
516
+
517
+ @admin_group.group("wal")
518
+ def wal_group() -> None:
519
+ """Unified write-ahead log - census and dead-row compaction.
520
+
521
+ Requires trusted-operator access. A legacy data dir is refused with 409
522
+ RETENTION_UNSUPPORTED_BACKEND naming the offline migrator.
523
+ """
524
+
525
+
526
+ @wal_group.command("status")
527
+ @click.option(
528
+ "--verify",
529
+ is_flag=True,
530
+ default=False,
531
+ help=(
532
+ "Also run the bounded row-level self-check (clamped server-side). "
533
+ "The check is bounded at 1M rows / 512 MiB, which a production-sized "
534
+ "log exceeds: it then reports 'not run', which is not a failure."
535
+ ),
536
+ )
537
+ @click.pass_context
538
+ @handle_errors
539
+ def wal_status(ctx: click.Context, verify: bool) -> None:
540
+ """Live rows, offsets, consumer floors, markers (GET /v1/admin/wal)."""
541
+ cfg = ctx.obj
542
+ params = {"verify": "true"} if verify else None
543
+ with get_client(cfg) as client:
544
+ if not is_json_mode(cfg):
545
+ with spinner("Loading WAL census..."):
546
+ resp = client.get("/v1/admin/wal", params=params)
547
+ else:
548
+ resp = client.get("/v1/admin/wal", params=params)
549
+ resp.raise_for_status()
550
+ _print_wal_status(resp.json(), cfg)
551
+
552
+
553
+ @wal_group.command("compact")
554
+ @click.option(
555
+ "--apply",
556
+ "apply_",
557
+ is_flag=True,
558
+ default=False,
559
+ help="Physically remove the dead rows. Without it the run only reports.",
560
+ )
561
+ @click.option(
562
+ "--max-rows",
563
+ type=click.IntRange(min=1),
564
+ default=None,
565
+ help="Upper bound on dead rows removed in one sweep.",
566
+ )
567
+ @click.option(
568
+ "--below-offset",
569
+ type=click.IntRange(min=0),
570
+ default=None,
571
+ help="Exclusive offset bound. Defaults to the indexer watermark.",
572
+ )
573
+ @click.option(
574
+ "--compaction",
575
+ type=click.Choice(["none", "touched_ranges", "full"]),
576
+ default=None,
577
+ help="Physical reclamation mode. `full` is a whole-CF compaction.",
578
+ )
579
+ @click.pass_context
580
+ @handle_errors
581
+ def wal_compact(
582
+ ctx: click.Context,
583
+ apply_: bool,
584
+ max_rows: int | None,
585
+ below_offset: int | None,
586
+ compaction: str | None,
587
+ ) -> None:
588
+ """Reclaim already-dead WAL rows (POST /v1/admin/wal/compact).
589
+
590
+ Removes only rows that are ALREADY logically dead - redaction
591
+ tombstones and orphaned Derived twins. It never removes a live row and
592
+ never touches derived-artifact history.
593
+
594
+ DRY RUN IS THE DEFAULT; `--apply` is what sends `dry_run: false` and
595
+ needs `admin.compact`. Single-flight: 409 WAL_COMPACTION_RUNNING.
596
+ """
597
+ cfg = ctx.obj
598
+ body: dict[str, Any] = {"dry_run": not apply_}
599
+ if max_rows is not None:
600
+ body["max_rows"] = max_rows
601
+ if below_offset is not None:
602
+ body["below_offset"] = below_offset
603
+ if compaction is not None:
604
+ body["compaction"] = compaction
605
+ with get_client(cfg) as client:
606
+ if not is_json_mode(cfg):
607
+ with spinner("Compacting..."):
608
+ resp = client.post("/v1/admin/wal/compact", json=body)
609
+ else:
610
+ resp = client.post("/v1/admin/wal/compact", json=body)
611
+ resp.raise_for_status()
612
+ _print_compaction(resp.json(), cfg)
@@ -36,25 +36,81 @@ def _echo(cfg, payload) -> None:
36
36
  @code_group.command("add")
37
37
  @click.argument("name")
38
38
  @click.argument("path", type=click.Path(exists=True, file_okay=False))
39
+ @click.option(
40
+ "--scope",
41
+ default=None,
42
+ help=(
43
+ "Parent scope the repository binds under (default: the tenant "
44
+ "root org:<billing id>). Every later read or mutation is "
45
+ "authorized against that binding."
46
+ ),
47
+ )
39
48
  @click.pass_context
40
49
  @handle_errors
41
- def code_add(ctx: click.Context, name: str, path: str) -> None:
50
+ def code_add(ctx: click.Context, name: str, path: str, scope: str | None) -> None:
42
51
  """Register + index a local repository (POST /v1/code/repos).
43
52
 
44
53
  NAME is the repo handle used by every other verb; PATH is the local
45
54
  checkout. Indexing runs synchronously and reports the fact counts.
55
+
56
+ --scope binds the repository under a scope you already own; the
57
+ server appends a stable ``repo:<dirname>-<digest>`` segment and the
58
+ response reports the resulting binding. A scope that does not parse,
59
+ or a ``global:`` one, is refused 422 INVALID_SCOPE; one you may not
60
+ register under is 403 POLICY_DENIED.
46
61
  """
47
62
  cfg = ctx.obj
48
63
  absolute = str(pathlib.Path(path).resolve())
64
+ body: dict = {"name": name, "path": absolute}
65
+ if scope is not None:
66
+ body["scope"] = scope
49
67
  with get_client(cfg) as client:
50
68
  with spinner(f"Indexing {name}..."):
51
- resp = client.post(
52
- "/v1/code/repos", json={"name": name, "path": absolute}
53
- )
69
+ resp = client.post("/v1/code/repos", json=body)
54
70
  resp.raise_for_status()
55
71
  _echo(cfg, resp.json())
56
72
 
57
73
 
74
+ @code_group.command("remove")
75
+ @click.argument("name")
76
+ @click.option("--yes", "-y", is_flag=True, help="Skip confirmation")
77
+ @click.pass_context
78
+ @handle_errors
79
+ def code_remove(ctx: click.Context, name: str, yes: bool) -> None:
80
+ """De-register a repository AND free its bytes (DELETE
81
+ /v1/code/repos/NAME).
82
+
83
+ Prints the purge report: the directory that was removed, how many
84
+ bytes it held, and whether the registry journal was compacted (a
85
+ false there means the bytes are gone but the journal still names the
86
+ checkout path; removing the same name again retries it).
87
+
88
+ 503 REPO_PURGE_BUSY means the repository is ALREADY de-registered but
89
+ readers still hold its stores open - run the same command again to
90
+ finish freeing the directory.
91
+ """
92
+ cfg = ctx.obj
93
+ if not yes and not is_json_mode(cfg):
94
+ click.confirm(
95
+ f"Remove repository {name} and delete its indexed data?", abort=True
96
+ )
97
+ with get_client(cfg) as client:
98
+ with spinner(f"Purging {name}..."):
99
+ resp = client.delete(f"/v1/code/repos/{name}")
100
+ resp.raise_for_status()
101
+ body = resp.json()
102
+ if is_json_mode(cfg):
103
+ _echo(cfg, body)
104
+ return
105
+ purge = body.get("purge") or {}
106
+ click.echo(f"Removed {body.get('removed', name)}")
107
+ click.echo(f" scope: {purge.get('scope', '')}")
108
+ click.echo(f" directory: {purge.get('directory', '')}")
109
+ click.echo(f" freed: {purge.get('bytes_freed', 0)} bytes")
110
+ compacted = "compacted" if purge.get("registry_compacted") else "NOT compacted"
111
+ click.echo(f" registry: {compacted}")
112
+
113
+
58
114
  @code_group.command("status")
59
115
  @click.argument("name", required=False)
60
116
  @click.pass_context
@@ -1,56 +1,56 @@
1
- [build-system]
2
- requires = ["hatchling"]
3
- build-backend = "hatchling.build"
4
-
5
- [project]
6
- name = "cortexdb-cli"
7
- version = "0.5.3"
8
- description = "Command-line interface for CortexDB — the long-term memory layer for AI."
9
- readme = "README.md"
10
- license = "Apache-2.0"
11
- requires-python = ">=3.10"
12
- authors = [
13
- { name = "CortexDB Team", email = "team@cortexdb.ai" },
14
- ]
15
- keywords = ["ai", "memory", "cli", "cortexdb", "agents", "llm"]
16
- classifiers = [
17
- "Development Status :: 4 - Beta",
18
- "Environment :: Console",
19
- "Intended Audience :: Developers",
20
- "License :: OSI Approved :: Apache Software License",
21
- "Programming Language :: Python :: 3",
22
- "Programming Language :: Python :: 3.10",
23
- "Programming Language :: Python :: 3.11",
24
- "Programming Language :: Python :: 3.12",
25
- "Programming Language :: Python :: 3.13",
26
- "Topic :: Scientific/Engineering :: Artificial Intelligence",
27
- "Topic :: Database",
28
- ]
29
- dependencies = [
30
- "click>=8.1",
31
- "rich>=13.0",
32
- "rich-click>=1.7",
33
- "httpx>=0.27",
34
- "prompt-toolkit>=3.0",
35
- "tomli>=2.0; python_version < '3.11'",
36
- "tomli-w>=1.0",
37
- ]
38
-
39
- [project.scripts]
40
- cortexdb = "cortexdb_cli.main:cli"
41
-
42
- [project.urls]
43
- Homepage = "https://cortexdb.ai"
44
- Documentation = "https://cortexdb.ai/docs"
45
- Repository = "https://github.com/cortexdb/cortexdb"
46
- Issues = "https://github.com/cortexdb/cortexdb/issues"
47
-
48
- [tool.hatch.build.targets.wheel]
49
- packages = ["cortexdb_cli"]
50
-
51
- [tool.hatch.build.targets.sdist]
52
- include = ["cortexdb_cli", "README.md", "LICENSE"]
53
-
54
- [tool.ruff]
55
- target-version = "py310"
56
- line-length = 100
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "cortexdb-cli"
7
+ version = "0.5.4"
8
+ description = "Command-line interface for CortexDB — the long-term memory layer for AI."
9
+ readme = "README.md"
10
+ license = "Apache-2.0"
11
+ requires-python = ">=3.10"
12
+ authors = [
13
+ { name = "CortexDB Team", email = "team@cortexdb.ai" },
14
+ ]
15
+ keywords = ["ai", "memory", "cli", "cortexdb", "agents", "llm"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Environment :: Console",
19
+ "Intended Audience :: Developers",
20
+ "License :: OSI Approved :: Apache Software License",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
27
+ "Topic :: Database",
28
+ ]
29
+ dependencies = [
30
+ "click>=8.1",
31
+ "rich>=13.0",
32
+ "rich-click>=1.7",
33
+ "httpx>=0.27",
34
+ "prompt-toolkit>=3.0",
35
+ "tomli>=2.0; python_version < '3.11'",
36
+ "tomli-w>=1.0",
37
+ ]
38
+
39
+ [project.scripts]
40
+ cortexdb = "cortexdb_cli.main:cli"
41
+
42
+ [project.urls]
43
+ Homepage = "https://cortexdb.ai"
44
+ Documentation = "https://cortexdb.ai/docs"
45
+ Repository = "https://github.com/cortexdb/cortexdb"
46
+ Issues = "https://github.com/cortexdb/cortexdb/issues"
47
+
48
+ [tool.hatch.build.targets.wheel]
49
+ packages = ["cortexdb_cli"]
50
+
51
+ [tool.hatch.build.targets.sdist]
52
+ include = ["cortexdb_cli", "README.md", "LICENSE"]
53
+
54
+ [tool.ruff]
55
+ target-version = "py310"
56
+ line-length = 100
@@ -1,83 +0,0 @@
1
- """cortexdb admin - health, usage, layer stats."""
2
-
3
- from __future__ import annotations
4
-
5
- import click
6
-
7
- from cortexdb_cli.client import get_client
8
- from cortexdb_cli.errors import handle_errors
9
- from cortexdb_cli.output import (
10
- is_json_mode,
11
- print_health,
12
- print_ontology,
13
- print_usage,
14
- spinner,
15
- )
16
-
17
-
18
- @click.group("admin")
19
- def admin_group() -> None:
20
- """Diagnostics - reachability, identity, rate-limit, layer status."""
21
-
22
-
23
- @admin_group.command("health")
24
- @click.pass_context
25
- @handle_errors
26
- def admin_health(ctx: click.Context) -> None:
27
- """Auth + reachability check (GET /v1/auth/whoami)."""
28
- cfg = ctx.obj
29
- with get_client(cfg) as client:
30
- resp = client.get("/v1/auth/whoami")
31
- resp.raise_for_status()
32
- body = resp.json()
33
- # Normalise into the shape print_health expects.
34
- body.setdefault("status", "ok")
35
- print_health(body, cfg)
36
-
37
-
38
- @admin_group.command("usage")
39
- @click.pass_context
40
- @handle_errors
41
- def admin_usage(ctx: click.Context) -> None:
42
- """Tier, capabilities, rate-limit headroom, token expiry."""
43
- cfg = ctx.obj
44
- with get_client(cfg) as client:
45
- if not is_json_mode(cfg):
46
- with spinner("Loading usage..."):
47
- resp = client.get("/v1/auth/whoami")
48
- else:
49
- resp = client.get("/v1/auth/whoami")
50
- resp.raise_for_status()
51
- body = resp.json()
52
- # Fold rate-limit + expiry response headers into the body so the
53
- # output formatter can render them in one block.
54
- for k in (
55
- "X-RateLimit-Limit",
56
- "X-RateLimit-Reset",
57
- "X-RateLimit-Remaining",
58
- "X-Cortex-Token-Expires-In",
59
- "X-Cortex-Token-Expires-At",
60
- ):
61
- if k in resp.headers:
62
- body[k] = resp.headers[k]
63
- print_usage(body, cfg)
64
-
65
-
66
- @admin_group.command("layers")
67
- @click.pass_context
68
- @handle_errors
69
- def admin_layers(ctx: click.Context) -> None:
70
- """Per-layer counts and synthesis lag (GET /v1/admin/layers/stats).
71
-
72
- Stability: experimental - the endpoint is gated by the
73
- `admin.layers.stats` capability and may not be present on the free tier.
74
- """
75
- cfg = ctx.obj
76
- with get_client(cfg) as client:
77
- if not is_json_mode(cfg):
78
- with spinner("Loading layer stats..."):
79
- resp = client.get("/v1/admin/layers/stats")
80
- else:
81
- resp = client.get("/v1/admin/layers/stats")
82
- resp.raise_for_status()
83
- print_ontology(resp.json(), cfg)
File without changes