diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 56ef5fc..f7abd9f 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -115,7 +115,7 @@ corrections, retry semantics and the difference between retraction and deletion. | `restart --with-mcp` / `--no-mcp` | preserve current | Enables or disables mounted MCP while restarting. | | `restart --mcp-path PATH` | preserve current | Overrides the mounted MCP path while restarting. | | `restart --force` | off | Allows one-time migration of a live legacy registration after independently verifying its PID. | -| `status` | `--json` (since 0.10.0) | Database/owner, runtime and saved-client discovery. Inspection preserves stale registrations and does not open graphs. | +| `status` | `--json` (since 0.10.0), `--verbose` | Database/owner, runtime and saved-client discovery. Inspection preserves stale registrations and does not open graphs. The text view groups sections, flags problems with a fix command (for example an owner on an older grag, a registration selecting another database, outdated skills, a code root needing ingestion) and uses color on terminals (`NO_COLOR` disables it). `--verbose` adds every running server, full paths, runtime details and raw index state; `--json` is unchanged apart from added structured fields. | | `stop` | — | Gracefully stops the managed background server for the selected database. | | `stop -a` / `-all` / `--all` | off | Stops every safely verifiable managed grag server. Refuses unresolved legacy registrations instead of reporting false success. | | `stop --force` | off | Also permits signaling a live legacy/unverified registration; use only after independently verifying its recorded PID. | diff --git a/src/grag/cli.py b/src/grag/cli.py index 206179d..434afbf 100644 --- a/src/grag/cli.py +++ b/src/grag/cli.py @@ -276,6 +276,7 @@ def main(argv: list[str] | None = None) -> int: status = sub.add_parser("status", help="inspect database, server and client registrations without opening the graph") status.add_argument("--json", action="store_true", help="machine-readable installation and registration discovery") + status.add_argument("--verbose", "-v", action="store_true", help="show every server, full paths, runtime details and raw index state") start = sub.add_parser( "start", diff --git a/src/grag/diagnostics.py b/src/grag/diagnostics.py index 9fe627a..c5e79b5 100644 --- a/src/grag/diagnostics.py +++ b/src/grag/diagnostics.py @@ -221,6 +221,17 @@ def _owner(config: GragConfig) -> dict: target = admin.server_target(config) result: dict = {"status_lines": admin.status_lines(config), "log_path": str(admin.log_path(target)), "registration_path": str(admin.pidfile_path(target)), "status": "not reachable"} + # Structured fields for the readable status view (no secrets such as shutdown tokens). + if str(target) != ":memory:": + resolved = target.resolve() + result["database_bytes"] = resolved.stat().st_size if resolved.is_file() else None + result["database_id8"] = admin._identity8(target) + result["embedder"] = ({"provider": config.embedder.provider, "model": config.embedder.model} + if config.embedder is not None else None) + result["servers"] = [ + {"pid": s.pid, "port": s.port, "host": s.host, "db": s.db, "is_db_dir": s.is_db_dir, "started_at": s.started_at, + "healthy": s.healthy, "verified": s.verified, "version": s.version, "current": s.is_current} + for s in admin.list_servers(current_db=target, cleanup=False)] info = admin.find_server(target) if info is None: return result @@ -518,5 +529,12 @@ def run(args: argparse.Namespace) -> int: report["verification"] = verify(report, private, args.verify_client, args.timeout or 45) report["client_readiness"] = report["verification"]["status"] exit_ok = exit_ok and report["client_readiness"] == "verified" - print(json.dumps(report, ensure_ascii=True) if args.json else "\n".join(lines(report))) + if args.json: + print(json.dumps(report, ensure_ascii=True)) + elif args.cmd == "status": + from grag.status_view import render, use_color + + print("\n".join(render(report, color=use_color(sys.stdout), verbose=getattr(args, "verbose", False)))) + else: + print("\n".join(lines(report))) return 0 if exit_ok else 1 diff --git a/src/grag/status_view.py b/src/grag/status_view.py new file mode 100644 index 0000000..328d3c7 --- /dev/null +++ b/src/grag/status_view.py @@ -0,0 +1,221 @@ +"""Readable `grag status`: grouped sections, color on terminals, and problems with fixes. + +The JSON report (`grag status --json`) stays the machine interface; this is only its +human rendering. Color is used only on a terminal and honours NO_COLOR/FORCE_COLOR. +`--verbose` adds every server, full paths, runtime details and the raw index state. +""" + +from __future__ import annotations + +import json +import os +import re +from collections import Counter +from pathlib import Path +from typing import Any, TextIO + +_CODES = {"ok": "32", "warn": "33", "bad": "31", "dim": "2", "bold": "1", "accent": "36"} +_UUID = re.compile(r"-([0-9a-f]{8})-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}") +LABEL = 10 + + +def use_color(stream: TextIO) -> bool: + if os.environ.get("NO_COLOR"): + return False + if os.environ.get("FORCE_COLOR"): + return True + return bool(getattr(stream, "isatty", lambda: False)()) and os.environ.get("TERM") != "dumb" + + +class _Style: + def __init__(self, enabled: bool): + self.enabled = enabled + + def __call__(self, kind: str, text: str) -> str: + return f"\033[{_CODES[kind]}m{text}\033[0m" if self.enabled else text + + +def short(path: Any, *, verbose: bool = False) -> str: + """Home as ~, and UUID-bearing database names cut to their first block.""" + text = str(path) + home = str(Path.home()) + if text == home or text.startswith(home + os.sep): + text = "~" + text[len(home):] + return text if verbose else _UUID.sub(r"-\1…", text) + + +def _version_key(value: str) -> tuple: + return tuple(int(part) if part.isdigit() else -1 for part in re.split(r"[.+-]", value)) if value != "?" else (-1,) + + +def _size(n: int | None) -> str: + if n is None: + return "" + value = float(n) + for unit in ("B", "KB", "MB", "GB"): + if value < 1024 or unit == "GB": + return f"{value:.0f} {unit}" if unit == "B" else f"{value:.1f} {unit}" + value /= 1024 + return "" + + +def render(report: dict, *, color: bool = False, verbose: bool = False) -> list[str]: + st = _Style(color) + out: list[str] = [] + problems = 0 + + def row(label: str, text: str) -> None: + if out and out[-1] != "": # a blank line between sections + out.append("") + out.append(st("bold", label.ljust(LABEL)) + text) + + def more(text: str) -> None: + out.append(" " * LABEL + text) + + def warn(text: str, fix: str | None = None) -> None: + nonlocal problems + problems += 1 + more(st("warn", "⚠ " + text)) + if fix: + more(" " + st("dim", "fix: ") + st("accent", fix)) + + runtime, project = report["runtime"], report["project"] + server = report.get("server", {}) + version = runtime.get("grag_version", "?") + out.append(f"{st('bold', 'grag ' + version)} {st('dim', 'checkout')} {short(project.get('root', '?'), verbose=verbose)}") + if verbose: + out.append(st("dim", f" python {runtime.get('python')} · source {runtime.get('grag_module')}")) + out.append("") + + # --- database and server ----------------------------------------------------- + if "error" in project: + problems += 1 + row("Database", st("bad", "✗ configuration error")) + more(str(project["error"])) + if project.get("repair"): + more(st("accent", str(project["repair"]))) + else: + details = ", ".join(x for x in (_size(server.get("database_bytes")), + f"id {server['database_id8']}" if server.get("database_id8") else "") if x) + row("Database", short(project["database"], verbose=verbose) + (st("dim", f" ({details})") if details else "")) + more(st("dim", f"selected by {project.get('selection_source', '?')}")) + if not project.get("exists", True): + more(st("dim", "not created yet; the first write creates it")) + if project.get("server_url"): + row("Server", f"remote {project['server_url']}" + st("dim", " (not probed)")) + elif server.get("status") == "reachable": + origin = f"http://{server.get('host')}:{server.get('port')}/" + mcp = f"mcp {server.get('mcp_path') or '/mcp'}" if server.get("mcp_enabled") else "mcp off" + row("Server", st("ok", "● running ") + origin + st("dim", f" pid {server.get('pid')} · grag {server.get('version') or '?'} · {mcp}")) + if server.get("version") and server["version"] != version: + warn(f"the server runs grag {server['version']}; this CLI is {version}", "grag restart") + elif "error" not in project: + row("Server", st("dim", "○ not running")) + more(st("dim", "starts on demand from a registered client, or: ") + st("accent", "grag start")) + if verbose and server.get("log_path"): + more(st("dim", f"log {short(server['log_path'], verbose=True)}")) + embedder = server.get("embedder") + row("Embedder", f"{embedder['provider']} ({embedder['model']})" if embedder + else st("dim", "off: BM25 only (set GRAG_EMBED_PROVIDER for local embeddings)")) + + indexed = report.get("indexed_roots") + roots = indexed.get("roots") or [] if isinstance(indexed, dict) else [] + if isinstance(indexed, str) and indexed and "error" not in project: + row("Index", st("dim", indexed)) + if roots: + bad_roots = [r for r in roots if r.get("error")] + if bad_roots: + row("Index", st("warn", f"⚠ {len(bad_roots)} of {len(roots)} code root(s) need attention")) + problems += 1 + for r in bad_roots: + message = str(r["error"]).split(". ")[0].rstrip(".") + more(st("warn", f"{short(r.get('path'), verbose=verbose)}: {message}")) + more(" " + st("dim", "fix: ") + st("accent", "run ingest_code (or grag ingest-code ) once for each root")) + else: + row("Index", st("ok", f"✓ {len(roots)} code root(s) indexed")) + out.append("") + + # --- clients and skills ------------------------------------------------------ + registrations = report.get("registrations", []) + if not registrations: + row("Clients", st("dim", "no grag registrations found in the known client files")) + for i, record in enumerate(registrations): + issues = record.get("issues") or [] + precedence = record.get("scope_precedence", "") + shadowed = "shadowed" in precedence + mark = st("dim", "·") if shadowed else st("warn", "⚠") if issues else st("ok", "✓") + name = f"{record.get('client')} ({record.get('scope')})" + text = f"{mark} {name.ljust(18)} {short(record.get('path', ''), verbose=verbose)}" + if shadowed: + text += st("dim", f" {precedence}") + (row if i == 0 else lambda _label, t: more(t))("Clients", text) + for issue in issues: + code, _, message = str(issue).partition(": ") + if shadowed: # another registration takes precedence here, so this one is informational + more(" " + st("dim", f"{message or code} (not used here)")) + continue + problems += 1 + more(" " + st("warn", message or code) + (st("dim", f" [{code}]") if message else "")) + if verbose: + more(" " + st("dim", f"launcher {record.get('resolved_command') or record.get('url') or 'unavailable'}; " + f"database {short(record.get('database') or 'unverified', verbose=True)}")) + if registrations and report.get("client_readiness") == "unverified": + more(st("dim", "connections not verified; check one with: ") + st("accent", "grag doctor --verify-client CLIENT:SCOPE:grag")) + + skills = report.get("skills", []) + groups: dict[str, list[dict]] = {"match": [], "differ": [], "other": []} + for skill in skills: + state = str(skill.get("bundle", skill.get("error", ""))) + groups["match" if "matches" in state else "differ" if "different" in state else "other"].append(skill) + if skills: + lines_before = len(out) + if groups["match"]: + row("Skills", st("ok", f"✓ {len(groups['match'])} match this runtime")) + if verbose: + for s in groups["match"]: + more(" " + st("dim", short(s["path"], verbose=True))) + if groups["differ"]: + text = st("warn", f"⚠ {len(groups['differ'])} differ from this runtime") + (row if len(out) == lines_before else lambda _label, t: more(t))("Skills", text) + problems += 1 + for s in groups["differ"]: + more(" " + st("warn", short(s["path"], verbose=verbose))) + project_level = any(not str(s["path"]).startswith(str(Path.home() / ".")) for s in groups["differ"]) + more(" " + st("dim", "fix: ") + st("accent", "grag init" if project_level else "grag init --global-skill")) + for s in groups["other"]: + text = st("warn", f"⚠ {short(s['path'], verbose=verbose)}: {s.get('bundle', s.get('error', 'unreadable'))}") + (row if len(out) == lines_before else lambda _label, t: more(t))("Skills", text) + problems += 1 + out.append("") + + # --- other servers ------------------------------------------------------------- + others = [s for s in server.get("servers", []) if not s.get("current")] + if others: + versions = Counter(s.get("version") or "?" for s in others) + tally = ", ".join(f"{v} \u00d7{n}" for v, n in sorted(versions.items(), key=lambda kv: _version_key(kv[0]))) + older = sum(1 for s in others if s.get("version") and _version_key(s["version"]) < _version_key(version)) + row("Others", f"{len(others)} other grag server(s) running" + st("dim", f" (grag {tally})")) + if older: + more(st("dim", f"{older} run an older grag; restart each from its checkout to update")) + if verbose: + for s in server.get("servers", []): + marker = st("accent", "*") if s.get("current") else " " + where = f"http://{s.get('host')}:{s.get('port')}/" if s.get("port") else "port ?" + more(f"{marker} pid {s.get('pid')} {where} " + st("dim", f"grag {s.get('version') or '?'} since {s.get('started_at') or '?'}")) + more(" " + st("dim", short(s.get("db") or "?", verbose=True))) + else: + more(st("dim", "list them: ") + st("accent", "grag status --verbose")) + out.append("") + + if verbose: + out.append(st("dim", "indexed roots: " + json.dumps(report.get("indexed_roots"), ensure_ascii=True))) + if server.get("observed"): + owner = server["observed"]["runtime"] + out.append(st("dim", f"owner source: {owner['grag_module']} | Python {owner['python']}")) + out.append(st("dim", str(report.get("discovery_limits", "")))) + out.append("") + + out.append(st("warn", f"{problems} issue(s) above.") if problems else st("ok", "No issues found.")) + if not verbose: + out.append(st("dim", "More detail: grag status --verbose · machine-readable: grag status --json")) + return out diff --git a/tests/test_status_view.py b/tests/test_status_view.py new file mode 100644 index 0000000..3d25ffc --- /dev/null +++ b/tests/test_status_view.py @@ -0,0 +1,96 @@ +"""Readable `grag status`: sections, color only when wanted, problems with fixes.""" +from __future__ import annotations + +import io +import re +from pathlib import Path + +from grag import status_view + +HOME = str(Path.home()) +DB = f"{HOME}/.grag/platform-bc2146f4-7f41-437b-9505-00e3302162e9.lbdb" + + +def report(**overrides): + base = { + "runtime": {"grag_version": "0.13.0", "python": "/py", "grag_module": "/src/grag/__init__.py"}, + "project": {"root": f"{HOME}/work/platform", "selection_source": "project manifest", "database": DB, "exists": True}, + "server": {"status": "reachable", "host": "127.0.0.1", "port": 43036, "pid": 7, "version": "0.10.1", + "mcp_enabled": True, "mcp_path": "/mcp", "database_bytes": 31_246_336, "database_id8": "b1d5e564", + "embedder": {"provider": "fastembed", "model": "BAAI/bge-small-en-v1.5"}, + "servers": [{"pid": 7, "port": 43036, "host": "127.0.0.1", "db": DB, "version": "0.10.1", "current": True}, + {"pid": 8, "port": 41007, "host": "127.0.0.1", "db": f"{HOME}/.grag/lumio.lbdb", "version": "0.9.0"}, + {"pid": 9, "port": 41656, "host": "127.0.0.1", "db": f"{HOME}/.grag/adam.lbdb", "version": "0.13.0"}]}, + "registrations": [ + {"id": "claude:project:grag", "client": "claude", "scope": "project", "path": f"{HOME}/work/platform/.mcp.json", + "scope_precedence": "highest discovered", "issues": []}, + {"id": "cursor:user:grag", "client": "cursor", "scope": "user", "path": f"{HOME}/.cursor/mcp.json", + "scope_precedence": "shadowed by cursor:project:grag", + "issues": ["database_mismatch: this registration selects a different database than this CLI"]}, + {"id": "cursor:project:grag", "client": "cursor", "scope": "project", "path": f"{HOME}/work/platform/.cursor/mcp.json", + "scope_precedence": "highest discovered", "issues": ["database_mismatch: this registration selects a different database than this CLI"]}, + ], + "client_readiness": "unverified", + "skills": [{"path": f"{HOME}/work/platform/.claude/skills/grag/SKILL.md", "bundle": "different from this runtime"}, + {"path": f"{HOME}/.claude/skills/grag/SKILL.md", "bundle": "matches this runtime"}], + "indexed_roots": {"roots": [{"path": f"{HOME}/work/platform", "error": "Legacy index has no saved scope/options. Run ingest_code once."}]}, + "discovery_limits": "limits text", + } + base.update(overrides) + return base + + +def test_problems_are_grouped_with_fixes_and_noise_is_hidden(): + text = "\n".join(status_view.render(report())) + assert "\033[" not in text # no color unless asked + assert "~/.grag/platform-bc2146f4….lbdb (29.8 MB, id b1d5e564)" in text + assert "the server runs grag 0.10.1; this CLI is 0.13.0" in text and "fix: grag restart" in text + assert "1 of 1 code root(s) need attention" in text and "Legacy index has no saved scope/options" in text + assert "(not used here)" in text # the shadowed registration's mismatch is informational + assert text.count("[database_mismatch]") == 1 # the active one is a problem + assert "⚠ 1 differ from this runtime" in text and "fix: grag init" in text + assert "2 other grag server(s) running (grag 0.9.0 \u00d71, 0.13.0 \u00d71)" in text and "1 run an older grag" in text + assert "4 issue(s) above." in text + assert "lumio.lbdb" not in text and "indexed roots:" not in text and "limits text" not in text + + +def test_verbose_lists_servers_full_paths_and_raw_state(): + text = "\n".join(status_view.render(report(), verbose=True)) + assert DB.replace(HOME, "~") in text and "lumio.lbdb" in text and "pid 8" in text + assert "indexed roots:" in text and "limits text" in text and "source /src/grag/__init__.py" in text + + +def test_healthy_setup_reports_no_issues(): + healthy = report(server={**report()["server"], "version": "0.13.0", "servers": []}, indexed_roots={"roots": []}, + skills=[{"path": f"{HOME}/.claude/skills/grag/SKILL.md", "bundle": "matches this runtime"}], + registrations=[report()["registrations"][0]]) + assert status_view.render(healthy)[-2] == "No issues found." + + +def test_configuration_errors_are_shown_first(): + broken = report(project={"root": f"{HOME}/w", "error": "GRAG_STATEMENT_TIMEOUT_MS must be positive", "repair": "Unset it."}, + server={}, indexed_roots="unavailable until the configuration is fixed") + text = "\n".join(status_view.render(broken)) + assert "configuration error" in text and "GRAG_STATEMENT_TIMEOUT_MS must be positive" in text + + +def test_color_follows_the_terminal_and_no_color(monkeypatch): + class Tty(io.StringIO): + def isatty(self): + return True + + monkeypatch.delenv("NO_COLOR", raising=False) + monkeypatch.delenv("FORCE_COLOR", raising=False) + monkeypatch.setenv("TERM", "xterm-256color") + assert status_view.use_color(Tty()) and not status_view.use_color(io.StringIO()) + monkeypatch.setenv("NO_COLOR", "1") + assert not status_view.use_color(Tty()) + colored = "\n".join(status_view.render(report(), color=True)) + assert re.search(r"\033\[33m⚠ the server runs grag 0.10.1", colored) + + +def test_sections_are_separated_by_blank_lines(): + lines = status_view.render(report()) + labelled = [i for i, line in enumerate(lines) if line[:1].isalpha() and not line.startswith(("grag ", "More detail")) + and line.split()[0] in {"Database", "Server", "Embedder", "Index", "Clients", "Skills", "Others"}] + assert len(labelled) == 7 and all(lines[i - 1] == "" for i in labelled)