diff --git a/CHANGES b/CHANGES index 9eb2717..cd19655 100644 --- a/CHANGES +++ b/CHANGES @@ -16,6 +16,12 @@ The MCP install widget on the front page, {doc}`/quickstart`, and {ref}`clients` 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) +### Documentation + +**MCP install widget ships working cooldown snippets** + +The install widget on the front page, {doc}`/quickstart`, and {ref}`clients` now emits a runnable snippet in every method × cooldown cell. uvx days panels apply the cooldown to transitive dependencies while exempting libtmux-mcp itself, so a fresh release stays installable. pipx and pip days panels fall back to the bare install command — neither tool exposes a per-package cooldown override today — and their cooldown note redirects readers to the uvx tab when they want true cooldown enforcement. (#58) + ## libtmux-mcp 0.1.0a7 (2026-05-16) ### Breaking changes diff --git a/docs/_ext/widgets/mcp_install.py b/docs/_ext/widgets/mcp_install.py index 8375288..4fa68bd 100644 --- a/docs/_ext/widgets/mcp_install.py +++ b/docs/_ext/widgets/mcp_install.py @@ -239,13 +239,10 @@ class Panel: # re-evaluates ``now - N days`` at every resolver call, so the saved # ``.mcp.json`` arg ``"P7D"`` stays fresh forever. pip 26.1+ does the # same at flag-parse time on every invocation. -# * ```` is used by pipx days bodies because pipx 1.8.0 -# bundles a pip older than 26.1, which rejects the duration form with -# ``Invalid isoformat``. The absolute date is computed in JS from -# ``today - savedDays``; the build-time default drifts daily but -# ``widget.js`` refreshes the slot on every page load. +# pipx and pip days panels don't embed a sentinel — they fall back to +# the bare command because pip has no per-package cooldown override +# (see ``_tool_command`` / ``_pip_prereq_for`` / ``_cooldown_note``). _DURATION_SENTINEL = "" -_DATE_SENTINEL = "" def default_cooldown_date(days: int) -> str: @@ -263,21 +260,18 @@ def default_cooldown_date(days: int) -> str: PIP_PREREQ_OFF: str = "pip install --user --upgrade libtmux libtmux-mcp" -PIP_PREREQ_DAYS: str = ( - f"pip install --user --upgrade --uploaded-prior-to {_DURATION_SENTINEL}" - " libtmux libtmux-mcp" -) def _pip_prereq_for(cooldown: Cooldown) -> str: """Return the prereq ``pip install`` line for the given cooldown mode. - ``bypass`` falls through to the ``off`` form: pip has no global cooldown - config to bypass, so the prereq is identical and the cooldown note on - the panel explains the situation. + All three modes (off / days / bypass) emit the same bare install + line. pip's ``--uploaded-prior-to`` has no per-package override, + so a days-mode cutoff filters fresh libtmux-mcp releases out of + the resolver — the snippet would fail to install. The per-panel + ``_cooldown_note`` redirects users to the uvx snippet, which + carries ``--exclude-newer-package`` for true cooldown enforcement. """ - if cooldown.id == "days": - return PIP_PREREQ_DAYS return PIP_PREREQ_OFF @@ -291,22 +285,36 @@ def _tool_command(method: Method, cooldown: Cooldown) -> str: """ if method.id == "uvx": if cooldown.id == "days": - return f"uvx --exclude-newer {_DURATION_SENTINEL} libtmux-mcp" + # ``--exclude-newer-package libtmux-mcp=2099-01-01`` exempts + # libtmux-mcp itself from the global cutoff so a recently- + # released libtmux-mcp stays inside the resolver. Without + # this, the snippet emits ``no versions of libtmux-mcp`` + # whenever the latest libtmux-mcp release is newer than the + # cooldown window. pipx + pip have no per-package override, + # so their days panels carry a caveat note instead (see + # ``_cooldown_note``). + return ( + f"uvx --exclude-newer {_DURATION_SENTINEL}" + " --exclude-newer-package libtmux-mcp=2099-01-01" + " libtmux-mcp" + ) if cooldown.id == "bypass": return "uvx --no-config libtmux-mcp" return "uvx libtmux-mcp" if method.id == "pipx": - if cooldown.id == "days": - # pipx 1.8.0 bundles pip <26.1, which doesn't accept the - # duration form — emit an absolute date instead. JS recomputes - # ``today - savedDays`` on every page load. - return ( - f"pipx run --pip-args=--uploaded-prior-to={_DATE_SENTINEL} libtmux-mcp" - ) - # off + bypass (pipx default backend has no global cooldown) + # pipx's pip backend has no per-package cooldown override, and + # pipx's uv backend doesn't translate ``--uploaded-prior-to`` or + # ``--exclude-newer`` from ``--pip-args`` (see pipx's + # ``commands/run_uv.py::_UV_TRANSLATABLE_VALUE_FLAGS``). Both + # paths fail on fresh libtmux-mcp releases when a days-mode + # cutoff filters the target package out. All three modes emit + # the bare ``pipx run`` command; the per-cell ``_cooldown_note`` + # redirects users to the uvx snippet for true cooldown + # enforcement. return "pipx run libtmux-mcp" - # pip method: the cooldown applies on the prereq line above, not on - # the registered command. The register step is just ``libtmux-mcp``. + # pip method: same per-package-override limitation. The prereq line + # (``_pip_prereq_for``) emits the bare ``pip install``; the register + # step is just ``libtmux-mcp``. return "libtmux-mcp" @@ -343,18 +351,20 @@ def _json_body(method: Method, cooldown: Cooldown) -> str: if method.id == "uvx": command = "uvx" if cooldown.id == "days": - args = f'"--exclude-newer", "{_DURATION_SENTINEL}", "libtmux-mcp"' + # See ``_tool_command`` for the rationale on the + # ``--exclude-newer-package libtmux-mcp=2099-01-01`` exempt. + args = ( + f'"--exclude-newer", "{_DURATION_SENTINEL}",' + ' "--exclude-newer-package", "libtmux-mcp=2099-01-01",' + ' "libtmux-mcp"' + ) else: args = '"libtmux-mcp"' elif method.id == "pipx": + # All three modes emit the bare ``pipx run`` args (no + # ``--pip-args``) — see ``_tool_command`` for the reasoning. command = "pipx" - if cooldown.id == "days": - args = ( - '"run", "--pip-args=--uploaded-prior-to=' - f'{_DATE_SENTINEL}", "libtmux-mcp"' - ) - else: - args = '"run", "libtmux-mcp"' + args = '"run", "libtmux-mcp"' else: # pip command = "libtmux-mcp" args = None @@ -392,14 +402,17 @@ def _toml_body(method: Method, cooldown: Cooldown) -> str: if method.id == "uvx": command, args_inner = "uvx", '"libtmux-mcp"' if cooldown.id == "days": - args_inner = f'"--exclude-newer", "{_DURATION_SENTINEL}", "libtmux-mcp"' - elif method.id == "pipx": - command, args_inner = "pipx", '"run", "libtmux-mcp"' - if cooldown.id == "days": + # See ``_tool_command`` for the rationale on the + # ``--exclude-newer-package libtmux-mcp=2099-01-01`` exempt. args_inner = ( - f'"run", "--pip-args=--uploaded-prior-to={_DATE_SENTINEL}",' + f'"--exclude-newer", "{_DURATION_SENTINEL}",' + ' "--exclude-newer-package", "libtmux-mcp=2099-01-01",' ' "libtmux-mcp"' ) + elif method.id == "pipx": + # All three modes emit the same args — see ``_tool_command`` for + # the per-package-override rationale. + command, args_inner = "pipx", '"run", "libtmux-mcp"' else: # pip command, args_inner = "libtmux-mcp", None @@ -415,19 +428,21 @@ def _toml_body(method: Method, cooldown: Cooldown) -> str: def _cooldown_note(method: Method, cooldown: Cooldown) -> str | None: - """Return a one-line caveat for cells where cooldown is a no-op.""" - if cooldown.id != "bypass": - return None - if method.id == "pip": - return ( - "pip has no global cooldown, so bypass is a no-op. Use this" - " when pairing the snippet with a uv-backed parent command." - ) - if method.id == "pipx": + """Return a one-line caveat for cells where the snippet has caveats.""" + if method.id in {"pipx", "pip"} and cooldown.id in {"days", "bypass"}: + # pip's ``--uploaded-prior-to`` has no per-package override (so + # ``days`` mode would filter the target package out of the + # resolver for fresh releases) and there's no global cooldown + # for pip to bypass either. Both modes fall back to the bare + # command; the note redirects users to the uvx snippet, which + # carries ``--exclude-newer-package`` for true per-package + # cooldown enforcement. return ( - "pipx's default backend (pip) has no global cooldown." - " For uv-style cooldown control, install `pipx[uv]` and set" - " `UV_NO_CONFIG=1` in your shell." + "pip has no per-package cooldown override, so this snippet" + " runs without cooldown enforcement. Switch to the uvx tab —" + " it applies the cooldown to transitive deps via" + " `--exclude-newer` while exempting libtmux-mcp itself via" + " `--exclude-newer-package`." ) return None diff --git a/tests/docs/test_widgets.py b/tests/docs/test_widgets.py index b2b0d09..b9e1b44 100644 --- a/tests/docs/test_widgets.py +++ b/tests/docs/test_widgets.py @@ -117,12 +117,17 @@ def test_body_for_uvx_days_inserts_duration_sentinel() -> None: uv stores the value as ``ExcludeNewerValue::Relative(ExcludeNewerSpan)`` and re-evaluates ``now - N days`` on every resolver call, so a saved - ``.mcp.json`` arg ``"P7D"`` stays fresh forever. + ``.mcp.json`` arg ``"P7D"`` stays fresh forever. The + ``--exclude-newer-package libtmux-mcp=2099-01-01`` exemption keeps + libtmux-mcp itself inside the resolver when the cutoff would + otherwise filter the package out (a fresh release vs a 7-day + cooldown window). """ client = CLIENTS[0] body, language, note = _body_for(client, METHODS[0], client.scopes[0], _DAYS) assert body == ( - "claude mcp add tmux -- uvx --exclude-newer libtmux-mcp" + "claude mcp add tmux -- uvx --exclude-newer " + " --exclude-newer-package libtmux-mcp=2099-01-01 libtmux-mcp" ) assert language == "console" assert note is None @@ -138,7 +143,7 @@ def test_body_for_uvx_bypass_inserts_no_config_flag() -> None: def test_body_for_pipx_bypass_returns_caveat_note() -> None: - """Pipx bypass renders identically to off and surfaces a no-op note.""" + """Pipx bypass renders identically to off and surfaces a caveat note.""" client = CLIENTS[0] pipx = next(m for m in METHODS if m.id == "pipx") body_off, _, note_off = _body_for(client, pipx, client.scopes[0], _OFF) @@ -146,11 +151,12 @@ def test_body_for_pipx_bypass_returns_caveat_note() -> None: assert body_off == body_bp # same command emitted assert note_off is None assert note_bp is not None - assert "pipx" in note_bp.lower() + # Note redirects users to the uvx tab for true cooldown support. + assert "uvx" in note_bp.lower() def test_body_for_pip_bypass_returns_caveat_note() -> None: - """Pip bypass renders identically to off and surfaces a no-op note.""" + """Pip bypass renders identically to off and surfaces a caveat note.""" client = CLIENTS[0] pip = next(m for m in METHODS if m.id == "pip") body_off, _, note_off = _body_for(client, pip, client.scopes[0], _OFF) @@ -161,13 +167,37 @@ def test_body_for_pip_bypass_returns_caveat_note() -> None: assert "pip" in note_bp.lower() -def test_body_for_pipx_days_uses_pip_args_form_with_absolute_date() -> None: - """Pipx days uses absolute date (pipx 1.8.0's bundled pip rejects PD).""" +def test_body_for_pipx_days_falls_back_to_bare_run() -> None: + """Pipx days renders identically to off (no per-package cooldown override). + + pipx's pip backend has no per-package ``--uploaded-prior-to`` flag, + so a days-mode cutoff would filter fresh ``libtmux-mcp`` releases + out of the resolver. The caveat note redirects users to the uvx tab. + """ client = CLIENTS[0] pipx = next(m for m in METHODS if m.id == "pipx") - body, language, _ = _body_for(client, pipx, client.scopes[0], _DAYS) - assert "--pip-args=--uploaded-prior-to=" in body + body_off, _, _ = _body_for(client, pipx, client.scopes[0], _OFF) + body_days, language, note = _body_for(client, pipx, client.scopes[0], _DAYS) + assert body_days == body_off # bare command, no cooldown flag + assert "--pip-args" not in body_days + assert "--uploaded-prior-to" not in body_days + assert "" not in body_days assert language == "console" + assert note is not None + assert "uvx" in note.lower() + + +def test_body_for_pip_days_falls_back_to_bare_install() -> None: + """Pip days renders identically to off (same per-package-override limit).""" + client = CLIENTS[0] + pip = next(m for m in METHODS if m.id == "pip") + body_off, _, _ = _body_for(client, pip, client.scopes[0], _OFF) + body_days, _, note = _body_for(client, pip, client.scopes[0], _DAYS) + assert body_days == body_off + assert "--uploaded-prior-to" not in body_days + assert "" not in body_days + assert note is not None + assert "uvx" in note.lower() def test_body_for_json_client_off_returns_config_snippet() -> None: @@ -237,8 +267,14 @@ def test_body_for_gemini_user_inserts_scope_flag() -> None: assert language == "console" -def test_pip_panel_has_cooldown_aware_pip_prereq() -> None: - """Panel.pip_prereq is set only for the pip method, with cooldown applied.""" +def test_pip_panel_has_bare_pip_prereq_across_modes() -> None: + """Pip panels emit the same bare ``pip install`` line across modes. + + pip's ``--uploaded-prior-to`` has no per-package override (so a + days-mode cutoff would filter fresh ``libtmux-mcp`` releases out of + the resolver). All three modes fall back to the bare install line; + the per-panel cooldown note redirects users to the uvx snippet. + """ panels = build_panels() pip_panels = [p for p in panels if p.method.id == "pip"] non_pip = [p for p in panels if p.method.id != "pip"] @@ -246,10 +282,12 @@ def test_pip_panel_has_cooldown_aware_pip_prereq() -> None: assert all(p.pip_prereq is not None for p in pip_panels) days_pip = next(p for p in pip_panels if p.cooldown.id == "days") off_pip = next(p for p in pip_panels if p.cooldown.id == "off") - assert days_pip.pip_prereq is not None - assert off_pip.pip_prereq is not None - assert "--uploaded-prior-to " in days_pip.pip_prereq - assert "--uploaded-prior-to" not in off_pip.pip_prereq + bypass_pip = next(p for p in pip_panels if p.cooldown.id == "bypass") + days_prereq = days_pip.pip_prereq + assert days_prereq is not None + assert days_prereq == off_pip.pip_prereq == bypass_pip.pip_prereq + assert "--uploaded-prior-to" not in days_prereq + assert "" not in days_prereq def test_body_for_unknown_kind_raises() -> None: @@ -342,10 +380,10 @@ def test_cooldown_days_slot_filter_registered_on_widget_render( ) app = _build(make_app, real_widget_srcdir) html = (pathlib.Path(app.outdir) / "index.html").read_text(encoding="utf-8") - # uvx + pip days bodies emit the duration slot; pipx days bodies - # emit the date slot. Both kinds must appear in any complete build. + # uvx days bodies emit the duration slot. pipx and pip days bodies + # fall back to the bare command (pip has no per-package cooldown + # override) so no date slot appears in any rendered snippet. assert "data-cooldown-duration-slot" in html - assert "data-cooldown-date-slot" in html # And no raw sentinel escapes to the rendered page. assert "<COOLDOWN_DURATION>" not in html assert "<COOLDOWN_DATE>" not in html