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.
- {ravmem_cli-1.0.0/ravmem_cli.egg-info → ravmem_cli-1.1.0}/PKG-INFO +6 -2
- {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/README.md +5 -1
- {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/pyproject.toml +1 -1
- ravmem_cli-1.1.0/ravmem/cli/_common.py +138 -0
- {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/ravmem/cli/_errors.py +64 -6
- {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/ravmem/cli/analyze.py +30 -6
- {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/ravmem/cli/auth.py +61 -3
- {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/ravmem/cli/info.py +5 -1
- {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/ravmem/cli/kg.py +197 -9
- ravmem_cli-1.1.0/ravmem/cli/license.py +470 -0
- {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/ravmem/cli/main.py +10 -2
- {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/ravmem/cli/remote.py +119 -3
- {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/ravmem/cli/serve.py +5 -0
- {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/ravmem/client/api.py +36 -2
- {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/ravmem/config.py +38 -0
- {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/ravmem/config_loader.py +20 -0
- ravmem_cli-1.0.0/ravmem/knowledge/config.py → ravmem_cli-1.1.0/ravmem/kg_config.py +1 -1
- ravmem_cli-1.0.0/ravmem/knowledge/init.py → ravmem_cli-1.1.0/ravmem/kg_init.py +1 -1
- {ravmem_cli-1.0.0 → ravmem_cli-1.1.0/ravmem_cli.egg-info}/PKG-INFO +6 -2
- {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/ravmem_cli.egg-info/SOURCES.txt +3 -3
- ravmem_cli-1.0.0/ravmem/cli/_common.py +0 -41
- ravmem_cli-1.0.0/ravmem/knowledge/__init__.py +0 -0
- {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/LICENSE +0 -0
- {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/ravmem/cli/__init__.py +0 -0
- {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/ravmem/cli/audit.py +0 -0
- {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/ravmem/cli/browse.py +0 -0
- {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/ravmem/cli/config_cmd.py +0 -0
- {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/ravmem/cli/diff.py +0 -0
- {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/ravmem/cli/doctor.py +0 -0
- {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/ravmem/cli/git_local.py +0 -0
- {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/ravmem/cli/impact.py +0 -0
- {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/ravmem/cli/search.py +0 -0
- {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/ravmem/client/__init__.py +0 -0
- {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/ravmem/client/config.py +0 -0
- {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/ravmem_cli.egg-info/dependency_links.txt +0 -0
- {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/ravmem_cli.egg-info/entry_points.txt +0 -0
- {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/ravmem_cli.egg-info/requires.txt +0 -0
- {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/ravmem_cli.egg-info/top_level.txt +0 -0
- {ravmem_cli-1.0.0 → ravmem_cli-1.1.0}/setup.cfg +0 -0
- {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.
|
|
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
|
|
@@ -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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}]{
|
|
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(
|
|
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
|
-
|
|
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)
|