cortexdb-cli 0.5.2__py3-none-any.whl → 0.5.4__py3-none-any.whl

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.
cortexdb_cli/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
- """CortexDB CLI - command-line interface for the v1 memory API."""
2
-
3
- __version__ = "0.5.2"
1
+ """CortexDB CLI - command-line interface for the v1 memory API."""
2
+
3
+ __version__ = "0.5.4"
cortexdb_cli/client.py CHANGED
@@ -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
 
@@ -1,15 +1,21 @@
1
- """cortexdb admin - health, usage, layer stats."""
1
+ """cortexdb admin - health, usage, layer stats, retention, unified WAL."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from typing import Any
6
+
5
7
  import click
6
8
 
7
9
  from cortexdb_cli.client import get_client
8
10
  from cortexdb_cli.errors import handle_errors
9
11
  from cortexdb_cli.output import (
12
+ console,
13
+ escape,
10
14
  is_json_mode,
11
15
  print_health,
16
+ print_json,
12
17
  print_ontology,
18
+ print_success,
13
19
  print_usage,
14
20
  spinner,
15
21
  )
@@ -81,3 +87,526 @@ def admin_layers(ctx: click.Context) -> None:
81
87
  resp = client.get("/v1/admin/layers/stats")
82
88
  resp.raise_for_status()
83
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,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cortexdb-cli
3
- Version: 0.5.2
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
@@ -62,8 +62,22 @@ cortexdb recall "Q3 revenue?"
62
62
  cortexdb
63
63
  ```
64
64
 
65
- The signup token has a 7-day TTL on the free tier. Re-run `cortexdb init`
66
- to refresh, or pass `--api-key` + `--actor` to use a token from your IdP.
65
+ The signup token has a 7-day TTL on the free tier.
66
+
67
+ **Re-running `cortexdb init` does not refresh it.** `init` reuses an existing
68
+ identity and changes nothing; against an expired one it refuses and exits 3.
69
+ That is deliberate: a new identity is a new *brain*, and memories written
70
+ under the old actor stay where they are, unreachable from the new config.
71
+
72
+ When a token expires you have two safe options:
73
+
74
+ - `cortexdb init --api-key <token> --actor <actor>` — keep the same brain
75
+ with a token you already hold (e.g. from your IdP).
76
+ - `cortexdb init --force` — deliberately start a NEW brain. The previous
77
+ identity is backed up to `state.json.replaced` first.
78
+
79
+ For a second brain alongside the first, isolate instead of replacing:
80
+ `cortexdb --config-dir <dir> init`, or set `CORTEXDB_HOME`.
67
81
 
68
82
  ## Commands
69
83
 
@@ -112,7 +126,9 @@ Force JSON mode any time with `--json`.
112
126
  - The v1 API requires both `Authorization: Bearer <PASETO>` and `X-Cortex-Actor`.
113
127
  The CLI stamps both on every request — you never need to pass them by hand.
114
128
  - 401s show the specific error code (`TOKEN_EXPIRED`, `actor_mismatch`, …)
115
- and the remediation: usually `cortexdb init`.
129
+ and the remediation. Note that `TOKEN_EXPIRED` is **not** fixed by re-running
130
+ `cortexdb init` — see the identity note above; that would mint a different
131
+ brain rather than restore access to yours.
116
132
  - 403s cite the missing capability and the policy tier that denied. If a
117
133
  recall returns `diagnostics.read denied`, pass `--diagnostics none` —
118
134
  free-tier tokens don't carry the `diagnostics.read` capability.
@@ -1,5 +1,5 @@
1
- cortexdb_cli/__init__.py,sha256=E9HyqRe9juxBYu8xwyA9tk6TUvi3rTeVOfrdIvcJFPA,90
2
- cortexdb_cli/client.py,sha256=CQwGo4Gn7cHDnfLUlJBZMsZ9-AyGVz8W6bJKxNMwlRA,2862
1
+ cortexdb_cli/__init__.py,sha256=R-3t2jLxSGdc1xNb9pu6tbckqSCXYYrp2zCV8lOqSsE,93
2
+ cortexdb_cli/client.py,sha256=Abgv8CLbhNYKG0QqHjsdGFXWZuVhke4Zuc4TAOuGjG4,3086
3
3
  cortexdb_cli/config.py,sha256=ee1qmOfqMwilL81ujaqjf6TjjlrFG7WZAHRIgeyif9U,5690
4
4
  cortexdb_cli/errors.py,sha256=xwxAWYY7KtNa-e7dyLQECnsKcciVcKC41CXFFF_-0VU,3924
5
5
  cortexdb_cli/harness_packs_data.py,sha256=38oyPX47a_S3BRgiNxml37auhIUyWafeJ_MtApX9tWE,10693
@@ -8,13 +8,13 @@ cortexdb_cli/onboarding.py,sha256=kjCxhMOY2nReNyEfGGGsmHO6_j4CXS5o2LIwzpFzIok,17
8
8
  cortexdb_cli/output.py,sha256=9NvXhX3ncCIgP9BMzt7c9VReQc0O1FUbQTwj2vUQ2JI,13611
9
9
  cortexdb_cli/state.py,sha256=pfslyJ9xuZno4n13-maJ0qKmD7nxWkFznjrVvItYK4I,1527
10
10
  cortexdb_cli/commands/__init__.py,sha256=TeF4ieMQkbgBr9Fco9VAiIZvKQ4vdg4yhkz4fvwfToc,37
11
- cortexdb_cli/commands/admin.py,sha256=0vy8B1Lk1M0-6P964GgFkdxpujQ-on_pjfJyLvafuzw,2510
11
+ cortexdb_cli/commands/admin.py,sha256=KzCiRSLeDkVHGa_V1AYwk4--KVkC2GI9giZc0LDN8pY,21633
12
12
  cortexdb_cli/commands/answer.py,sha256=41Yl9pjfODyRnWzYL68nRA7GPDb73Cccj0Kq-8sLv9k,3343
13
13
  cortexdb_cli/commands/audit.py,sha256=br9ksMeR4dHBkfvNvGG4mjYdhh7E1IRxJ1FOX1klckQ,2620
14
14
  cortexdb_cli/commands/auth.py,sha256=6YoKl5mZcyep2FRMrnXRv_EKvblC6H2vKfVOBzDXgpo,4505
15
15
  cortexdb_cli/commands/beliefs.py,sha256=akjOg2drLqQKlrGVL-Z6oLdP6TY-4YHe3yg9lJpQPH4,6180
16
16
  cortexdb_cli/commands/claims.py,sha256=NApq0i8NXX0j7uwnHdHtendnblMBnHh0OXlwQwB-tCQ,3720
17
- cortexdb_cli/commands/code.py,sha256=VWUV3uxpBuEu7N_EJfZvtSsQfTggf4xv3Buc7qdsOPI,8454
17
+ cortexdb_cli/commands/code.py,sha256=QMlE9RwNFEQsRqQrUvqlKA2c0v7sKLU2vcyQiW1uZ10,10719
18
18
  cortexdb_cli/commands/compose.py,sha256=i9fEugN818mt1t8nWsAxvv9h0nNLvBAf-WZZIl64xiw,3853
19
19
  cortexdb_cli/commands/config_cmd.py,sha256=MCb5XoulnGLkeOzYY54R_hFnTxGWAboLARSe8zAEhXA,1522
20
20
  cortexdb_cli/commands/conflicts.py,sha256=s4sp3BtPzPEy3T73inx68vhmucvjh-zQRnWYsJ_ADQQ,10506
@@ -34,7 +34,7 @@ cortexdb_cli/commands/scopes.py,sha256=Cx_zV-zyIrnDxwp_I4AX_Teh7UFu4EdvDOYe9hTEF
34
34
  cortexdb_cli/commands/search.py,sha256=4wsgNU47deb0x5o7xC9eWlhwdIIbz56q7ne8GIiCmYo,1953
35
35
  cortexdb_cli/commands/understanding.py,sha256=BktqOn-GloOzSSGJGK9xh5kGCpxcrwkNogtXhjj4pBI,5371
36
36
  cortexdb_cli/commands/wire.py,sha256=M3UUHScmvr_MDrX_RaMBg-YLTcHKUq9woNkT4KHJKfs,8131
37
- cortexdb_cli-0.5.2.dist-info/METADATA,sha256=CIgu0dksEWHPqVq3WHPmOq2kQRxkMf3U00ZlltMO620,4429
38
- cortexdb_cli-0.5.2.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
39
- cortexdb_cli-0.5.2.dist-info/entry_points.txt,sha256=h3sEbdqqvtv8LBbRLUgybYPo9N2lpU85fQUSh5ToEvs,51
40
- cortexdb_cli-0.5.2.dist-info/RECORD,,
37
+ cortexdb_cli-0.5.4.dist-info/METADATA,sha256=wNeADaguuwEJjnQgsLpwnOnMKaWGSZMfnKfgXARS2JU,5234
38
+ cortexdb_cli-0.5.4.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
39
+ cortexdb_cli-0.5.4.dist-info/entry_points.txt,sha256=h3sEbdqqvtv8LBbRLUgybYPo9N2lpU85fQUSh5ToEvs,51
40
+ cortexdb_cli-0.5.4.dist-info/RECORD,,