diff --git a/CHANGES b/CHANGES index 38d801c1..1b365eef 100644 --- a/CHANGES +++ b/CHANGES @@ -6,6 +6,12 @@ _Notes on upcoming releases will be added here_ +### Documentation + +**Grok CLI and Antigravity in the MCP install picker** + +The MCP client picker now covers Grok CLI and Antigravity (Google's agy) alongside Claude, Codex, Gemini, and Cursor. Grok registers through its own `grok mcp add` verb (`~/.grok/config.toml`); Antigravity has no such verb, so its `mcpServers` snippet is pasted into `~/.gemini/config/mcp_config.json`. (#89) + ## libtmux-mcp 0.1.0a16 (2026-07-04) libtmux-mcp 0.1.0a16 raises the libtmux floor to 0.61.0, picking up libtmux 0.61.0's hardening of the tmux 3.7 patch line. The server runs cleanly against tmux 3.7a and 3.7b, and tmux 3.2a-3.6 keep working unchanged. The bump requires no server code or test changes. diff --git a/docs/_ext/widgets/mcp_install.py b/docs/_ext/widgets/mcp_install.py index 4fa68bd5..39e6e411 100644 --- a/docs/_ext/widgets/mcp_install.py +++ b/docs/_ext/widgets/mcp_install.py @@ -1,13 +1,13 @@ """MCP install picker widget: client x method x scope x cooldown matrix. Each MCP client carries its own ``scopes`` tuple (Claude Code has three, -Claude Desktop has one, the rest have two). On top of that, every panel -exists in three **cooldown** modes — ``off`` (no extra flag), ``days`` -(insert a ``--exclude-newer`` / ``--uploaded-prior-to`` flag with a -user-configurable day count), and ``bypass`` (skip any global uv -cooldown via ``--no-config`` / ``UV_NO_CONFIG``). Together these -produce 90 server-rendered panels (3 + 1 + 2 + 2 + 2 = 10 scopes, -times 3 methods, times 3 cooldown modes). +Claude Desktop and Antigravity have one, the rest have two). On top of +that, every panel exists in three **cooldown** modes — ``off`` (no extra +flag), ``days`` (insert a ``--exclude-newer`` / ``--uploaded-prior-to`` +flag with a user-configurable day count), and ``bypass`` (skip any global +uv cooldown via ``--no-config`` / ``UV_NO_CONFIG``). Together these +produce 117 server-rendered panels (3 + 1 + 2 + 2 + 2 + 2 + 1 = 13 +scopes, times 3 methods, times 3 cooldown modes). Cooldown state is split across three orthogonal axes: ``DEFAULT_COOLDOWN_ENABLED`` (master on/off), ``DEFAULT_COOLDOWN_TYPE`` @@ -170,6 +170,30 @@ class Panel: ), ) +_GROK_SCOPES: tuple[Scope, ...] = ( + Scope( + id="user", + label="User", + config_file="~/.grok/config.toml", + note=None, + ), + Scope( + id="project", + label="Project", + config_file="./.grok/config.toml (in repo)", + note=None, + ), +) + +_ANTIGRAVITY_SCOPES: tuple[Scope, ...] = ( + Scope( + id="global", + label="Global", + config_file="~/.gemini/config/mcp_config.json", + note=None, + ), +) + CLIENTS: tuple[Client, ...] = ( Client( @@ -202,6 +226,18 @@ class Panel: kind="json", scopes=_CURSOR_SCOPES, ), + Client( + id="grok", + label="Grok CLI", + kind="cli", + scopes=_GROK_SCOPES, + ), + Client( + id="antigravity", + label="Antigravity", + kind="json", + scopes=_ANTIGRAVITY_SCOPES, + ), ) @@ -334,6 +370,13 @@ def _cli_body(client: Client, scope: Scope, method: Method, cooldown: Cooldown) if client.id == "claude-code": flag = "" if scope.id == "local" else f"--scope {scope.id} " return f"claude mcp add tmux {flag}-- {tool_cmd}".replace(" ", " ") + # grok: ``grok mcp add --scope -- `` writes + # both scopes itself (``~/.grok/config.toml`` / ``./.grok/config.toml``, + # TOML ``[mcp_servers.]`` — same shape as Codex), so unlike Codex + # no manual-paste path is needed for project scope. Everything after + # ``--`` is handed to the server process verbatim. + if client.id == "grok": + return f"grok mcp add --scope {scope.id} tmux -- {tool_cmd}" # codex: CLI doesn't write project scope; the project-scope panel # uses the TOML body path (see ``_body_for``). return f"codex mcp add tmux -- {tool_cmd}" diff --git a/docs/_widgets/mcp-install/widget.js b/docs/_widgets/mcp-install/widget.js index 6b276a8a..76202f6f 100644 --- a/docs/_widgets/mcp-install/widget.js +++ b/docs/_widgets/mcp-install/widget.js @@ -44,7 +44,7 @@ }; // Mirror of docs/_ext/widgets/mcp_install.py:DEFAULT_SCOPES. The prehydrate - // script emits the same map; this duplicate is small enough (5 + // script emits the same map; this duplicate is small enough (7 // entries) that the cost of keeping them in sync beats reading the literal // back out of the DOM. Update both when adding a client. var DEFAULT_SCOPES = { @@ -53,6 +53,8 @@ "codex": "user", "gemini": "user", "cursor": "project", + "grok": "user", + "antigravity": "global", }; var DEFAULT_COOLDOWN_ENABLED = false; diff --git a/docs/clients.md b/docs/clients.md index bed1a6be..28a6b8ca 100644 --- a/docs/clients.md +++ b/docs/clients.md @@ -33,6 +33,8 @@ $ npx @modelcontextprotocol/inspector | Codex CLI | `~/.codex/config.toml` (user) or `.codex/config.toml` (project, manual) | TOML | | Gemini CLI | `~/.gemini/settings.json` (user) or `.gemini/settings.json` (project) | JSON | | Cursor | `.cursor/mcp.json` (project) or `~/.cursor/mcp.json` (global) | JSON | +| Grok CLI | `~/.grok/config.toml` (user) or `./.grok/config.toml` (project) | TOML | +| Antigravity | `~/.gemini/config/mcp_config.json` (global) | JSON | ## Local checkout (development) @@ -49,7 +51,7 @@ $ claude mcp add \ ```
-Codex CLI / Gemini CLI / Cursor +Codex CLI / Gemini CLI / Grok CLI / Cursor / Antigravity **Codex CLI:** @@ -69,6 +71,16 @@ $ gemini mcp add \ run libtmux-mcp ``` +**Grok CLI:** + +```console +$ grok mcp add \ + --scope user \ + tmux -- \ + uv --directory ~/work/python/libtmux-mcp \ + run libtmux-mcp +``` + **Cursor** — add to `~/.cursor/mcp.json`: ```json @@ -85,6 +97,22 @@ $ gemini mcp add \ } ``` +**Antigravity** — add to `~/.gemini/config/mcp_config.json`: + +```json +{ + "mcpServers": { + "tmux": { + "command": "uv", + "args": [ + "--directory", "~/work/python/libtmux-mcp", + "run", "libtmux-mcp" + ] + } + } +} +``` +
## Common pitfalls diff --git a/scripts/mcp_swap.py b/scripts/mcp_swap.py index 9c3128c6..22dc96f9 100644 --- a/scripts/mcp_swap.py +++ b/scripts/mcp_swap.py @@ -34,9 +34,9 @@ ``~/.claude.json``, ``~/.codex/config.toml``, ``~/.gemini/settings.json``, ``~/.grok/config.toml`` (TOML ``mcp_servers``, same shape as Codex), and - ``~/.gemini/antigravity/mcp_config.json`` (agy, JSON - ``mcpServers``). The agy CLI may read a different profile from the - desktop app; only the profile path above is written. Workspace / project-local configs + ``~/.gemini/config/mcp_config.json`` (agy / Antigravity CLI, JSON + ``mcpServers`` — the shared-config file the CLI reads, sibling to the + ``config.json`` it loads at startup). Workspace / project-local configs (``$PWD/.cursor/mcp.json``, ``$PWD/.gemini/settings.json``, per-project ``projects..mcpServers`` entries inside ``~/.claude.json`` *are* recognised for Claude only) are NOT @@ -227,9 +227,7 @@ class CLIInfo: "agy": CLIInfo( name="agy", binary="agy", - config_path=( - pathlib.Path.home() / ".gemini" / "antigravity" / "mcp_config.json" - ), + config_path=(pathlib.Path.home() / ".gemini" / "config" / "mcp_config.json"), fmt="json", ), } diff --git a/tests/docs/test_widgets.py b/tests/docs/test_widgets.py index b9e1b44f..af4fa93f 100644 --- a/tests/docs/test_widgets.py +++ b/tests/docs/test_widgets.py @@ -23,7 +23,10 @@ DEFAULT_COOLDOWN_ENABLED, DEFAULT_COOLDOWN_TYPE, METHODS, + Client, + Cooldown, MCPInstallWidget, + Method, _body_for, build_panels, ) @@ -292,7 +295,7 @@ def test_pip_panel_has_bare_pip_prereq_across_modes() -> None: def test_body_for_unknown_kind_raises() -> None: """An unrecognised ``client.kind`` surfaces as a ``ValueError``.""" - from docs._ext.widgets.mcp_install import Client, Scope + from docs._ext.widgets.mcp_install import Scope fake_scope = Scope(id="user", label="User", config_file="", note=None) fake = Client(id="x", label="X", kind="bogus", scopes=(fake_scope,)) @@ -300,6 +303,188 @@ def test_body_for_unknown_kind_raises() -> None: _body_for(fake, METHODS[0], fake_scope, _OFF) +# ---------- unit: Grok CLI + Antigravity clients ------------------------- + + +def _client(client_id: str) -> Client: + """Look up a registered client by id.""" + return next(c for c in CLIENTS if c.id == client_id) + + +def _method(method_id: str) -> Method: + """Look up a registered install method by id.""" + return next(m for m in METHODS if m.id == method_id) + + +def _cooldown(cooldown_id: str) -> Cooldown: + """Look up a cooldown mode by id.""" + return next(c for c in COOLDOWNS if c.id == cooldown_id) + + +def test_grok_and_antigravity_registered_with_expected_scopes() -> None: + """Grok (CLI, user+project) and Antigravity (JSON, single global) are registered. + + Locks the config-file paths so a future edit can't silently regress + them — in particular Antigravity's ``~/.gemini/config/mcp_config.json``, + the file the ``agy`` binary actually reads (it has no ``mcp add`` verb). + """ + grok = _client("grok") + assert grok.label == "Grok CLI" + assert grok.kind == "cli" + assert [s.id for s in grok.scopes] == ["user", "project"] + assert grok.scopes[0].config_file == "~/.grok/config.toml" + assert grok.scopes[1].config_file == "./.grok/config.toml (in repo)" + + agy = _client("antigravity") + assert agy.label == "Antigravity" + assert agy.kind == "json" + assert [s.id for s in agy.scopes] == ["global"] + assert agy.scopes[0].config_file == "~/.gemini/config/mcp_config.json" + + +class GrokBodyCase(t.NamedTuple): + """One ``_body_for`` expectation for the Grok CLI client.""" + + test_id: str + method_id: str + scope_id: str + cooldown_id: str + expected: str + + +_GROK_BODY_CASES: list[GrokBodyCase] = [ + GrokBodyCase( + test_id="uvx-user-off", + method_id="uvx", + scope_id="user", + cooldown_id="off", + expected="grok mcp add --scope user tmux -- uvx libtmux-mcp", + ), + GrokBodyCase( + test_id="uvx-project-off", + method_id="uvx", + scope_id="project", + cooldown_id="off", + expected="grok mcp add --scope project tmux -- uvx libtmux-mcp", + ), + GrokBodyCase( + test_id="pipx-user-off", + method_id="pipx", + scope_id="user", + cooldown_id="off", + expected="grok mcp add --scope user tmux -- pipx run libtmux-mcp", + ), + GrokBodyCase( + test_id="pip-user-off", + method_id="pip", + scope_id="user", + cooldown_id="off", + expected="grok mcp add --scope user tmux -- libtmux-mcp", + ), + GrokBodyCase( + test_id="uvx-user-days", + method_id="uvx", + scope_id="user", + cooldown_id="days", + expected=( + "grok mcp add --scope user tmux -- uvx --exclude-newer" + " --exclude-newer-package" + " libtmux-mcp=2099-01-01 libtmux-mcp" + ), + ), + GrokBodyCase( + test_id="uvx-user-bypass", + method_id="uvx", + scope_id="user", + cooldown_id="bypass", + expected="grok mcp add --scope user tmux -- uvx --no-config libtmux-mcp", + ), +] + + +@pytest.mark.parametrize( + list(GrokBodyCase._fields), + _GROK_BODY_CASES, + ids=[c.test_id for c in _GROK_BODY_CASES], +) +def test_body_for_grok_cli( + test_id: str, + method_id: str, + scope_id: str, + cooldown_id: str, + expected: str, +) -> None: + """Grok renders ``grok mcp add --scope tmux -- `` for both scopes. + + Unlike Codex, Grok's CLI writes both user and project scopes itself + (``~/.grok/config.toml`` / ``./.grok/config.toml``), so there is no + manual-TOML-paste cell — every Grok panel is a ``console`` command. + """ + grok = _client("grok") + scope = next(s for s in grok.scopes if s.id == scope_id) + body, language, _ = _body_for( + grok, _method(method_id), scope, _cooldown(cooldown_id) + ) + assert body == expected + assert language == "console" + + +class AntigravityBodyCase(t.NamedTuple): + """One ``_body_for`` expectation for the Antigravity (agy) JSON client.""" + + test_id: str + method_id: str + cooldown_id: str + expected_substrings: tuple[str, ...] + + +_ANTIGRAVITY_BODY_CASES: list[AntigravityBodyCase] = [ + AntigravityBodyCase( + test_id="uvx-off", + method_id="uvx", + cooldown_id="off", + expected_substrings=('"mcpServers"', '"command": "uvx"', '"libtmux-mcp"'), + ), + AntigravityBodyCase( + test_id="uvx-days", + method_id="uvx", + cooldown_id="days", + expected_substrings=('"--exclude-newer"', '""'), + ), + AntigravityBodyCase( + test_id="uvx-bypass", + method_id="uvx", + cooldown_id="bypass", + expected_substrings=('"env":', '"UV_NO_CONFIG": "1"'), + ), +] + + +@pytest.mark.parametrize( + list(AntigravityBodyCase._fields), + _ANTIGRAVITY_BODY_CASES, + ids=[c.test_id for c in _ANTIGRAVITY_BODY_CASES], +) +def test_body_for_antigravity_json( + test_id: str, + method_id: str, + cooldown_id: str, + expected_substrings: tuple[str, ...], +) -> None: + """Antigravity reuses the JSON ``mcpServers`` body — ``agy`` has no ``mcp add``. + + The rendered snippet is what the user pastes into + ``~/.gemini/config/mcp_config.json`` (single global scope). + """ + agy = _client("antigravity") + body, language, _ = _body_for( + agy, _method(method_id), agy.scopes[0], _cooldown(cooldown_id) + ) + assert language == "json" + for needle in expected_substrings: + assert needle in body + + # ---------- unit: cooldown_days_slot Jinja filter ------------------------ diff --git a/tests/test_mcp_swap.py b/tests/test_mcp_swap.py index ac1e9479..5b9fdf5f 100644 --- a/tests/test_mcp_swap.py +++ b/tests/test_mcp_swap.py @@ -71,7 +71,7 @@ def fake_home(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathli "agy": mcp_swap.CLIInfo( name="agy", binary="agy", - config_path=tmp_path / ".gemini" / "antigravity" / "mcp_config.json", + config_path=tmp_path / ".gemini" / "config" / "mcp_config.json", fmt="json", ), },