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.
Files changed (39) hide show
  1. {ravmem_cli-0.1.0/ravmem_cli.egg-info → ravmem_cli-1.1.0}/PKG-INFO +11 -2
  2. {ravmem_cli-0.1.0 → ravmem_cli-1.1.0}/README.md +5 -1
  3. {ravmem_cli-0.1.0 → ravmem_cli-1.1.0}/pyproject.toml +7 -1
  4. ravmem_cli-1.1.0/ravmem/cli/_common.py +138 -0
  5. ravmem_cli-1.1.0/ravmem/cli/_errors.py +160 -0
  6. {ravmem_cli-0.1.0 → ravmem_cli-1.1.0}/ravmem/cli/analyze.py +62 -24
  7. ravmem_cli-1.1.0/ravmem/cli/audit.py +83 -0
  8. {ravmem_cli-0.1.0 → ravmem_cli-1.1.0}/ravmem/cli/auth.py +157 -89
  9. ravmem_cli-1.1.0/ravmem/cli/browse.py +206 -0
  10. {ravmem_cli-0.1.0 → ravmem_cli-1.1.0}/ravmem/cli/diff.py +24 -39
  11. ravmem_cli-1.1.0/ravmem/cli/doctor.py +119 -0
  12. {ravmem_cli-0.1.0 → ravmem_cli-1.1.0}/ravmem/cli/impact.py +25 -48
  13. {ravmem_cli-0.1.0 → ravmem_cli-1.1.0}/ravmem/cli/info.py +22 -19
  14. {ravmem_cli-0.1.0 → ravmem_cli-1.1.0}/ravmem/cli/kg.py +500 -76
  15. ravmem_cli-1.1.0/ravmem/cli/license.py +470 -0
  16. {ravmem_cli-0.1.0 → ravmem_cli-1.1.0}/ravmem/cli/main.py +19 -4
  17. {ravmem_cli-0.1.0 → ravmem_cli-1.1.0}/ravmem/cli/remote.py +359 -30
  18. {ravmem_cli-0.1.0 → ravmem_cli-1.1.0}/ravmem/cli/search.py +23 -43
  19. {ravmem_cli-0.1.0 → ravmem_cli-1.1.0}/ravmem/cli/serve.py +32 -4
  20. {ravmem_cli-0.1.0 → ravmem_cli-1.1.0}/ravmem/client/api.py +158 -38
  21. {ravmem_cli-0.1.0 → ravmem_cli-1.1.0}/ravmem/config.py +38 -0
  22. {ravmem_cli-0.1.0 → ravmem_cli-1.1.0}/ravmem/config_loader.py +20 -0
  23. ravmem_cli-1.1.0/ravmem/kg_config.py +139 -0
  24. ravmem_cli-1.1.0/ravmem/kg_init.py +142 -0
  25. {ravmem_cli-0.1.0 → ravmem_cli-1.1.0/ravmem_cli.egg-info}/PKG-INFO +11 -2
  26. {ravmem_cli-0.1.0 → ravmem_cli-1.1.0}/ravmem_cli.egg-info/SOURCES.txt +9 -1
  27. {ravmem_cli-0.1.0 → ravmem_cli-1.1.0}/ravmem_cli.egg-info/requires.txt +6 -0
  28. ravmem_cli-1.1.0/tests/test_smoke.py +144 -0
  29. ravmem_cli-0.1.0/ravmem/cli/browse.py +0 -144
  30. {ravmem_cli-0.1.0 → ravmem_cli-1.1.0}/LICENSE +0 -0
  31. {ravmem_cli-0.1.0 → ravmem_cli-1.1.0}/ravmem/cli/__init__.py +0 -0
  32. {ravmem_cli-0.1.0 → ravmem_cli-1.1.0}/ravmem/cli/config_cmd.py +0 -0
  33. {ravmem_cli-0.1.0 → ravmem_cli-1.1.0}/ravmem/cli/git_local.py +0 -0
  34. {ravmem_cli-0.1.0 → ravmem_cli-1.1.0}/ravmem/client/__init__.py +0 -0
  35. {ravmem_cli-0.1.0 → ravmem_cli-1.1.0}/ravmem/client/config.py +0 -0
  36. {ravmem_cli-0.1.0 → ravmem_cli-1.1.0}/ravmem_cli.egg-info/dependency_links.txt +0 -0
  37. {ravmem_cli-0.1.0 → ravmem_cli-1.1.0}/ravmem_cli.egg-info/entry_points.txt +0 -0
  38. {ravmem_cli-0.1.0 → ravmem_cli-1.1.0}/ravmem_cli.egg-info/top_level.txt +0 -0
  39. {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: 0.1.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
@@ -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 = "0.1.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"
@@ -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
- from ravmem.client.api import RavMemClient
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
- 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)
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
- 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)
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
- 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)
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 — detects upload/clone/mount mode from the project registration."""
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}")