From 2153b54630a896f86300edaefbb277f2ad3d0f68 Mon Sep 17 00:00:00 2001 From: SuMayaBee Date: Fri, 21 Aug 2026 00:28:18 +0600 Subject: [PATCH 1/3] feat: add pls install support for 10 more MCP clients --- docs/tutorials/installation.md | 254 +++++++++++++++++++++++++++++ docs/tutorials/mcp-server.md | 2 +- pixi.lock | 21 +++ pixi.toml | 1 + pyproject.toml | 1 + src/panel_live_server/cli.py | 272 +++++++++++++++++++++++++++++++ src/panel_live_server/install.py | 179 +++++++++++++++++++- tests/test_cli.py | 82 ++++++++++ tests/test_install.py | 238 +++++++++++++++++++++++++++ 9 files changed, 1048 insertions(+), 2 deletions(-) diff --git a/docs/tutorials/installation.md b/docs/tutorials/installation.md index 8d17791..7acda0f 100644 --- a/docs/tutorials/installation.md +++ b/docs/tutorials/installation.md @@ -274,6 +274,260 @@ directory is on your PATH, run `uv tool update-shell` and restart your terminal. If the server is already registered, remove it first with `claude mcp remove panel-live-server`. +=== "Windsurf" + + ```bash + pls install windsurf + ``` + + To set it up by hand instead, add to `~/.codeium/windsurf/mcp_config.json`: + + ```json + { + "mcpServers": { + "panel-live-server": { + "command": "/path/to/pls", + "args": ["mcp"] + } + } + } + ``` + + !!! warning "Use your absolute path" + Replace `"command": "/path/to/pls"` with the path printed by `which pls` above, + e.g. `"command": "/home/user/.local/bin/pls"` + + Open the MCP servers panel (hammer icon) in Cascade and confirm it's running. + +=== "Cline" + + ```bash + pls install cline + ``` + + Cline stores this separately from `.vscode/mcp.json`, in VS Code's own + per-extension storage. To set it up by hand instead, add to + `cline_mcp_settings.json` (open it from Cline's "Edit MCP Settings" button): + + ```json + { + "mcpServers": { + "panel-live-server": { + "command": "/path/to/pls", + "args": ["mcp"] + } + } + } + ``` + + !!! warning "Use your absolute path" + Replace `"command": "/path/to/pls"` with the path printed by `which pls` above, + e.g. `"command": "/home/user/.local/bin/pls"` + + Open Cline's MCP Servers panel and confirm panel-live-server is connected. + +=== "JetBrains / Junie" + + ```bash + pls install jetbrains + ``` + + To set it up by hand instead, add to `~/.junie/mcp/mcp.json`: + + ```json + { + "mcpServers": { + "panel-live-server": { + "command": "/path/to/pls", + "args": ["mcp"] + } + } + } + ``` + + !!! warning "Use your absolute path" + Replace `"command": "/path/to/pls"` with the path printed by `which pls` above, + e.g. `"command": "/home/user/.local/bin/pls"` + + Restart the IDE, or reopen Junie's MCP settings, for the change to take effect. + +=== "Gemini CLI" + + ```bash + pls install gemini-cli + ``` + + This writes into `~/.gemini/settings.json`, leaving every other setting there + (theme, auth, other servers) untouched. To set it up by hand instead, add: + + ```json + { + "mcpServers": { + "panel-live-server": { + "command": "/path/to/pls", + "args": ["mcp"] + } + } + } + ``` + + !!! warning "Use your absolute path" + Replace `"command": "/path/to/pls"` with the path printed by `which pls` above, + e.g. `"command": "/home/user/.local/bin/pls"` + + Restart the Gemini CLI for the change to take effect. + +=== "Antigravity" + + ```bash + pls install antigravity + ``` + + To set it up by hand instead, add to `~/.gemini/config/mcp_config.json`: + + ```json + { + "mcpServers": { + "panel-live-server": { + "command": "/path/to/pls", + "args": ["mcp"] + } + } + } + ``` + + !!! warning "Use your absolute path" + Replace `"command": "/path/to/pls"` with the path printed by `which pls` above, + e.g. `"command": "/home/user/.local/bin/pls"` + + Open Manage MCP Servers in Antigravity and confirm it's connected. + +=== "Kiro" + + ```bash + pls install kiro + ``` + + To set it up by hand instead, add to `~/.kiro/settings/mcp.json`: + + ```json + { + "mcpServers": { + "panel-live-server": { + "command": "/path/to/pls", + "args": ["mcp"] + } + } + } + ``` + + !!! warning "Use your absolute path" + Replace `"command": "/path/to/pls"` with the path printed by `which pls` above, + e.g. `"command": "/home/user/.local/bin/pls"` + + Reload Kiro, or open the MCP panel, for the change to take effect. + +=== "Copilot CLI" + + ```bash + pls install copilot + ``` + + This is the standalone `copilot` CLI, not VS Code's Copilot Chat (which already + reads `.vscode/mcp.json`, see the VS Code tab). To set it up by hand instead, add + to `~/.copilot/mcp-config.json`: + + ```json + { + "mcpServers": { + "panel-live-server": { + "type": "local", + "command": "/path/to/pls", + "args": ["mcp"], + "tools": ["*"] + } + } + } + ``` + + !!! warning "Use your absolute path" + Replace `"command": "/path/to/pls"` with the path printed by `which pls` above, + e.g. `"command": "/home/user/.local/bin/pls"` + + Run `/mcp show` in the Copilot CLI to confirm panel-live-server is connected. + +=== "Kilo Code" + + ```bash + pls install kilo-code + ``` + + Kilo Code packs the command and its arguments into one array under a `mcp` key, + rather than separate `command`/`args` fields under `mcpServers`. To set it up by + hand instead, add to `~/.config/kilo/kilo.jsonc`: + + ```json + { + "mcp": { + "panel-live-server": { + "type": "local", + "command": ["/path/to/pls", "mcp"], + "enabled": true + } + } + } + ``` + + !!! warning "Use your absolute path" + Replace `/path/to/pls` with the path printed by `which pls` above, + e.g. `"command": ["/home/user/.local/bin/pls", "mcp"]` + + Reload the window for the change to take effect. + +=== "Codex CLI" + + ```bash + pls install codex + ``` + + Codex uses TOML, not JSON. To set it up by hand instead, add to `~/.codex/config.toml`: + + ```toml + [mcp_servers.panel-live-server] + command = "/path/to/pls" + args = ["mcp"] + ``` + + !!! warning "Use your absolute path" + Replace `/path/to/pls` with the path printed by `which pls` above, + e.g. `command = "/home/user/.local/bin/pls"` + + Restart the Codex CLI for the change to take effect. + +=== "Mistral Vibe" + + ```bash + pls install mistral-vibe + ``` + + Vibe also uses TOML, and lists servers as an array of tables identified by a + `name` field rather than keyed by their own table name. To set it up by hand + instead, add to `~/.vibe/config.toml`: + + ```toml + [[mcp_servers]] + name = "panel-live-server" + transport = "stdio" + command = "/path/to/pls" + args = ["mcp"] + ``` + + !!! warning "Use your absolute path" + Replace `/path/to/pls` with the path printed by `which pls` above, + e.g. `command = "/home/user/.local/bin/pls"` + + Restart Vibe for the change to take effect. + === "claude.ai" claude.ai requires HTTP transport and a public URL. You can use any tunneling service diff --git a/docs/tutorials/mcp-server.md b/docs/tutorials/mcp-server.md index a6822d4..2c5b649 100644 --- a/docs/tutorials/mcp-server.md +++ b/docs/tutorials/mcp-server.md @@ -15,7 +15,7 @@ asked an AI to produce a chart and seen it rendered live in your IDE. ## Step 1: Add Panel Live Server to your MCP configuration See [Installation → Connect to your MCP client](installation.md#connect-to-your-mcp-client) for -the full setup instructions for VS Code, Cursor, Claude Desktop, Claude Code, and claude.ai. +the full setup instructions for VS Code, Cursor, Claude Desktop, Claude Code, claude.ai, and more. !!! note When the MCP server starts, it automatically starts the Panel server in the background. diff --git a/pixi.lock b/pixi.lock index 8ab72e8..489e079 100644 --- a/pixi.lock +++ b/pixi.lock @@ -206,6 +206,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/61/28/8cb142d3fe80c4a2d8af54ca0b003f47ce0ba920974e7990fa6e016402d1/sse_starlette-3.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/13/bc/8c13eb66537dce1d2bd3a57132902f38d0e7f5bb46fa9f4daed9fe9d76ee/tomlkit-0.15.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ff/7f/4320d9ce3be404e6310b915c3629fe27bf1e2f438a1a7a3cb0396e32e9a9/uncalled_for-0.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl @@ -397,6 +398,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl - pypi: https://files.pythonhosted.org/packages/61/28/8cb142d3fe80c4a2d8af54ca0b003f47ce0ba920974e7990fa6e016402d1/sse_starlette-3.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/13/bc/8c13eb66537dce1d2bd3a57132902f38d0e7f5bb46fa9f4daed9fe9d76ee/tomlkit-0.15.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ff/7f/4320d9ce3be404e6310b915c3629fe27bf1e2f438a1a7a3cb0396e32e9a9/uncalled_for-0.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl @@ -589,6 +591,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/61/28/8cb142d3fe80c4a2d8af54ca0b003f47ce0ba920974e7990fa6e016402d1/sse_starlette-3.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/13/bc/8c13eb66537dce1d2bd3a57132902f38d0e7f5bb46fa9f4daed9fe9d76ee/tomlkit-0.15.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ff/7f/4320d9ce3be404e6310b915c3629fe27bf1e2f438a1a7a3cb0396e32e9a9/uncalled_for-0.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl @@ -790,6 +793,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/61/28/8cb142d3fe80c4a2d8af54ca0b003f47ce0ba920974e7990fa6e016402d1/sse_starlette-3.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/13/bc/8c13eb66537dce1d2bd3a57132902f38d0e7f5bb46fa9f4daed9fe9d76ee/tomlkit-0.15.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ff/7f/4320d9ce3be404e6310b915c3629fe27bf1e2f438a1a7a3cb0396e32e9a9/uncalled_for-0.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl @@ -989,6 +993,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/61/28/8cb142d3fe80c4a2d8af54ca0b003f47ce0ba920974e7990fa6e016402d1/sse_starlette-3.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/13/bc/8c13eb66537dce1d2bd3a57132902f38d0e7f5bb46fa9f4daed9fe9d76ee/tomlkit-0.15.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ff/7f/4320d9ce3be404e6310b915c3629fe27bf1e2f438a1a7a3cb0396e32e9a9/uncalled_for-0.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl @@ -1175,6 +1180,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl - pypi: https://files.pythonhosted.org/packages/61/28/8cb142d3fe80c4a2d8af54ca0b003f47ce0ba920974e7990fa6e016402d1/sse_starlette-3.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/13/bc/8c13eb66537dce1d2bd3a57132902f38d0e7f5bb46fa9f4daed9fe9d76ee/tomlkit-0.15.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ff/7f/4320d9ce3be404e6310b915c3629fe27bf1e2f438a1a7a3cb0396e32e9a9/uncalled_for-0.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl @@ -1360,6 +1366,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/61/28/8cb142d3fe80c4a2d8af54ca0b003f47ce0ba920974e7990fa6e016402d1/sse_starlette-3.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/13/bc/8c13eb66537dce1d2bd3a57132902f38d0e7f5bb46fa9f4daed9fe9d76ee/tomlkit-0.15.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ff/7f/4320d9ce3be404e6310b915c3629fe27bf1e2f438a1a7a3cb0396e32e9a9/uncalled_for-0.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl @@ -1555,6 +1562,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/61/28/8cb142d3fe80c4a2d8af54ca0b003f47ce0ba920974e7990fa6e016402d1/sse_starlette-3.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/13/bc/8c13eb66537dce1d2bd3a57132902f38d0e7f5bb46fa9f4daed9fe9d76ee/tomlkit-0.15.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ff/7f/4320d9ce3be404e6310b915c3629fe27bf1e2f438a1a7a3cb0396e32e9a9/uncalled_for-0.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl @@ -1763,6 +1771,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/61/28/8cb142d3fe80c4a2d8af54ca0b003f47ce0ba920974e7990fa6e016402d1/sse_starlette-3.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/13/bc/8c13eb66537dce1d2bd3a57132902f38d0e7f5bb46fa9f4daed9fe9d76ee/tomlkit-0.15.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ff/7f/4320d9ce3be404e6310b915c3629fe27bf1e2f438a1a7a3cb0396e32e9a9/uncalled_for-0.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl @@ -1956,6 +1965,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl - pypi: https://files.pythonhosted.org/packages/61/28/8cb142d3fe80c4a2d8af54ca0b003f47ce0ba920974e7990fa6e016402d1/sse_starlette-3.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/13/bc/8c13eb66537dce1d2bd3a57132902f38d0e7f5bb46fa9f4daed9fe9d76ee/tomlkit-0.15.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ff/7f/4320d9ce3be404e6310b915c3629fe27bf1e2f438a1a7a3cb0396e32e9a9/uncalled_for-0.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl @@ -2150,6 +2160,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/61/28/8cb142d3fe80c4a2d8af54ca0b003f47ce0ba920974e7990fa6e016402d1/sse_starlette-3.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/13/bc/8c13eb66537dce1d2bd3a57132902f38d0e7f5bb46fa9f4daed9fe9d76ee/tomlkit-0.15.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ff/7f/4320d9ce3be404e6310b915c3629fe27bf1e2f438a1a7a3cb0396e32e9a9/uncalled_for-0.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl @@ -2353,6 +2364,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/61/28/8cb142d3fe80c4a2d8af54ca0b003f47ce0ba920974e7990fa6e016402d1/sse_starlette-3.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/13/bc/8c13eb66537dce1d2bd3a57132902f38d0e7f5bb46fa9f4daed9fe9d76ee/tomlkit-0.15.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ff/7f/4320d9ce3be404e6310b915c3629fe27bf1e2f438a1a7a3cb0396e32e9a9/uncalled_for-0.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl @@ -2713,6 +2725,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/61/28/8cb142d3fe80c4a2d8af54ca0b003f47ce0ba920974e7990fa6e016402d1/sse_starlette-3.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/13/bc/8c13eb66537dce1d2bd3a57132902f38d0e7f5bb46fa9f4daed9fe9d76ee/tomlkit-0.15.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ff/7f/4320d9ce3be404e6310b915c3629fe27bf1e2f438a1a7a3cb0396e32e9a9/uncalled_for-0.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl @@ -2898,6 +2911,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl - pypi: https://files.pythonhosted.org/packages/61/28/8cb142d3fe80c4a2d8af54ca0b003f47ce0ba920974e7990fa6e016402d1/sse_starlette-3.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/13/bc/8c13eb66537dce1d2bd3a57132902f38d0e7f5bb46fa9f4daed9fe9d76ee/tomlkit-0.15.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ff/7f/4320d9ce3be404e6310b915c3629fe27bf1e2f438a1a7a3cb0396e32e9a9/uncalled_for-0.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl @@ -3084,6 +3098,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/61/28/8cb142d3fe80c4a2d8af54ca0b003f47ce0ba920974e7990fa6e016402d1/sse_starlette-3.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/13/bc/8c13eb66537dce1d2bd3a57132902f38d0e7f5bb46fa9f4daed9fe9d76ee/tomlkit-0.15.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ff/7f/4320d9ce3be404e6310b915c3629fe27bf1e2f438a1a7a3cb0396e32e9a9/uncalled_for-0.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl @@ -3279,6 +3294,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/61/28/8cb142d3fe80c4a2d8af54ca0b003f47ce0ba920974e7990fa6e016402d1/sse_starlette-3.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/13/bc/8c13eb66537dce1d2bd3a57132902f38d0e7f5bb46fa9f4daed9fe9d76ee/tomlkit-0.15.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ff/7f/4320d9ce3be404e6310b915c3629fe27bf1e2f438a1a7a3cb0396e32e9a9/uncalled_for-0.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl @@ -11546,6 +11562,11 @@ packages: - pkg:pypi/tomli?source=compressed-mapping size: 21453 timestamp: 1768146676791 +- pypi: https://files.pythonhosted.org/packages/13/bc/8c13eb66537dce1d2bd3a57132902f38d0e7f5bb46fa9f4daed9fe9d76ee/tomlkit-0.15.1-py3-none-any.whl + name: tomlkit + version: 0.15.1 + sha256: 177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304 + requires_python: '>=3.9' - conda: https://conda.anaconda.org/conda-forge/linux-64/tornado-6.5.3-py312h4c3975b_0.conda sha256: bed440cad040f0fe76266f9a527feecbaf00385b68a96532aa69614fe5153f8e md5: e03a4bf52d2170d64c816b2a52972097 diff --git a/pixi.toml b/pixi.toml index 3641456..542f534 100644 --- a/pixi.toml +++ b/pixi.toml @@ -26,6 +26,7 @@ setuptools-scm = "*" fastmcp = ">=3.0" panel-material-ui = "*" playwright = "*" +tomlkit = "*" [feature.test.dependencies] pytest = ">=6" diff --git a/pyproject.toml b/pyproject.toml index ca809ce..d1efbf5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ dependencies = [ "typer", "ruff", "playwright", + "tomlkit", ] [project.scripts] diff --git a/src/panel_live_server/cli.py b/src/panel_live_server/cli.py index 54c37dd..0f01d75 100644 --- a/src/panel_live_server/cli.py +++ b/src/panel_live_server/cli.py @@ -26,12 +26,25 @@ from panel_live_server.config import reset_config from panel_live_server.install import SERVER_NAME from panel_live_server.install import InstallError +from panel_live_server.install import antigravity_config_path from panel_live_server.install import claude_desktop_config_path +from panel_live_server.install import cline_config_path +from panel_live_server.install import codex_config_path +from panel_live_server.install import copilot_config_path from panel_live_server.install import cursor_config_path +from panel_live_server.install import gemini_cli_config_path +from panel_live_server.install import jetbrains_config_path +from panel_live_server.install import kilo_code_config_path +from panel_live_server.install import kiro_config_path +from panel_live_server.install import merge_codex_server +from panel_live_server.install import merge_kilo_code_server from panel_live_server.install import merge_mcp_server +from panel_live_server.install import merge_mistral_vibe_server +from panel_live_server.install import mistral_vibe_config_path from panel_live_server.install import register_with_claude_code from panel_live_server.install import resolve_pls_command from panel_live_server.install import vscode_config_path +from panel_live_server.install import windsurf_config_path from panel_live_server.prompts import render_instructions from panel_live_server.screenshot import install_browser as _install_browser @@ -325,6 +338,7 @@ def _register_in_json_config( servers_key: str, entry_type: str, restart_hint: str, + extra_fields: dict | None = None, ) -> None: """Shared body for the clients configured by a JSON file on disk.""" try: @@ -337,6 +351,7 @@ def _register_in_json_config( args, servers_key=servers_key, entry_type=entry_type, + extra_fields=extra_fields, ) except InstallError as exc: typer.echo(f"Error: {exc}", err=True) @@ -445,6 +460,263 @@ def install_claude_code( typer.echo(f" {ran}") +@install_app.command(name="windsurf") +def install_windsurf( + command: str = _COMMAND_OPTION, + prompts: str = _PROMPTS_OPTION, + config_path: str = _CONFIG_PATH_OPTION, +) -> None: + """Register panel-live-server with Windsurf. + + Writes to `~/.codeium/windsurf/mcp_config.json`, leaving any other server + there untouched. + """ + _register_in_json_config( + command=command, + prompts=prompts, + config_path=config_path, + default_path=windsurf_config_path, + servers_key="mcpServers", + entry_type="", + restart_hint="Open the MCP servers panel (hammer icon) in Cascade and confirm panel-live-server is running.", + ) + + +@install_app.command(name="cline") +def install_cline( + command: str = _COMMAND_OPTION, + prompts: str = _PROMPTS_OPTION, + config_path: str = _CONFIG_PATH_OPTION, +) -> None: + """Register panel-live-server with Cline. + + Cline stores MCP servers in VS Code's per-extension global storage rather + than `.vscode/mcp.json`, so this is a separate config file from the `vscode` + command even when both run in the same editor. + """ + _register_in_json_config( + command=command, + prompts=prompts, + config_path=config_path, + default_path=cline_config_path, + servers_key="mcpServers", + entry_type="", + restart_hint="Open Cline's MCP Servers panel and confirm panel-live-server is connected.", + ) + + +@install_app.command(name="jetbrains") +def install_jetbrains( + command: str = _COMMAND_OPTION, + prompts: str = _PROMPTS_OPTION, + config_path: str = _CONFIG_PATH_OPTION, +) -> None: + """Register panel-live-server with Junie (JetBrains AI Assistant). + + Writes to `~/.junie/mcp/mcp.json`, which applies to every project opened in + the IDE. Add an `.junie/mcp/mcp.json` in a specific project instead if you + only want it there. + """ + _register_in_json_config( + command=command, + prompts=prompts, + config_path=config_path, + default_path=jetbrains_config_path, + servers_key="mcpServers", + entry_type="", + restart_hint="Restart the IDE, or reopen Junie's MCP settings, for the change to take effect.", + ) + + +@install_app.command(name="gemini-cli") +def install_gemini_cli( + command: str = _COMMAND_OPTION, + prompts: str = _PROMPTS_OPTION, + config_path: str = _CONFIG_PATH_OPTION, +) -> None: + """Register panel-live-server with the Gemini CLI. + + Writes to `~/.gemini/settings.json`, leaving every other setting there + (theme, auth, other servers) untouched. + """ + _register_in_json_config( + command=command, + prompts=prompts, + config_path=config_path, + default_path=gemini_cli_config_path, + servers_key="mcpServers", + entry_type="", + restart_hint="Restart the Gemini CLI for the change to take effect.", + ) + + +@install_app.command(name="antigravity") +def install_antigravity( + command: str = _COMMAND_OPTION, + prompts: str = _PROMPTS_OPTION, + config_path: str = _CONFIG_PATH_OPTION, +) -> None: + """Register panel-live-server with Google Antigravity. + + Writes to `~/.gemini/config/mcp_config.json`, Antigravity's global MCP + config. Add an `.agents/mcp_config.json` in a specific project instead if + you only want it there. + """ + _register_in_json_config( + command=command, + prompts=prompts, + config_path=config_path, + default_path=antigravity_config_path, + servers_key="mcpServers", + entry_type="", + restart_hint="Open Manage MCP Servers in Antigravity and confirm panel-live-server is connected.", + ) + + +@install_app.command(name="kiro") +def install_kiro( + command: str = _COMMAND_OPTION, + prompts: str = _PROMPTS_OPTION, + config_path: str = _CONFIG_PATH_OPTION, +) -> None: + """Register panel-live-server with Kiro. + + Writes to `~/.kiro/settings/mcp.json`, which applies to every workspace. + Add a `.kiro/settings/mcp.json` in a specific project instead if you only + want it there. + """ + _register_in_json_config( + command=command, + prompts=prompts, + config_path=config_path, + default_path=kiro_config_path, + servers_key="mcpServers", + entry_type="", + restart_hint="Reload Kiro, or open the MCP panel, for the change to take effect.", + ) + + +@install_app.command(name="copilot") +def install_copilot( + command: str = _COMMAND_OPTION, + prompts: str = _PROMPTS_OPTION, + config_path: str = _CONFIG_PATH_OPTION, +) -> None: + """Register panel-live-server with the GitHub Copilot CLI. + + Writes to `~/.copilot/mcp-config.json` (or `$COPILOT_HOME/mcp-config.json` + if set). This is the standalone `copilot` CLI, not VS Code's Copilot Chat, + which already reads `.vscode/mcp.json` via the `vscode` command. + """ + _register_in_json_config( + command=command, + prompts=prompts, + config_path=config_path, + default_path=copilot_config_path, + servers_key="mcpServers", + entry_type="local", + extra_fields={"tools": ["*"]}, + restart_hint="Run `/mcp show` in the Copilot CLI to confirm panel-live-server is connected.", + ) + + +@install_app.command(name="kilo-code") +def install_kilo_code( + command: str = _COMMAND_OPTION, + prompts: str = _PROMPTS_OPTION, + config_path: str = _CONFIG_PATH_OPTION, +) -> None: + """Register panel-live-server with Kilo Code. + + Writes to `~/.config/kilo/kilo.jsonc`. Kilo Code packs the command and its + arguments into one array rather than separate `command`/`args` fields, so + the entry looks different from every other client here. + """ + try: + pls_command = command or resolve_pls_command() + path = Path(config_path).expanduser() if config_path else kilo_code_config_path() + args = ["mcp", "--prompts", str(Path(prompts).expanduser())] if prompts else ["mcp"] + already_installed, entry = merge_kilo_code_server(path, pls_command, args) + except InstallError as exc: + typer.echo(f"Error: {exc}", err=True) + raise typer.Exit(1) from None + + if already_installed: + typer.echo(f"{SERVER_NAME} is already registered in {path}") + else: + typer.echo(f"Registered {SERVER_NAME} in {path}") + typer.echo("") + typer.echo(json.dumps({"mcp": {SERVER_NAME: entry}}, indent=2)) + typer.echo("") + typer.echo("Reload the window for the change to take effect.") + + +@install_app.command(name="codex") +def install_codex( + command: str = _COMMAND_OPTION, + prompts: str = _PROMPTS_OPTION, + config_path: str = _CONFIG_PATH_OPTION, +) -> None: + """Register panel-live-server with the OpenAI Codex CLI. + + Writes to `~/.codex/config.toml`, leaving every other setting and server + there untouched. + """ + try: + pls_command = command or resolve_pls_command() + path = Path(config_path).expanduser() if config_path else codex_config_path() + args = ["mcp", "--prompts", str(Path(prompts).expanduser())] if prompts else ["mcp"] + already_installed, entry = merge_codex_server(path, pls_command, args) + except InstallError as exc: + typer.echo(f"Error: {exc}", err=True) + raise typer.Exit(1) from None + + if already_installed: + typer.echo(f"{SERVER_NAME} is already registered in {path}") + else: + typer.echo(f"Registered {SERVER_NAME} in {path}") + typer.echo("") + typer.echo(f"[mcp_servers.{SERVER_NAME}]") + typer.echo(f'command = "{entry["command"]}"') + typer.echo(f"args = {json.dumps(entry['args'])}") + typer.echo("") + typer.echo("Restart the Codex CLI for the change to take effect.") + + +@install_app.command(name="mistral-vibe") +def install_mistral_vibe( + command: str = _COMMAND_OPTION, + prompts: str = _PROMPTS_OPTION, + config_path: str = _CONFIG_PATH_OPTION, +) -> None: + """Register panel-live-server with Mistral Vibe. + + Writes to `~/.vibe/config.toml`, leaving every other setting and server + there untouched. + """ + try: + pls_command = command or resolve_pls_command() + path = Path(config_path).expanduser() if config_path else mistral_vibe_config_path() + args = ["mcp", "--prompts", str(Path(prompts).expanduser())] if prompts else ["mcp"] + already_installed, entry = merge_mistral_vibe_server(path, pls_command, args) + except InstallError as exc: + typer.echo(f"Error: {exc}", err=True) + raise typer.Exit(1) from None + + if already_installed: + typer.echo(f"{SERVER_NAME} is already registered in {path}") + else: + typer.echo(f"Registered {SERVER_NAME} in {path}") + typer.echo("") + typer.echo("[[mcp_servers]]") + typer.echo(f'name = "{entry["name"]}"') + typer.echo(f'transport = "{entry["transport"]}"') + typer.echo(f'command = "{entry["command"]}"') + typer.echo(f"args = {json.dumps(entry['args'])}") + typer.echo("") + typer.echo("Restart Vibe for the change to take effect.") + + @app.command(name="install-browser") def install_browser() -> None: """Download the Chromium browser the `screenshot` MCP tool needs. diff --git a/src/panel_live_server/install.py b/src/panel_live_server/install.py index 01de23e..7621fce 100644 --- a/src/panel_live_server/install.py +++ b/src/panel_live_server/install.py @@ -7,6 +7,9 @@ import sys from pathlib import Path +import tomlkit +from tomlkit.exceptions import TOMLKitError + SERVER_NAME = "panel-live-server" @@ -40,6 +43,74 @@ def vscode_config_path() -> Path: return Path(".vscode/mcp.json") +def windsurf_config_path() -> Path: + """Return Windsurf's MCP config file path.""" + return Path("~/.codeium/windsurf/mcp_config.json").expanduser() + + +def cline_config_path() -> Path: + """Return Cline's MCP config file path for the current OS. + + Cline is a VS Code extension, so it stores its config in VS Code's own + per-extension global storage rather than a location of its own. + """ + if sys.platform == "darwin": + return Path("~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json").expanduser() + if sys.platform == "win32": + appdata = os.environ.get("APPDATA") + if not appdata: + raise InstallError("APPDATA is not set, cannot locate Cline's config.") + return Path(appdata) / "Code" / "User" / "globalStorage" / "saoudrizwan.claude-dev" / "settings" / "cline_mcp_settings.json" + return Path("~/.config/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json").expanduser() + + +def jetbrains_config_path() -> Path: + """Return Junie's (JetBrains AI Assistant) global MCP config file path.""" + return Path("~/.junie/mcp/mcp.json").expanduser() + + +def gemini_cli_config_path() -> Path: + """Return the Gemini CLI's global settings file path.""" + return Path("~/.gemini/settings.json").expanduser() + + +def antigravity_config_path() -> Path: + """Return Google Antigravity's global MCP config file path.""" + return Path("~/.gemini/config/mcp_config.json").expanduser() + + +def kiro_config_path() -> Path: + """Return Kiro's global MCP config file path.""" + return Path("~/.kiro/settings/mcp.json").expanduser() + + +def copilot_config_path() -> Path: + """Return the GitHub Copilot CLI's MCP config file path. + + Respects ``COPILOT_HOME`` the same way Copilot itself does, since that is + the documented way to relocate it. + """ + home = os.environ.get("COPILOT_HOME") + if home: + return Path(home) / "mcp-config.json" + return Path("~/.copilot/mcp-config.json").expanduser() + + +def kilo_code_config_path() -> Path: + """Return Kilo Code's global config file path.""" + return Path("~/.config/kilo/kilo.jsonc").expanduser() + + +def codex_config_path() -> Path: + """Return the Codex CLI's global config file path.""" + return Path("~/.codex/config.toml").expanduser() + + +def mistral_vibe_config_path() -> Path: + """Return Mistral Vibe's global config file path.""" + return Path("~/.vibe/config.toml").expanduser() + + def resolve_pls_command() -> str: """Return the absolute path to the `pls` that is running right now. @@ -82,6 +153,7 @@ def merge_mcp_server( *, servers_key: str = "mcpServers", entry_type: str = "", + extra_fields: dict | None = None, ) -> tuple[bool, dict]: """Merge a `panel-live-server` entry into an MCP client's config file. @@ -92,7 +164,8 @@ def merge_mcp_server( Clients disagree on the shape: VS Code nests under ``servers`` and wants an explicit ``type``, while Claude Desktop and Cursor use ``mcpServers`` without - one, hence the two keyword arguments. + one, hence the two keyword arguments. ``extra_fields`` covers the rest, e.g. + GitHub Copilot CLI's ``tools`` list. Returns whether the file already held this exact entry (nothing changed), and the entry itself, so callers can show what a hand-written config would need. @@ -109,6 +182,8 @@ def merge_mcp_server( entry: dict = {"type": entry_type} if entry_type else {} entry["command"] = command entry["args"] = _merge_args(servers.get(SERVER_NAME), args) + if extra_fields: + entry.update(extra_fields) if servers.get(SERVER_NAME) == entry: return True, entry @@ -119,6 +194,108 @@ def merge_mcp_server( return False, entry +def merge_kilo_code_server(config_path: Path, command: str, args: list[str]) -> tuple[bool, dict]: + """Merge a `panel-live-server` entry into Kilo Code's config. + + Kilo Code nests servers under ``mcp`` rather than ``mcpServers``, and packs + the binary and its arguments into one ``command`` array instead of separate + ``command``/``args`` fields, so it needs its own merge instead of + ``merge_mcp_server``. The config file is JSONC, but hand-written comments + are rare before a first install, so this reads it as plain JSON and reports + a clear error if that fails, same as every other client here. + """ + if config_path.exists(): + try: + data = json.loads(config_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise InstallError(f"{config_path} is not valid JSON: {exc}") from exc + else: + data = {} + + servers = data.setdefault("mcp", {}) + existing = servers.get(SERVER_NAME) + old_command = existing.get("command") if isinstance(existing, dict) else None + full_args = args + if len(args) <= 1 and isinstance(old_command, list) and len(old_command) > 2: + full_args = args + [a for a in old_command[2:] if isinstance(a, str)] + + entry = {"type": "local", "command": [command, *full_args], "enabled": True} + + if servers.get(SERVER_NAME) == entry: + return True, entry + + servers[SERVER_NAME] = entry + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + return False, entry + + +def merge_codex_server(config_path: Path, command: str, args: list[str]) -> tuple[bool, dict]: + """Merge a `panel-live-server` table into the Codex CLI's config.toml. + + Codex nests each server under ``[mcp_servers.]``, a TOML variant of + the ``mcpServers`` shape ``merge_mcp_server`` handles for JSON clients. + tomlkit is used instead of stdlib ``tomllib`` (which is read-only), so the + rest of a hand-edited config.toml (comments, other tables, formatting) + round-trips untouched. + """ + if config_path.exists(): + try: + doc = tomlkit.parse(config_path.read_text(encoding="utf-8")) + except TOMLKitError as exc: + raise InstallError(f"{config_path} is not valid TOML: {exc}") from exc + else: + doc = tomlkit.document() + + servers = doc.setdefault("mcp_servers", tomlkit.table(is_super_table=True)) + entry = tomlkit.table() + entry["command"] = command + entry["args"] = _merge_args(servers.get(SERVER_NAME), args) + + if servers.get(SERVER_NAME) is not None and dict(servers[SERVER_NAME]) == dict(entry): + return True, dict(entry) + + servers[SERVER_NAME] = entry + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text(tomlkit.dumps(doc), encoding="utf-8") + return False, dict(entry) + + +def merge_mistral_vibe_server(config_path: Path, command: str, args: list[str]) -> tuple[bool, dict]: + """Merge a `panel-live-server` entry into Mistral Vibe's config.toml. + + Vibe lists servers as ``[[mcp_servers]]``, an array of tables identified by + a ``name`` field rather than keyed by their own table name, so it needs its + own merge instead of ``merge_codex_server``. + """ + if config_path.exists(): + try: + doc = tomlkit.parse(config_path.read_text(encoding="utf-8")) + except TOMLKitError as exc: + raise InstallError(f"{config_path} is not valid TOML: {exc}") from exc + else: + doc = tomlkit.document() + + servers = doc.setdefault("mcp_servers", tomlkit.aot()) + existing = next((s for s in servers if s.get("name") == SERVER_NAME), None) + + entry = tomlkit.table() + entry["name"] = SERVER_NAME + entry["transport"] = "stdio" + entry["command"] = command + entry["args"] = _merge_args(existing, args) + + if existing is not None and dict(existing) == dict(entry): + return True, dict(entry) + + if existing is not None: + servers.remove(existing) + servers.append(entry) + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text(tomlkit.dumps(doc), encoding="utf-8") + return False, dict(entry) + + def register_with_claude_code(command: str, args: list[str]) -> str: """Register the server with Claude Code, which owns its own config format. diff --git a/tests/test_cli.py b/tests/test_cli.py index 8e5ccfb..ca0b7d5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -4,6 +4,7 @@ import os import re +import pytest from typer.testing import CliRunner import panel_live_server.server as server_module @@ -175,6 +176,87 @@ def test_invalid_existing_json_fails_cleanly(self, tmp_path): assert result.exit_code == 1 assert "not valid JSON" in result.output + @pytest.mark.parametrize("client", ["windsurf", "cline", "jetbrains", "gemini-cli", "antigravity", "kiro"]) + def test_plain_mcp_servers_clients_write_the_entry(self, tmp_path, client): + """These all share the plain `mcpServers` shape Claude Desktop/Cursor use.""" + config_path = tmp_path / "mcp.json" + + result = runner.invoke( + app, + ["install", client, "--command", "/usr/local/bin/pls", "--config-path", str(config_path)], + ) + + assert result.exit_code == 0, result.output + data = json.loads(config_path.read_text(encoding="utf-8")) + assert data["mcpServers"]["panel-live-server"] == {"command": "/usr/local/bin/pls", "args": ["mcp"]} + + def test_copilot_writes_a_typed_entry_with_tools(self, tmp_path): + config_path = tmp_path / "mcp-config.json" + + result = runner.invoke( + app, + ["install", "copilot", "--command", "/usr/local/bin/pls", "--config-path", str(config_path)], + ) + + assert result.exit_code == 0, result.output + data = json.loads(config_path.read_text(encoding="utf-8")) + assert data["mcpServers"]["panel-live-server"] == { + "type": "local", + "command": "/usr/local/bin/pls", + "args": ["mcp"], + "tools": ["*"], + } + + def test_kilo_code_writes_a_combined_command_array(self, tmp_path): + config_path = tmp_path / "kilo.jsonc" + + result = runner.invoke( + app, + ["install", "kilo-code", "--command", "/usr/local/bin/pls", "--config-path", str(config_path)], + ) + + assert result.exit_code == 0, result.output + data = json.loads(config_path.read_text(encoding="utf-8")) + assert data["mcp"]["panel-live-server"] == { + "type": "local", + "command": ["/usr/local/bin/pls", "mcp"], + "enabled": True, + } + + def test_codex_writes_a_toml_table(self, tmp_path): + config_path = tmp_path / "config.toml" + + result = runner.invoke( + app, + ["install", "codex", "--command", "/usr/local/bin/pls", "--config-path", str(config_path)], + ) + + assert result.exit_code == 0, result.output + text = config_path.read_text(encoding="utf-8") + assert "[mcp_servers.panel-live-server]" in text + assert 'command = "/usr/local/bin/pls"' in text + + def test_mistral_vibe_writes_an_array_of_tables_entry(self, tmp_path): + config_path = tmp_path / "config.toml" + + result = runner.invoke( + app, + ["install", "mistral-vibe", "--command", "/usr/local/bin/pls", "--config-path", str(config_path)], + ) + + assert result.exit_code == 0, result.output + text = config_path.read_text(encoding="utf-8") + assert "[[mcp_servers]]" in text + assert 'name = "panel-live-server"' in text + + @pytest.mark.parametrize( + "client", + ["windsurf", "cline", "jetbrains", "gemini-cli", "antigravity", "kiro", "copilot", "kilo-code", "codex", "mistral-vibe"], + ) + def test_help_works(self, client): + result = runner.invoke(app, ["install", client, "--help"]) + assert result.exit_code == 0, result.output + class TestPromptsFlag: """`--prompts` must reach the prompt layer before server.py renders instructions.""" diff --git a/tests/test_install.py b/tests/test_install.py index 86a3a9c..f185a5b 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -8,12 +8,25 @@ import pytest from panel_live_server.install import InstallError +from panel_live_server.install import antigravity_config_path from panel_live_server.install import claude_desktop_config_path +from panel_live_server.install import cline_config_path +from panel_live_server.install import codex_config_path +from panel_live_server.install import copilot_config_path from panel_live_server.install import cursor_config_path +from panel_live_server.install import gemini_cli_config_path +from panel_live_server.install import jetbrains_config_path +from panel_live_server.install import kilo_code_config_path +from panel_live_server.install import kiro_config_path +from panel_live_server.install import merge_codex_server +from panel_live_server.install import merge_kilo_code_server from panel_live_server.install import merge_mcp_server +from panel_live_server.install import merge_mistral_vibe_server +from panel_live_server.install import mistral_vibe_config_path from panel_live_server.install import register_with_claude_code from panel_live_server.install import resolve_pls_command from panel_live_server.install import vscode_config_path +from panel_live_server.install import windsurf_config_path class TestMergeMcpServer: @@ -143,6 +156,231 @@ def test_vscode_path_is_project_relative(self): assert vscode_config_path() == Path(".vscode/mcp.json") assert not vscode_config_path().is_absolute() + def test_extra_fields_are_added_to_the_entry(self, tmp_path): + """Copilot CLI needs a `tools` list alongside command/args.""" + path = tmp_path / "mcp-config.json" + + merge_mcp_server(path, "/usr/local/bin/pls", ["mcp"], entry_type="local", extra_fields={"tools": ["*"]}) + + data = json.loads(path.read_text(encoding="utf-8")) + assert data["mcpServers"]["panel-live-server"] == { + "type": "local", + "command": "/usr/local/bin/pls", + "args": ["mcp"], + "tools": ["*"], + } + + def test_extra_fields_stay_idempotent(self, tmp_path): + path = tmp_path / "mcp-config.json" + merge_mcp_server(path, "/usr/local/bin/pls", ["mcp"], entry_type="local", extra_fields={"tools": ["*"]}) + + already_installed, _ = merge_mcp_server(path, "/usr/local/bin/pls", ["mcp"], entry_type="local", extra_fields={"tools": ["*"]}) + + assert already_installed is True + + +class TestNewClientConfigPaths: + """Path resolution for the clients added alongside claude/cursor/vscode/claude-code.""" + + def test_windsurf(self): + assert str(windsurf_config_path()).endswith(".codeium/windsurf/mcp_config.json") + + def test_jetbrains(self): + assert str(jetbrains_config_path()).endswith(".junie/mcp/mcp.json") + + def test_gemini_cli(self): + assert str(gemini_cli_config_path()).endswith(".gemini/settings.json") + + def test_antigravity(self): + assert str(antigravity_config_path()).endswith(".gemini/config/mcp_config.json") + + def test_kiro(self): + assert str(kiro_config_path()).endswith(".kiro/settings/mcp.json") + + def test_kilo_code(self): + assert str(kilo_code_config_path()).endswith(".config/kilo/kilo.jsonc") + + def test_codex(self): + assert str(codex_config_path()).endswith(".codex/config.toml") + + def test_mistral_vibe(self): + assert str(mistral_vibe_config_path()).endswith(".vibe/config.toml") + + def test_copilot_defaults_to_the_dotfile(self, monkeypatch): + monkeypatch.delenv("COPILOT_HOME", raising=False) + assert str(copilot_config_path()).endswith(".copilot/mcp-config.json") + + def test_copilot_respects_copilot_home(self, monkeypatch, tmp_path): + monkeypatch.setenv("COPILOT_HOME", str(tmp_path)) + assert copilot_config_path() == tmp_path / "mcp-config.json" + + def test_cline_macos(self, monkeypatch): + monkeypatch.setattr(sys, "platform", "darwin") + path = cline_config_path() + assert path.name == "cline_mcp_settings.json" + assert "saoudrizwan.claude-dev" in path.parts + + def test_cline_windows(self, monkeypatch): + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setenv("APPDATA", "C:\\Users\\test\\AppData\\Roaming") + path = cline_config_path() + assert path.name == "cline_mcp_settings.json" + + def test_cline_windows_without_appdata_raises(self, monkeypatch): + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.delenv("APPDATA", raising=False) + with pytest.raises(InstallError): + cline_config_path() + + def test_cline_linux(self, monkeypatch): + monkeypatch.setattr(sys, "platform", "linux") + assert str(cline_config_path()).endswith(".config/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json") + + +class TestMergeKiloCodeServer: + """Kilo Code packs command+args into one array under a `mcp` key, not `mcpServers`.""" + + def test_writes_the_entry(self, tmp_path): + path = tmp_path / "kilo.jsonc" + + already_installed, entry = merge_kilo_code_server(path, "/usr/local/bin/pls", ["mcp"]) + + assert already_installed is False + data = json.loads(path.read_text(encoding="utf-8")) + assert data["mcp"]["panel-live-server"] == { + "type": "local", + "command": ["/usr/local/bin/pls", "mcp"], + "enabled": True, + } + assert entry == data["mcp"]["panel-live-server"] + + def test_second_call_with_same_entry_is_a_noop(self, tmp_path): + path = tmp_path / "kilo.jsonc" + merge_kilo_code_server(path, "/usr/local/bin/pls", ["mcp"]) + + already_installed, _ = merge_kilo_code_server(path, "/usr/local/bin/pls", ["mcp"]) + + assert already_installed is True + + def test_preserves_flags_the_existing_entry_passed(self, tmp_path): + path = tmp_path / "kilo.jsonc" + existing = {"type": "local", "command": ["/old/pls", "mcp", "--prompts", "/p.json"], "enabled": True} + path.write_text(json.dumps({"mcp": {"panel-live-server": existing}}), encoding="utf-8") + + merge_kilo_code_server(path, "/new/pls", ["mcp"]) + + entry = json.loads(path.read_text(encoding="utf-8"))["mcp"]["panel-live-server"] + assert entry["command"] == ["/new/pls", "mcp", "--prompts", "/p.json"] + + def test_invalid_json_raises_install_error(self, tmp_path): + path = tmp_path / "kilo.jsonc" + path.write_text("{not json", encoding="utf-8") + + with pytest.raises(InstallError): + merge_kilo_code_server(path, "/usr/local/bin/pls", ["mcp"]) + + +class TestMergeCodexServer: + """Codex nests servers under [mcp_servers.] in a config.toml.""" + + def test_writes_the_entry(self, tmp_path): + path = tmp_path / "config.toml" + + already_installed, entry = merge_codex_server(path, "/usr/local/bin/pls", ["mcp"]) + + assert already_installed is False + assert entry == {"command": "/usr/local/bin/pls", "args": ["mcp"]} + assert "[mcp_servers.panel-live-server]" in path.read_text(encoding="utf-8") + + def test_preserves_other_toml_content(self, tmp_path): + path = tmp_path / "config.toml" + path.write_text('model = "gpt-5"\n\n[mcp_servers.other]\ncommand = "foo"\n', encoding="utf-8") + + merge_codex_server(path, "/usr/local/bin/pls", ["mcp"]) + + text = path.read_text(encoding="utf-8") + assert 'model = "gpt-5"' in text + assert "[mcp_servers.other]" in text + assert "[mcp_servers.panel-live-server]" in text + + def test_second_call_with_same_entry_is_a_noop(self, tmp_path): + path = tmp_path / "config.toml" + merge_codex_server(path, "/usr/local/bin/pls", ["mcp"]) + before = path.read_text(encoding="utf-8") + + already_installed, _ = merge_codex_server(path, "/usr/local/bin/pls", ["mcp"]) + + assert already_installed is True + assert path.read_text(encoding="utf-8") == before + + def test_preserves_flags_the_existing_entry_passed(self, tmp_path): + path = tmp_path / "config.toml" + path.write_text('[mcp_servers.panel-live-server]\ncommand = "/old/pls"\nargs = ["mcp", "--prompts", "/p.json"]\n', encoding="utf-8") + + merge_codex_server(path, "/new/pls", ["mcp"]) + + assert 'args = ["mcp", "--prompts", "/p.json"]' in path.read_text(encoding="utf-8") + + def test_invalid_toml_raises_install_error(self, tmp_path): + path = tmp_path / "config.toml" + path.write_text("not [ valid toml", encoding="utf-8") + + with pytest.raises(InstallError): + merge_codex_server(path, "/usr/local/bin/pls", ["mcp"]) + + +class TestMergeMistralVibeServer: + """Vibe lists servers as [[mcp_servers]] array-of-tables, identified by name.""" + + def test_writes_the_entry(self, tmp_path): + path = tmp_path / "config.toml" + + already_installed, entry = merge_mistral_vibe_server(path, "/usr/local/bin/pls", ["mcp"]) + + assert already_installed is False + assert entry == { + "name": "panel-live-server", + "transport": "stdio", + "command": "/usr/local/bin/pls", + "args": ["mcp"], + } + assert "[[mcp_servers]]" in path.read_text(encoding="utf-8") + + def test_preserves_other_entries_in_the_array(self, tmp_path): + path = tmp_path / "config.toml" + path.write_text('[[mcp_servers]]\nname = "git"\ntransport = "stdio"\ncommand = "uvx"\nargs = []\n', encoding="utf-8") + + merge_mistral_vibe_server(path, "/usr/local/bin/pls", ["mcp"]) + + text = path.read_text(encoding="utf-8") + assert 'name = "git"' in text + assert 'name = "panel-live-server"' in text + + def test_second_call_with_same_entry_is_a_noop(self, tmp_path): + path = tmp_path / "config.toml" + merge_mistral_vibe_server(path, "/usr/local/bin/pls", ["mcp"]) + + already_installed, _ = merge_mistral_vibe_server(path, "/usr/local/bin/pls", ["mcp"]) + + assert already_installed is True + + def test_updates_a_stale_entry_without_duplicating_it(self, tmp_path): + path = tmp_path / "config.toml" + merge_mistral_vibe_server(path, "/old/pls", ["mcp"]) + + merge_mistral_vibe_server(path, "/new/pls", ["mcp"]) + + text = path.read_text(encoding="utf-8") + assert text.count("[[mcp_servers]]") == 1 + assert '"/new/pls"' in text + + def test_invalid_toml_raises_install_error(self, tmp_path): + path = tmp_path / "config.toml" + path.write_text("not [ valid toml", encoding="utf-8") + + with pytest.raises(InstallError): + merge_mistral_vibe_server(path, "/usr/local/bin/pls", ["mcp"]) + class TestResolvePlsCommand: """The `pls` running the command wins over whatever PATH happens to hold.""" From 39ba0cc21a798fe84a56cce36cb98bf126475300 Mon Sep 17 00:00:00 2001 From: SuMayaBee Date: Fri, 21 Aug 2026 00:50:39 +0600 Subject: [PATCH 2/3] fix: correct MCP client config paths and stop clobbering existing entries --- docs/tutorials/installation.md | 33 ++++-- src/panel_live_server/cli.py | 25 +++-- src/panel_live_server/install.py | 148 ++++++++++++++++++++++----- tests/test_install.py | 169 +++++++++++++++++++++++++++---- 4 files changed, 308 insertions(+), 67 deletions(-) diff --git a/docs/tutorials/installation.md b/docs/tutorials/installation.md index 7acda0f..a638460 100644 --- a/docs/tutorials/installation.md +++ b/docs/tutorials/installation.md @@ -280,7 +280,15 @@ directory is on your PATH, run `uv tool update-shell` and restart your terminal. pls install windsurf ``` - To set it up by hand instead, add to `~/.codeium/windsurf/mcp_config.json`: + That covers the Windsurf editor. If you run Cascade as a plugin inside another + IDE, it reads a different file, so point the command at it: + + ```bash + pls install windsurf --config-path ~/.codeium/mcp_config.json + ``` + + To set it up by hand instead, add to `~/.codeium/windsurf/mcp_config.json` + (editor) or `~/.codeium/mcp_config.json` (plugin): ```json { @@ -305,9 +313,12 @@ directory is on your PATH, run `uv tool update-shell` and restart your terminal. pls install cline ``` - Cline stores this separately from `.vscode/mcp.json`, in VS Code's own - per-extension storage. To set it up by hand instead, add to - `cline_mcp_settings.json` (open it from Cline's "Edit MCP Settings" button): + Cline stores this separately from `.vscode/mcp.json`, so running `pls install + vscode` does not configure Cline. One file serves every editor Cline runs in, + VS Code, Insiders, Cursor, and VSCodium alike. + + To set it up by hand instead, add to + `~/.cline/data/settings/cline_mcp_settings.json`: ```json { @@ -332,7 +343,11 @@ directory is on your PATH, run `uv tool update-shell` and restart your terminal. pls install jetbrains ``` - To set it up by hand instead, add to `~/.junie/mcp/mcp.json`: + This sets up **Junie**. JetBrains AI Assistant is a separate product with no + config file to write: add the server there from Settings → Tools → AI Assistant + → Model Context Protocol, using the same `command` and `args` shown below. + + To set Junie up by hand instead, add to `~/.junie/mcp/mcp.json`: ```json { @@ -383,7 +398,7 @@ directory is on your PATH, run `uv tool update-shell` and restart your terminal. pls install antigravity ``` - To set it up by hand instead, add to `~/.gemini/config/mcp_config.json`: + To set it up by hand instead, add to `~/.gemini/antigravity/mcp_config.json`: ```json { @@ -400,6 +415,12 @@ directory is on your PATH, run `uv tool update-shell` and restart your terminal. Replace `"command": "/path/to/pls"` with the path printed by `which pls` above, e.g. `"command": "/home/user/.local/bin/pls"` + !!! note "Two possible locations" + Antigravity's own docs give this path as `~/.gemini/config/mcp_config.json`, + but the shipping build reads `~/.gemini/antigravity/mcp_config.json`. The + command writes whichever one your install already uses, so prefer it over + editing by hand if you are unsure. + Open Manage MCP Servers in Antigravity and confirm it's connected. === "Kiro" diff --git a/src/panel_live_server/cli.py b/src/panel_live_server/cli.py index 0f01d75..a7a7054 100644 --- a/src/panel_live_server/cli.py +++ b/src/panel_live_server/cli.py @@ -466,10 +466,11 @@ def install_windsurf( prompts: str = _PROMPTS_OPTION, config_path: str = _CONFIG_PATH_OPTION, ) -> None: - """Register panel-live-server with Windsurf. + """Register panel-live-server with the Windsurf editor. Writes to `~/.codeium/windsurf/mcp_config.json`, leaving any other server - there untouched. + there untouched. To configure the Cascade plugin inside another IDE instead, + point this at its own file: `--config-path ~/.codeium/mcp_config.json`. """ _register_in_json_config( command=command, @@ -490,9 +491,9 @@ def install_cline( ) -> None: """Register panel-live-server with Cline. - Cline stores MCP servers in VS Code's per-extension global storage rather - than `.vscode/mcp.json`, so this is a separate config file from the `vscode` - command even when both run in the same editor. + Writes to `~/.cline/data/settings/cline_mcp_settings.json`, which Cline reads + from whichever editor it runs in. This is a separate file from the one the + `vscode` command writes, so running one does not configure the other. """ _register_in_json_config( command=command, @@ -511,11 +512,12 @@ def install_jetbrains( prompts: str = _PROMPTS_OPTION, config_path: str = _CONFIG_PATH_OPTION, ) -> None: - """Register panel-live-server with Junie (JetBrains AI Assistant). + """Register panel-live-server with Junie. Writes to `~/.junie/mcp/mcp.json`, which applies to every project opened in - the IDE. Add an `.junie/mcp/mcp.json` in a specific project instead if you - only want it there. + the IDE. Add a `.junie/mcp/mcp.json` in a specific project instead if you + only want it there. JetBrains AI Assistant is a separate product configured + from Settings, Tools, AI Assistant, Model Context Protocol, not from a file. """ _register_in_json_config( command=command, @@ -558,9 +560,10 @@ def install_antigravity( ) -> None: """Register panel-live-server with Google Antigravity. - Writes to `~/.gemini/config/mcp_config.json`, Antigravity's global MCP - config. Add an `.agents/mcp_config.json` in a specific project instead if - you only want it there. + Writes to `~/.gemini/antigravity/mcp_config.json`, which is what the shipping + build reads, falling back to the `~/.gemini/config/mcp_config.json` the docs + describe when that is the file already present. Add an + `.agents/mcp_config.json` in a project instead if you only want it there. """ _register_in_json_config( command=command, diff --git a/src/panel_live_server/install.py b/src/panel_live_server/install.py index 7621fce..1f3147f 100644 --- a/src/panel_live_server/install.py +++ b/src/panel_live_server/install.py @@ -9,6 +9,8 @@ import tomlkit from tomlkit.exceptions import TOMLKitError +from tomlkit.items import AoT +from tomlkit.items import Table SERVER_NAME = "panel-live-server" @@ -44,28 +46,43 @@ def vscode_config_path() -> Path: def windsurf_config_path() -> Path: - """Return Windsurf's MCP config file path.""" + """Return the Windsurf editor's MCP config file path. + + The editor and the Cascade plugin (Windsurf inside another IDE) keep + separate files, and only differ by the ``windsurf`` segment. This returns + the editor's; plugin users pass ``--config-path ~/.codeium/mcp_config.json``. + """ return Path("~/.codeium/windsurf/mcp_config.json").expanduser() def cline_config_path() -> Path: - """Return Cline's MCP config file path for the current OS. - - Cline is a VS Code extension, so it stores its config in VS Code's own - per-extension global storage rather than a location of its own. + """Return Cline's MCP config file path. + + Cline 4 moved this out of VS Code's per-extension storage and into a home + directory of its own, so one file now serves the VS Code extension, the CLI, + and the JetBrains plugin alike. That also means no per-OS branching and no + special case for VS Code forks: Insiders, Cursor, and VSCodium all read the + same path. The old ``globalStorage/saoudrizwan.claude-dev`` location is now + only a migration source, so writing there would have no effect. """ - if sys.platform == "darwin": - return Path("~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json").expanduser() - if sys.platform == "win32": - appdata = os.environ.get("APPDATA") - if not appdata: - raise InstallError("APPDATA is not set, cannot locate Cline's config.") - return Path(appdata) / "Code" / "User" / "globalStorage" / "saoudrizwan.claude-dev" / "settings" / "cline_mcp_settings.json" - return Path("~/.config/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json").expanduser() + if explicit := os.environ.get("CLINE_MCP_SETTINGS_PATH", "").strip(): + return Path(explicit).expanduser() + if data_dir := os.environ.get("CLINE_DATA_DIR", "").strip(): + base = Path(data_dir).expanduser() + elif cline_dir := os.environ.get("CLINE_DIR", "").strip(): + base = Path(cline_dir).expanduser() / "data" + else: + base = Path("~/.cline/data").expanduser() + return base / "settings" / "cline_mcp_settings.json" def jetbrains_config_path() -> Path: - """Return Junie's (JetBrains AI Assistant) global MCP config file path.""" + """Return Junie's global MCP config file path. + + Junie, not JetBrains AI Assistant: the two are separate products, and only + Junie documents a config file. AI Assistant is set up from the IDE settings + UI instead, so there is nothing for us to write there. + """ return Path("~/.junie/mcp/mcp.json").expanduser() @@ -75,8 +92,21 @@ def gemini_cli_config_path() -> Path: def antigravity_config_path() -> Path: - """Return Google Antigravity's global MCP config file path.""" - return Path("~/.gemini/config/mcp_config.json").expanduser() + """Return Google Antigravity's global MCP config file path. + + Antigravity's docs give this as ``~/.gemini/config/mcp_config.json``, but the + shipping build reads ``~/.gemini/antigravity/mcp_config.json`` instead: that + is the directory it calls its data directory, and the string + ``.gemini/config`` appears nowhere in the application bundle. Since the two + layouts presumably belong to different versions, an existing file wins over + either default, so whichever one the installed build already uses is the one + that gets updated. + """ + installed = Path("~/.gemini/antigravity/mcp_config.json").expanduser() + documented = Path("~/.gemini/config/mcp_config.json").expanduser() + if not installed.exists() and documented.exists(): + return documented + return installed def kiro_config_path() -> Path: @@ -102,13 +132,17 @@ def kilo_code_config_path() -> Path: def codex_config_path() -> Path: - """Return the Codex CLI's global config file path.""" - return Path("~/.codex/config.toml").expanduser() + """Return the Codex CLI's global config file path, honouring ``CODEX_HOME``.""" + home = os.environ.get("CODEX_HOME", "").strip() + base = Path(home).expanduser() if home else Path("~/.codex").expanduser() + return base / "config.toml" def mistral_vibe_config_path() -> Path: - """Return Mistral Vibe's global config file path.""" - return Path("~/.vibe/config.toml").expanduser() + """Return Mistral Vibe's global config file path, honouring ``VIBE_HOME``.""" + home = os.environ.get("VIBE_HOME", "").strip() + base = Path(home).expanduser() if home else Path("~/.vibe").expanduser() + return base / "config.toml" def resolve_pls_command() -> str: @@ -167,6 +201,11 @@ def merge_mcp_server( one, hence the two keyword arguments. ``extra_fields`` covers the rest, e.g. GitHub Copilot CLI's ``tools`` list. + Fields on an existing entry that this does not manage are carried over rather + than rebuilt away. Several clients write their own alongside ours (Kiro adds + ``disabled`` and ``autoApprove``, others add ``env`` or ``timeout``), and + re-running an install should not silently reset a server the user had tuned. + Returns whether the file already held this exact entry (nothing changed), and the entry itself, so callers can show what a hand-written config would need. """ @@ -179,13 +218,17 @@ def merge_mcp_server( data = {} servers = data.setdefault(servers_key, {}) - entry: dict = {"type": entry_type} if entry_type else {} + existing = servers.get(SERVER_NAME) + entry: dict = dict(existing) if isinstance(existing, dict) else {} + if entry_type: + entry["type"] = entry_type entry["command"] = command - entry["args"] = _merge_args(servers.get(SERVER_NAME), args) - if extra_fields: - entry.update(extra_fields) + entry["args"] = _merge_args(existing, args) + # setdefault, not update: a `tools` list the user narrowed by hand is theirs. + for key, value in (extra_fields or {}).items(): + entry.setdefault(key, value) - if servers.get(SERVER_NAME) == entry: + if existing == entry: return True, entry servers[SERVER_NAME] = entry @@ -219,9 +262,14 @@ def merge_kilo_code_server(config_path: Path, command: str, args: list[str]) -> if len(args) <= 1 and isinstance(old_command, list) and len(old_command) > 2: full_args = args + [a for a in old_command[2:] if isinstance(a, str)] - entry = {"type": "local", "command": [command, *full_args], "enabled": True} + # Same as merge_mcp_server: keep fields we do not manage, e.g. `environment` + # or a `timeout` the user raised for a slow start-up. + entry: dict = dict(existing) if isinstance(existing, dict) else {} + entry["type"] = "local" + entry["command"] = [command, *full_args] + entry.setdefault("enabled", True) - if servers.get(SERVER_NAME) == entry: + if existing == entry: return True, entry servers[SERVER_NAME] = entry @@ -248,6 +296,15 @@ def merge_codex_server(config_path: Path, command: str, args: list[str]) -> tupl doc = tomlkit.document() servers = doc.setdefault("mcp_servers", tomlkit.table(is_super_table=True)) + # A config can legally hold `mcp_servers = []` or `mcp_servers = {}` instead + # of the `[mcp_servers.]` tables Codex documents. Writing a sub-table + # into either produces TOML that no longer parses, so refuse rather than + # hand back a config the client can no longer read. + if not isinstance(servers, Table): + raise InstallError( + f"{config_path} has an `mcp_servers` entry that is not a table. Remove that line (or convert it to `[mcp_servers.]` tables), then re-run this." + ) + entry = tomlkit.table() entry["command"] = command entry["args"] = _merge_args(servers.get(SERVER_NAME), args) @@ -261,6 +318,41 @@ def merge_codex_server(config_path: Path, command: str, args: list[str]) -> tupl return False, dict(entry) +def _vibe_servers_aot(doc, config_path: Path) -> AoT: + """Return Vibe's ``mcp_servers`` as an array of tables, converting if needed. + + ``mcp_servers = []`` is the same key in a different TOML shape, and a config + can hold it either because a tool wrote it or because someone cleared the + list by hand. Appending a table to that array writes a file that no longer + parses, so the array form is rewritten as a real ``[[mcp_servers]]`` first. + Entries already written inline are carried across rather than dropped. + """ + servers = doc.get("mcp_servers") + if isinstance(servers, AoT): + return servers + if servers is None: + return doc.setdefault("mcp_servers", tomlkit.aot()) + + try: + existing = list(servers) + except TypeError: + existing = None + if existing is None or any(not isinstance(s, dict) for s in existing): + raise InstallError( + f"{config_path} has an `mcp_servers` entry that is not a list of servers. " + f"Remove that line (or convert it to `[[mcp_servers]]` entries), then re-run this." + ) + + converted = tomlkit.aot() + for server in existing: + table = tomlkit.table() + for key, value in server.items(): + table[key] = value + converted.append(table) + doc["mcp_servers"] = converted + return doc["mcp_servers"] + + def merge_mistral_vibe_server(config_path: Path, command: str, args: list[str]) -> tuple[bool, dict]: """Merge a `panel-live-server` entry into Mistral Vibe's config.toml. @@ -276,7 +368,7 @@ def merge_mistral_vibe_server(config_path: Path, command: str, args: list[str]) else: doc = tomlkit.document() - servers = doc.setdefault("mcp_servers", tomlkit.aot()) + servers = _vibe_servers_aot(doc, config_path) existing = next((s for s in servers if s.get("name") == SERVER_NAME), None) entry = tomlkit.table() diff --git a/tests/test_install.py b/tests/test_install.py index f185a5b..e523773 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -3,6 +3,7 @@ import json import subprocess import sys +import tomllib from pathlib import Path import pytest @@ -105,6 +106,37 @@ def test_preserving_flags_stays_idempotent(self, tmp_path): assert merge_mcp_server(path, "/usr/local/bin/pls", ["mcp"])[0] is True + def test_preserves_fields_the_client_added_itself(self, tmp_path): + """Kiro writes `disabled`/`autoApprove` next to ours, and a re-run was resetting them.""" + path = tmp_path / "mcp.json" + existing = {"command": "/old/pls", "args": ["mcp"], "disabled": False, "autoApprove": ["show"]} + path.write_text(json.dumps({"mcpServers": {"panel-live-server": existing}}), encoding="utf-8") + + merge_mcp_server(path, "/new/pls", ["mcp"]) + + entry = json.loads(path.read_text(encoding="utf-8"))["mcpServers"]["panel-live-server"] + assert entry["command"] == "/new/pls" + assert entry["disabled"] is False + assert entry["autoApprove"] == ["show"] + + def test_preserving_client_fields_stays_idempotent(self, tmp_path): + path = tmp_path / "mcp.json" + existing = {"command": "/usr/local/bin/pls", "args": ["mcp"], "disabled": False, "autoApprove": []} + path.write_text(json.dumps({"mcpServers": {"panel-live-server": existing}}), encoding="utf-8") + + assert merge_mcp_server(path, "/usr/local/bin/pls", ["mcp"])[0] is True + + def test_does_not_clobber_an_extra_field_the_user_narrowed(self, tmp_path): + """`tools: ["*"]` is our default, not an override of a hand-picked list.""" + path = tmp_path / "mcp-config.json" + existing = {"type": "local", "command": "/old/pls", "args": ["mcp"], "tools": ["show"]} + path.write_text(json.dumps({"mcpServers": {"panel-live-server": existing}}), encoding="utf-8") + + merge_mcp_server(path, "/new/pls", ["mcp"], entry_type="local", extra_fields={"tools": ["*"]}) + + entry = json.loads(path.read_text(encoding="utf-8"))["mcpServers"]["panel-live-server"] + assert entry["tools"] == ["show"] + def test_preserves_top_level_keys_besides_mcp_servers(self, tmp_path): path = tmp_path / "claude_desktop_config.json" path.write_text(json.dumps({"mcpServers": {}, "globalShortcut": "Cmd+Shift+Space"}), encoding="utf-8") @@ -191,8 +223,27 @@ def test_jetbrains(self): def test_gemini_cli(self): assert str(gemini_cli_config_path()).endswith(".gemini/settings.json") - def test_antigravity(self): - assert str(antigravity_config_path()).endswith(".gemini/config/mcp_config.json") + def test_antigravity_defaults_to_the_path_the_shipping_build_reads(self, monkeypatch, tmp_path): + monkeypatch.setenv("HOME", str(tmp_path)) + assert str(antigravity_config_path()).endswith(".gemini/antigravity/mcp_config.json") + + def test_antigravity_falls_back_to_the_documented_path_when_that_is_the_one_present(self, monkeypatch, tmp_path): + """The docs and the shipped app disagree, so an existing file decides.""" + monkeypatch.setenv("HOME", str(tmp_path)) + documented = tmp_path / ".gemini" / "config" / "mcp_config.json" + documented.parent.mkdir(parents=True) + documented.write_text("{}", encoding="utf-8") + + assert antigravity_config_path() == documented + + def test_antigravity_prefers_the_installed_path_when_both_exist(self, monkeypatch, tmp_path): + monkeypatch.setenv("HOME", str(tmp_path)) + for sub in ("config", "antigravity"): + path = tmp_path / ".gemini" / sub / "mcp_config.json" + path.parent.mkdir(parents=True) + path.write_text("{}", encoding="utf-8") + + assert str(antigravity_config_path()).endswith(".gemini/antigravity/mcp_config.json") def test_kiro(self): assert str(kiro_config_path()).endswith(".kiro/settings/mcp.json") @@ -200,12 +251,22 @@ def test_kiro(self): def test_kilo_code(self): assert str(kilo_code_config_path()).endswith(".config/kilo/kilo.jsonc") - def test_codex(self): + def test_codex(self, monkeypatch): + monkeypatch.delenv("CODEX_HOME", raising=False) assert str(codex_config_path()).endswith(".codex/config.toml") - def test_mistral_vibe(self): + def test_codex_honours_codex_home(self, monkeypatch, tmp_path): + monkeypatch.setenv("CODEX_HOME", str(tmp_path)) + assert codex_config_path() == tmp_path / "config.toml" + + def test_mistral_vibe(self, monkeypatch): + monkeypatch.delenv("VIBE_HOME", raising=False) assert str(mistral_vibe_config_path()).endswith(".vibe/config.toml") + def test_mistral_vibe_honours_vibe_home(self, monkeypatch, tmp_path): + monkeypatch.setenv("VIBE_HOME", str(tmp_path)) + assert mistral_vibe_config_path() == tmp_path / "config.toml" + def test_copilot_defaults_to_the_dotfile(self, monkeypatch): monkeypatch.delenv("COPILOT_HOME", raising=False) assert str(copilot_config_path()).endswith(".copilot/mcp-config.json") @@ -214,27 +275,32 @@ def test_copilot_respects_copilot_home(self, monkeypatch, tmp_path): monkeypatch.setenv("COPILOT_HOME", str(tmp_path)) assert copilot_config_path() == tmp_path / "mcp-config.json" - def test_cline_macos(self, monkeypatch): - monkeypatch.setattr(sys, "platform", "darwin") - path = cline_config_path() - assert path.name == "cline_mcp_settings.json" - assert "saoudrizwan.claude-dev" in path.parts + @pytest.fixture(autouse=True) + def _no_cline_env(self, monkeypatch): + """Cline reads its location from the environment, so clear it before asserting defaults.""" + for var in ("CLINE_MCP_SETTINGS_PATH", "CLINE_DATA_DIR", "CLINE_DIR"): + monkeypatch.delenv(var, raising=False) - def test_cline_windows(self, monkeypatch): - monkeypatch.setattr(sys, "platform", "win32") - monkeypatch.setenv("APPDATA", "C:\\Users\\test\\AppData\\Roaming") - path = cline_config_path() - assert path.name == "cline_mcp_settings.json" + def test_cline_defaults_below_the_home_directory(self): + assert str(cline_config_path()).endswith(".cline/data/settings/cline_mcp_settings.json") - def test_cline_windows_without_appdata_raises(self, monkeypatch): - monkeypatch.setattr(sys, "platform", "win32") - monkeypatch.delenv("APPDATA", raising=False) - with pytest.raises(InstallError): - cline_config_path() + @pytest.mark.parametrize("platform", ["darwin", "win32", "linux"]) + def test_cline_does_not_branch_on_the_platform(self, monkeypatch, platform): + """Cline 4 shares one file across editors and OSes, unlike Claude Desktop.""" + monkeypatch.setattr(sys, "platform", platform) + assert str(cline_config_path()).endswith(".cline/data/settings/cline_mcp_settings.json") - def test_cline_linux(self, monkeypatch): - monkeypatch.setattr(sys, "platform", "linux") - assert str(cline_config_path()).endswith(".config/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json") + def test_cline_honours_an_explicit_settings_path(self, monkeypatch, tmp_path): + monkeypatch.setenv("CLINE_MCP_SETTINGS_PATH", str(tmp_path / "custom.json")) + assert cline_config_path() == tmp_path / "custom.json" + + def test_cline_honours_the_data_dir(self, monkeypatch, tmp_path): + monkeypatch.setenv("CLINE_DATA_DIR", str(tmp_path)) + assert cline_config_path() == tmp_path / "settings" / "cline_mcp_settings.json" + + def test_cline_honours_the_cline_dir(self, monkeypatch, tmp_path): + monkeypatch.setenv("CLINE_DIR", str(tmp_path)) + assert cline_config_path() == tmp_path / "data" / "settings" / "cline_mcp_settings.json" class TestMergeKiloCodeServer: @@ -328,6 +394,26 @@ def test_invalid_toml_raises_install_error(self, tmp_path): with pytest.raises(InstallError): merge_codex_server(path, "/usr/local/bin/pls", ["mcp"]) + def test_hyphenated_name_round_trips_through_a_strict_parser(self, tmp_path): + """`panel-live-server` is a bare key with hyphens, so check a real TOML reader accepts it.""" + path = tmp_path / "config.toml" + + merge_codex_server(path, "/usr/local/bin/pls", ["mcp"]) + + parsed = tomllib.loads(path.read_text(encoding="utf-8")) + assert parsed["mcp_servers"]["panel-live-server"]["command"] == "/usr/local/bin/pls" + + @pytest.mark.parametrize("existing", ["mcp_servers = []\n", "mcp_servers = {}\n", "mcp_servers = 42\n"]) + def test_refuses_a_non_table_mcp_servers_without_touching_the_file(self, tmp_path, existing): + """Writing a sub-table into an array or inline table emits TOML that no longer parses.""" + path = tmp_path / "config.toml" + path.write_text(existing, encoding="utf-8") + + with pytest.raises(InstallError, match="not a table"): + merge_codex_server(path, "/usr/local/bin/pls", ["mcp"]) + + assert path.read_text(encoding="utf-8") == existing + class TestMergeMistralVibeServer: """Vibe lists servers as [[mcp_servers]] array-of-tables, identified by name.""" @@ -381,6 +467,45 @@ def test_invalid_toml_raises_install_error(self, tmp_path): with pytest.raises(InstallError): merge_mistral_vibe_server(path, "/usr/local/bin/pls", ["mcp"]) + def test_converts_the_empty_array_vibe_ships_by_default(self, tmp_path): + """`mcp_servers = []` is the same key in a different TOML shape, and appending a table to it wrote unparsable TOML.""" + path = tmp_path / "config.toml" + path.write_text('model = "devstral"\nmcp_servers = []\n', encoding="utf-8") + + merge_mistral_vibe_server(path, "/usr/local/bin/pls", ["mcp"]) + + parsed = tomllib.loads(path.read_text(encoding="utf-8")) + assert parsed["mcp_servers"] == [{"name": "panel-live-server", "transport": "stdio", "command": "/usr/local/bin/pls", "args": ["mcp"]}] + assert parsed["model"] == "devstral" + + def test_carries_across_servers_written_as_inline_tables(self, tmp_path): + """Converting the array form must not drop entries the user already had.""" + path = tmp_path / "config.toml" + path.write_text('mcp_servers = [{name = "git", command = "uvx"}]\n', encoding="utf-8") + + merge_mistral_vibe_server(path, "/usr/local/bin/pls", ["mcp"]) + + parsed = tomllib.loads(path.read_text(encoding="utf-8")) + assert [s["name"] for s in parsed["mcp_servers"]] == ["git", "panel-live-server"] + + @pytest.mark.parametrize("existing", ["mcp_servers = 42\n", "mcp_servers = [1, 2]\n"]) + def test_refuses_a_junk_mcp_servers_without_touching_the_file(self, tmp_path, existing): + path = tmp_path / "config.toml" + path.write_text(existing, encoding="utf-8") + + with pytest.raises(InstallError, match="not a list of servers"): + merge_mistral_vibe_server(path, "/usr/local/bin/pls", ["mcp"]) + + assert path.read_text(encoding="utf-8") == existing + + def test_written_file_parses_with_a_strict_parser(self, tmp_path): + path = tmp_path / "config.toml" + + merge_mistral_vibe_server(path, "/usr/local/bin/pls", ["mcp"]) + + parsed = tomllib.loads(path.read_text(encoding="utf-8")) + assert parsed["mcp_servers"][0]["name"] == "panel-live-server" + class TestResolvePlsCommand: """The `pls` running the command wins over whatever PATH happens to hold.""" From 6705ac21895e5db5b9d01f4b6d672b1956be2234 Mon Sep 17 00:00:00 2001 From: SuMayaBee Date: Mon, 24 Aug 2026 22:15:30 +0600 Subject: [PATCH 3/3] fix: collapse cli.py's install imports into one statement Groups the 20+ names pulled from panel_live_server.install into a single import, per review feedback on #71. Scoped to this file via a per-file ruff ignore for I001, since the repo-wide isort force-single-line = true setting would otherwise keep re-splitting it. --- pyproject.toml | 2 ++ src/panel_live_server/cli.py | 44 +++++++++++++++++++----------------- 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d1efbf5..b09d400 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -163,6 +163,8 @@ convention = "numpy" "tests/**" = ["D"] # Ignore all files that end in `_test.py`. "*_test.py" = ["D"] +# Allow grouped imports here: cli.py pulls 20+ names from panel_live_server.install alone. +"src/panel_live_server/cli.py" = ["I001"] [tool.ruff.lint.isort] force-single-line = true diff --git a/src/panel_live_server/cli.py b/src/panel_live_server/cli.py index a7a7054..c9fe091 100644 --- a/src/panel_live_server/cli.py +++ b/src/panel_live_server/cli.py @@ -24,27 +24,29 @@ from panel_live_server import __version__ from panel_live_server.config import default_panel_port from panel_live_server.config import reset_config -from panel_live_server.install import SERVER_NAME -from panel_live_server.install import InstallError -from panel_live_server.install import antigravity_config_path -from panel_live_server.install import claude_desktop_config_path -from panel_live_server.install import cline_config_path -from panel_live_server.install import codex_config_path -from panel_live_server.install import copilot_config_path -from panel_live_server.install import cursor_config_path -from panel_live_server.install import gemini_cli_config_path -from panel_live_server.install import jetbrains_config_path -from panel_live_server.install import kilo_code_config_path -from panel_live_server.install import kiro_config_path -from panel_live_server.install import merge_codex_server -from panel_live_server.install import merge_kilo_code_server -from panel_live_server.install import merge_mcp_server -from panel_live_server.install import merge_mistral_vibe_server -from panel_live_server.install import mistral_vibe_config_path -from panel_live_server.install import register_with_claude_code -from panel_live_server.install import resolve_pls_command -from panel_live_server.install import vscode_config_path -from panel_live_server.install import windsurf_config_path +from panel_live_server.install import ( + SERVER_NAME, + InstallError, + antigravity_config_path, + claude_desktop_config_path, + cline_config_path, + codex_config_path, + copilot_config_path, + cursor_config_path, + gemini_cli_config_path, + jetbrains_config_path, + kilo_code_config_path, + kiro_config_path, + merge_codex_server, + merge_kilo_code_server, + merge_mcp_server, + merge_mistral_vibe_server, + mistral_vibe_config_path, + register_with_claude_code, + resolve_pls_command, + vscode_config_path, + windsurf_config_path, +) from panel_live_server.prompts import render_instructions from panel_live_server.screenshot import install_browser as _install_browser