"""Interactive inspection shell (REPL) over the FablePool reference node. The shell is intentionally a *thin, testable layer* over the non-interactive CLI: every shell command is translated into an argv vector and dispatched through :func:`fablepool.cli.main`, so the REPL and the one-shot CLI can never drift apart. On top of that, the shell adds the conveniences that make interactive inspection pleasant: * listings are numbered, and any later command may refer to an item as ``#3`` (or just ``3``) instead of pasting a full claim id; * mutating commands (``refute``, ``correct``) accept free-text reasons without quoting gymnastics; * errors are reported but never terminate the session — a user poking at their own memory graph should not be punished for a typo. The shell only depends on the standard library. When the host provides ``readline``, line editing and history work automatically. """ from __future__ import annotations import contextlib import io import json import shlex import sys from pathlib import Path from typing import Callable, Dict, List, Optional, Sequence, TextIO, Tuple from .cli import main as _default_cli with contextlib.suppress(ImportError): # pragma: no cover - host dependent import readline # noqa: F401 (enables line editing / history when available) class ShellUsageError(Exception): """Raised when an interactive command is malformed. The shell catches this, prints the message, and keeps running. """ DEFAULT_QUESTION = "what do you know about me and why?" BANNER = "FablePool inspection shell — type 'help' for commands, 'quit' to leave." HELP_TEXT = """\ Commands topics list topics with claim counts claims [TOPIC] [--all] list claims, numbered (--all includes refuted/invalidated) show REF full detail for one claim why REF derivation explanation down to raw evidence evidence ID show one raw evidence record with provenance ask [QUESTION...] ask the node what it knows about you (and why) refute REF [REASON...] refute a claim; downstream claims are invalidated correct REF VALUE... [--reason TEXT] correct a claim's value; the cascade re-derives export [PATH] [--topic T]... export the graph (or a topic subset) in wire format audit verify signatures and the operation log hash chain log [N] show the last N operations (default 20) home print the node home directory help this text quit leave the shell REF is a claim id, a unique id prefix, or #N / N from the last listing. Lines starting with '#' are treated as comments (useful in scripted input). """ class FableShell: """A line-oriented REPL bound to one node home directory. Parameters ---------- home: Node home directory, forwarded to every CLI invocation as ``--home``. stdin / stdout: Streams for the session. Defaults to the process streams; tests pass ``io.StringIO`` objects. cli: The CLI entry point to dispatch through. Defaults to :func:`fablepool.cli.main`; tests inject a stub. """ PROMPT = "fablepool> " def __init__( self, home: str | Path, stdin: Optional[TextIO] = None, stdout: Optional[TextIO] = None, cli: Optional[Callable[[Sequence[str]], Optional[int]]] = None, ) -> None: self.home = str(home) self.stdin: TextIO = stdin if stdin is not None else sys.stdin self.stdout: TextIO = stdout if stdout is not None else sys.stdout self._cli = cli if cli is not None else _default_cli self._last_ids: List[str] = [] self.last_code: int = 0 # ------------------------------------------------------------------ # # Plumbing # ------------------------------------------------------------------ # def _write(self, text: str) -> None: self.stdout.write(text) def _error(self, message: str) -> None: self._write(f"error: {message}\n") def _invoke(self, args: Sequence[str], capture: bool = False) -> Tuple[int, str]: """Run one CLI command against this node, never letting it kill the REPL. Returns ``(exit_code, combined_output)``. When ``capture`` is False the output is also written to the session's stdout. """ argv = ["--home", self.home, *[str(a) for a in args]] buf = io.StringIO() try: with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(buf): code = self._cli(argv) except SystemExit as exc: # argparse error paths inside the CLI code = exc.code if isinstance(exc.code, int) else 1 except Exception as exc: # the shell must survive node-level errors buf.write(f"error: {exc}\n") code = 1 code = 0 if code is None else int(code) self.last_code = code out = buf.getvalue() if not capture: self._write(out) return code, out def _resolve(self, ref: str) -> str: """Resolve ``#N`` / bare ordinals against the last listing.""" raw = ref[1:] if ref.startswith("#") else ref if raw.isdigit(): if not self._last_ids: raise ShellUsageError( "no listing yet — run 'claims' first, then refer to items as #N" ) n = int(raw) if not 1 <= n <= len(self._last_ids): raise ShellUsageError( f"#{n} is out of range — the last listing had " f"{len(self._last_ids)} item(s)" ) return self._last_ids[n - 1] return ref @staticmethod def _coerce_claim_list(text: str) -> Optional[List[dict]]: """Parse CLI ``--json`` output into a list of claim dicts, or None.""" try: data = json.loads(text) except ValueError: return None if isinstance(data, dict): for key in ("claims", "items", "results"): if key in data and isinstance(data[key], list): data = data[key] break if not isinstance(data, list): return None if any(not isinstance(c, dict) or "id" not in c for c in data): return None return data def _render_claims(self, claims: List[dict]) -> None: if not claims: self._last_ids = [] self._write("(no claims — try 'claims --all', or run 'seed' on a fresh node)\n") return self._last_ids = [str(c.get("id", "")) for c in claims] for i, c in enumerate(claims, 1): conf = c.get("confidence") conf_s = f"{conf:.2f}" if isinstance(conf, (int, float)) else " - " self._write( f"#{i:<3} {str(c.get('id', ''))[:12]:<12} " f"{str(c.get('status', '?')):<11} {conf_s:>5} " f"[{c.get('topic', '-')}] {c.get('statement', '')}\n" ) self._write(f"{len(claims)} claim(s). Use 'show #N' or 'why #N' to drill in.\n") # ------------------------------------------------------------------ # # Commands # ------------------------------------------------------------------ # def do_help(self, args: List[str]) -> None: self._write(HELP_TEXT) def do_home(self, args: List[str]) -> None: self._write(f"{self.home}\n") def do_quit(self, args: List[str]) -> bool: self._write("bye.\n") return False def do_topics(self, args: List[str]) -> None: if args: raise ShellUsageError("usage: topics") code, out = self._invoke(["topics", "--json"], capture=True) if code != 0: self._write(out) return try: data = json.loads(out) except ValueError: self._write(out) # fall back to whatever the CLI printed return if isinstance(data, dict) and isinstance(data.get("topics"), list): data = data["topics"] if not isinstance(data, list): self._write(out) return if not data: self._write("(no topics yet — run 'seed' on a fresh node)\n") return for entry in data: if isinstance(entry, dict): topic = entry.get("topic", "?") count = entry.get("count", "?") self._write(f" {topic:<24} {count} claim(s)\n") else: self._write(f" {entry}\n") self._write("Use 'claims TOPIC' to list one topic.\n") def do_claims(self, args: List[str]) -> None: topic: Optional[str] = None show_all = False for a in args: if a in ("--all", "-a"): show_all = True elif a.startswith("-"): raise ShellUsageError( f"unknown option {a!r} (usage: claims [TOPIC] [--all])" ) elif topic is None: topic = a else: raise ShellUsageError("usage: claims [TOPIC] [--all]") argv = ["claims", "--json"] if topic: argv += ["--topic", topic] if show_all: argv.append("--all") code, out = self._invoke(argv, capture=True) if code != 0: self._write(out) return claims = self._coerce_claim_list(out) if claims is None: self._write(out) # schema fallback: show raw CLI output return self._render_claims(claims) def do_show(self, args: List[str]) -> None: if len(args) != 1: raise ShellUsageError("usage: show REF") self._invoke(["show", self._resolve(args[0])]) def do_why(self, args: List[str]) -> None: if len(args) != 1: raise ShellUsageError("usage: why REF") self._invoke(["why", self._resolve(args[0])]) def do_evidence(self, args: List[str]) -> None: if len(args) != 1: raise ShellUsageError("usage: evidence ID") self._invoke(["evidence", self._resolve(args[0])]) def do_ask(self, args: List[str]) -> None: question = " ".join(args).strip() or DEFAULT_QUESTION self._invoke(["ask", question]) def do_refute(self, args: List[str]) -> None: if not args: raise ShellUsageError("usage: refute REF [REASON...]") ref, rest = args[0], list(args[1:]) if rest and rest[0] in ("--reason", "-r"): rest = rest[1:] reason = " ".join(rest).strip() or "refuted interactively via shell" self._invoke(["refute", self._resolve(ref), "--reason", reason]) def do_correct(self, args: List[str]) -> None: usage = "usage: correct REF VALUE... [--reason TEXT]" if len(args) < 2: raise ShellUsageError(usage) ref = args[0] tail = list(args[1:]) if "--reason" in tail: i = tail.index("--reason") value_tokens, reason_tokens = tail[:i], tail[i + 1 :] else: value_tokens, reason_tokens = tail, [] if not value_tokens: raise ShellUsageError(usage) value = " ".join(value_tokens) reason = " ".join(reason_tokens).strip() or "corrected interactively via shell" self._invoke( ["correct", self._resolve(ref), "--value", value, "--reason", reason] ) def do_export(self, args: List[str]) -> None: usage = "usage: export [PATH] [--topic T]..." out_path: Optional[str] = None topics: List[str] = [] i = 0 while i < len(args): a = args[i] if a == "--topic": if i + 1 >= len(args): raise ShellUsageError("--topic requires a value") topics.append(args[i + 1]) i += 2 elif a.startswith("-"): raise ShellUsageError(f"unknown option {a!r} ({usage})") elif out_path is None: out_path = a i += 1 else: raise ShellUsageError(usage) argv = ["export"] if out_path: argv += ["--out", out_path] for t in topics: argv += ["--topic", t] self._invoke(argv) def do_audit(self, args: List[str]) -> None: if args: raise ShellUsageError("usage: audit") self._invoke(["audit"]) def do_log(self, args: List[str]) -> None: if len(args) > 1 or (args and not args[0].isdigit()): raise ShellUsageError("usage: log [N]") argv = ["log"] if args: argv += ["--limit", args[0]] self._invoke(argv) COMMANDS: Dict[str, Callable[["FableShell", List[str]], Optional[bool]]] = { "help": do_help, "h": do_help, "?": do_help, "home": do_home, "topics": do_topics, "claims": do_claims, "ls": do_claims, "show": do_show, "why": do_why, "explain": do_why, "evidence": do_evidence, "ev": do_evidence, "ask": do_ask, "refute": do_refute, "correct": do_correct, "export": do_export, "audit": do_audit, "log": do_log, "quit": do_quit, "exit": do_quit, "q": do_quit, } # ------------------------------------------------------------------ # # Session loop # ------------------------------------------------------------------ # def dispatch(self, line: str) -> bool: """Execute one input line. Returns False when the session should end.""" line = line.strip() if not line or line.startswith("#"): return True try: tokens = shlex.split(line) except ValueError as exc: self._error(f"could not parse input: {exc}") return True name, args = tokens[0].lower(), tokens[1:] handler = self.COMMANDS.get(name) if handler is None: self._error(f"unknown command {name!r} — type 'help' for the command list") return True try: result = handler(self, args) except ShellUsageError as exc: self._error(str(exc)) return True except Exception as exc: # never let one command kill the session self._error(str(exc)) return True return result is not False def run(self) -> int: self._write(BANNER + "\n") self._write(f"node home: {self.home}\n") interactive = bool(getattr(self.stdin, "isatty", lambda: False)()) while True: if interactive: try: line = input(self.PROMPT) except EOFError: self._write("\n") break except KeyboardInterrupt: self._write("\n(interrupted — type 'quit' to leave)\n") continue else: line = self.stdin.readline() if line == "": break if not self.dispatch(line): break return 0 def run_shell( home: str | Path, stdin: Optional[TextIO] = None, stdout: Optional[TextIO] = None, cli: Optional[Callable[[Sequence[str]], Optional[int]]] = None, ) -> int: """Entry point used by the ``fablepool shell`` subcommand.""" return FableShell(home, stdin=stdin, stdout=stdout, cli=cli).run() def _main(argv: Optional[Sequence[str]] = None) -> int: """Standalone entry point: ``python -m fablepool.shell [--home DIR]``.""" import argparse import os parser = argparse.ArgumentParser( prog="fablepool-shell", description="Interactive inspection shell over a FablePool node.", ) parser.add_argument( "--home", default=os.environ.get("FABLEPOOL_HOME") or str(Path.home() / ".fablepool"), help="node home directory (default: $FABLEPOOL_HOME or ~/.fablepool)", ) ns = parser.parse_args(argv) return run_shell(ns.home) if __name__ == "__main__": # pragma: no cover raise SystemExit(_main())