ravmem-cli 0.1.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-0.1.0/ravmem_cli.egg-info → ravmem_cli-1.1.0}/PKG-INFO +11 -2
- {ravmem_cli-0.1.0 → ravmem_cli-1.1.0}/README.md +5 -1
- {ravmem_cli-0.1.0 → ravmem_cli-1.1.0}/pyproject.toml +7 -1
- ravmem_cli-1.1.0/ravmem/cli/_common.py +138 -0
- ravmem_cli-1.1.0/ravmem/cli/_errors.py +160 -0
- {ravmem_cli-0.1.0 → ravmem_cli-1.1.0}/ravmem/cli/analyze.py +62 -24
- ravmem_cli-1.1.0/ravmem/cli/audit.py +83 -0
- {ravmem_cli-0.1.0 → ravmem_cli-1.1.0}/ravmem/cli/auth.py +157 -89
- ravmem_cli-1.1.0/ravmem/cli/browse.py +206 -0
- {ravmem_cli-0.1.0 → ravmem_cli-1.1.0}/ravmem/cli/diff.py +24 -39
- ravmem_cli-1.1.0/ravmem/cli/doctor.py +119 -0
- {ravmem_cli-0.1.0 → ravmem_cli-1.1.0}/ravmem/cli/impact.py +25 -48
- {ravmem_cli-0.1.0 → ravmem_cli-1.1.0}/ravmem/cli/info.py +22 -19
- {ravmem_cli-0.1.0 → ravmem_cli-1.1.0}/ravmem/cli/kg.py +500 -76
- ravmem_cli-1.1.0/ravmem/cli/license.py +470 -0
- {ravmem_cli-0.1.0 → ravmem_cli-1.1.0}/ravmem/cli/main.py +19 -4
- {ravmem_cli-0.1.0 → ravmem_cli-1.1.0}/ravmem/cli/remote.py +359 -30
- {ravmem_cli-0.1.0 → ravmem_cli-1.1.0}/ravmem/cli/search.py +23 -43
- {ravmem_cli-0.1.0 → ravmem_cli-1.1.0}/ravmem/cli/serve.py +32 -4
- {ravmem_cli-0.1.0 → ravmem_cli-1.1.0}/ravmem/client/api.py +158 -38
- {ravmem_cli-0.1.0 → ravmem_cli-1.1.0}/ravmem/config.py +38 -0
- {ravmem_cli-0.1.0 → ravmem_cli-1.1.0}/ravmem/config_loader.py +20 -0
- ravmem_cli-1.1.0/ravmem/kg_config.py +139 -0
- ravmem_cli-1.1.0/ravmem/kg_init.py +142 -0
- {ravmem_cli-0.1.0 → ravmem_cli-1.1.0/ravmem_cli.egg-info}/PKG-INFO +11 -2
- {ravmem_cli-0.1.0 → ravmem_cli-1.1.0}/ravmem_cli.egg-info/SOURCES.txt +9 -1
- {ravmem_cli-0.1.0 → ravmem_cli-1.1.0}/ravmem_cli.egg-info/requires.txt +6 -0
- ravmem_cli-1.1.0/tests/test_smoke.py +144 -0
- ravmem_cli-0.1.0/ravmem/cli/browse.py +0 -144
- {ravmem_cli-0.1.0 → ravmem_cli-1.1.0}/LICENSE +0 -0
- {ravmem_cli-0.1.0 → ravmem_cli-1.1.0}/ravmem/cli/__init__.py +0 -0
- {ravmem_cli-0.1.0 → ravmem_cli-1.1.0}/ravmem/cli/config_cmd.py +0 -0
- {ravmem_cli-0.1.0 → ravmem_cli-1.1.0}/ravmem/cli/git_local.py +0 -0
- {ravmem_cli-0.1.0 → ravmem_cli-1.1.0}/ravmem/client/__init__.py +0 -0
- {ravmem_cli-0.1.0 → ravmem_cli-1.1.0}/ravmem/client/config.py +0 -0
- {ravmem_cli-0.1.0 → ravmem_cli-1.1.0}/ravmem_cli.egg-info/dependency_links.txt +0 -0
- {ravmem_cli-0.1.0 → ravmem_cli-1.1.0}/ravmem_cli.egg-info/entry_points.txt +0 -0
- {ravmem_cli-0.1.0 → ravmem_cli-1.1.0}/ravmem_cli.egg-info/top_level.txt +0 -0
- {ravmem_cli-0.1.0 → ravmem_cli-1.1.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ravmem-cli
|
|
3
|
-
Version:
|
|
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
|
|
@@ -18,9 +18,14 @@ Requires-Dist: typer[all]>=0.12.0
|
|
|
18
18
|
Requires-Dist: httpx>=0.27.0
|
|
19
19
|
Requires-Dist: pyyaml>=6.0
|
|
20
20
|
Requires-Dist: pydantic>=2.0
|
|
21
|
+
Requires-Dist: uvicorn>=0.27.0
|
|
22
|
+
Requires-Dist: rich>=13.0
|
|
23
|
+
Provides-Extra: serve
|
|
24
|
+
Requires-Dist: ravmem-server>=0.2.2; extra == "serve"
|
|
21
25
|
Provides-Extra: dev
|
|
22
26
|
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
23
27
|
Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest-httpx>=0.30.0; extra == "dev"
|
|
24
29
|
Dynamic: license-file
|
|
25
30
|
|
|
26
31
|
# ravmem-cli
|
|
@@ -37,7 +42,11 @@ pip install ravmem-cli
|
|
|
37
42
|
|
|
38
43
|
```bash
|
|
39
44
|
# Point the CLI at your RavMem server
|
|
40
|
-
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
|
|
41
50
|
|
|
42
51
|
# Register a repository
|
|
43
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 = "
|
|
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"
|
|
@@ -26,15 +26,21 @@ dependencies = [
|
|
|
26
26
|
"httpx>=0.27.0",
|
|
27
27
|
"pyyaml>=6.0",
|
|
28
28
|
"pydantic>=2.0",
|
|
29
|
+
"uvicorn>=0.27.0",
|
|
30
|
+
"rich>=13.0",
|
|
29
31
|
]
|
|
30
32
|
|
|
31
33
|
[project.urls]
|
|
32
34
|
Homepage = "https://www.ravmem.com"
|
|
33
35
|
|
|
34
36
|
[project.optional-dependencies]
|
|
37
|
+
serve = [
|
|
38
|
+
"ravmem-server>=0.2.2",
|
|
39
|
+
]
|
|
35
40
|
dev = [
|
|
36
41
|
"pytest>=8.0.0",
|
|
37
42
|
"pytest-asyncio>=0.23.0",
|
|
43
|
+
"pytest-httpx>=0.30.0",
|
|
38
44
|
]
|
|
39
45
|
|
|
40
46
|
[project.scripts]
|
|
@@ -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)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Shared CLI error-handling utilities.
|
|
2
|
+
|
|
3
|
+
All CLI modules import from here so every command surfaces the same
|
|
4
|
+
clean, human-readable messages instead of raw Python tracebacks.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _extract_detail(exc) -> str:
|
|
12
|
+
"""Pull the best human-readable string out of an httpx HTTP error body."""
|
|
13
|
+
try:
|
|
14
|
+
body = exc.response.json()
|
|
15
|
+
if isinstance(body, dict):
|
|
16
|
+
detail = body.get("detail") or body.get("message") or body.get("error")
|
|
17
|
+
if isinstance(detail, list):
|
|
18
|
+
# FastAPI validation error list - join the loc + msg pairs
|
|
19
|
+
parts = []
|
|
20
|
+
for item in detail:
|
|
21
|
+
loc = " -> ".join(str(x) for x in item.get("loc", []))
|
|
22
|
+
msg = item.get("msg", "")
|
|
23
|
+
parts.append(f"{loc}: {msg}" if loc else msg)
|
|
24
|
+
return "; ".join(parts)
|
|
25
|
+
if detail:
|
|
26
|
+
return str(detail)
|
|
27
|
+
return exc.response.text or str(exc)
|
|
28
|
+
except Exception:
|
|
29
|
+
return exc.response.text or str(exc)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def handle_error(exc: Exception, server_url: str = "") -> None:
|
|
33
|
+
"""Print a clean, user-friendly error message to stderr.
|
|
34
|
+
|
|
35
|
+
Does NOT raise - callers are responsible for raising typer.Exit(1) after.
|
|
36
|
+
"""
|
|
37
|
+
import httpx
|
|
38
|
+
|
|
39
|
+
if isinstance(exc, (httpx.ConnectError, httpx.ConnectTimeout)):
|
|
40
|
+
target = server_url
|
|
41
|
+
if not target:
|
|
42
|
+
try:
|
|
43
|
+
target = str(exc.request.url) if exc.request else ""
|
|
44
|
+
except Exception:
|
|
45
|
+
pass
|
|
46
|
+
target = target or "the server"
|
|
47
|
+
typer.echo(
|
|
48
|
+
f"Error: Cannot connect to {target}.\n"
|
|
49
|
+
" Is the server running? Check: ravmem health",
|
|
50
|
+
err=True,
|
|
51
|
+
)
|
|
52
|
+
elif isinstance(exc, httpx.TimeoutException):
|
|
53
|
+
typer.echo(
|
|
54
|
+
"Error: Request timed out. The server may be overloaded or unreachable.",
|
|
55
|
+
err=True,
|
|
56
|
+
)
|
|
57
|
+
elif isinstance(exc, httpx.RemoteProtocolError):
|
|
58
|
+
typer.echo(
|
|
59
|
+
f"Error: Invalid response from server - {exc}",
|
|
60
|
+
err=True,
|
|
61
|
+
)
|
|
62
|
+
elif isinstance(exc, httpx.HTTPStatusError):
|
|
63
|
+
code = exc.response.status_code
|
|
64
|
+
detail = _extract_detail(exc)
|
|
65
|
+
if code == 400:
|
|
66
|
+
typer.echo(f"Error: Bad request - {detail}", err=True)
|
|
67
|
+
elif code == 401:
|
|
68
|
+
typer.echo(
|
|
69
|
+
"Error: Not authenticated.\n"
|
|
70
|
+
" Set an API token with: ravmem remote config --api-key <TOKEN>\n"
|
|
71
|
+
" Or log in with: ravmem auth login",
|
|
72
|
+
err=True,
|
|
73
|
+
)
|
|
74
|
+
elif code == 403:
|
|
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
|
+
)
|
|
135
|
+
elif code == 404:
|
|
136
|
+
typer.echo(f"Error: Not found - {detail}", err=True)
|
|
137
|
+
elif code == 409:
|
|
138
|
+
typer.echo(f"Error: Conflict - {detail}", err=True)
|
|
139
|
+
elif code == 422:
|
|
140
|
+
typer.echo(f"Error: Invalid request parameters - {detail}", err=True)
|
|
141
|
+
elif code == 429:
|
|
142
|
+
typer.echo(
|
|
143
|
+
"Error: Rate limit exceeded - too many requests. Wait a moment and retry.",
|
|
144
|
+
err=True,
|
|
145
|
+
)
|
|
146
|
+
elif code == 500:
|
|
147
|
+
typer.echo(f"Error: Server error - {detail}", err=True)
|
|
148
|
+
elif code == 503:
|
|
149
|
+
typer.echo(
|
|
150
|
+
f"Error: Server unavailable - {detail}\n"
|
|
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",
|
|
155
|
+
err=True,
|
|
156
|
+
)
|
|
157
|
+
else:
|
|
158
|
+
typer.echo(f"Error {code}: {detail}", err=True)
|
|
159
|
+
else:
|
|
160
|
+
typer.echo(f"Error: {exc}", err=True)
|
|
@@ -8,6 +8,8 @@ from typing import Optional
|
|
|
8
8
|
|
|
9
9
|
import typer
|
|
10
10
|
|
|
11
|
+
from ravmem.cli._common import get_client as _get_client_shared
|
|
12
|
+
from ravmem.client.api import handle_api_error
|
|
11
13
|
from ravmem.config import DEFAULT_LANGS
|
|
12
14
|
|
|
13
15
|
# Extensions whose content is never useful for the code-graph pipeline.
|
|
@@ -38,38 +40,25 @@ _CLI_MAX_FILE_BYTES = 5 * 1024 * 1024
|
|
|
38
40
|
|
|
39
41
|
|
|
40
42
|
def _get_client(remote: str | None):
|
|
41
|
-
|
|
42
|
-
from ravmem.client.config import load_config
|
|
43
|
-
|
|
44
|
-
url = remote
|
|
45
|
-
api_key = None
|
|
46
|
-
if not url:
|
|
47
|
-
cfg = load_config()
|
|
48
|
-
url = cfg.get("server_url")
|
|
49
|
-
api_key = cfg.get("api_key")
|
|
50
|
-
if not url:
|
|
51
|
-
typer.echo(
|
|
52
|
-
"Error: No server configured. "
|
|
53
|
-
"Run `ravmem remote config --url <URL>` or pass --remote <URL>.",
|
|
54
|
-
err=True,
|
|
55
|
-
)
|
|
56
|
-
raise typer.Exit(1)
|
|
57
|
-
return RavMemClient(base_url=url, api_key=api_key or None)
|
|
43
|
+
return _get_client_shared(remote)
|
|
58
44
|
|
|
59
45
|
|
|
60
46
|
def _print_job_result(job: dict) -> None:
|
|
61
47
|
status = job.get("status", "unknown")
|
|
62
48
|
if status == "done":
|
|
63
|
-
typer.echo(f"✓ Indexing complete (job {job.get('job_id', '?')}).")
|
|
49
|
+
typer.echo(f"✓ Indexing complete (job {job.get('id', job.get('job_id', '?'))}).")
|
|
64
50
|
elif status == "failed":
|
|
65
51
|
typer.echo(
|
|
66
|
-
f"✗ Indexing failed (job {job.get('job_id', '?')}): "
|
|
52
|
+
f"✗ Indexing failed (job {job.get('id', job.get('job_id', '?'))}): "
|
|
67
53
|
f"{job.get('failure_reason', 'unknown')}",
|
|
68
54
|
err=True,
|
|
69
55
|
)
|
|
70
56
|
raise typer.Exit(1)
|
|
71
57
|
else:
|
|
72
|
-
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)
|
|
73
62
|
|
|
74
63
|
|
|
75
64
|
def _analyze_upload(
|
|
@@ -81,6 +70,7 @@ def _analyze_upload(
|
|
|
81
70
|
batch_size: int,
|
|
82
71
|
lang_list: list[str],
|
|
83
72
|
wait: bool,
|
|
73
|
+
wait_for: str = "graph",
|
|
84
74
|
) -> None:
|
|
85
75
|
"""Upload committed files to the server in batches and trigger indexing."""
|
|
86
76
|
from ravmem.cli.git_local import ls_files, rev_parse, LocalGitError
|
|
@@ -171,7 +161,8 @@ def _analyze_upload(
|
|
|
171
161
|
|
|
172
162
|
if wait:
|
|
173
163
|
typer.echo("Waiting for completion…")
|
|
174
|
-
|
|
164
|
+
from ravmem.cli._common import wait_for_indexing
|
|
165
|
+
job = wait_for_indexing(client, project_id, branch_name, job_id, wait_for)
|
|
175
166
|
_print_job_result(job)
|
|
176
167
|
|
|
177
168
|
|
|
@@ -184,6 +175,7 @@ def _analyze_incremental_upload(
|
|
|
184
175
|
batch_size: int,
|
|
185
176
|
lang_list: list[str],
|
|
186
177
|
wait: bool,
|
|
178
|
+
wait_for: str = "graph",
|
|
187
179
|
) -> None:
|
|
188
180
|
"""Upload only the files changed since the last index, plus a deletion list."""
|
|
189
181
|
from ravmem.cli.git_local import diff_name_status, rev_parse, LocalGitError
|
|
@@ -208,6 +200,7 @@ def _analyze_incremental_upload(
|
|
|
208
200
|
batch_size=batch_size,
|
|
209
201
|
lang_list=lang_list,
|
|
210
202
|
wait=wait,
|
|
203
|
+
wait_for=wait_for,
|
|
211
204
|
)
|
|
212
205
|
return
|
|
213
206
|
|
|
@@ -295,7 +288,8 @@ def _analyze_incremental_upload(
|
|
|
295
288
|
|
|
296
289
|
if wait:
|
|
297
290
|
typer.echo("Waiting for completion…")
|
|
298
|
-
|
|
291
|
+
from ravmem.cli._common import wait_for_indexing
|
|
292
|
+
job = wait_for_indexing(client, project_id, branch_name, job_id, wait_for)
|
|
299
293
|
_print_job_result(job)
|
|
300
294
|
|
|
301
295
|
|
|
@@ -308,6 +302,7 @@ def _analyze_server_side(
|
|
|
308
302
|
wait: bool,
|
|
309
303
|
yes: bool,
|
|
310
304
|
no_auto_sync: bool = False,
|
|
305
|
+
wait_for: str = "graph",
|
|
311
306
|
) -> None:
|
|
312
307
|
"""Trigger server-side indexing (mount / clone projects)."""
|
|
313
308
|
# Pre-flight divergence check
|
|
@@ -354,7 +349,8 @@ def _analyze_server_side(
|
|
|
354
349
|
|
|
355
350
|
if wait and job_id is not None:
|
|
356
351
|
typer.echo("Waiting for completion…")
|
|
357
|
-
|
|
352
|
+
from ravmem.cli._common import wait_for_indexing
|
|
353
|
+
job = wait_for_indexing(client, project_id, branch_name, job_id, wait_for)
|
|
358
354
|
_print_job_result(job)
|
|
359
355
|
|
|
360
356
|
|
|
@@ -388,6 +384,13 @@ def analyze_cmd(
|
|
|
388
384
|
help="Set/update the project's default/parent branch",
|
|
389
385
|
),
|
|
390
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
|
+
),
|
|
391
394
|
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompts (CI/scripts)"),
|
|
392
395
|
no_auto_sync: bool = typer.Option(
|
|
393
396
|
False, "--no-auto-sync",
|
|
@@ -395,7 +398,39 @@ def analyze_cmd(
|
|
|
395
398
|
"Use when you know the workspace is already up to date.",
|
|
396
399
|
),
|
|
397
400
|
) -> None:
|
|
398
|
-
"""Index a repository
|
|
401
|
+
"""Index a repository (auto-detects mode from the project registration).
|
|
402
|
+
|
|
403
|
+
\b
|
|
404
|
+
Register your project first with `ravmem remote add`, then run this command.
|
|
405
|
+
|
|
406
|
+
\b
|
|
407
|
+
Mode behaviour:
|
|
408
|
+
upload — CLI reads committed files locally and pushes them to the server.
|
|
409
|
+
The project must be registered without --git-url and without --mount.
|
|
410
|
+
Only committed files are indexed; commit before running.
|
|
411
|
+
clone — Server pulls from the git remote it cloned at registration.
|
|
412
|
+
No local files are uploaded. Use `ravmem remote clone` for first run.
|
|
413
|
+
mount — Server reads directly from the mounted path. No upload needed.
|
|
414
|
+
|
|
415
|
+
\b
|
|
416
|
+
Quick-start examples:
|
|
417
|
+
# Upload mode (local repo → server)
|
|
418
|
+
ravmem remote add /my/repo --name myproject
|
|
419
|
+
ravmem analyze /my/repo --project myproject --wait
|
|
420
|
+
|
|
421
|
+
# Clone mode (server pulls from GitHub)
|
|
422
|
+
ravmem remote add --git-url https://github.com/org/repo --name myproject
|
|
423
|
+
ravmem remote clone --project myproject --wait
|
|
424
|
+
ravmem analyze --project myproject --incremental --wait # subsequent runs
|
|
425
|
+
|
|
426
|
+
# Mount mode (repo already on the server filesystem)
|
|
427
|
+
ravmem remote add /server/path/repo --name myproject --mount
|
|
428
|
+
ravmem analyze --project myproject --wait
|
|
429
|
+
"""
|
|
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)
|
|
399
434
|
|
|
400
435
|
repo_path = repo.resolve()
|
|
401
436
|
|
|
@@ -454,6 +489,7 @@ def analyze_cmd(
|
|
|
454
489
|
batch_size=batch_size,
|
|
455
490
|
lang_list=lang_list,
|
|
456
491
|
wait=wait,
|
|
492
|
+
wait_for=wait_for,
|
|
457
493
|
)
|
|
458
494
|
else:
|
|
459
495
|
_analyze_upload(
|
|
@@ -465,6 +501,7 @@ def analyze_cmd(
|
|
|
465
501
|
batch_size=batch_size,
|
|
466
502
|
lang_list=lang_list,
|
|
467
503
|
wait=wait,
|
|
504
|
+
wait_for=wait_for,
|
|
468
505
|
)
|
|
469
506
|
else:
|
|
470
507
|
# mount or clone — server-side indexing
|
|
@@ -478,6 +515,7 @@ def analyze_cmd(
|
|
|
478
515
|
wait=wait,
|
|
479
516
|
yes=yes,
|
|
480
517
|
no_auto_sync=no_auto_sync,
|
|
518
|
+
wait_for=wait_for,
|
|
481
519
|
)
|
|
482
520
|
|
|
483
521
|
finally:
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""ravmem audit — audit log management commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from ravmem.cli._common import get_client
|
|
10
|
+
from ravmem.cli._errors import handle_error
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def audit_list_cmd(
|
|
14
|
+
limit: int = typer.Option(50, "--limit", "-n", help="Max entries to return"),
|
|
15
|
+
user: Optional[str] = typer.Option(None, "--user", "-u", help="Filter by user ID"),
|
|
16
|
+
action: Optional[str] = typer.Option(None, "--action", "-a", help="Filter by action name"),
|
|
17
|
+
remote: Optional[str] = typer.Option(None, "--remote", help="Server URL override"),
|
|
18
|
+
output_json: bool = typer.Option(False, "--json", help="Raw JSON output"),
|
|
19
|
+
) -> None:
|
|
20
|
+
"""List audit log entries (admin only)."""
|
|
21
|
+
import json
|
|
22
|
+
from rich.console import Console
|
|
23
|
+
from rich.table import Table
|
|
24
|
+
from rich import box
|
|
25
|
+
|
|
26
|
+
client = get_client(remote)
|
|
27
|
+
try:
|
|
28
|
+
entries = client.list_audit(limit=limit, user_id=user, action=action)
|
|
29
|
+
except Exception as exc:
|
|
30
|
+
handle_error(exc)
|
|
31
|
+
raise typer.Exit(1)
|
|
32
|
+
finally:
|
|
33
|
+
client.close()
|
|
34
|
+
|
|
35
|
+
if output_json:
|
|
36
|
+
typer.echo(json.dumps(entries, indent=2, default=str))
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
if not entries:
|
|
40
|
+
typer.echo("No audit entries found.")
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
console = Console()
|
|
44
|
+
t = Table(box=box.SIMPLE, show_header=True, header_style="bold",
|
|
45
|
+
title=f"Audit log ({len(entries)} entries)")
|
|
46
|
+
t.add_column("Time", width=19)
|
|
47
|
+
t.add_column("User", width=16)
|
|
48
|
+
t.add_column("Action", width=20)
|
|
49
|
+
t.add_column("Resource", width=30)
|
|
50
|
+
t.add_column("Result", width=8)
|
|
51
|
+
|
|
52
|
+
for e in entries:
|
|
53
|
+
result_color = "green" if e.get("result") == "ok" else "red"
|
|
54
|
+
t.add_row(
|
|
55
|
+
str(e.get("timestamp", ""))[:19],
|
|
56
|
+
str(e.get("user_id", ""))[:16],
|
|
57
|
+
str(e.get("action", ""))[:20],
|
|
58
|
+
str(e.get("resource", ""))[:30],
|
|
59
|
+
f"[{result_color}]{e.get('result', '?')}[/{result_color}]",
|
|
60
|
+
)
|
|
61
|
+
console.print(t)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def audit_rotate_cmd(
|
|
65
|
+
retain_days: int = typer.Option(90, "--retain-days", help="Keep entries newer than N days"),
|
|
66
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
|
|
67
|
+
remote: Optional[str] = typer.Option(None, "--remote", help="Server URL override"),
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Rotate audit logs, discarding entries older than --retain-days (admin only)."""
|
|
70
|
+
if not yes:
|
|
71
|
+
typer.confirm(f"Rotate audit log (keep last {retain_days} days)?", abort=True)
|
|
72
|
+
|
|
73
|
+
client = get_client(remote)
|
|
74
|
+
try:
|
|
75
|
+
result = client.rotate_audit()
|
|
76
|
+
except Exception as exc:
|
|
77
|
+
handle_error(exc)
|
|
78
|
+
raise typer.Exit(1)
|
|
79
|
+
finally:
|
|
80
|
+
client.close()
|
|
81
|
+
|
|
82
|
+
removed = result.get("removed", result.get("deleted", "?"))
|
|
83
|
+
typer.echo(f"Audit log rotated. Entries removed: {removed}")
|