From eb95f14a7cc7ff04d4fa008a44fc6b3240cb04fe Mon Sep 17 00:00:00 2001 From: Allen Hutchison Date: Sun, 15 Nov 2026 01:22:50 +0000 Subject: [PATCH 1/2] WIP: pepper graph entity and catalog read commands (unverified) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01QNDeh973CCmhrDbk68xBDz --- CHANGELOG.md | 4 + README.md | 2 + help/cli.md | 7 ++ src/pepper_cli/api_client.py | 22 +++- src/pepper_cli/cli.py | 91 +++++++++++++- src/pepper_cli/data/agent-skill/SKILL.md | 2 +- src/pepper_cli/graph_view.py | 94 ++++++++++++++ tests/test_cli_graph.py | 153 +++++++++++++++++++++++ 8 files changed, 372 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31e4d83..bf52039 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to `pepper` (distribution `vycari-pepper`). Newest first. Th ### Added +- `pepper graph entity ID` and `pepper graph catalog ID | --scheme S --value V`: an entity's page (edges by + type, follows included, its catalog entry) and a catalog entry with the caller's linked entities, both + with `--history` and `--json`, over `GET /api/v1/graph/entities/{id}` and `/catalog` (Vycari/pepper#6407; + refs Vycari/pepper#6365). Needs that server change deployed. - `pepper graph list | search | show | upcoming`: read-only views of the entity graph (people, ties, last contact, past meetings, and attendee briefs for an event or the next meetings), over `GET /api/v1/graph/*` (Vycari/pepper#6321). 404 until the `entity-graph` rollout reaches the diff --git a/README.md b/README.md index 6330bbd..90c6107 100644 --- a/README.md +++ b/README.md @@ -223,6 +223,8 @@ Every command except the interactive REPL takes `--json`, and also switches to J | `pepper graph search QUERY [--limit N] [--json]` | One person by exact email address, else by part of a name or address. | | `pepper graph show EVENT_ID [--json]` | A card for each attendee of one calendar event the graph holds. An event the graph lacks says so. | | `pepper graph upcoming [--limit N] [--json]` | The next meetings the graph holds (30 days ahead), each with its attendees' cards. A `status` of `timeout` or `error` means the server returned what it had (often nothing); the read is fail-soft and never a 5xx. | +| `pepper graph entity ID [--history] [--json]` | One of your entities: its name and kind, the catalog entry it points at, and its edges grouped by type (follows show their follow ref). A merged-away id shows the entity it became. `--history` adds closed edges, marked with when they closed. 404 means unknown, not yours, or the `entity-graph` rollout is off (one message for all three). A `status` of `timeout` or `error` prints a plain note and exits 1. | +| `pepper graph catalog ID [--history] [--json]` or `pepper graph catalog --scheme S --value V` | A catalog entry (external ids, providers, neighbours) and the entities of yours that link to it. `S` is one of `espn`, `tvmaze`, `feed_url`, `site_host`, `domain`, `feeds_topic`, `wikidata`. Only an entry your own graph links to is readable. Same errors as `graph entity`. | | `pepper calendar publish set [--add-calendar C]… [--remove-calendar C]… [--add-follow F]… [--remove-follow F]… [--titles-off C]… [--titles-on C]… [--detail busy\|full] [--meals\|--no-meals] [--workouts\|--no-workouts] [--todos\|--no-todos] [--consent] [--json]` | Change only what you name. `C` and `F` are a label (exact, or a unique prefix, any case) or an id; the CLI looks them up in the publishing status and sends back the whole list, so the ones you did not name stay as they were. `--titles-off C` / `--titles-on C` hide or show the titles of a calendar someone shared with you in Full details (it then appears as busy blocks); the server refuses a calendar that has no titles switch. Which events are published is the server's call. Adding your first Google calendar publishes its events at the address, so the server asks for consent: on a terminal `set` prints its disclosure and asks y/N (a no changes nothing and exits 1); without one it refuses and prints the disclosure, and you rerun with `--consent` only after the person has agreed. `--consent` is never implied. Prints the new status. | | `pepper drafts send DRAFT_ID [--json]` | Send a draft Pepper staged in chat (an email, or a suggested change to an event). `pepper chat` prints each unsent draft with this command under it, and the account it goes from when you have several; the terminal UI boxes it under the reply and `s` sends it after a confirmation. Nothing is sent until you do. | | `pepper apps show SLUG [--room R] [--json]` | An app's data document, `{"rev", "data"}`, yours or the room's. | diff --git a/help/cli.md b/help/cli.md index 611d78d..4eab5ea 100644 --- a/help/cli.md +++ b/help/cli.md @@ -148,6 +148,13 @@ ever want to look back at a specific conversation in detail. you have never mailed or met are marked "new to you". It only reads, and the data comes from the headers of your own mail and calendar attendees, never message text. If a read is slow it shows what it had and says so. +- **One entity, or one catalog entry:** `pepper graph entity ` shows one of + your entities with what it is linked to, grouped by kind of link (a follow + shows what you follow), and the shared catalog entry it points at. `pepper + graph catalog ` shows a catalog entry and which of your entities link to + it; `pepper graph catalog --scheme espn --value ` finds one by an outside + id. Both take `--history` to include links that have ended. If one says it is + not found, the id is unknown or not in your own graph. - **What's next:** `pepper next` shows what's on now (with the time left) and what's after it, the rest of today's calendar, and your top ten open to-dos diff --git a/src/pepper_cli/api_client.py b/src/pepper_cli/api_client.py index 5b1eba2..a2ca69c 100644 --- a/src/pepper_cli/api_client.py +++ b/src/pepper_cli/api_client.py @@ -36,7 +36,7 @@ from collections.abc import Callable, Generator, Iterator from dataclasses import dataclass, field from typing import Any, NoReturn, Self, cast -from urllib.parse import urlencode +from urllib.parse import quote, urlencode import httpx @@ -2567,6 +2567,26 @@ def graph_upcoming(self, *, limit: int = 5) -> dict[str, Any]: """``GET /api/v1/graph/meetings/upcoming``: the next meetings, each with attendee cards.""" return self._get_object(f"/api/v1/graph/meetings/upcoming?{urlencode({'limit': limit})}") + def graph_entity(self, entity_id: str, *, history: bool = False) -> dict[str, Any]: + """``GET /api/v1/graph/entities/{id}``: one of the caller's entities with its edges. + + 404 for an unknown id, another owner's id, or the ``entity-graph`` rollout being off. + """ + q = urlencode({"history": "true" if history else "false"}) + return self._get_object(f"/api/v1/graph/entities/{quote(entity_id, safe='')}?{q}") + + def graph_catalog(self, catalog_id: str, *, history: bool = False) -> dict[str, Any]: + """``GET /api/v1/graph/catalog/{id}``: a catalog entry and the caller's entities on it.""" + q = urlencode({"history": "true" if history else "false"}) + return self._get_object(f"/api/v1/graph/catalog/{quote(catalog_id, safe='')}?{q}") + + def graph_catalog_lookup( + self, scheme: str, value: str, *, history: bool = False + ) -> dict[str, Any]: + """``GET /api/v1/graph/catalog?scheme=&value=``: the same page, by one external id.""" + q = urlencode({"scheme": scheme, "value": value, "history": "true" if history else "false"}) + return self._get_object(f"/api/v1/graph/catalog?{q}") + def _get_object(self, path: str) -> dict[str, Any]: resp = self._request("GET", path) body = resp.json() diff --git a/src/pepper_cli/cli.py b/src/pepper_cli/cli.py index d431f0a..bcfe8dd 100644 --- a/src/pepper_cli/cli.py +++ b/src/pepper_cli/cli.py @@ -1082,6 +1082,36 @@ def _build_parser() -> tuple[argparse.ArgumentParser, frozenset[str]]: "--limit", type=int, default=5, metavar="N", help="How many meetings (default 5, max 50)." ) p.set_defaults(handler=cmd_graph_upcoming) + p = sub_graph.add_parser( + "entity", + help="One of your entities: its edges by type, and its catalog entry.", + description=( + "An entity page: the entity, the shared catalog entry it points at, and its edges " + "grouped by type (follows included). A merged-away id shows the entity it became." + ), + parents=[_json_parent], + ) + p.add_argument("entity_id", metavar="ID", help="An entity id (see 'graph list').") + p.add_argument( + "--history", action="store_true", help="Include closed edges, marked with when they closed." + ) + p.set_defaults(handler=cmd_graph_entity) + p = sub_graph.add_parser( + "catalog", + help="A catalog entry and the entities of yours that link to it.", + description=( + "A catalog page by id, or by one external id with --scheme and --value. Only an " + "entry your own graph links to is readable." + ), + parents=[_json_parent], + ) + p.add_argument("catalog_id", nargs="?", metavar="ID", help="A catalog entry id.") + p.add_argument("--scheme", choices=_GRAPH_SCHEMES, help="Look up by this kind of external id.") + p.add_argument("--value", help="The external id to look up (with --scheme).") + p.add_argument( + "--history", action="store_true", help="Include closed edges, marked with when they closed." + ) + p.set_defaults(handler=cmd_graph_catalog) p_todos = sub.add_parser( "todos", @@ -9631,7 +9661,8 @@ def _report_graph_error(args: argparse.Namespace, exc: api_client.ApiError) -> i _emit_error( args, "not_found", - "The entity graph is not available for your account (or this server is too old).", + "Not found: the entity or catalog entry is unknown, is not in your graph, or the " + "entity graph is not available for your account (or this server is too old).", status=404, ) return 1 @@ -9681,6 +9712,64 @@ def run(api: api_client.ApiClient) -> int: return _run_personal(args, run, _report_graph_error) +#: The external-id schemes ``GET /api/v1/graph/catalog`` accepts. +_GRAPH_SCHEMES = ("espn", "tvmaze", "feed_url", "site_host", "domain", "feeds_topic", "wikidata") + + +def _graph_page( + args: argparse.Namespace, body: dict[str, Any], key: str, human: Callable[[], None] +) -> int: + """Print a page read, or say plainly that a fail-soft read gave nothing (exit 1).""" + status = body.get("status") + if status != "ok" or not isinstance(body.get(key), dict): + what = "timed out" if status == "timeout" else "failed" + if _json_mode(args): + _print_json(body) + _emit_error(args, "server", f"The graph read {what}; nothing to show. Try again.") + return 1 + _emit(body, args, human) + return 0 + + +def cmd_graph_entity(args: argparse.Namespace) -> int: + """``pepper graph entity ID [--history] [--json]``: ``GET /api/v1/graph/entities/{id}``.""" + entity_id = args.entity_id.strip() + if not entity_id: + _emit_error(args, "usage", "ID must not be blank.") + return 2 + + def run(api: api_client.ApiClient) -> int: + body = api.graph_entity(entity_id, history=args.history) + return _graph_page( + args, body, "entity", lambda: graph_view.entity_page(body, history=args.history) + ) + + return _run_personal(args, run, _report_graph_error) + + +def cmd_graph_catalog(args: argparse.Namespace) -> int: + """``pepper graph catalog ID | --scheme S --value V [--history] [--json]``.""" + catalog_id = (args.catalog_id or "").strip() + scheme, value = args.scheme, (args.value or "").strip() + if catalog_id and (scheme or args.value is not None): + _emit_error(args, "usage", "Give a catalog ID, or --scheme with --value, not both.") + return 2 + if not catalog_id and not (scheme and value): + _emit_error(args, "usage", "Give a catalog ID, or both --scheme and --value.") + return 2 + + def run(api: api_client.ApiClient) -> int: + if catalog_id: + body = api.graph_catalog(catalog_id, history=args.history) + else: + body = api.graph_catalog_lookup(scheme, value, history=args.history) + return _graph_page( + args, body, "page", lambda: graph_view.catalog_page(body, history=args.history) + ) + + return _run_personal(args, run, _report_graph_error) + + def cmd_graph_upcoming(args: argparse.Namespace) -> int: """``pepper graph upcoming [--limit N] [--json]``: ``GET /api/v1/graph/meetings/upcoming``.""" if (limit := _graph_limit(args)) is None: diff --git a/src/pepper_cli/data/agent-skill/SKILL.md b/src/pepper_cli/data/agent-skill/SKILL.md index 0089826..0cd496a 100644 --- a/src/pepper_cli/data/agent-skill/SKILL.md +++ b/src/pepper_cli/data/agent-skill/SKILL.md @@ -552,7 +552,7 @@ is changed. That covers: every other kind of artifact (images, links, files) are out of reach too. - Calendar publishing (`pepper calendar publish`, the private address another calendar app subscribes to, including the per-calendar titles switch that `set` offers for calendars shared with the person) and every other calendar command. They act on a person's own account, so only a person's own token reaches them. -- `pepper graph list`, `pepper graph search`, `pepper graph show` and `pepper graph upcoming`: a person's own entity graph of the people in their mail and meetings. Read-only, and only a person's own token reaches them. +- `pepper graph list`, `pepper graph search`, `pepper graph show` and `pepper graph upcoming`: a person's own entity graph of the people in their mail and meetings. Read-only. `pepper graph entity ID` and `pepper graph catalog ID` (or `--scheme S --value V`) show one entity or catalog entry with its edges (`--history` adds closed ones); a timeout/error status exits 1, and only a person's own token reaches them. - Every other `rooms`, `topics`, `messages`, `attachments`, `members`, `sharing` or `apps` command, which are the ones that change a room, its topics, its members or its apps, or that read or search across rooms. - Every command outside those nouns that talks to the server, chat, `run` and `tui` included diff --git a/src/pepper_cli/graph_view.py b/src/pepper_cli/graph_view.py index e4bc6aa..9db10b0 100644 --- a/src/pepper_cli/graph_view.py +++ b/src/pepper_cli/graph_view.py @@ -83,3 +83,97 @@ def upcoming(body: dict[str, Any]) -> None: ) for row in m.get("attendees") or []: card(row, indent=" ") + + +def _ref(e: object) -> str: + """``name (kind/subtype)`` for a ``{id, kind, subtype, name}`` entry.""" + if not isinstance(e, dict): + return "?" + kind = "/".join(_s(e[k]) for k in ("kind", "subtype") if e.get(k)) + return f"{_s(e.get('name'))} ({kind})" if kind else _s(e.get("name")) + + +def _edge(e: dict[str, Any], *, history: bool) -> str: + other = e.get("other") + if isinstance(other, dict): + arrow = "<-" if e.get("direction") == "in" else "->" + who = f"{arrow} {_ref(other)}" + elif e.get("follow_ref"): + who = f"follow {_s(e.get('follow_ref'))}" + else: + who = "follow" + bits = [who] + if e.get("source"): + bits.append(f"source {_s(e.get('source'))}") + since = _stamp(e.get("valid_from")) + if since: + bits.append(f"since {since}") + if history and e.get("valid_to"): + bits.append(f"[red]closed {_stamp(e.get('valid_to'))}[/red]") + elif history: + bits.append("open") + return " " + " · ".join(bits) + + +def _edges(edges: list[dict[str, Any]], *, history: bool, indent: str = "") -> None: + """Edges grouped by type, in the order the server sent them.""" + groups: dict[str, list[dict[str, Any]]] = {} + for e in edges: + groups.setdefault(_s(e.get("type")), []).append(e) + for kind, rows in groups.items(): + _line(f"{indent}[bold]{kind}[/bold]") + for e in rows: + _line(f"{indent}{_edge(e, history=history)}") + + +def _catalog_card(c: dict[str, Any], *, indent: str = "") -> None: + sensitive = " [yellow](sensitive)[/yellow]" if c.get("sensitive") else "" + _line(f"{indent}[bold]{_ref(c)}[/bold] [dim]{_s(c.get('id'))}[/dim]{sensitive}") + ids = ", ".join( + f"{_s(i.get('scheme'))}:{_s(i.get('value'))}" for i in c.get("external_ids") or [] + ) + if ids: + _line(f"{indent} external ids: {ids}") + provs = ", ".join( + f"{_s(p.get('provider'))}:{_s(p.get('ref'))}" for p in c.get("providers") or [] + ) + if provs: + _line(f"{indent} providers: {provs}") + for n in c.get("neighbours") or []: + arrow = "<-" if n.get("direction") == "in" else "->" + _line(f"{indent} {_s(n.get('type'))} {arrow} {_ref(n.get('entry'))}") + + +def entity_page(body: dict[str, Any], *, history: bool) -> None: + """One of the caller's entities: header, catalog card, then its edges by type.""" + page = body.get("entity") or {} + ent = page.get("entity") or {} + sens = " [yellow](sensitive)[/yellow]" if page.get("sensitive") else "" + _line(f"[bold]{_ref(ent)}[/bold] [dim]{_s(ent.get('id'))}[/dim]{sens}") + requested = page.get("requested_id") + if requested and requested != ent.get("id"): + _line(f" [dim]merged: {_s(requested)} is now this entity[/dim]") + cat = page.get("catalog") + if isinstance(cat, dict): + _line(f" catalog: {_ref(cat)} [dim]{_s(cat.get('id'))}[/dim]") + _line(f" [dim]pepper graph catalog {_s(cat.get('id'))}[/dim]") + edges = page.get("edges") or [] + if not edges: + _line("[dim](no edges)[/dim]") + return + _edges(edges, history=history) + + +def catalog_page(body: dict[str, Any], *, history: bool) -> None: + """A catalog entry, then the caller's own entities that point at it.""" + page = body.get("page") or {} + _catalog_card(page.get("catalog") or {}) + rows = page.get("entities") or [] + if not rows: + _line("[dim](none of your entities link here)[/dim]") + return + _line("[bold]Your entities[/bold]") + for row in rows: + ent = row.get("entity") or {} + _line(f" [bold]{_ref(ent)}[/bold] [dim]{_s(ent.get('id'))}[/dim]") + _edges(row.get("edges") or [], history=history, indent=" ") diff --git a/tests/test_cli_graph.py b/tests/test_cli_graph.py index 41d3a43..a5095a7 100644 --- a/tests/test_cli_graph.py +++ b/tests/test_cli_graph.py @@ -157,3 +157,156 @@ def test_a_blank_query_or_event_is_refused_before_any_request(server, capsys, ar assert cli.main(argv) == 2 assert "must not be blank" in capsys.readouterr().err assert server.seen == [] + + +_ENTITY_PATH = "/api/v1/graph/entities/e-1" +_CATALOG_PATH = "/api/v1/graph/catalog/c-1" +_CATALOG_LOOKUP = "/api/v1/graph/catalog" +_TEAM = {"id": "e-1", "kind": "topic", "subtype": "team", "name": "Seattle Kraken"} +_CARD_C = { + "id": "c-1", + "kind": "topic", + "subtype": "team", + "name": "Seattle Kraken", + "curated": True, + "sensitive": False, + "attrs": {}, + "external_ids": [{"scheme": "espn", "value": "nhl:26"}], + "providers": [{"provider": "espn", "ref": "nhl/26"}], + "neighbours": [ + { + "direction": "out", + "type": "member_of", + "entry": {"id": "c-9", "kind": "topic", "subtype": "league", "name": "NHL"}, + } + ], +} +_FOLLOW = { + "id": "x1", + "type": "follows", + "origin": "follow", + "direction": "in", + "other": None, + "follow_ref": "espn:nhl:26", + "source": "follow_sources", + "valid_from": "2026-11-01T00:00:00Z", + "valid_to": None, + "confidence": 1.0, +} +_CLOSED = { + **_FOLLOW, + "id": "x2", + "type": "mentions", + "direction": "out", + "other": {"id": "o1", "kind": "person", "subtype": None, "name": "Casey Close"}, + "follow_ref": None, + "valid_to": "2026-11-05T00:00:00Z", +} +_ENTITY_BODY = { + "status": "ok", + "entity": { + "entity": _TEAM, + "sensitive": False, + "requested_id": "old-id", + "catalog": _CARD_C, + "edges": [_FOLLOW, _CLOSED], + }, +} + + +def test_entity_page_groups_edges_and_shows_the_merge(server, capsys) -> None: + server.reply(_ENTITY_PATH, _ENTITY_BODY) + assert cli.main(["graph", "entity", "e-1"]) == 0 + out = capsys.readouterr().out + assert "Seattle Kraken (topic/team)" in out and "catalog: Seattle Kraken" in out + assert "merged: old-id is now this entity" in out + assert "follows" in out and "follow espn:nhl:26" in out and "source follow_sources" in out + assert "mentions" in out and "-> Casey Close (person)" in out + assert "closed" not in out and "open" not in out + assert server.seen[0].url.params["history"] == "false" + + +def test_entity_page_with_history_marks_closed_edges(server, capsys) -> None: + server.reply(_ENTITY_PATH, _ENTITY_BODY) + assert cli.main(["graph", "entity", "e-1", "--history"]) == 0 + out = capsys.readouterr().out + assert "closed" in out and "open" in out + assert server.seen[0].url.params["history"] == "true" + + +def test_catalog_page_by_id_shows_the_card_and_your_entities(server, capsys) -> None: + body = { + "status": "ok", + "page": {"catalog": _CARD_C, "entities": [{"entity": _TEAM, "edges": [_FOLLOW]}]}, + } + server.reply(_CATALOG_PATH, body) + assert cli.main(["graph", "catalog", "c-1"]) == 0 + out = capsys.readouterr().out + assert "external ids: espn:nhl:26" in out and "providers: espn:nhl/26" in out + assert "member_of -> NHL (topic/league)" in out + assert "Your entities" in out and "follow espn:nhl:26" in out + + +def test_catalog_lookup_by_external_id(server, capsys) -> None: + body = {"status": "ok", "page": {"catalog": _CARD_C, "entities": []}} + server.reply(_CATALOG_LOOKUP, body) + assert cli.main(["graph", "catalog", "--scheme", "espn", "--value", "nhl:26"]) == 0 + assert "none of your entities link here" in capsys.readouterr().out + params = server.seen[0].url.params + assert params["scheme"] == "espn" and params["value"] == "nhl:26" + + +@pytest.mark.parametrize( + "argv,path", + [ + (["graph", "entity", "e-1"], _ENTITY_PATH), + (["graph", "catalog", "c-1"], _CATALOG_PATH), + ], +) +def test_new_pages_json_is_the_body(server, capsys, argv, path) -> None: + body = {"status": "ok", "entity": {"entity": _TEAM, "edges": []}, "page": {"catalog": _CARD_C}} + server.reply(path, body) + assert cli.main([*argv, "--json"]) == 0 + assert json.loads(capsys.readouterr().out) == body + + +@pytest.mark.parametrize("status", ["timeout", "error"]) +def test_a_fail_soft_page_exits_non_zero(server, capsys, status) -> None: + server.reply(_ENTITY_PATH, {"status": status, "entity": None}) + assert cli.main(["graph", "entity", "e-1"]) == 1 + err = capsys.readouterr().err + assert "nothing to show" in err and ("timed out" in err) == (status == "timeout") + + +@pytest.mark.parametrize( + "argv,path", + [ + (["graph", "entity", "e-1"], _ENTITY_PATH), + (["graph", "catalog", "c-1"], _CATALOG_PATH), + (["graph", "catalog", "--scheme", "espn", "--value", "v"], _CATALOG_LOOKUP), + ], +) +def test_new_pages_404_uses_one_message(server, capsys, argv, path) -> None: + server.reply(path, {"detail": "Not Found"}, status=404) + assert cli.main(argv) == 1 + assert "unknown, is not in your graph" in capsys.readouterr().err + + +@pytest.mark.parametrize( + "argv", + [ + ["graph", "entity", " "], + ["graph", "catalog"], + ["graph", "catalog", "--scheme", "espn"], + ["graph", "catalog", "c-1", "--scheme", "espn", "--value", "v"], + ], +) +def test_bad_arguments_are_refused_before_any_request(server, capsys, argv) -> None: + assert cli.main(argv) == 2 + assert server.seen == [] + + +def test_an_unknown_scheme_is_refused_by_the_parser(server, capsys) -> None: + with pytest.raises(SystemExit): + cli.main(["graph", "catalog", "--scheme", "nope", "--value", "v"]) + assert server.seen == [] From abc8af15283f631bace10e768f5937596a67ab7e Mon Sep 17 00:00:00 2001 From: Allen Hutchison Date: Sun, 15 Nov 2026 01:44:31 +0000 Subject: [PATCH 2/2] fix(graph): satisfy the CLI conformance checks for graph entity and catalog Allow the entity and catalog verbs, mark both leaves as not room-scoped, give them JSON-mode fixtures, and drop the word 'grouped' from the entity help. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01QNDeh973CCmhrDbk68xBDz --- src/pepper_cli/cli.py | 2 +- tests/test_cli_conformance.py | 25 +++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/pepper_cli/cli.py b/src/pepper_cli/cli.py index bcfe8dd..e98058f 100644 --- a/src/pepper_cli/cli.py +++ b/src/pepper_cli/cli.py @@ -1087,7 +1087,7 @@ def _build_parser() -> tuple[argparse.ArgumentParser, frozenset[str]]: help="One of your entities: its edges by type, and its catalog entry.", description=( "An entity page: the entity, the shared catalog entry it points at, and its edges " - "grouped by type (follows included). A merged-away id shows the entity it became." + "listed by type (follows included). A merged-away id shows the entity it became." ), parents=[_json_parent], ) diff --git a/tests/test_cli_conformance.py b/tests/test_cli_conformance.py index 95c4687..71ae955 100644 --- a/tests/test_cli_conformance.py +++ b/tests/test_cli_conformance.py @@ -143,6 +143,10 @@ # `graph upcoming` is the next meetings the graph holds: a bounded list, but of # meetings, which `graph list` (people) is not. "upcoming", + # `graph entity` and `graph catalog` read one page by id: the entity page and the shared + # catalog entry, which are the graph's two kinds of object (not a `show` of a list row). + "entity", + "catalog", # Research projects (IA §3.1): `steer` queues guidance for an object's next run; # `take` and `reconsider` mark a ledger item. "steer", @@ -195,6 +199,8 @@ "graph search": "the graph is your own, never a room's (D12 keeps a room's separate)", "graph show": "the graph is your own, never a room's (D12 keeps a room's separate)", "graph upcoming": "the graph is your own, never a room's (D12 keeps a room's separate)", + "graph entity": "the graph is your own, never a room's (D12 keeps a room's separate)", + "graph catalog": "the graph is your own, never a room's (D12 keeps a room's separate)", "brief": "your own morning brief; the server exposes no room's brief to a PAT here", "artifacts show": "reads any artifact the viewer can see; the id carries the room", "artifacts download": "reads any artifact the viewer can see; the id carries the room", @@ -714,6 +720,23 @@ def test_pending_lists_name_real_leaves() -> None: "attendees": [], }, "/api/v1/graph/meetings/upcoming": {"status": "ok", "meetings": []}, + "/api/v1/graph/entities/e1": { + "status": "ok", + "entity": { + "entity": {"id": "e1", "kind": "topic", "subtype": "team", "name": "Kraken"}, + "sensitive": False, + "requested_id": "e1", + "catalog": None, + "edges": [], + }, + }, + "/api/v1/graph/catalog/c1": { + "status": "ok", + "page": { + "catalog": {"id": "c1", "kind": "topic", "subtype": "team", "name": "Kraken"}, + "entities": [], + }, + }, "/api/v1/calendars/schedule-plan": { "now": "2026-10-02T16:00:00+00:00", "active": "urgent", @@ -1133,6 +1156,8 @@ def fake_watch(self, *, since=0, last_event_id=None, **_kw): "graph search": ["graph", "search", "casey"], "graph show": ["graph", "show", "e1"], "graph upcoming": ["graph", "upcoming"], + "graph entity": ["graph", "entity", "e1"], + "graph catalog": ["graph", "catalog", "c1"], "todos list": ["todos", "list"], "rooms list": ["rooms", "list"], "rooms show": ["rooms", "show", _G],