ravmem-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ravmem/cli/browse.py ADDED
@@ -0,0 +1,144 @@
1
+ """ravmem clusters / symbols — browse indexed code graph data."""
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: str | None) -> 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 _get_client(remote: str | None):
26
+ from ravmem.client.api import RavMemClient
27
+ from ravmem.client.config import load_config
28
+
29
+ url = remote
30
+ api_key = None
31
+ if not url:
32
+ cfg = load_config()
33
+ url = cfg.get("server_url")
34
+ api_key = cfg.get("api_key")
35
+ if not url:
36
+ typer.echo("No server configured. Run: ravmem remote config --url <URL>", err=True)
37
+ raise typer.Exit(1)
38
+ return RavMemClient(base_url=url, api_key=api_key or None)
39
+
40
+
41
+ def clusters_cmd(
42
+ repo: Path = typer.Option(Path("."), "--repo", "-r", help="Repo path for branch auto-detection"),
43
+ remote: Optional[str] = typer.Option(None, "--remote", help="Server URL override"),
44
+ project: Optional[str] = typer.Option(None, "--project", help="Project ID"),
45
+ branch: Optional[str] = typer.Option(None, "--branch", help="Branch name"),
46
+ output_json: bool = typer.Option(False, "--json", help="Raw JSON output"),
47
+ ) -> None:
48
+ """List all clusters for a branch."""
49
+ import json
50
+ from rich.console import Console
51
+ from rich.table import Table
52
+ from rich import box
53
+
54
+ if not project:
55
+ typer.echo("--project is required.", err=True)
56
+ raise typer.Exit(1)
57
+
58
+ branch_name = _detect_branch(repo.resolve(), branch)
59
+ client = _get_client(remote)
60
+ try:
61
+ clusters = client.get_clusters(project, branch_name)
62
+ finally:
63
+ client.close()
64
+
65
+ if output_json:
66
+ typer.echo(json.dumps(clusters, indent=2, default=str))
67
+ return
68
+
69
+ if not clusters:
70
+ typer.echo("No clusters found.")
71
+ return
72
+
73
+ console = Console()
74
+ t = Table(box=box.SIMPLE, show_header=True, header_style="bold")
75
+ t.add_column("Cluster ID", width=18)
76
+ t.add_column("Label", width=40)
77
+ t.add_column("Symbols", justify="right", width=8)
78
+ for c in clusters:
79
+ t.add_row(
80
+ str(c.get("id", ""))[:18],
81
+ str(c.get("label", c.get("id", "")))[:40],
82
+ str(c.get("symbol_count", c.get("size", "?"))),
83
+ )
84
+ console.print(t)
85
+ console.print(f"[dim]{len(clusters)} cluster(s) on branch '{branch_name}'[/dim]")
86
+
87
+
88
+ def symbols_cmd(
89
+ path: str = typer.Argument(..., help="File path (exact) or directory prefix"),
90
+ repo: Path = typer.Option(Path("."), "--repo", "-r", help="Repo path for branch auto-detection"),
91
+ remote: Optional[str] = typer.Option(None, "--remote", help="Server URL override"),
92
+ project: Optional[str] = typer.Option(None, "--project", help="Project ID"),
93
+ branch: Optional[str] = typer.Option(None, "--branch", help="Branch name"),
94
+ prefix: bool = typer.Option(False, "--prefix", "-p",
95
+ help="Treat path as a directory prefix (list all files under it)"),
96
+ output_json: bool = typer.Option(False, "--json", help="Raw JSON output"),
97
+ ) -> None:
98
+ """List symbols in a file or directory."""
99
+ import json
100
+ from rich.console import Console
101
+ from rich.table import Table
102
+ from rich import box
103
+
104
+ if not project:
105
+ typer.echo("--project is required.", err=True)
106
+ raise typer.Exit(1)
107
+
108
+ branch_name = _detect_branch(repo.resolve(), branch)
109
+ client = _get_client(remote)
110
+ try:
111
+ result = client._request(
112
+ "GET",
113
+ f"/api/projects/{project}/branches/{branch_name}/files/symbols",
114
+ params={"path": path, "prefix": str(prefix).lower()},
115
+ )
116
+ finally:
117
+ client.close()
118
+
119
+ symbols = result if isinstance(result, list) else result.get("symbols", [])
120
+
121
+ if output_json:
122
+ typer.echo(json.dumps(symbols, indent=2, default=str))
123
+ return
124
+
125
+ if not symbols:
126
+ typer.echo(f"No symbols found for path: {path}")
127
+ return
128
+
129
+ console = Console()
130
+ t = Table(box=box.SIMPLE, show_header=True, header_style="bold")
131
+ t.add_column("Name", width=30)
132
+ t.add_column("Kind", width=12)
133
+ t.add_column("File", width=35)
134
+ t.add_column("Line", justify="right", width=6)
135
+ for s in symbols:
136
+ file_short = Path(str(s.get("file", ""))).name
137
+ t.add_row(
138
+ str(s.get("name", ""))[:30],
139
+ str(s.get("kind", "")),
140
+ file_short[:35],
141
+ str(s.get("line_start", "")),
142
+ )
143
+ console.print(t)
144
+ console.print(f"[dim]{len(symbols)} symbol(s)[/dim]")
@@ -0,0 +1,69 @@
1
+ """ravmem config — inspect and validate the resolved configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import typer
8
+ import yaml
9
+
10
+ app = typer.Typer(help="Inspect or validate the ravmem configuration.", no_args_is_help=True)
11
+
12
+ _SENSITIVE = {"password", "admin_token"}
13
+
14
+
15
+ def _redact(data: object, path: tuple[str, ...] = ()) -> object:
16
+ """Recursively redact sensitive keys for display."""
17
+ if isinstance(data, dict):
18
+ return {
19
+ k: ("***REDACTED***" if k in _SENSITIVE else _redact(v, path + (k,)))
20
+ for k, v in data.items()
21
+ }
22
+ return data
23
+
24
+
25
+ @app.command("show")
26
+ def show_cmd(
27
+ section: Optional[str] = typer.Option(None, "--section", "-s", help="Show only this top-level section"),
28
+ config: Optional[str] = typer.Option(None, "--config", "-c", help="Path to ravmem.yaml"),
29
+ ) -> None:
30
+ """Print the resolved configuration as YAML (sensitive values redacted)."""
31
+ from ravmem.config_loader import load_config
32
+
33
+ try:
34
+ cfg = load_config(config)
35
+ except ValueError as exc:
36
+ typer.echo(f"[error] {exc}", err=True)
37
+ raise typer.Exit(1)
38
+
39
+ data = cfg.model_dump()
40
+ data = _redact(data)
41
+
42
+ if section:
43
+ if section not in data:
44
+ typer.echo(f"[error] Unknown section '{section}'. Available: {', '.join(data)}", err=True)
45
+ raise typer.Exit(1)
46
+ data = {section: data[section]}
47
+
48
+ typer.echo(yaml.dump(data, default_flow_style=False, sort_keys=False).rstrip())
49
+
50
+
51
+ @app.command("validate")
52
+ def validate_cmd(
53
+ config: Optional[str] = typer.Option(None, "--config", "-c", help="Path to ravmem.yaml"),
54
+ ) -> None:
55
+ """Validate the config file, print any errors, and exit non-zero on failure."""
56
+ from ravmem.config_loader import _find_config_file, load_config
57
+
58
+ found = _find_config_file(config)
59
+ if not found:
60
+ typer.echo("No config file found — using built-in defaults (all valid).")
61
+ raise typer.Exit(0)
62
+
63
+ typer.echo(f"Validating {found} …")
64
+ try:
65
+ load_config(config)
66
+ typer.echo("[ok] Config is valid.")
67
+ except ValueError as exc:
68
+ typer.echo(f"[error] {exc}", err=True)
69
+ raise typer.Exit(1)
ravmem/cli/diff.py ADDED
@@ -0,0 +1,122 @@
1
+ """ravmem diff-impact — blast radius from a git diff."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ import typer
11
+
12
+
13
+ def _detect_branch(repo_path: Path, explicit: str | None) -> str:
14
+ if explicit:
15
+ return explicit
16
+ try:
17
+ out = subprocess.run(
18
+ ["git", "-C", str(repo_path), "rev-parse", "--abbrev-ref", "HEAD"],
19
+ capture_output=True, text=True, check=True,
20
+ ).stdout.strip()
21
+ return out if out and out != "HEAD" else "main"
22
+ except Exception:
23
+ return "main"
24
+
25
+
26
+ def _get_client(remote: str | None):
27
+ from ravmem.client.api import RavMemClient
28
+ from ravmem.client.config import load_config
29
+
30
+ url = remote
31
+ api_key = None
32
+ if not url:
33
+ cfg = load_config()
34
+ url = cfg.get("server_url")
35
+ api_key = cfg.get("api_key")
36
+ if not url:
37
+ typer.echo("No server configured. Run: ravmem remote config --url <URL>", err=True)
38
+ raise typer.Exit(1)
39
+ return RavMemClient(base_url=url, api_key=api_key or None)
40
+
41
+
42
+ def diff_impact_cmd(
43
+ diff_path: Optional[Path] = typer.Argument(None,
44
+ help="Path to unified diff file. Reads stdin if omitted."),
45
+ repo: Path = typer.Option(Path("."), "--repo", "-r",
46
+ help="Repo path for branch auto-detection"),
47
+ remote: Optional[str] = typer.Option(None, "--remote", help="Server URL override"),
48
+ project: Optional[str] = typer.Option(None, "--project", help="Project ID"),
49
+ branch: Optional[str] = typer.Option(None, "--branch", help="Branch name"),
50
+ depth: int = typer.Option(3, "--depth", help="Blast radius traversal depth"),
51
+ output_json: bool = typer.Option(False, "--json", help="Output raw JSON"),
52
+ ) -> None:
53
+ """Show blast radius of all symbols changed in a diff.
54
+
55
+ Reads a unified diff from a file argument or stdin.
56
+
57
+ Examples:
58
+
59
+ git diff HEAD~1 | ravmem diff-impact --project my-proj
60
+
61
+ ravmem diff-impact changes.patch --project my-proj --branch main
62
+ """
63
+ import json
64
+ if not project:
65
+ typer.echo("--project is required.", err=True)
66
+ raise typer.Exit(1)
67
+
68
+ branch_name = _detect_branch(repo.resolve(), branch)
69
+
70
+ # Read diff
71
+ if diff_path:
72
+ diff_text = diff_path.read_text()
73
+ elif not sys.stdin.isatty():
74
+ diff_text = sys.stdin.read()
75
+ else:
76
+ typer.echo("Provide a diff file as argument or pipe it to stdin.", err=True)
77
+ raise typer.Exit(1)
78
+
79
+ if not diff_text.strip():
80
+ typer.echo("Empty diff — nothing to analyze.")
81
+ return
82
+
83
+ client = _get_client(remote)
84
+ try:
85
+ result = client._request(
86
+ "POST",
87
+ f"/api/projects/{project}/branches/{branch_name}/diff-impact",
88
+ json={"diff": diff_text, "depth": depth},
89
+ )
90
+ finally:
91
+ client.close()
92
+
93
+ if output_json:
94
+ typer.echo(json.dumps(result, indent=2, default=str))
95
+ return
96
+
97
+ affected = result.get("affected", [])
98
+ changed_symbols = result.get("changed_symbols", [])
99
+
100
+ if not changed_symbols:
101
+ typer.echo("No recognizable symbols found in the diff.")
102
+ return
103
+
104
+ typer.echo(f"\nChanged symbols detected: {', '.join(changed_symbols)}")
105
+
106
+ if not affected:
107
+ typer.echo("No downstream dependents found.")
108
+ return
109
+
110
+ typer.echo(f"\n{len(affected)} symbol(s) would be affected:\n")
111
+ for node in sorted(affected, key=lambda n: n.get("depth", 0)):
112
+ d = node.get("depth", 0)
113
+ sym = node.get("symbol", {})
114
+ name = sym.get("name", "")
115
+ kind = sym.get("kind", "")
116
+ file = sym.get("file", "")
117
+ line = sym.get("line_start", "")
118
+ indent = " " + " " * d
119
+ location = f"{Path(file).name}:{line}" if file else ""
120
+ typer.echo(
121
+ f"{indent}depth={d} {typer.style(name, bold=True)} ({kind}) {location}"
122
+ )
@@ -0,0 +1,131 @@
1
+ """CLI-side git helpers — run on the client machine, not on the server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+
9
+ class LocalGitError(Exception):
10
+ """Raised when a local git command fails."""
11
+
12
+
13
+ def _run(args: list[str], cwd: Path, timeout: int = 30) -> str:
14
+ result = subprocess.run(
15
+ args, cwd=str(cwd), capture_output=True, text=True, timeout=timeout,
16
+ )
17
+ if result.returncode != 0:
18
+ raise LocalGitError(
19
+ f"git command failed: {' '.join(args)}\nstderr: {result.stderr.strip()}"
20
+ )
21
+ return result.stdout.strip()
22
+
23
+
24
+ def rev_parse(repo_path: Path, ref: str = "HEAD") -> str:
25
+ """Return the full SHA for *ref*."""
26
+ return _run(["git", "rev-parse", ref], cwd=repo_path)
27
+
28
+
29
+ def current_branch(repo_path: Path) -> str:
30
+ """Return the current branch name, or empty string if detached HEAD."""
31
+ try:
32
+ return _run(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=repo_path)
33
+ except LocalGitError:
34
+ return ""
35
+
36
+
37
+ def merge_base(repo_path: Path, ref_a: str, ref_b: str) -> str:
38
+ """Return the merge-base commit SHA of two refs."""
39
+ return _run(["git", "merge-base", ref_a, ref_b], cwd=repo_path)
40
+
41
+
42
+ def ls_files(repo_path: Path) -> list[str]:
43
+ """Return all file paths tracked by git (honours .gitignore, no untracked files).
44
+
45
+ Paths are relative to *repo_path*, forward-slash separated.
46
+ Binary files are NOT excluded here — callers filter by extension as needed.
47
+ """
48
+ out = _run(["git", "ls-files"], cwd=repo_path)
49
+ return [line for line in out.splitlines() if line]
50
+
51
+
52
+ def diff_name_status(
53
+ repo_path: Path, base_commit: str, target: str = "HEAD",
54
+ ) -> tuple[list[str], list[str]]:
55
+ """Return (modified_paths, deleted_paths) between *base_commit* and *target*.
56
+
57
+ Paths are relative to *repo_path*.
58
+ """
59
+ out = _run(
60
+ ["git", "diff", "--name-status", base_commit, target], cwd=repo_path,
61
+ )
62
+ modified: list[str] = []
63
+ deleted: list[str] = []
64
+ for line in out.splitlines():
65
+ line = line.strip()
66
+ if not line:
67
+ continue
68
+ parts = line.split("\t", 1)
69
+ if len(parts) != 2:
70
+ continue
71
+ status, path = parts[0].strip(), parts[1].strip()
72
+ if status.startswith("D"):
73
+ deleted.append(path)
74
+ else:
75
+ modified.append(path)
76
+ return modified, deleted
77
+
78
+
79
+ def diff_unified_per_file(
80
+ repo_path: Path,
81
+ base_commit: str,
82
+ target: str = "HEAD",
83
+ context: int = 3,
84
+ ) -> list[dict[str, str]]:
85
+ """Return per-file unified diffs between *base_commit* and *target*.
86
+
87
+ Each item in the returned list is::
88
+
89
+ {"path": "relative/path.py", "diff": "<unified diff text>"}
90
+
91
+ Only files with actual changes are included.
92
+ """
93
+ out = _run(
94
+ ["git", "diff", f"--unified={context}", base_commit, target],
95
+ cwd=repo_path,
96
+ timeout=120,
97
+ )
98
+ return _split_diff_by_file(out)
99
+
100
+
101
+ def _split_diff_by_file(raw: str) -> list[dict[str, str]]:
102
+ """Split a multi-file unified diff into per-file chunks."""
103
+ files: list[dict[str, str]] = []
104
+ current_path: str | None = None
105
+ current_lines: list[str] = []
106
+
107
+ for line in raw.splitlines(keepends=True):
108
+ if line.startswith("diff --git "):
109
+ if current_path is not None and current_lines:
110
+ files.append({"path": current_path, "diff": "".join(current_lines)})
111
+ current_lines = [line]
112
+ current_path = None
113
+ elif line.startswith("+++ b/") and current_path is None:
114
+ current_path = line[6:].rstrip("\n")
115
+ current_lines.append(line)
116
+ else:
117
+ current_lines.append(line)
118
+
119
+ if current_path is not None and current_lines:
120
+ files.append({"path": current_path, "diff": "".join(current_lines)})
121
+
122
+ return files
123
+
124
+
125
+ def is_git_repo(path: Path) -> bool:
126
+ """Return True if *path* is inside a git repository."""
127
+ result = subprocess.run(
128
+ ["git", "-C", str(path), "rev-parse", "--git-dir"],
129
+ capture_output=True, timeout=5,
130
+ )
131
+ return result.returncode == 0
ravmem/cli/impact.py ADDED
@@ -0,0 +1,106 @@
1
+ """ravmem impact — blast radius analysis via the remote server."""
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: str | None) -> 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 _get_client(remote: str | None):
26
+ from ravmem.client.api import RavMemClient
27
+ from ravmem.client.config import load_config
28
+
29
+ url = remote
30
+ api_key = None
31
+ if not url:
32
+ cfg = load_config()
33
+ url = cfg.get("server_url")
34
+ api_key = cfg.get("api_key")
35
+ if not url:
36
+ typer.echo(
37
+ "Error: No server configured. "
38
+ "Run `ravmem remote config --url <URL>` or pass --remote <URL>.",
39
+ err=True,
40
+ )
41
+ raise typer.Exit(1)
42
+ return RavMemClient(base_url=url, api_key=api_key or None)
43
+
44
+
45
+ def impact_cmd(
46
+ symbol: str = typer.Argument(..., help="Symbol name or ID to analyze"),
47
+ repo: Path = typer.Option(Path("."), "--repo", "-r", help="Path to the repository (used for branch auto-detection)"),
48
+ depth: int = typer.Option(3, "--depth", "-d", help="Max traversal depth"),
49
+ remote: Optional[str] = typer.Option(None, "--remote", help="Server URL (overrides config)"),
50
+ project: Optional[str] = typer.Option(None, "--project", help="Project ID on the server"),
51
+ branch: Optional[str] = typer.Option(None, "--branch", help="Branch name (auto-detected from git if omitted)"),
52
+ output_json: bool = typer.Option(False, "--json", help="Output raw JSON"),
53
+ ) -> None:
54
+ """Show what breaks if you change a symbol (callers, transitively)."""
55
+ import json
56
+
57
+ repo_path = repo.resolve()
58
+ branch_name = _detect_branch(repo_path, branch)
59
+
60
+ if not project:
61
+ typer.echo("--project is required. Use `ravmem remote add` to register the repo first.", err=True)
62
+ raise typer.Exit(1)
63
+
64
+ client = _get_client(remote)
65
+ try:
66
+ # The impact API expects an MD5 symbol ID, not a name.
67
+ # If the argument looks like a name (not a 16-char hex ID), search for it first.
68
+ import re
69
+ symbol_id = symbol
70
+ if not re.fullmatch(r"[0-9a-f]{16}", symbol):
71
+ hits = client.search(project, branch_name, symbol, limit=1)
72
+ if not hits:
73
+ typer.echo(f"Symbol '{symbol}' not found on branch '{branch_name}'.", err=True)
74
+ raise typer.Exit(1)
75
+ symbol_id = hits[0]["symbol"]["id"]
76
+ resolved_name = hits[0]["symbol"]["name"]
77
+ if resolved_name != symbol:
78
+ typer.echo(f"Resolved '{symbol}' → '{resolved_name}' ({symbol_id})")
79
+ result = client.impact(project, branch_name, symbol_id, depth=depth)
80
+ finally:
81
+ client.close()
82
+
83
+ if output_json:
84
+ typer.echo(json.dumps(result, indent=2, default=str))
85
+ return
86
+
87
+ affected = result.get("affected", [])
88
+ if not affected:
89
+ typer.echo(f"No dependents found for '{symbol}' on branch '{branch_name}' (nothing would break).")
90
+ return
91
+
92
+ typer.echo(f"\nImpact analysis for '{symbol}' on branch '{branch_name}' (depth={depth}):\n")
93
+ typer.echo(f" {len(affected)} symbol(s) would be affected:\n")
94
+ for node in sorted(affected, key=lambda n: n.get("depth", 0)):
95
+ d = node.get("depth", 0)
96
+ sym = node.get("symbol", {})
97
+ name = sym.get("name", "")
98
+ kind = sym.get("kind", "")
99
+ file = sym.get("file", "")
100
+ line = sym.get("line_start", "")
101
+ indent = " " + " " * d
102
+ location = f"{Path(file).name}:{line}" if file else ""
103
+ typer.echo(
104
+ f"{indent}depth={d} {typer.style(name, bold=True)} "
105
+ f"({kind}) {location}"
106
+ )
ravmem/cli/info.py ADDED
@@ -0,0 +1,82 @@
1
+ """ravmem health / metrics — server status commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import typer
8
+
9
+
10
+ def _get_client(remote: str | None):
11
+ from ravmem.client.api import RavMemClient
12
+ from ravmem.client.config import load_config
13
+
14
+ url = remote
15
+ api_key = None
16
+ if not url:
17
+ cfg = load_config()
18
+ url = cfg.get("server_url")
19
+ api_key = cfg.get("api_key")
20
+ if not url:
21
+ typer.echo("No server configured. Run: ravmem remote config --url <URL>", err=True)
22
+ raise typer.Exit(1)
23
+ return RavMemClient(base_url=url, api_key=api_key or None)
24
+
25
+
26
+ def health_cmd(
27
+ remote: Optional[str] = typer.Option(None, "--remote", help="Server URL override"),
28
+ output_json: bool = typer.Option(False, "--json", help="Output raw JSON"),
29
+ ) -> None:
30
+ """Show server health status and statistics."""
31
+ import json
32
+ from rich.console import Console
33
+ from rich.table import Table
34
+ from rich import box
35
+
36
+ client = _get_client(remote)
37
+ try:
38
+ data = client._request("GET", "/health")
39
+ finally:
40
+ client.close()
41
+
42
+ if output_json:
43
+ typer.echo(json.dumps(data, indent=2, default=str))
44
+ return
45
+
46
+ console = Console()
47
+ t = Table(box=box.SIMPLE, show_header=False, padding=(0, 2))
48
+ t.add_column("Key", style="bold")
49
+ t.add_column("Value")
50
+ t.add_row("Status", data.get("status", "?"))
51
+ t.add_row("Version", str(data.get("version", "?")))
52
+ t.add_row("Uptime", f"{data.get('uptime_seconds', 0):.0f}s")
53
+ t.add_row("Projects", str(data.get("projects_count", "?")))
54
+ t.add_row("Active jobs", str(data.get("active_jobs", 0)))
55
+ t.add_row("Queued jobs", str(data.get("queued_jobs", 0)))
56
+ console.print(t)
57
+
58
+
59
+ def metrics_cmd(
60
+ remote: Optional[str] = typer.Option(None, "--remote", help="Server URL override"),
61
+ ) -> None:
62
+ """Print Prometheus-style server metrics."""
63
+ import httpx
64
+ from ravmem.client.config import load_config
65
+
66
+ url = remote
67
+ api_key = None
68
+ if not url:
69
+ cfg = load_config()
70
+ url = cfg.get("server_url")
71
+ api_key = cfg.get("api_key")
72
+ if not url:
73
+ typer.echo("No server configured. Run: ravmem remote config --url <URL>", err=True)
74
+ raise typer.Exit(1)
75
+
76
+ headers = {}
77
+ if api_key:
78
+ headers["Authorization"] = f"Bearer {api_key}"
79
+
80
+ resp = httpx.get(f"{url.rstrip('/')}/api/metrics", headers=headers, timeout=10)
81
+ resp.raise_for_status()
82
+ typer.echo(resp.text)