diff --git a/CHANGES b/CHANGES index 900fe6e..90efde5 100644 --- a/CHANGES +++ b/CHANGES @@ -42,6 +42,111 @@ $ uvx --from 'agentgrep' --prerelease allow python _Notes on the upcoming release will go here._ +agentgrep 0.1.0a3 promotes the MCP server to a first-class +product surface alongside the library. Eight new MCP tools and +three new resources expose the full catalog and discovery layer +to clients without dropping back to the CLI; a tabbed install +widget on both the MCP and library landing pages picks the right +snippet for each client, install method, and config scope. The +docs sidebar now treats Library and MCP as top-level sections +rather than members of a Packages group. + +### What's new + +#### Eight new MCP tools + +The MCP server gains {tooliconl}`list_stores`, +{tooliconl}`get_store_descriptor`, {tooliconl}`inspect_record_sample`, +{tooliconl}`list_sources`, {tooliconl}`filter_sources`, +{tooliconl}`summarize_discovery`, {tooliconl}`recent_sessions`, and +{tooliconl}`validate_query`. Together they let MCP clients +introspect the canonical store catalog (role, format, upstream +schema notes), filter discovered sources by path-kind and +source-kind, summarize what's discoverable per agent, fetch a +small sample of parsed records from one adapter+path, narrow +recent activity by mtime window, and dry-run a regex against +sample text before issuing a broad cross-agent search. + +#### Three new MCP resources + +`agentgrep://catalog` returns the full {class}`~agentgrep.stores.StoreCatalog` +Pydantic payload (every store agentgrep knows about — including +ones that aren't searched by default — with role, format, +upstream reference, and schema notes). `agentgrep://store-roles` +and `agentgrep://store-formats` enumerate the supporting enum +values with one-line descriptions so an agent can build prompts +or summaries without scraping the docs site. + +#### MCP server hardening + +The MCP server now runs through FastMCP's timing, response-size, +and error-handling middleware plus an agentgrep-flavored audit +log that records `agentgrep_tool` / `agentgrep_outcome` / +`agentgrep_duration_ms` / `agentgrep_args_summary` on every call. +Sensitive argument payloads (`terms`, `pattern`, `sample_text`) +are redacted to `{len, sha256_prefix}` before logging so the +audit stream stays operator-debuggable without leaking the +caller's prompts. The server instructions are now composed from +named segments (HEADER / SCOPE / SEARCH_VS_DISCOVERY / DEFAULTS / +RESOURCES / PRIVACY) so future agent-context segments slot in +without rewriting the base set. + +#### MCP install widget + +The MCP landing page hosts a tabbed installer that picks the +right snippet across Claude Code, Claude Desktop, Codex, Gemini, +and Cursor, three install methods (uvx / pipx / pip), each +client's relevant config scopes, and an optional dependency +cooldown (off / N-day cooldown / bypass-global). The widget +remembers your selections across pages so the same snippet stays +visible while you browse the docs. + +#### Library install + quickstart widget + +The library landing page hosts a sibling widget with one row of +install-method tabs (uvx run, pipx run, uv add, pip install) and +a runnable Python quickstart on every panel — install command on +top, end-to-end search snippet below. The quickstart calls +{func}`~agentgrep.run_search_query` directly so readers see the +same shape the CLI uses. + +#### Sidebar: Library and MCP first-class + +The Packages group is gone. The left sidebar now lists Get +started, Library, MCP, Reference, and Project as siblings, with +{doc}`library/index` and {doc}`mcp/index` carrying the install +widgets. Old `/packages/agentgrep/*` URLs redirect to +`/library/*`. + +### Development + +#### `scripts/mcp_swap.py` + +A new dev-only script swaps the MCP server entry in every +installed agent CLI (Claude, Codex, Cursor, Gemini) between a +pinned release and the local checkout. `just mcp-detect` lists +which CLIs are present, `just mcp-status` shows the current +entry, `just mcp-use-local` rewrites configs to run +`uv --directory run agentgrep-mcp`, and `just mcp-revert` +restores from a timestamped backup. State and backups live in +`$XDG_STATE_HOME/agentgrep-dev/swap/` so the script never edits +the same file twice without a recoverable undo path. + +#### MCP server refactor + +`src/agentgrep/mcp.py` is now a `mcp/` subpackage with per-domain +tool modules (`search_tools`, `discovery_tools`, `catalog_tools`, +`diagnostic_tools`) plus separate `models.py`, `resources.py`, +`prompts.py`, `instructions.py`, and `middleware.py`. The entry +point `agentgrep-mcp = "agentgrep.mcp:main"` is preserved through +`__init__.py` re-exports. + +#### Top-level README + +The project ships a top-level `README.md` for GitHub and PyPI, +with the project pitch, a single-client install snippet, the +library quickstart, and links to docs / source / issues. + ## agentgrep 0.1.0a2 (2026-05-17) agentgrep 0.1.0a2 fixes silent "No matches found." failures for diff --git a/README.md b/README.md new file mode 100644 index 0000000..ffbdda3 --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +# agentgrep + +[![PyPI version](https://img.shields.io/pypi/v/agentgrep.svg)](https://pypi.org/project/agentgrep/) +[![Python versions](https://img.shields.io/pypi/pyversions/agentgrep.svg)](https://pypi.org/project/agentgrep/) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) + +Read-only search for local AI agent prompts and history across Codex, +Claude Code, Cursor, and Gemini. + +`agentgrep` ships two surfaces over the same discovery + parsing layer: + +- **A terminal CLI** (`agentgrep`) with a Textual TUI for interactive + browsing of normalized records. +- **An MCP server** (`agentgrep-mcp`) that exposes search, discovery, + catalog, and validation tools to any client that speaks Model + Context Protocol. + +> **Pre-alpha.** APIs may change. + +## Install + +```console +$ uvx agentgrep --help +``` + +Other install methods (pipx, uv add, pip install) and full MCP-client +setup snippets live in the [installer widgets on agentgrep.org](https://agentgrep.org/library/) +— one tabbed picker per surface. + +## MCP server: quickest setup + +In Claude Code: + +```console +$ claude mcp add agentgrep -- uvx --from agentgrep agentgrep-mcp +``` + +For Claude Desktop / Codex / Cursor / Gemini snippets, see +. + +## Library quickstart + +```python +from pathlib import Path + +import agentgrep + +backends = agentgrep.select_backends() +query = agentgrep.SearchQuery( + terms=("hello",), + search_type="all", + any_term=False, + regex=False, + case_sensitive=False, + agents=agentgrep.AGENT_CHOICES, + limit=10, +) +for record in agentgrep.run_search_query(Path.home(), query, backends=backends): + print(record.agent, record.title or record.path) +``` + +## Links + +- Documentation: +- Source: +- Issues: +- Changelog: [CHANGES](CHANGES) +- License: [MIT](LICENSE) diff --git a/docs/_ext/agentgrep_fastmcp.py b/docs/_ext/agentgrep_fastmcp.py index 24a0d99..e9bee1e 100644 --- a/docs/_ext/agentgrep_fastmcp.py +++ b/docs/_ext/agentgrep_fastmcp.py @@ -19,6 +19,15 @@ SearchToolResponse, SearchTypeName, ) +from agentgrep.mcp.models import ( + DiscoverySummaryResponse, + InspectSampleResponse, + ListSourcesResponse, + ListStoresResponse, + RecentSessionsResponse, + StoreDescriptorModel, + ValidateQueryResponse, +) READONLY_TAGS = {"readonly", "agentgrep"} DOCS_ONLY_MESSAGE = "Documentation signature only." @@ -104,3 +113,252 @@ async def find( tags=READONLY_TAGS | {"discovery"}, annotations=None, ) + + +async def list_stores( + agent: t.Annotated[ + str, + Field( + default="all", + description="Filter to one agent or 'all' for every catalog entry.", + examples=["all", "claude", "cursor"], + ), + ] = "all", + role_filter: t.Annotated[ + str | None, + Field( + default=None, + description="Filter to one StoreRole value (e.g. 'primary_chat').", + examples=["primary_chat", "prompt_history"], + ), + ] = None, + search_default_only: t.Annotated[ + bool, + Field( + default=False, + description="Return only stores that are searched by default.", + ), + ] = False, +) -> ListStoresResponse: + """List on-disk agent stores from the agentgrep catalog.""" + raise NotImplementedError(DOCS_ONLY_MESSAGE) + + +t.cast(t.Any, list_stores).__fastmcp__ = types.SimpleNamespace( + name="list_stores", + title="List Stores", + tags=READONLY_TAGS | {"catalog"}, + annotations=None, +) + + +async def get_store_descriptor( + store_id: t.Annotated[ + str, + Field( + min_length=1, + description="Store id (e.g. 'claude.projects.session').", + examples=["claude.projects.session", "codex.history"], + ), + ], +) -> StoreDescriptorModel: + """Return the catalog descriptor for a single store by id.""" + raise NotImplementedError(DOCS_ONLY_MESSAGE) + + +t.cast(t.Any, get_store_descriptor).__fastmcp__ = types.SimpleNamespace( + name="get_store_descriptor", + title="Get Store Descriptor", + tags=READONLY_TAGS | {"catalog"}, + annotations=None, +) + + +async def inspect_record_sample( + adapter_id: t.Annotated[ + str, + Field( + min_length=1, + description="Adapter id (e.g. 'claude.projects_jsonl.v1').", + examples=["claude.projects_jsonl.v1", "codex.history_json.v1"], + ), + ], + source_path: t.Annotated[ + str, + Field( + min_length=1, + description="Absolute path to the source file.", + ), + ], + sample_size: t.Annotated[ + int, + Field( + default=1, + ge=1, + le=20, + description="Number of records to return (1-20).", + ), + ] = 1, +) -> InspectSampleResponse: + """Read the first N records from one adapter+path for schema inspection.""" + raise NotImplementedError(DOCS_ONLY_MESSAGE) + + +t.cast(t.Any, inspect_record_sample).__fastmcp__ = types.SimpleNamespace( + name="inspect_record_sample", + title="Inspect Record Sample", + tags=READONLY_TAGS | {"catalog"}, + annotations=None, +) + + +async def list_sources( + agent: t.Annotated[ + AgentSelector, + Field(description="Limit discovery to one agent or scan every agent."), + ] = "all", + path_kind_filter: t.Annotated[ + t.Literal["history_file", "session_file", "sqlite_db"] | None, + Field(default=None, description="Filter by path kind."), + ] = None, + source_kind_filter: t.Annotated[ + t.Literal["json", "jsonl", "sqlite"] | None, + Field(default=None, description="Filter by on-disk source kind."), + ] = None, + limit: t.Annotated[ + int | None, + Field(default=None, ge=1, description="Maximum number of sources to return."), + ] = None, +) -> ListSourcesResponse: + """List discovered sources with structured path-kind/source-kind filters.""" + raise NotImplementedError(DOCS_ONLY_MESSAGE) + + +t.cast(t.Any, list_sources).__fastmcp__ = types.SimpleNamespace( + name="list_sources", + title="List Sources", + tags=READONLY_TAGS | {"discovery"}, + annotations=None, +) + + +async def filter_sources( + pattern: t.Annotated[ + str, + Field( + min_length=1, + description="Required substring pattern.", + examples=["state", ".jsonl"], + ), + ], + agent: t.Annotated[ + AgentSelector, + Field(description="Limit discovery to one agent or scan every agent."), + ] = "all", + limit: t.Annotated[ + int | None, + Field(default=50, ge=1, description="Maximum number of sources to return."), + ] = 50, +) -> FindToolResponse: + """Filter discovered sources by required substring pattern.""" + raise NotImplementedError(DOCS_ONLY_MESSAGE) + + +t.cast(t.Any, filter_sources).__fastmcp__ = types.SimpleNamespace( + name="filter_sources", + title="Filter Sources", + tags=READONLY_TAGS | {"discovery"}, + annotations=None, +) + + +async def summarize_discovery( + agent: t.Annotated[ + AgentSelector, + Field(description="Limit discovery to one agent or scan every agent."), + ] = "all", +) -> DiscoverySummaryResponse: + """Aggregate counts of discovered sources by agent, format, and kind.""" + raise NotImplementedError(DOCS_ONLY_MESSAGE) + + +t.cast(t.Any, summarize_discovery).__fastmcp__ = types.SimpleNamespace( + name="summarize_discovery", + title="Summarize Discovery", + tags=READONLY_TAGS | {"discovery"}, + annotations=None, +) + + +async def validate_query( + terms: t.Annotated[ + list[str], + Field( + min_length=1, + description="One or more literal or regex search terms.", + examples=[["alpha"], ["foo.*bar"]], + ), + ], + sample_text: t.Annotated[ + str, + Field(description="Sample text to test the query against."), + ], + regex: t.Annotated[ + bool, + Field(description="Treat terms as regular expressions."), + ] = False, + case_sensitive: t.Annotated[ + bool, + Field(description="Perform case-sensitive matching."), + ] = False, + any_term: t.Annotated[ + bool, + Field(description="Match any term instead of requiring all terms."), + ] = False, +) -> ValidateQueryResponse: + """Dry-run a query against sample text without searching files.""" + raise NotImplementedError(DOCS_ONLY_MESSAGE) + + +t.cast(t.Any, validate_query).__fastmcp__ = types.SimpleNamespace( + name="validate_query", + title="Validate Query", + tags=READONLY_TAGS | {"diagnostic"}, + annotations=None, +) + + +async def recent_sessions( + agent: t.Annotated[ + AgentSelector, + Field(description="Limit discovery to one agent or scan every agent."), + ] = "all", + hours: t.Annotated[ + int, + Field( + default=24, + ge=1, + le=24 * 30, + description="Look back this many hours (max 30 days).", + examples=[1, 24, 168], + ), + ] = 24, + limit: t.Annotated[ + int | None, + Field( + default=10, + ge=1, + description="Maximum number of sources to return.", + ), + ] = 10, +) -> RecentSessionsResponse: + """Return sources modified in the last N hours, newest-first.""" + raise NotImplementedError(DOCS_ONLY_MESSAGE) + + +t.cast(t.Any, recent_sessions).__fastmcp__ = types.SimpleNamespace( + name="recent_sessions", + title="Recent Sessions", + tags=READONLY_TAGS | {"search"}, + annotations=None, +) diff --git a/docs/_ext/widgets/__init__.py b/docs/_ext/widgets/__init__.py new file mode 100644 index 0000000..6379458 --- /dev/null +++ b/docs/_ext/widgets/__init__.py @@ -0,0 +1,70 @@ +"""Reusable widget framework for Sphinx docs. + +Each widget is a ``BaseWidget`` subclass in a sibling module (e.g. +``mcp_install.py``) plus a ``/_widgets//widget.{html,js,css}`` +asset directory. Widgets autodiscover at ``setup()`` time — adding a new one +requires no registry edits. Usage from Markdown/RST: + +.. code-block:: markdown + + ```{mcp-install} + :variant: compact + ``` +""" + +from __future__ import annotations + +import functools +import typing as t + +from ._assets import install_widget_assets +from ._base import ( + BaseWidget, + depart_widget_container, + visit_widget_container, + widget_container, +) +from ._directive import make_widget_directive +from ._discovery import discover +from ._prehydrate import ( + inject_library_install_prehydrate, + inject_mcp_install_prehydrate, +) + +if t.TYPE_CHECKING: + from sphinx.application import Sphinx + +__version__ = "0.1.0" + +__all__ = [ + "BaseWidget", + "__version__", + "setup", + "widget_container", +] + + +def setup(app: Sphinx) -> dict[str, t.Any]: + """Register every discovered widget and wire the asset pipeline.""" + widgets = discover() + + app.add_node( + widget_container, + html=(visit_widget_container, depart_widget_container), + ) + + for name, widget_cls in widgets.items(): + app.add_directive(name, make_widget_directive(widget_cls)) + + app.connect( + "builder-inited", + functools.partial(install_widget_assets, widgets=widgets), + ) + app.connect("html-page-context", inject_mcp_install_prehydrate) + app.connect("html-page-context", inject_library_install_prehydrate) + + return { + "version": __version__, + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/docs/_ext/widgets/_assets.py b/docs/_ext/widgets/_assets.py new file mode 100644 index 0000000..bfd7c61 --- /dev/null +++ b/docs/_ext/widgets/_assets.py @@ -0,0 +1,58 @@ +"""Copy widget assets into ``_static/widgets//`` and register them.""" + +from __future__ import annotations + +import pathlib +import shutil +import typing as t + +from sphinx.util import logging + +from ._base import BaseWidget + +if t.TYPE_CHECKING: + from sphinx.application import Sphinx + +logger = logging.getLogger(__name__) + +STATIC_SUBDIR = "widgets" + + +def install_widget_assets( + app: Sphinx, + widgets: dict[str, type[BaseWidget]], +) -> None: + """Copy each widget's ``widget.{css,js}`` into ``_static/widgets//``. + + Assets are then registered via ``app.add_css_file`` / ``app.add_js_file`` so + every page includes them (same pattern as ``sphinx-copybutton``). This is + intentionally simpler than per-page inclusion — the files are small and the + docs are not bandwidth-constrained. + + Uses :func:`shutil.copy2` directly. Recent Sphinx releases tightened + ``copy_asset_file`` to refuse overwriting (it emits a + ``misc.copy_overwrite`` warning and aborts), which leaves stale widget + JS/CSS on every incremental rebuild. The fix is to do the byte copy + ourselves: the cache-busting ``?v=`` querystring on the + ``add_*_file`` registration line keeps browser caches honest. + """ + if app.builder.format != "html": + return + + srcdir = pathlib.Path(app.srcdir) + outdir_static = pathlib.Path(app.outdir) / "_static" / STATIC_SUBDIR + + for name, widget_cls in widgets.items(): + asset_dir = widget_cls.assets_dir(srcdir) + dest = outdir_static / name + + for filename, register in ( + ("widget.css", app.add_css_file), + ("widget.js", app.add_js_file), + ): + source = asset_dir / filename + if not source.is_file(): + continue + dest.mkdir(parents=True, exist_ok=True) + shutil.copy2(str(source), str(dest / filename)) + register(f"{STATIC_SUBDIR}/{name}/{filename}") diff --git a/docs/_ext/widgets/_base.py b/docs/_ext/widgets/_base.py new file mode 100644 index 0000000..842e89e --- /dev/null +++ b/docs/_ext/widgets/_base.py @@ -0,0 +1,196 @@ +"""Base class for widgets and the docutils node that wraps rendered output.""" + +from __future__ import annotations + +import abc +import collections.abc +import pathlib +import typing as t + +import jinja2 +import markupsafe +from docutils import nodes +from sphinx.builders.html import StandaloneHTMLBuilder + +if t.TYPE_CHECKING: + from sphinx.environment import BuildEnvironment + from sphinx.writers.html5 import HTML5Translator + + +class HighlightFilter(t.Protocol): + """Callable signature for the Jinja ``highlight`` filter.""" + + def __call__(self, code: str, language: str = "default") -> markupsafe.Markup: ... + + +class CooldownDaysSlotFilter(t.Protocol): + """Callable signature for the Jinja ``cooldown_days_slot`` filter.""" + + def __call__(self, html: object) -> markupsafe.Markup: ... + + +class widget_container(nodes.container): # type: ignore[misc] # docutils nodes are untyped + """Wraps a widget's rendered HTML; visit/depart emit the outer div.""" + + +def visit_widget_container( + translator: HTML5Translator, + node: widget_container, +) -> None: + """Open ``
`` for the widget.""" + name = node["widget_name"] + translator.body.append(f'
') + + +def depart_widget_container( + translator: HTML5Translator, + node: widget_container, +) -> None: + """Close the widget wrapper div.""" + translator.body.append("
") + + +ASSET_FILES: tuple[str, ...] = ("widget.html", "widget.js", "widget.css") + + +class BaseWidget(abc.ABC): + """Base class every concrete widget subclasses. + + Subclasses declare ``name`` plus optional ``option_spec`` / ``default_options`` + and may override ``context(env)`` to feed data into the Jinja template. + Assets (``widget.html``, ``widget.js``, ``widget.css``) live at + ``/_widgets//``; only ``widget.html`` is required. + """ + + name: t.ClassVar[str] + option_spec: t.ClassVar[ + collections.abc.Mapping[str, collections.abc.Callable[[str], t.Any]] + ] = {} + default_options: t.ClassVar[collections.abc.Mapping[str, t.Any]] = {} + + @classmethod + def assets_dir(cls, srcdir: pathlib.Path) -> pathlib.Path: + return srcdir / "_widgets" / cls.name + + @classmethod + def template_path(cls, srcdir: pathlib.Path) -> pathlib.Path: + return cls.assets_dir(srcdir) / "widget.html" + + @classmethod + def has_asset(cls, srcdir: pathlib.Path, filename: str) -> bool: + return (cls.assets_dir(srcdir) / filename).is_file() + + @classmethod + def context(cls, env: BuildEnvironment) -> collections.abc.Mapping[str, t.Any]: + """Return extra Jinja context. Override in subclasses for widget data.""" + return {} + + @classmethod + def render( + cls, + *, + options: collections.abc.Mapping[str, t.Any], + env: BuildEnvironment, + ) -> str: + """Render the Jinja template with merged context, return HTML.""" + template_path = cls.template_path(pathlib.Path(env.srcdir)) + source = template_path.read_text(encoding="utf-8") + jenv = jinja2.Environment( + undefined=jinja2.StrictUndefined, + autoescape=jinja2.select_autoescape(["html"]), + keep_trailing_newline=False, + trim_blocks=True, + lstrip_blocks=True, + ) + jenv.filters["highlight"] = make_highlight_filter(env) + jenv.filters["cooldown_days_slot"] = make_cooldown_days_slot_filter() + template = jenv.from_string(source) + context: dict[str, t.Any] = { + **cls.default_options, + **options, + **cls.context(env), + "widget_name": cls.name, + } + return template.render(**context) + + +def make_cooldown_days_slot_filter() -> CooldownDaysSlotFilter: + """Return a Jinja filter that injects cooldown slot ````s. + + The Pygments highlighter escapes two days-mode sentinels emitted in + snippet bodies (see :mod:`docs._ext.widgets.mcp_install`): + + * ``<COOLDOWN_DURATION>`` — used by uvx and pip days bodies. + Swapped for a span whose default text content is ``PD`` (ISO + 8601 duration). uv stores the value as + ``ExcludeNewerValue::Relative(ExcludeNewerSpan)`` and recomputes + ``now - N days`` on every resolver call; pip 26.1+ does the same + at flag-parse time per invocation. The snippet stays fresh + forever once saved to an MCP config. + * ``<COOLDOWN_DATE>`` — used by pipx days bodies because + pipx 1.8.0 bundles a pip older than 26.1 that rejects the + duration form with ``Invalid isoformat``. Swapped for a span + whose default text content is an absolute ISO date + (``today - default-days``). Drifts daily but ``widget.js`` + refreshes the slot on every page load. + + Both spans live inside a Pygments string-literal span and inherit + the parent's color. Their ``textContent`` is rewritten by + ``widget.js`` whenever the user changes the days input. The filter + is a no-op for outputs without either sentinel (off and bypass + cooldown modes never emit one). + """ + from .mcp_install import DEFAULT_COOLDOWN_DAYS, default_cooldown_date + + default_date = default_cooldown_date(DEFAULT_COOLDOWN_DAYS) + duration_span = ( + 'P{DEFAULT_COOLDOWN_DAYS}D" + ) + date_span = ( + f'{default_date}' + ) + + def _filter(html: object) -> markupsafe.Markup: + s = str(html) + s = s.replace("<COOLDOWN_DURATION>", duration_span) + s = s.replace("<COOLDOWN_DATE>", date_span) + return markupsafe.Markup(s) + + return _filter + + +def make_highlight_filter(env: BuildEnvironment) -> HighlightFilter: + r"""Return a Jinja filter that runs Sphinx's Pygments highlighter. + + Output matches ``sphinx.writers.html5.HTML5Translator.visit_literal_block`` + byte-for-byte: the inner ``highlight_block`` call already returns + ``
...
\n``; we wrap it with the + ``
...
\n`` starttag Sphinx + produces. This means sphinx-copybutton's default selector + (``div.highlight pre``) matches and the prompt-strip regex from gp-sphinx's + ``DEFAULT_COPYBUTTON_PROMPT_TEXT`` works automatically. + + ``highlighter`` is declared on ``StandaloneHTMLBuilder`` and its subclasses + (``DirectoryHTMLBuilder``, ``SingleFileHTMLBuilder``), not on the ``Builder`` + base. For non-HTML builders (``text``, ``linkcheck``, ``gettext``, ``man``, + ...), fall back to an HTML-escaped ``
`` block; it still flows through
+    the ``nodes.raw("html", ...)`` output path and is harmlessly ignored by
+    non-HTML writers.
+    """
+    builder = env.app.builder
+    if isinstance(builder, StandaloneHTMLBuilder):
+        highlighter = builder.highlighter
+
+        def _highlight(code: str, language: str = "default") -> markupsafe.Markup:
+            inner = highlighter.highlight_block(code, language)
+            return markupsafe.Markup(
+                f'
{inner}
\n' + ) + else: + + def _highlight(code: str, language: str = "default") -> markupsafe.Markup: + escaped = markupsafe.escape(code) + return markupsafe.Markup(f"
{escaped}
\n") + + return _highlight diff --git a/docs/_ext/widgets/_directive.py b/docs/_ext/widgets/_directive.py new file mode 100644 index 0000000..568be6d --- /dev/null +++ b/docs/_ext/widgets/_directive.py @@ -0,0 +1,62 @@ +"""Factory that manufactures a Sphinx Directive class for a given widget.""" + +from __future__ import annotations + +import pathlib +import typing as t + +from docutils import nodes +from sphinx.util.docutils import SphinxDirective + +from ._base import ASSET_FILES, BaseWidget, widget_container + + +def make_widget_directive(widget_cls: type[BaseWidget]) -> type[SphinxDirective]: + """Create a ``SphinxDirective`` subclass bound to ``widget_cls``. + + Each widget gets its own Directive subclass (not a single dispatcher) because + docutils parses ``:option:`` lines against ``option_spec`` *before* calling + ``run()`` -- so the spec must be static per directive name. + """ + + class _WidgetDirective(SphinxDirective): + has_content = False + required_arguments = 0 + optional_arguments = 0 + final_argument_whitespace = False + # Copy the widget's option_spec so per-directive mutations don't leak. + option_spec: t.ClassVar[dict[str, t.Any]] = dict(widget_cls.option_spec) + + def run(self) -> list[nodes.Node]: + """Render the widget and return a single ``widget_container`` node.""" + merged: dict[str, t.Any] = { + **widget_cls.default_options, + **self.options, + } + self._note_asset_dependencies() + html = self._render(merged) + container = widget_container(widget_name=widget_cls.name) + container += nodes.raw("", html, format="html") + self.set_source_info(container) + return [container] + + def _render(self, options: dict[str, t.Any]) -> str: + try: + return widget_cls.render(options=options, env=self.env) + except FileNotFoundError as exc: + msg = f"widget {widget_cls.name!r}: template not found -- expected {exc.filename}" + raise self.severe(msg) from exc + except Exception as exc: # Jinja UndefinedError, etc. + msg = f"widget {widget_cls.name!r} render failed: {exc}" + raise self.error(msg) from exc + + def _note_asset_dependencies(self) -> None: + assets_dir = widget_cls.assets_dir(pathlib.Path(self.env.srcdir)) + for filename in ASSET_FILES: + path = assets_dir / filename + if path.is_file(): + self.env.note_dependency(str(path)) + + _WidgetDirective.__name__ = f"{widget_cls.__name__}Directive" + _WidgetDirective.__qualname__ = _WidgetDirective.__name__ + return _WidgetDirective diff --git a/docs/_ext/widgets/_discovery.py b/docs/_ext/widgets/_discovery.py new file mode 100644 index 0000000..a09c96c --- /dev/null +++ b/docs/_ext/widgets/_discovery.py @@ -0,0 +1,45 @@ +"""Autodiscover widget classes from sibling modules in this package.""" + +from __future__ import annotations + +import importlib +import pkgutil + +from ._base import BaseWidget + + +def discover() -> dict[str, type[BaseWidget]]: + """Import every non-underscore submodule; collect ``BaseWidget`` subclasses. + + Adding a new widget means: drop ``mywidget.py`` next to ``mcp_install.py`` with a + ``MyWidget(BaseWidget)`` that sets ``name = "mywidget"`` -- the discovery sweep + at ``setup()`` time registers it automatically. + """ + from . import __name__ as pkg_name, __path__ as pkg_path + + registry: dict[str, type[BaseWidget]] = {} + for info in pkgutil.iter_modules(pkg_path): + if info.name.startswith("_"): + continue + module = importlib.import_module(f"{pkg_name}.{info.name}") + for obj in vars(module).values(): + if not _is_widget_class(obj): + continue + existing = registry.get(obj.name) + if existing is not None and existing is not obj: + msg = ( + f"Duplicate widget name {obj.name!r}: {existing.__module__} vs {obj.__module__}" + ) + raise RuntimeError(msg) + registry[obj.name] = obj + return registry + + +def _is_widget_class(obj: object) -> bool: + """Return True iff ``obj`` is a concrete ``BaseWidget`` subclass with a name.""" + return ( + isinstance(obj, type) + and issubclass(obj, BaseWidget) + and obj is not BaseWidget + and getattr(obj, "name", None) is not None + ) diff --git a/docs/_ext/widgets/_prehydrate.py b/docs/_ext/widgets/_prehydrate.py new file mode 100644 index 0000000..d0ab21e --- /dev/null +++ b/docs/_ext/widgets/_prehydrate.py @@ -0,0 +1,382 @@ +"""Prevent flash-of-wrong-selection on the ``mcp-install`` widget. + +The widget's server-rendered HTML always marks the first +client/method/scope tab ``aria-selected="true"`` and ``hidden=""`` on +every panel except the ``(claude-code, uvx, local, off)`` cell. +``widget.js`` then reads ``localStorage`` and mutates the DOM to the +user's saved selection — a visible flash on initial page paint and on +every gp-sphinx SPA navigation between docs pages. + +This module emits an inline ```` script that copies the saved +selection from ``localStorage`` onto ```` as +``data-mcp-install-client`` / ``data-mcp-install-method`` / +``data-mcp-install-scope`` / ``data-mcp-install-cooldown-enabled`` / +``data-mcp-install-cooldown-type`` / ``data-mcp-install-cooldown-days`` +attributes *before first paint*, plus a ``" + + +def _snippet() -> str: + return _build_style() + _script() + + +def inject_mcp_install_prehydrate( + app: Sphinx, + pagename: str, + templatename: str, + context: dict[str, t.Any], + doctree: object, +) -> None: + """Inject the prehydrate ``