diff --git a/plugins/devagentic-docs/README.md b/plugins/devagentic-docs/README.md new file mode 100644 index 0000000000000..1166ebab83d0d --- /dev/null +++ b/plugins/devagentic-docs/README.md @@ -0,0 +1,73 @@ +# devagentic-docs plugin + +Hermes plugin that surfaces devagentic's GraphQL `writeDoc` / +`searchDocs` primitives as `/doc` slash commands inside a hermes +session. + +Sibling to [`devagentic-canvas`](../devagentic-canvas/README.md). +Same env conventions, same loss-tolerant failure semantics, same +plugin enable/disable contract. + +## Enable / disable + +```bash +hermes plugin enable devagentic-docs +hermes plugin disable devagentic-docs +hermes plugin list +``` + +When disabled, the plugin is fully inert — no `/doc` command is +registered. + +## Surface + +* `/doc search [--tag ] [--limit N]` — top hits from + `searchDocs`. Lexical + embedding ranking; tag scopes to docs + with that exact tag. +* `/doc write [--tags t1,t2,...]` — persist a doc via + `writeDoc`. Auto-tags with `source:hermes-cli` for downstream + filtering. +* `/doc show ` — fetch a doc by id (top-1 search match, + identity-verified). + +## Configuration + +Reuses the devagentic-local provider's environment, same as +`devagentic-canvas`: + +| env var | default | purpose | +|---|---|---| +| `DEVAGENTIC_BASE_URL` | `http://127.0.0.1:6071/v1` | devagentic root; GraphQL lives at `/graphql` | +| `DEVAGENTIC_API_KEY` | _(empty)_ | bearer token; any non-empty value works when devagentic runs in `DEVAGENTIC_TRUST_HEADER=1` mode | +| `DEVAGENTIC_USER_ID` | _(empty)_ | manual override for the `X-User-Id` header; falls back to `hermes_cli.profiles.get_active_profile_name()` | + +## Failure semantics + +Every code path is loss-tolerant: + +* Devagentic unreachable → slash commands return a user-facing + error string with a `Reason:` clause naming the specific failure + (auth / 404 / unreachable / parse / no user_id). +* Empty result set → user-facing "no docs matched" message; + not a failure. +* The plugin **never raises** out of a slash command — a broken + devagentic doesn't brick the session. + +## Layout + +``` +plugins/devagentic-docs/ +├── plugin.yaml # manifest (no hooks in MVP) +├── __init__.py # register() entrypoint +├── client.py # GraphQL client + last_error_text() +└── commands.py # /doc slash command handlers +``` + +## Out of scope for MVP + +* `/fork open ` + `/fork close` (planned per #12; deferred + to keep MVP small). +* `pre_llm_call` hook for pinned-doc context injection (planned + per #12; deferred — needs the `/fork` marker file first). +* `/doc delete` — devagentic's doc graph is append-only by design; + if a delete primitive lands, surface it then. diff --git a/plugins/devagentic-docs/__init__.py b/plugins/devagentic-docs/__init__.py new file mode 100644 index 0000000000000..e2bcfe2665e0a --- /dev/null +++ b/plugins/devagentic-docs/__init__.py @@ -0,0 +1,31 @@ +"""devagentic-docs plugin entrypoint (hermes issue #12). + +Surfaces devagentic's writeDoc + searchDocs GraphQL primitives as +`/doc` slash commands. Sibling to devagentic-canvas — same env +conventions, same loss-tolerant contract, same enable/disable +semantics. + +When disabled, the plugin is fully inert — no slash command is +registered. Hermes' other flows are unchanged. +""" +from __future__ import annotations + +import logging + +from . import commands as _commands + + +logger = logging.getLogger(__name__) + + +def register(ctx) -> None: + """Plugin loader entrypoint. Wires the /doc slash command into + the host's PluginContext.""" + ctx.register_command( + name="doc", + handler=_commands.doc_command, + description=("Search, write, and show devagentic doc-graph " + "entries from inside a hermes session."), + args_hint=("search [--tag t] [--limit N] | " + "write [--tags a,b] | show "), + ) diff --git a/plugins/devagentic-docs/client.py b/plugins/devagentic-docs/client.py new file mode 100644 index 0000000000000..54cb54eb1b229 --- /dev/null +++ b/plugins/devagentic-docs/client.py @@ -0,0 +1,219 @@ +"""Thin GraphQL client for devagentic's writeDoc + searchDocs +primitives. Sibling to plugins/devagentic-canvas/client.py — same +env conventions (DEVAGENTIC_BASE_URL, DEVAGENTIC_API_KEY, +DEVAGENTIC_USER_ID + profile fallback), same loss-tolerant contract. + +Each method returns Optional[] — None on any failure. The +slash commands read `last_error_text()` to surface the specific +failure kind in user-facing messages (auth vs unreachable vs parse +vs no user_id), the same pattern used by the canvas plugin after +#15. + +Devagentic's GraphQL surface is at $DEVAGENTIC_BASE_URL/graphql +(the /v1 suffix in the env var is stripped if present, mirroring +agent/devagentic_skills.py + agent/devagentic_memory.py). +""" +from __future__ import annotations + +import json +import logging +import os +import urllib.error +import urllib.request +from typing import Any, Optional + + +logger = logging.getLogger(__name__) + + +_DEFAULT_TIMEOUT = 8.0 + + +def _base_url() -> str: + """Resolve the GraphQL root URL. Accepts both `…:6070` and + `…:6070/v1` forms (the latter has its /v1 stripped so we can + compose `…/graphql`). Mirrors devagentic_skills + memory. + """ + raw = os.environ.get("DEVAGENTIC_BASE_URL", "http://127.0.0.1:6071/v1") + base = raw.rstrip("/") + if base.endswith("/v1"): + base = base[:-3] + return base + + +def _api_key() -> str: + return (os.environ.get("DEVAGENTIC_API_KEY") or "").strip() + + +def _user_id() -> Optional[str]: + override = (os.environ.get("DEVAGENTIC_USER_ID") or "").strip() + if override: + return override + try: + from hermes_cli.profiles import get_active_profile_name + name = (get_active_profile_name() or "").strip() + return name or None + except Exception as exc: # noqa: BLE001 + logger.debug("docs client: profile resolution failed: %s", exc) + return None + + +_last_error: Optional[str] = None + + +def last_error_text() -> Optional[str]: + """Short, human-readable description of the most recent failure. + Slash commands append this as `Reason: …` to their user-facing + error strings. Cleared on every successful call.""" + return _last_error + + +def _record_error(text: Optional[str]) -> None: + global _last_error + _last_error = text + + +def _post_graphql(query: str, variables: dict, + *, timeout: float = _DEFAULT_TIMEOUT) -> Optional[dict]: + """POST a GraphQL query. Returns the `data` dict on success, + None on any failure. Never raises. Populates last_error_text() + with the specific failure kind on None returns.""" + _record_error(None) + user = _user_id() + if not user: + msg = ("could not resolve user_id — set DEVAGENTIC_USER_ID " + "or run inside a hermes profile") + logger.debug("docs client: %s", msg) + _record_error(msg) + return None + base = _base_url() + url = f"{base}/graphql" + body = json.dumps({"query": query, "variables": variables}).encode("utf-8") + req = urllib.request.Request(url, data=body, method="POST") + req.add_header("Content-Type", "application/json") + req.add_header("Accept", "application/json") + req.add_header("X-User-Id", user) + api_key = _api_key() + if api_key: + req.add_header("Authorization", f"Bearer {api_key}") + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + raw = resp.read().decode("utf-8") + except urllib.error.HTTPError as exc: + if exc.code in (401, 403): + msg = ("authentication failed — set DEVAGENTIC_API_KEY " + "(any non-empty value works when devagentic runs " + "in trust-header mode)") + elif exc.code == 404: + msg = f"not found at {url}" + else: + msg = f"HTTP {exc.code} from {url}" + logger.debug("docs client: %s %s → %s", "POST", url, msg) + _record_error(msg) + return None + except (urllib.error.URLError, OSError, TimeoutError) as exc: + msg = f"unreachable at {url} ({exc})" + logger.debug("docs client: %s", msg) + _record_error(msg) + return None + try: + payload = json.loads(raw) + except json.JSONDecodeError as exc: + logger.debug("docs client: parse failed: %s", exc) + _record_error("invalid response body (not JSON)") + return None + if not isinstance(payload, dict): + _record_error("response was not a JSON object") + return None + if payload.get("errors"): + # GraphQL-level error (resolver failure, invalid query, etc.) + first = (payload.get("errors") or [{}])[0] + msg = f"GraphQL error: {first.get('message', 'unknown')}" + logger.debug("docs client: %s", msg) + _record_error(msg) + return None + return payload.get("data") or None + + +# --- public API ---------------------------------------------- + +def search_docs(query: str, limit: int = 10, + tag: Optional[str] = None, + *, timeout: float = _DEFAULT_TIMEOUT + ) -> Optional[list[dict]]: + """searchDocs(query, limit, tag) → list of {id, content, tags, + score}. Returns None on any failure (caller falls through); + empty list when the search succeeds but returns no hits. + """ + if not query: + _record_error("query is required") + return None + gql = ( + "query($q:String!,$l:Int,$t:String){" + " searchDocs(query:$q,limit:$l,tag:$t){ id content tags score }" + "}" + ) + variables: dict[str, Any] = {"q": query, "l": int(limit)} + if tag: + variables["t"] = tag + data = _post_graphql(gql, variables, timeout=timeout) + if data is None: + return None + hits = data.get("searchDocs") + if not isinstance(hits, list): + return [] + return hits + + +def write_doc(content: str, tags: Optional[list[str]] = None, + source: Optional[str] = None, + *, timeout: float = _DEFAULT_TIMEOUT) -> Optional[dict]: + """writeDoc(content, tags, source) → {id}. Returns the new doc + dict on success, None on any failure.""" + if not content: + _record_error("content is required") + return None + gql = ( + "mutation($c:String!,$t:[String!],$s:String){" + " writeDoc(content:$c, tags:$t, source:$s){ id }" + "}" + ) + variables: dict[str, Any] = {"c": content} + if tags: + variables["t"] = list(tags) + if source: + variables["s"] = source + data = _post_graphql(gql, variables, timeout=timeout) + if data is None: + return None + return data.get("writeDoc") or None + + +def get_doc(doc_id: str, *, + timeout: float = _DEFAULT_TIMEOUT) -> Optional[dict]: + """Fetch a single doc by id via searchDocs (devagentic's + canonical retrieval primitive — there is no GET /doc/ + GraphQL field in the current surface). Filters down to the + requested id from a limit=1-by-id lookup.""" + if not doc_id: + _record_error("doc_id is required") + return None + gql = ( + "query($q:String!){" + " searchDocs(query:$q, limit:1){ id content tags }" + "}" + ) + data = _post_graphql(gql, {"q": doc_id}, timeout=timeout) + if data is None: + return None + hits = data.get("searchDocs") or [] + if not isinstance(hits, list) or not hits: + _record_error(f"no doc matched id={doc_id}") + return None + # searchDocs is lexical+embedding — verify the top hit's id + top = hits[0] + if (top.get("id") or "") != doc_id: + _record_error( + f"top match was {top.get('id')!r}, not requested {doc_id!r}") + return None + return top diff --git a/plugins/devagentic-docs/commands.py b/plugins/devagentic-docs/commands.py new file mode 100644 index 0000000000000..00d1da6697d95 --- /dev/null +++ b/plugins/devagentic-docs/commands.py @@ -0,0 +1,164 @@ +"""Slash command surface for the devagentic-docs plugin (hermes #12). +Registers `/doc` with subcommands: + + * `/doc search [--tag ] [--limit N]` — top hits + * `/doc write [--tags t1,t2,...]` — persist a doc + * `/doc show ` — full body + +Failure semantics: every handler catches its own exceptions and +returns a user-facing error string. Reasons are surfaced from +`docs_client.last_error_text()` when a primitive returns None, +so operators distinguish auth / network / parse / no-user-id +without trawling DEBUG logs (same pattern as canvas after #15). +""" +from __future__ import annotations + +import logging +import shlex +from typing import Optional + +from . import client as docs_client + + +logger = logging.getLogger(__name__) + + +def _failure_detail() -> str: + err = docs_client.last_error_text() + return f" Reason: {err}." if err else "" + + +def _parse_search_args(args: str) -> tuple[str, int, Optional[str]]: + """Return (query, limit, tag). Limit defaults to 10; tag to None. + Recognises `--tag ` and `--limit N` flags anywhere in args.""" + try: + tokens = shlex.split(args or "") + except ValueError: + tokens = (args or "").split() + limit = 10 + tag: Optional[str] = None + rest: list[str] = [] + i = 0 + while i < len(tokens): + t = tokens[i] + if t == "--tag" and i + 1 < len(tokens): + tag = tokens[i + 1] + i += 2 + continue + if t == "--limit" and i + 1 < len(tokens): + try: + limit = max(1, min(100, int(tokens[i + 1]))) + except ValueError: + pass + i += 2 + continue + rest.append(t) + i += 1 + return " ".join(rest).strip(), limit, tag + + +def _parse_write_args(args: str) -> tuple[str, list[str]]: + """Return (body, tags). Recognises a `--tags a,b,c` flag. + Everything else is the body.""" + try: + tokens = shlex.split(args or "") + except ValueError: + tokens = (args or "").split() + tags: list[str] = [] + rest: list[str] = [] + i = 0 + while i < len(tokens): + t = tokens[i] + if t == "--tags" and i + 1 < len(tokens): + tags = [s.strip() for s in tokens[i + 1].split(",") if s.strip()] + i += 2 + continue + rest.append(t) + i += 1 + return " ".join(rest).strip(), tags + + +def _handle_search(args: str) -> str: + query, limit, tag = _parse_search_args(args) + if not query: + return ("Usage: `/doc search [--tag ] " + "[--limit N]`. Tag scopes to docs with that exact " + "tag; limit defaults to 10.") + hits = docs_client.search_docs(query=query, limit=limit, tag=tag) + if hits is None: + return ("Couldn't reach devagentic for the doc search." + + _failure_detail()) + if not hits: + return (f"No docs matched `{query}`" + + (f" (tag=`{tag}`)" if tag else "") + ".") + lines = [f"**Top {len(hits)} hit(s)" + + (f" (tag=`{tag}`)" if tag else "") + ":**"] + for h in hits: + did = h.get("id") or "(no id)" + snippet = ((h.get("content") or "").splitlines() or [""])[0] + snippet = snippet[:120] + score = h.get("score") + score_str = f" _score={score:.2f}_" if isinstance( + score, (int, float)) else "" + lines.append(f"- `{did}` — {snippet}{score_str}") + return "\n".join(lines) + + +def _handle_write(args: str) -> str: + body, tags = _parse_write_args(args) + if not body: + return ("Usage: `/doc write [--tags t1,t2,...]`. " + "Body is required; tags are optional. The doc is " + "auto-tagged with `source:hermes-cli`.") + # Always tag with source:hermes-cli so doc-graph queries can + # distinguish CLI-authored finds from worker-authored. + if "source:hermes-cli" not in tags: + tags = list(tags) + ["source:hermes-cli"] + doc = docs_client.write_doc(content=body, tags=tags, + source="hermes-cli") + if doc is None: + return ("Couldn't persist the doc." + _failure_detail()) + did = doc.get("id") or "(no id)" + tag_str = " ".join(f"`{t}`" for t in tags) + return (f"Wrote doc **`{did}`**. Tags: {tag_str}.") + + +def _handle_show(args: str) -> str: + did = (args or "").strip().split()[0] if args.strip() else "" + if not did: + return ("Usage: `/doc show `. List ids with " + "`/doc search …` first.") + doc = docs_client.get_doc(did) + if doc is None: + return (f"Couldn't fetch doc `{did}`." + _failure_detail()) + content = doc.get("content") or "(empty)" + tags = doc.get("tags") or [] + tag_str = " ".join(f"`{t}`" for t in tags) if tags else "_(no tags)_" + return (f"**`{did}`** — {tag_str}\n\n{content}") + + +_SUBCOMMANDS = { + "search": _handle_search, + "write": _handle_write, + "show": _handle_show, +} + + +def doc_command(args: str) -> str: + """Dispatcher for `/doc `. Bare `/doc` is a usage + hint; unknown subcommands fall back to the same hint.""" + raw = (args or "").strip() + if not raw: + return ("Usage: `/doc ` — `search`, `write`, " + "or `show`.") + head, _, tail = raw.partition(" ") + sub = head.strip().lower() + handler = _SUBCOMMANDS.get(sub) + if handler is None: + return (f"Unknown `/doc` subcommand: `{sub}`. " + f"Available: {', '.join(_SUBCOMMANDS)}.") + try: + return handler(tail) + except Exception as exc: # noqa: BLE001 + logger.exception("devagentic-docs: handler crashed") + return f"`/doc {sub}` failed: {exc}" diff --git a/plugins/devagentic-docs/plugin.yaml b/plugins/devagentic-docs/plugin.yaml new file mode 100644 index 0000000000000..f92bc4be797d0 --- /dev/null +++ b/plugins/devagentic-docs/plugin.yaml @@ -0,0 +1,5 @@ +name: devagentic-docs +version: 0.1.0 +description: "Doc/finding authoring over devagentic's GraphQL surface. Provides /doc slash commands (search / write / show) backed by searchDocs + writeDoc. Sibling to devagentic-canvas — same env vars, same enable/disable contract. Disable with `hermes plugin disable devagentic-docs`; enabled state is fully inert otherwise." +author: TechDevGroup +kind: standalone diff --git a/tests/test_devagentic_docs_plugin.py b/tests/test_devagentic_docs_plugin.py new file mode 100644 index 0000000000000..d65ce2d3a0f19 --- /dev/null +++ b/tests/test_devagentic_docs_plugin.py @@ -0,0 +1,295 @@ +"""Unit tests for the devagentic-docs plugin (hermes #12). +Mirrors the structure of test_devagentic_canvas_plugin.py — same +synthetic-package fixture, same stubbing pattern.""" + +from __future__ import annotations + +import importlib.util +import json +import sys +import urllib.error +from pathlib import Path + +import pytest +import yaml + + +PLUGIN_DIR = (Path(__file__).resolve().parents[1] + / "plugins" / "devagentic-docs") + + +@pytest.fixture +def plugin_pkg(tmp_path, monkeypatch): + """Load the devagentic-docs plugin modules as a synthetic + package so relative imports resolve.""" + pkg_name = "_devagentic_docs_under_test" + spec = importlib.util.spec_from_file_location( + pkg_name, PLUGIN_DIR / "__init__.py", + submodule_search_locations=[str(PLUGIN_DIR)], + ) + pkg = importlib.util.module_from_spec(spec) + sys.modules[pkg_name] = pkg + + def _load(name): + sub_spec = importlib.util.spec_from_file_location( + f"{pkg_name}.{name}", PLUGIN_DIR / f"{name}.py") + mod = importlib.util.module_from_spec(sub_spec) + sys.modules[f"{pkg_name}.{name}"] = mod + sub_spec.loader.exec_module(mod) + return mod + + client = _load("client") + commands = _load("commands") + + assert spec.loader is not None + spec.loader.exec_module(pkg) + + from types import SimpleNamespace + return SimpleNamespace(pkg=pkg, client=client, commands=commands) + + +# ─── Manifest ─────────────────────────────────────────────── + +def test_manifest_parses_and_declares_expected_fields(): + manifest = yaml.safe_load( + (PLUGIN_DIR / "plugin.yaml").read_text()) + assert manifest["name"] == "devagentic-docs" + assert "version" in manifest + assert "description" in manifest + assert manifest.get("kind") == "standalone" + # MVP doesn't register a pre_llm_call hook. + assert not manifest.get("hooks") + + +# ─── Base URL normalization ───────────────────────────────── + +def test_base_url_strips_v1_for_graphql(plugin_pkg, monkeypatch): + """The /v1 suffix in DEVAGENTIC_BASE_URL is stripped because + GraphQL lives at /graphql, not /v1/graphql.""" + monkeypatch.setenv("DEVAGENTIC_BASE_URL", "http://devbox:6070/v1") + assert plugin_pkg.client._base_url() == "http://devbox:6070" + + +def test_base_url_passthrough_when_no_v1(plugin_pkg, monkeypatch): + monkeypatch.setenv("DEVAGENTIC_BASE_URL", "http://devbox:6070") + assert plugin_pkg.client._base_url() == "http://devbox:6070" + + +# ─── Client failure-loudness (mirrors canvas #15) ─────────── + +def test_last_error_unresolved_user_id(plugin_pkg, monkeypatch): + monkeypatch.delenv("DEVAGENTIC_USER_ID", raising=False) + fake = type("F", (), {"get_active_profile_name": staticmethod( + lambda: None)})() + monkeypatch.setitem(sys.modules, "hermes_cli.profiles", fake) + assert plugin_pkg.client.search_docs("anything") is None + assert "DEVAGENTIC_USER_ID" in ( + plugin_pkg.client.last_error_text() or "") + + +def test_last_error_auth_failed(plugin_pkg, monkeypatch): + monkeypatch.setenv("DEVAGENTIC_USER_ID", "alice") + + def _raise(*a, **k): + raise urllib.error.HTTPError( + "http://x/graphql", 401, "Unauthorized", {}, None) + + monkeypatch.setattr(plugin_pkg.client.urllib.request, + "urlopen", _raise) + assert plugin_pkg.client.search_docs("anything") is None + err = plugin_pkg.client.last_error_text() or "" + assert "authentication failed" in err + assert "DEVAGENTIC_API_KEY" in err + + +def test_last_error_unreachable(plugin_pkg, monkeypatch): + monkeypatch.setenv("DEVAGENTIC_USER_ID", "alice") + + def _raise(*a, **k): + raise urllib.error.URLError("connection refused") + + monkeypatch.setattr(plugin_pkg.client.urllib.request, + "urlopen", _raise) + assert plugin_pkg.client.write_doc("body") is None + err = plugin_pkg.client.last_error_text() or "" + assert "unreachable at" in err + + +def test_last_error_graphql_errors(plugin_pkg, monkeypatch): + monkeypatch.setenv("DEVAGENTIC_USER_ID", "alice") + + class _Resp: + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def read(self): + return json.dumps( + {"errors": [{"message": "schema mismatch"}]}).encode("utf-8") + + monkeypatch.setattr(plugin_pkg.client.urllib.request, + "urlopen", lambda *a, **k: _Resp()) + assert plugin_pkg.client.search_docs("x") is None + assert "schema mismatch" in (plugin_pkg.client.last_error_text() or "") + + +# ─── search_docs ──────────────────────────────────────────── + +def test_search_docs_returns_parsed_hits(plugin_pkg, monkeypatch): + monkeypatch.setenv("DEVAGENTIC_USER_ID", "alice") + hits = [{"id": "doc-1", "content": "hello", "tags": ["a"], + "score": 0.9}] + monkeypatch.setattr( + plugin_pkg.client, "_post_graphql", + lambda q, v, **k: {"searchDocs": hits}) + out = plugin_pkg.client.search_docs("hi", limit=5, tag="a") + assert out == hits + + +def test_search_docs_empty_query_short_circuits(plugin_pkg): + assert plugin_pkg.client.search_docs("") is None + + +# ─── write_doc ────────────────────────────────────────────── + +def test_write_doc_returns_id(plugin_pkg, monkeypatch): + monkeypatch.setenv("DEVAGENTIC_USER_ID", "alice") + monkeypatch.setattr( + plugin_pkg.client, "_post_graphql", + lambda q, v, **k: {"writeDoc": {"id": "doc-abc"}}) + out = plugin_pkg.client.write_doc("body", tags=["x"]) + assert out == {"id": "doc-abc"} + + +def test_write_doc_empty_body_short_circuits(plugin_pkg): + assert plugin_pkg.client.write_doc("") is None + + +# ─── /doc search command ──────────────────────────────────── + +def test_handle_search_usage_when_empty(plugin_pkg): + out = plugin_pkg.commands._handle_search("") + assert "Usage:" in out + + +def test_handle_search_renders_hits(plugin_pkg, monkeypatch): + monkeypatch.setattr( + plugin_pkg.client, "search_docs", + lambda **k: [{"id": "doc-1", "content": "line 1\nline 2", + "score": 0.8}, + {"id": "doc-2", "content": "another", "score": 0.5}]) + out = plugin_pkg.commands._handle_search("hello") + assert "doc-1" in out and "doc-2" in out + assert "Top 2" in out + # First line only of multi-line content is shown. + assert "line 1" in out and "line 2" not in out + + +def test_handle_search_appends_failure_detail(plugin_pkg, monkeypatch): + monkeypatch.setattr(plugin_pkg.client, "search_docs", + lambda **k: None) + monkeypatch.setattr(plugin_pkg.client, "last_error_text", + lambda: "auth failed") + out = plugin_pkg.commands._handle_search("hello") + assert "Reason: auth failed" in out + + +def test_handle_search_no_hits(plugin_pkg, monkeypatch): + monkeypatch.setattr(plugin_pkg.client, "search_docs", + lambda **k: []) + out = plugin_pkg.commands._handle_search("hello --tag k") + assert "No docs matched" in out + assert "tag=`k`" in out + + +def test_parse_search_args_extracts_flags(plugin_pkg): + q, limit, tag = plugin_pkg.commands._parse_search_args( + "find something --tag k:foo --limit 25") + assert q == "find something" + assert limit == 25 + assert tag == "k:foo" + + +def test_parse_search_args_limit_clamped(plugin_pkg): + _, limit, _ = plugin_pkg.commands._parse_search_args( + "x --limit 999999") + assert limit == 100 + _, limit, _ = plugin_pkg.commands._parse_search_args( + "x --limit 0") + assert limit == 1 + + +# ─── /doc write command ──────────────────────────────────── + +def test_handle_write_usage_when_empty(plugin_pkg): + out = plugin_pkg.commands._handle_write("") + assert "Usage:" in out + + +def test_handle_write_auto_tags_source(plugin_pkg, monkeypatch): + captured: dict = {} + + def _stub(content, tags, source, **k): + captured["content"] = content + captured["tags"] = list(tags or []) + captured["source"] = source + return {"id": "doc-x"} + + monkeypatch.setattr(plugin_pkg.client, "write_doc", _stub) + out = plugin_pkg.commands._handle_write( + "hello world --tags k:test,user:duplex") + assert "doc-x" in out + assert "source:hermes-cli" in captured["tags"] + assert "k:test" in captured["tags"] + assert "user:duplex" in captured["tags"] + assert captured["source"] == "hermes-cli" + assert captured["content"] == "hello world" + + +def test_handle_write_appends_failure_detail(plugin_pkg, monkeypatch): + monkeypatch.setattr(plugin_pkg.client, "write_doc", + lambda **k: None) + monkeypatch.setattr(plugin_pkg.client, "last_error_text", + lambda: "unreachable at http://x/graphql") + out = plugin_pkg.commands._handle_write("a body") + assert "Reason:" in out + assert "unreachable" in out + + +# ─── /doc show command ────────────────────────────────────── + +def test_handle_show_usage_when_empty(plugin_pkg): + out = plugin_pkg.commands._handle_show("") + assert "Usage:" in out + + +def test_handle_show_renders_doc(plugin_pkg, monkeypatch): + monkeypatch.setattr( + plugin_pkg.client, "get_doc", + lambda doc_id, **k: {"id": doc_id, "content": "body", + "tags": ["a", "b"]}) + out = plugin_pkg.commands._handle_show("doc-abc") + assert "doc-abc" in out + assert "body" in out + assert "`a`" in out and "`b`" in out + + +# ─── dispatcher ──────────────────────────────────────────── + +def test_doc_command_dispatcher_usage(plugin_pkg): + out = plugin_pkg.commands.doc_command("") + assert "Usage:" in out + + +def test_doc_command_dispatcher_unknown_sub(plugin_pkg): + out = plugin_pkg.commands.doc_command("nope hi") + assert "Unknown" in out + + +def test_doc_command_dispatcher_routes_search(plugin_pkg, monkeypatch): + monkeypatch.setattr(plugin_pkg.client, "search_docs", + lambda **k: []) + out = plugin_pkg.commands.doc_command("search anything") + assert "No docs matched" in out