diff --git a/CHANGES b/CHANGES index 25f2854..9eb2717 100644 --- a/CHANGES +++ b/CHANGES @@ -12,6 +12,10 @@ _Notes on upcoming releases will be added here_ The MCP install widget on the front page, {doc}`/quickstart`, and {ref}`clients` now lets you pick a config scope per LLM client alongside the install method — *local* / *user* / *project* for Claude Code, *user* / *project* for Codex and Gemini, *project* / *global* for Cursor. JSON-only clients (Cursor, Claude Desktop) also show the destination config-file path next to the snippet so the paste target is never a guess. Your scope choice is remembered per-client, so switching between clients restores each one's last selection. +**Installer picker can apply or bypass dependency cooldowns** + +A new *Configure cooldowns* control on the install widget lets you tack a cooldown onto the snippet without leaving the page. Pick a delay in days (uv's `--exclude-newer`, pip's `--uploaded-prior-to`, pipx's `--pip-args`) to wait out community vetting before pulling a fresh release, or pick *Bypass any global cooldown* to skip a `~/.config/uv/uv.toml` cutoff and grab the latest libtmux-mcp anyway. The setting persists across pages, and the embedded *What are cooldowns?* expander links to the [Datadog Security Labs writeup](https://securitylabs.datadoghq.com/articles/dependency-cooldowns/) and [cooldowns.dev](https://cooldowns.dev/) if you want the supply-chain context. (#31) + ## libtmux-mcp 0.1.0a7 (2026-05-16) ### Breaking changes diff --git a/docs/_ext/widgets/_assets.py b/docs/_ext/widgets/_assets.py index fb1b077..bfd7c61 100644 --- a/docs/_ext/widgets/_assets.py +++ b/docs/_ext/widgets/_assets.py @@ -3,10 +3,10 @@ from __future__ import annotations import pathlib +import shutil import typing as t from sphinx.util import logging -from sphinx.util.fileutil import copy_asset_file from ._base import BaseWidget @@ -28,6 +28,13 @@ def install_widget_assets( 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 @@ -47,5 +54,5 @@ def install_widget_assets( if not source.is_file(): continue dest.mkdir(parents=True, exist_ok=True) - copy_asset_file(str(source), str(dest)) + 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 index 9d18dd0..aaa3ab3 100644 --- a/docs/_ext/widgets/_base.py +++ b/docs/_ext/widgets/_base.py @@ -23,6 +23,12 @@ class HighlightFilter(t.Protocol): 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.""" @@ -99,6 +105,7 @@ def render( 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, @@ -109,6 +116,53 @@ def render( 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 = ( + '{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. diff --git a/docs/_ext/widgets/_prehydrate.py b/docs/_ext/widgets/_prehydrate.py index c09fef3..3d9ed61 100644 --- a/docs/_ext/widgets/_prehydrate.py +++ b/docs/_ext/widgets/_prehydrate.py @@ -1,27 +1,41 @@ """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)`` 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`` attributes *before -first paint*, plus a ``" @@ -216,9 +345,10 @@ def inject_mcp_install_prehydrate( ) -> None: """Inject the prehydrate ``