ravmem-cli 1.0.0__tar.gz → 1.1.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.
Files changed (40) hide show
  1. {ravmem_cli-1.0.0/ravmem_cli.egg-info → ravmem_cli-1.1.0}/PKG-INFO +6 -2
  2. {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/README.md +5 -1
  3. {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/pyproject.toml +1 -1
  4. ravmem_cli-1.1.0/ravmem/cli/_common.py +138 -0
  5. {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/ravmem/cli/_errors.py +64 -6
  6. {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/ravmem/cli/analyze.py +30 -6
  7. {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/ravmem/cli/auth.py +61 -3
  8. {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/ravmem/cli/info.py +5 -1
  9. {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/ravmem/cli/kg.py +197 -9
  10. ravmem_cli-1.1.0/ravmem/cli/license.py +470 -0
  11. {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/ravmem/cli/main.py +10 -2
  12. {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/ravmem/cli/remote.py +119 -3
  13. {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/ravmem/cli/serve.py +5 -0
  14. {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/ravmem/client/api.py +36 -2
  15. {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/ravmem/config.py +38 -0
  16. {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/ravmem/config_loader.py +20 -0
  17. ravmem_cli-1.0.0/ravmem/knowledge/config.py → ravmem_cli-1.1.0/ravmem/kg_config.py +1 -1
  18. ravmem_cli-1.0.0/ravmem/knowledge/init.py → ravmem_cli-1.1.0/ravmem/kg_init.py +1 -1
  19. {ravmem_cli-1.0.0 → ravmem_cli-1.1.0/ravmem_cli.egg-info}/PKG-INFO +6 -2
  20. {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/ravmem_cli.egg-info/SOURCES.txt +3 -3
  21. ravmem_cli-1.0.0/ravmem/cli/_common.py +0 -41
  22. ravmem_cli-1.0.0/ravmem/knowledge/__init__.py +0 -0
  23. {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/LICENSE +0 -0
  24. {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/ravmem/cli/__init__.py +0 -0
  25. {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/ravmem/cli/audit.py +0 -0
  26. {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/ravmem/cli/browse.py +0 -0
  27. {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/ravmem/cli/config_cmd.py +0 -0
  28. {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/ravmem/cli/diff.py +0 -0
  29. {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/ravmem/cli/doctor.py +0 -0
  30. {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/ravmem/cli/git_local.py +0 -0
  31. {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/ravmem/cli/impact.py +0 -0
  32. {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/ravmem/cli/search.py +0 -0
  33. {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/ravmem/client/__init__.py +0 -0
  34. {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/ravmem/client/config.py +0 -0
  35. {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/ravmem_cli.egg-info/dependency_links.txt +0 -0
  36. {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/ravmem_cli.egg-info/entry_points.txt +0 -0
  37. {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/ravmem_cli.egg-info/requires.txt +0 -0
  38. {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/ravmem_cli.egg-info/top_level.txt +0 -0
  39. {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/setup.cfg +0 -0
  40. {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/tests/test_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ravmem-cli
3
- Version: 1.0.0
3
+ Version: 1.1.0
4
4
  Summary: RavMem CLI — lightweight client for the RavMem code knowledge graph server
5
5
  Author-email: RavMem <harpreet.singh@ravmem.com>
6
6
  License-Expression: MIT
@@ -42,7 +42,11 @@ pip install ravmem-cli
42
42
 
43
43
  ```bash
44
44
  # Point the CLI at your RavMem server
45
- ravmem remote config --url http://localhost:7432
45
+ ravmem remote config --url http://localhost:7432 --api-key <ADMIN_TOKEN>
46
+
47
+ # Activate the license (one command: seals the key to this machine, then
48
+ # brings the server live — the key is never stored in a file)
49
+ ravmem license activate RAVMEM-XXXX-XXXX-XXXX-XXXX
46
50
 
47
51
  # Register a repository
48
52
  ravmem remote add /path/to/repo --name my-project --default-branch main
@@ -12,7 +12,11 @@ pip install ravmem-cli
12
12
 
13
13
  ```bash
14
14
  # Point the CLI at your RavMem server
15
- ravmem remote config --url http://localhost:7432
15
+ ravmem remote config --url http://localhost:7432 --api-key <ADMIN_TOKEN>
16
+
17
+ # Activate the license (one command: seals the key to this machine, then
18
+ # brings the server live — the key is never stored in a file)
19
+ ravmem license activate RAVMEM-XXXX-XXXX-XXXX-XXXX
16
20
 
17
21
  # Register a repository
18
22
  ravmem remote add /path/to/repo --name my-project --default-branch main
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ravmem-cli"
7
- version = "1.0.0"
7
+ version = "1.1.0"
8
8
  description = "RavMem CLI — lightweight client for the RavMem code knowledge graph server"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1,138 @@
1
+ """Shared helpers for ravmem CLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ import typer
10
+
11
+
12
+ def detect_branch(repo_path: Path, explicit: Optional[str]) -> str:
13
+ if explicit:
14
+ return explicit
15
+ try:
16
+ out = subprocess.run(
17
+ ["git", "-C", str(repo_path), "rev-parse", "--abbrev-ref", "HEAD"],
18
+ capture_output=True, text=True, check=True,
19
+ ).stdout.strip()
20
+ return out if out and out != "HEAD" else "main"
21
+ except Exception:
22
+ return "main"
23
+
24
+
25
+ def print_embed_job_summary(job: dict) -> None:
26
+ """Print a one-line summary for the embed job ``wait_for_indexing`` attaches
27
+ (as ``job["embed_job"]``) when called with ``wait_for="embed"``/``"both"``.
28
+ No-op if there is no embed job (``wait_for="graph"``, or the server isn't
29
+ running with embedding decoupled).
30
+ """
31
+ embed_job = job.get("embed_job")
32
+ if not embed_job:
33
+ return
34
+ status = embed_job.get("status", "unknown")
35
+ job_id = embed_job.get("id", "?")
36
+ if status == "done":
37
+ typer.echo(f"✓ Embedding complete (job {job_id}) — semantic search is ready.")
38
+ elif status == "failed":
39
+ typer.echo(f"✗ Embedding failed (job {job_id}): {embed_job.get('failure_reason', 'unknown')}", err=True)
40
+ else:
41
+ typer.echo(f"Embed job {job_id} ended with status: {status}")
42
+
43
+
44
+ def wait_for_indexing(client, project_id: str, branch_name: str, job_id: int, wait_for: str) -> dict:
45
+ """Poll the graph job to completion, then (per ``wait_for``) also poll the embed job.
46
+
47
+ ``wait_for="graph"`` (default) returns as soon as the graph job — the one
48
+ ``job_id`` returned by index/upload endpoints refers to — finishes. This
49
+ matches pre-decoupling behavior: search over structure/impact is usable.
50
+
51
+ ``wait_for="embed"``/``"both"`` additionally look up the branch's
52
+ ``last_embed_job`` (via ``get_branch_status``) once the graph job is done
53
+ and poll that too, since the embed job is a separate job the caller
54
+ doesn't otherwise have the id for. No-ops (no separate embed job exists)
55
+ when the server isn't running with embedding decoupled — the graph job's
56
+ own embedding already finished inline in that case.
57
+
58
+ Both polls render a live progress bar (phase + %) instead of streaming
59
+ each poll request to the console — see ``_poll_job_with_progress``.
60
+ """
61
+ job = _poll_job_with_progress(client, job_id, "Indexing")
62
+ if wait_for == "graph" or job.get("status") != "done":
63
+ return job
64
+ try:
65
+ status = client.get_branch_status(project_id, branch_name)
66
+ except Exception:
67
+ return job
68
+ embed_job = status.get("last_embed_job")
69
+ if not embed_job or not embed_job.get("id"):
70
+ return job
71
+ embed_result = _poll_job_with_progress(client, embed_job["id"], "Embedding")
72
+ return {**job, "embed_job": embed_result}
73
+
74
+
75
+ def _poll_job_with_progress(
76
+ client, job_id: int, label: str, interval: float = 1.0, timeout: float = 600.0,
77
+ ) -> dict:
78
+ """Poll a job to completion, rendering a live progress bar (phase + %)
79
+ instead of ``client.poll_job``'s silent loop — which, combined with the
80
+ default INFO log level, used to stream one raw HTTP request/response log
81
+ line to the console per poll (every 2s) rather than showing progress.
82
+
83
+ Temporarily silences the ``httpx`` logger regardless of the configured
84
+ log level — a log line printed mid-poll would corrupt the live-updating
85
+ bar's terminal redraw (rich's ``Progress`` assumes exclusive control of
86
+ the current line while its context is open).
87
+ """
88
+ import logging
89
+ import time
90
+ from rich.progress import BarColumn, Progress, TextColumn, TimeElapsedColumn
91
+
92
+ httpx_logger = logging.getLogger("httpx")
93
+ prev_level = httpx_logger.level
94
+ httpx_logger.setLevel(logging.WARNING)
95
+ deadline = time.monotonic() + timeout
96
+ try:
97
+ with Progress(
98
+ TextColumn("[bold]{task.description}"),
99
+ BarColumn(),
100
+ TextColumn("{task.percentage:>3.0f}%"),
101
+ TextColumn("[dim]{task.fields[phase]}"),
102
+ TimeElapsedColumn(),
103
+ transient=True,
104
+ ) as progress:
105
+ task = progress.add_task(label, total=100, phase="")
106
+ while True:
107
+ job = client.get_job(job_id)
108
+ status = job.get("status", "")
109
+ pct = job.get("progress_pct") or 0
110
+ phase = job.get("current_phase") or status
111
+ progress.update(task, completed=pct, phase=phase)
112
+ if status in ("done", "failed", "cancelled"):
113
+ progress.update(task, completed=100 if status == "done" else pct)
114
+ return job
115
+ if time.monotonic() >= deadline:
116
+ raise TimeoutError(f"Job {job_id} did not complete within {timeout}s")
117
+ time.sleep(interval)
118
+ finally:
119
+ httpx_logger.setLevel(prev_level)
120
+
121
+
122
+ def get_client(remote: Optional[str]):
123
+ from ravmem.client.api import RavMemClient
124
+ from ravmem.client.config import load_config
125
+
126
+ url = remote
127
+ api_key = None
128
+ if not url:
129
+ cfg = load_config()
130
+ url = cfg.get("server_url")
131
+ api_key = cfg.get("api_key")
132
+ if not url:
133
+ typer.echo(
134
+ "No server configured. Run: ravmem remote config --url <URL>",
135
+ err=True,
136
+ )
137
+ raise typer.Exit(1)
138
+ return RavMemClient(base_url=url, api_key=api_key or None)
@@ -72,11 +72,66 @@ def handle_error(exc: Exception, server_url: str = "") -> None:
72
72
  err=True,
73
73
  )
74
74
  elif code == 403:
75
- typer.echo(
76
- "Error: Permission denied - your token does not have the required permission.\n"
77
- " Ask an admin to grant the necessary role.",
78
- err=True,
79
- )
75
+ # Distinguish entitlement enforcement errors from plain RBAC denials
76
+ event_code = ""
77
+ try:
78
+ body = exc.response.json()
79
+ if isinstance(body.get("detail"), dict):
80
+ event_code = body["detail"].get("event_code", "")
81
+ detail = body["detail"].get("message", detail)
82
+ except Exception:
83
+ pass
84
+ _ENTITLEMENT_HINTS: dict[str, str] = {
85
+ "ENTITLEMENT_PROJECTS_LIMIT": (
86
+ "You have reached the project limit for your plan. "
87
+ "Delete an existing project or upgrade your plan."
88
+ ),
89
+ "ENTITLEMENT_LOC_LIMIT": (
90
+ "You have reached the lines-of-code limit for your plan. "
91
+ "Remove a project or upgrade your plan."
92
+ ),
93
+ "ENTITLEMENT_KG_NODE_LIMIT": (
94
+ "You have reached the knowledge graph node limit for your plan. "
95
+ "Deactivate unused nodes or upgrade your plan."
96
+ ),
97
+ "ENTITLEMENT_USER_LIMIT": (
98
+ "You have reached the user limit for your plan. "
99
+ "Remove a user or upgrade your plan to add more seats."
100
+ ),
101
+ "ENTITLEMENT_SINGLE_USER_MODE": (
102
+ "User management is disabled on this installation (single-user mode). "
103
+ "Upgrade your plan to add more users."
104
+ ),
105
+ "ENTITLEMENT_CROSSGRAPH_DISABLED": (
106
+ "Cross-graph linking is not available on your plan. "
107
+ "Upgrade to Pro or higher."
108
+ ),
109
+ "ENTITLEMENT_OBSERVABILITY_DISABLED": (
110
+ "Full observability/metrics are not available on your plan. "
111
+ "Upgrade to Pro or higher."
112
+ ),
113
+ "ENTITLEMENT_AUDIT_LOGS_DISABLED": (
114
+ "Audit logs are not available on your plan. "
115
+ "Upgrade to Team or higher."
116
+ ),
117
+ "ENTITLEMENT_OIDC_DISABLED": (
118
+ "OIDC authentication is not available on your plan. "
119
+ "Contact ravmem for Enterprise licensing."
120
+ ),
121
+ "ENTITLEMENT_CONNECTIONS_LIMIT": (
122
+ "Too many concurrent connections for your plan. "
123
+ "Retry shortly or upgrade your plan."
124
+ ),
125
+ }
126
+ hint = _ENTITLEMENT_HINTS.get(event_code)
127
+ if hint:
128
+ typer.echo(f"Error: License limit — {detail}\n Hint: {hint}", err=True)
129
+ else:
130
+ typer.echo(
131
+ f"Error: Permission denied - {detail}\n"
132
+ " Ask an admin to grant the necessary role.",
133
+ err=True,
134
+ )
80
135
  elif code == 404:
81
136
  typer.echo(f"Error: Not found - {detail}", err=True)
82
137
  elif code == 409:
@@ -93,7 +148,10 @@ def handle_error(exc: Exception, server_url: str = "") -> None:
93
148
  elif code == 503:
94
149
  typer.echo(
95
150
  f"Error: Server unavailable - {detail}\n"
96
- " ArcadeDB may be starting up. Check: ravmem health",
151
+ " Possible causes:\n"
152
+ " - License not activated: run 'ravmem license init'\n"
153
+ " - ArcadeDB is still starting up\n"
154
+ " Check server status with: ravmem health",
97
155
  err=True,
98
156
  )
99
157
  else:
@@ -46,16 +46,19 @@ def _get_client(remote: str | None):
46
46
  def _print_job_result(job: dict) -> None:
47
47
  status = job.get("status", "unknown")
48
48
  if status == "done":
49
- typer.echo(f"✓ Indexing complete (job {job.get('job_id', '?')}).")
49
+ typer.echo(f"✓ Indexing complete (job {job.get('id', job.get('job_id', '?'))}).")
50
50
  elif status == "failed":
51
51
  typer.echo(
52
- f"✗ Indexing failed (job {job.get('job_id', '?')}): "
52
+ f"✗ Indexing failed (job {job.get('id', job.get('job_id', '?'))}): "
53
53
  f"{job.get('failure_reason', 'unknown')}",
54
54
  err=True,
55
55
  )
56
56
  raise typer.Exit(1)
57
57
  else:
58
- typer.echo(f"Job {job.get('job_id', '?')} ended with status: {status}")
58
+ typer.echo(f"Job {job.get('id', job.get('job_id', '?'))} ended with status: {status}")
59
+
60
+ from ravmem.cli._common import print_embed_job_summary
61
+ print_embed_job_summary(job)
59
62
 
60
63
 
61
64
  def _analyze_upload(
@@ -67,6 +70,7 @@ def _analyze_upload(
67
70
  batch_size: int,
68
71
  lang_list: list[str],
69
72
  wait: bool,
73
+ wait_for: str = "graph",
70
74
  ) -> None:
71
75
  """Upload committed files to the server in batches and trigger indexing."""
72
76
  from ravmem.cli.git_local import ls_files, rev_parse, LocalGitError
@@ -157,7 +161,8 @@ def _analyze_upload(
157
161
 
158
162
  if wait:
159
163
  typer.echo("Waiting for completion…")
160
- job = client.poll_job(job_id)
164
+ from ravmem.cli._common import wait_for_indexing
165
+ job = wait_for_indexing(client, project_id, branch_name, job_id, wait_for)
161
166
  _print_job_result(job)
162
167
 
163
168
 
@@ -170,6 +175,7 @@ def _analyze_incremental_upload(
170
175
  batch_size: int,
171
176
  lang_list: list[str],
172
177
  wait: bool,
178
+ wait_for: str = "graph",
173
179
  ) -> None:
174
180
  """Upload only the files changed since the last index, plus a deletion list."""
175
181
  from ravmem.cli.git_local import diff_name_status, rev_parse, LocalGitError
@@ -194,6 +200,7 @@ def _analyze_incremental_upload(
194
200
  batch_size=batch_size,
195
201
  lang_list=lang_list,
196
202
  wait=wait,
203
+ wait_for=wait_for,
197
204
  )
198
205
  return
199
206
 
@@ -281,7 +288,8 @@ def _analyze_incremental_upload(
281
288
 
282
289
  if wait:
283
290
  typer.echo("Waiting for completion…")
284
- job = client.poll_job(job_id)
291
+ from ravmem.cli._common import wait_for_indexing
292
+ job = wait_for_indexing(client, project_id, branch_name, job_id, wait_for)
285
293
  _print_job_result(job)
286
294
 
287
295
 
@@ -294,6 +302,7 @@ def _analyze_server_side(
294
302
  wait: bool,
295
303
  yes: bool,
296
304
  no_auto_sync: bool = False,
305
+ wait_for: str = "graph",
297
306
  ) -> None:
298
307
  """Trigger server-side indexing (mount / clone projects)."""
299
308
  # Pre-flight divergence check
@@ -340,7 +349,8 @@ def _analyze_server_side(
340
349
 
341
350
  if wait and job_id is not None:
342
351
  typer.echo("Waiting for completion…")
343
- job = client.poll_job(job_id)
352
+ from ravmem.cli._common import wait_for_indexing
353
+ job = wait_for_indexing(client, project_id, branch_name, job_id, wait_for)
344
354
  _print_job_result(job)
345
355
 
346
356
 
@@ -374,6 +384,13 @@ def analyze_cmd(
374
384
  help="Set/update the project's default/parent branch",
375
385
  ),
376
386
  wait: bool = typer.Option(False, "--wait", "-w", help="Wait for indexing to complete"),
387
+ wait_for: str = typer.Option(
388
+ "graph", "--wait-for",
389
+ help="What --wait waits for: 'graph' (structure/impact ready — default), "
390
+ "'embed' (semantic search ready), or 'both'. Embedding is decoupled "
391
+ "into its own job by default on the server, so 'graph' now returns "
392
+ "~8x faster than the old single-job wait.",
393
+ ),
377
394
  yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompts (CI/scripts)"),
378
395
  no_auto_sync: bool = typer.Option(
379
396
  False, "--no-auto-sync",
@@ -411,6 +428,10 @@ def analyze_cmd(
411
428
  ravmem analyze --project myproject --wait
412
429
  """
413
430
 
431
+ if wait_for not in ("graph", "embed", "both"):
432
+ typer.echo(f"✗ --wait-for must be one of: graph, embed, both (got '{wait_for}')", err=True)
433
+ raise typer.Exit(1)
434
+
414
435
  repo_path = repo.resolve()
415
436
 
416
437
  client = _get_client(remote)
@@ -468,6 +489,7 @@ def analyze_cmd(
468
489
  batch_size=batch_size,
469
490
  lang_list=lang_list,
470
491
  wait=wait,
492
+ wait_for=wait_for,
471
493
  )
472
494
  else:
473
495
  _analyze_upload(
@@ -479,6 +501,7 @@ def analyze_cmd(
479
501
  batch_size=batch_size,
480
502
  lang_list=lang_list,
481
503
  wait=wait,
504
+ wait_for=wait_for,
482
505
  )
483
506
  else:
484
507
  # mount or clone — server-side indexing
@@ -492,6 +515,7 @@ def analyze_cmd(
492
515
  wait=wait,
493
516
  yes=yes,
494
517
  no_auto_sync=no_auto_sync,
518
+ wait_for=wait_for,
495
519
  )
496
520
 
497
521
  finally:
@@ -201,12 +201,13 @@ def list_users(
201
201
  table.add_column("Status")
202
202
  table.add_column("Created")
203
203
  for u in users:
204
- status_style = "green" if u.get("status") == "active" else "red"
204
+ st = u.get("status", "")
205
+ status_style = "green" if st == "active" else ("dim" if st == "deleted" else "red")
205
206
  table.add_row(
206
207
  u.get("id", ""),
207
208
  u.get("email", ""),
208
209
  u.get("name", ""),
209
- f"[{status_style}]{u.get('status', '')}[/{status_style}]",
210
+ f"[{status_style}]{st}[/{status_style}]",
210
211
  u.get("created_at", "")[:10],
211
212
  )
212
213
  console.print(table)
@@ -220,7 +221,11 @@ def delete_user(
220
221
  """Delete a user and all their tokens/roles (admin only)."""
221
222
 
222
223
  if not yes:
223
- typer.confirm(f"Are you sure you want to delete user '{user_id}'? This cannot be undone.", abort=True)
224
+ typer.confirm(
225
+ f"Soft-delete user '{user_id}'? Their account will be deactivated and all tokens revoked. "
226
+ "Knowledge graph nodes they authored are preserved.",
227
+ abort=True,
228
+ )
224
229
 
225
230
  client = _get_client()
226
231
  try:
@@ -683,3 +688,56 @@ def refresh_token():
683
688
  console.print("[bold green]Token refreshed.[/bold green] New API key saved locally.")
684
689
  console.print(f" Token ID: {result.get('id', '?')}")
685
690
  console.print(f" Expires: {result.get('expires_at') or 'never'}")
691
+
692
+
693
+ # ---------------------------------------------------------------------------
694
+ # Password management
695
+ # ---------------------------------------------------------------------------
696
+
697
+ @app.command("set-password")
698
+ def set_password(
699
+ user_id: str = typer.Argument(..., help="User ID to set password for"),
700
+ password: Optional[str] = typer.Option(None, "--password", "-p", help="New password (prompted if omitted)"),
701
+ ):
702
+ """Admin: set a user's password directly (no current password required)."""
703
+ console = _console()
704
+ if not password:
705
+ password = typer.prompt("New password", hide_input=True, confirmation_prompt=True)
706
+
707
+ client = _get_client()
708
+ try:
709
+ client._request("PATCH", f"/api/auth/users/{user_id}/password", json={"password": password})
710
+ except RavMemAPIError as exc:
711
+ _handle_api_error(exc)
712
+ raise typer.Exit(1)
713
+ finally:
714
+ client.close()
715
+
716
+ console.print(f"[bold green]Password updated[/bold green] for user [cyan]{user_id}[/cyan].")
717
+
718
+
719
+ @app.command("change-password")
720
+ def change_password(
721
+ current: Optional[str] = typer.Option(None, "--current", help="Current password (prompted if omitted)"),
722
+ new: Optional[str] = typer.Option(None, "--new", help="New password (prompted if omitted)"),
723
+ ):
724
+ """Self-service: change your own password. All other sessions are revoked on success."""
725
+ console = _console()
726
+ if not current:
727
+ current = typer.prompt("Current password", hide_input=True)
728
+ if not new:
729
+ new = typer.prompt("New password", hide_input=True, confirmation_prompt=True)
730
+
731
+ client = _get_client()
732
+ try:
733
+ result = client._request(
734
+ "POST", "/api/auth/users/me/password",
735
+ json={"current_password": current, "new_password": new},
736
+ )
737
+ except RavMemAPIError as exc:
738
+ _handle_api_error(exc)
739
+ raise typer.Exit(1)
740
+ finally:
741
+ client.close()
742
+
743
+ console.print("[bold green]Password changed.[/bold green]", result.get("message", ""))
@@ -75,7 +75,11 @@ def metrics_cmd(
75
75
  typer.echo("Cannot reach server. Is it running?", err=True)
76
76
  raise typer.Exit(1)
77
77
  except httpx.HTTPStatusError as exc:
78
- typer.echo(f"Error {exc.response.status_code} from server.", err=True)
78
+ try:
79
+ detail = exc.response.json().get("detail") or exc.response.text
80
+ except Exception:
81
+ detail = exc.response.text
82
+ typer.echo(f"Error {exc.response.status_code} from server: {detail}", err=True)
79
83
  raise typer.Exit(1)
80
84
 
81
85
  typer.echo(resp.text)