cmux-mcp 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. cmux_mcp-0.2.0/.gitignore +105 -0
  2. cmux_mcp-0.2.0/.gitignore.bak.20260921T082924Z +75 -0
  3. cmux_mcp-0.2.0/CHANGELOG.md +35 -0
  4. cmux_mcp-0.2.0/LICENSE +28 -0
  5. cmux_mcp-0.2.0/PKG-INFO +23 -0
  6. cmux_mcp-0.2.0/README.md +103 -0
  7. cmux_mcp-0.2.0/cmux_mcp/__init__.py +11 -0
  8. cmux_mcp-0.2.0/cmux_mcp/__main__.py +24 -0
  9. cmux_mcp-0.2.0/cmux_mcp/_tools.py +49 -0
  10. cmux_mcp-0.2.0/cmux_mcp/cli.py +3 -0
  11. cmux_mcp-0.2.0/cmux_mcp/cli_discovery.py +98 -0
  12. cmux_mcp-0.2.0/cmux_mcp/client.py +538 -0
  13. cmux_mcp-0.2.0/cmux_mcp/config.py +102 -0
  14. cmux_mcp-0.2.0/cmux_mcp/errors.py +204 -0
  15. cmux_mcp-0.2.0/cmux_mcp/health.py +120 -0
  16. cmux_mcp-0.2.0/cmux_mcp/logging_setup.py +52 -0
  17. cmux_mcp-0.2.0/cmux_mcp/models.py +349 -0
  18. cmux_mcp-0.2.0/cmux_mcp/server.py +223 -0
  19. cmux_mcp-0.2.0/cmux_mcp/tools/__init__.py +3 -0
  20. cmux_mcp-0.2.0/cmux_mcp/tools/browser_tools.py +419 -0
  21. cmux_mcp-0.2.0/cmux_mcp/tools/socket_tools.py +287 -0
  22. cmux_mcp-0.2.0/docs/superpowers/plans/2026-09-16-cmux-mcp-impl.md +5081 -0
  23. cmux_mcp-0.2.0/docs/superpowers/plans/PLAN_INDEX.md +47 -0
  24. cmux_mcp-0.2.0/docs/superpowers/specs/2026-09-16-cmux-mcp-design.md +1358 -0
  25. cmux_mcp-0.2.0/pyproject.toml +53 -0
  26. cmux_mcp-0.2.0/scripts/README.md +1 -0
  27. cmux_mcp-0.2.0/settings/cmux-mcp.yaml +18 -0
  28. cmux_mcp-0.2.0/tests/__init__.py +0 -0
  29. cmux_mcp-0.2.0/tests/e2e/__init__.py +0 -0
  30. cmux_mcp-0.2.0/tests/fixtures/cmux_responses.yaml +21 -0
  31. cmux_mcp-0.2.0/tests/integration/__init__.py +0 -0
  32. cmux_mcp-0.2.0/tests/unit/__init__.py +0 -0
  33. cmux_mcp-0.2.0/tests/unit/test_browser_tools.py +262 -0
  34. cmux_mcp-0.2.0/tests/unit/test_cli_discovery.py +93 -0
  35. cmux_mcp-0.2.0/tests/unit/test_config.py +115 -0
  36. cmux_mcp-0.2.0/tests/unit/test_errors.py +60 -0
  37. cmux_mcp-0.2.0/tests/unit/test_health.py +78 -0
  38. cmux_mcp-0.2.0/tests/unit/test_lifecycle.py +251 -0
  39. cmux_mcp-0.2.0/tests/unit/test_logging_pii.py +75 -0
  40. cmux_mcp-0.2.0/tests/unit/test_models.py +286 -0
  41. cmux_mcp-0.2.0/tests/unit/test_socket_tools.py +239 -0
  42. cmux_mcp-0.2.0/tests/unit/test_tools_registration.py +53 -0
  43. cmux_mcp-0.2.0/tests/unit/test_transport_cli.py +291 -0
  44. cmux_mcp-0.2.0/tests/unit/test_transport_mock.py +53 -0
  45. cmux_mcp-0.2.0/tests/unit/test_transport_socket.py +140 -0
  46. cmux_mcp-0.2.0/uv.lock +4676 -0
@@ -0,0 +1,105 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ build/
8
+ develop-eggs/
9
+ dist/
10
+ downloads/
11
+ eggs/
12
+ .eggs/
13
+ lib/
14
+ lib64/
15
+ parts/
16
+ sdist/
17
+ var/
18
+ wheels/
19
+ *.egg-info/
20
+ .installed.cfg
21
+ *.egg
22
+ MANIFEST
23
+
24
+ # Virtual environments
25
+ .venv/
26
+ venv/
27
+ ENV/
28
+ env/
29
+
30
+ # uv
31
+ uv.lock.bak
32
+
33
+ # Testing
34
+ .pytest_cache/
35
+ .coverage
36
+ .coverage.*
37
+ htmlcov/
38
+ coverage.json
39
+ .tox/
40
+ .nox/
41
+
42
+ # Type checkers
43
+ .mypy_cache/
44
+ .pyright_cache/
45
+
46
+ # Linters / formatters
47
+ .ruff_cache/
48
+ .black/
49
+
50
+ # Crackerjack
51
+ .crackerjack/
52
+ # Crackerjack tool output caches
53
+ .cache/
54
+ # lychee link checker persistent cache (single file at repo root)
55
+ .lycheecache
56
+
57
+ # IDE
58
+ .idea/
59
+ .vscode/
60
+ *.swp
61
+ *.swo
62
+ .DS_Store
63
+
64
+ # Logs
65
+ *.log
66
+ logs/
67
+
68
+ # Worktrees (no-op; designed for repos that use clones)
69
+ .claude/worktrees/
70
+
71
+ # Oneiric runtime cache (mcp-common BaseOneiricServerMixin writes snapshots here)
72
+ .oneiric_cache/
73
+
74
+ # direnv auto-generated files
75
+ .envrc
76
+
77
+ # >>> bodai-shared-gitignore >>>
78
+ # Source of truth: crackerjack/templates/GITIGNORE_BODAI.md
79
+ # Enforced by: crackerjack check `gitignore-conformance`
80
+ # Fleet list: /Users/les/Projects/mahavishnu/BODAI_REPO_REGISTRY.md
81
+ # Do NOT remove the marker line above — `crackerjack gitignore sync` uses it
82
+ # to detect already-applied state.
83
+
84
+ # Editor / pre-edit backups (foo.py.backup, foo.py.backup.json)
85
+ *.backup
86
+ *.backup.*
87
+ *.bak
88
+ *.tmpl
89
+ # XDG mistake dirs (env var expansion failed; literal names)
90
+ # Note: both `{` and `}` are escaped (`{` -> `[{]`, `}` -> `[}]`)
91
+ # because gitignore parsers interpret `${...}` as a brace-expansion
92
+ # alternate group; escaping only the open brace leaves a stray `}`.
93
+ ~
94
+ [{]HOME[}]
95
+ [{]XDG_DATA_HOME:-$HOME[}]
96
+ # pytest-benchmark autosave cache (sibling of the tracked `benchmarks/` test suite)
97
+ .benchmarks/
98
+ # Vitest config timestamp cache
99
+ vitest.config.js.timestamp-*.mjs
100
+ # Playwright output cruft
101
+ playwright-junit.xml
102
+ playwright-report/
103
+ playwright-results.json
104
+ test-results/
105
+ # <<< bodai-shared-gitignore <<<
@@ -0,0 +1,75 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ build/
8
+ develop-eggs/
9
+ dist/
10
+ downloads/
11
+ eggs/
12
+ .eggs/
13
+ lib/
14
+ lib64/
15
+ parts/
16
+ sdist/
17
+ var/
18
+ wheels/
19
+ *.egg-info/
20
+ .installed.cfg
21
+ *.egg
22
+ MANIFEST
23
+
24
+ # Virtual environments
25
+ .venv/
26
+ venv/
27
+ ENV/
28
+ env/
29
+
30
+ # uv
31
+ uv.lock.bak
32
+
33
+ # Testing
34
+ .pytest_cache/
35
+ .coverage
36
+ .coverage.*
37
+ htmlcov/
38
+ coverage.json
39
+ .tox/
40
+ .nox/
41
+
42
+ # Type checkers
43
+ .mypy_cache/
44
+ .pyright_cache/
45
+
46
+ # Linters / formatters
47
+ .ruff_cache/
48
+ .black/
49
+
50
+ # Crackerjack
51
+ .crackerjack/
52
+ # Crackerjack tool output caches
53
+ .cache/
54
+ # lychee link checker persistent cache (single file at repo root)
55
+ .lycheecache
56
+
57
+ # IDE
58
+ .idea/
59
+ .vscode/
60
+ *.swp
61
+ *.swo
62
+ .DS_Store
63
+
64
+ # Logs
65
+ *.log
66
+ logs/
67
+
68
+ # Worktrees (no-op; designed for repos that use clones)
69
+ .claude/worktrees/
70
+
71
+ # Oneiric runtime cache (mcp-common BaseOneiricServerMixin writes snapshots here)
72
+ .oneiric_cache/
73
+
74
+ # direnv auto-generated files
75
+ .envrc
@@ -0,0 +1,35 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.2.0] - 2026-09-21
9
+
10
+ ### Fixed
11
+
12
+ - browser: Handle CmuxMockTransport missing _config in truncate path
13
+ - client+server: H7/H9/H10 — counter, JSONDecodeError, PID PermissionError
14
+ - client: H8 — surface-lock refcount + eviction (no more lock leak)
15
+ - client: Subprocess lifecycle on cancellation + real aclose() (C2+C3)
16
+ - errors+README: H5 — missing exception classes + honest README (H6)
17
+ - gitignore: Exclude crackerjack tool-output caches (.cache/ + .lycheecache)
18
+ - logging: Use Oneiric's structlog-based logger (C7 — CLAUDE.md violation)
19
+ - quality: All fast hooks now pass — lint + tc-refs scope fix
20
+ - server: Custom /health route returns 503 on degraded (C4)
21
+ - tools: Cmux_browser_type returns BrowserTypeOutput (H3)
22
+ - tools: Cmux_list_workspaces iterates correct field + adds surface.list (H4)
23
+ - tools: H1/H2 — wire max_response_bytes + notify rate limit
24
+ - tools: Record_success() reachable in cmux_browser_navigate (C5)
25
+ - tools: Tool errors raise ToolError (C6 — was returning plain dict)
26
+ - Wire register_tools into startup (C1 — server exposed zero tools)
27
+
28
+ ### Documentation
29
+
30
+ - plans: Mark cmux-mcp plan + spec shipped (v0.1.0)
31
+
32
+ ### Internal
33
+
34
+ - deps: Add crackerjack to PEP 735 dependency-groups
35
+ - Migrate cmux-mcp from src-layout to flat layout
cmux_mcp-0.2.0/LICENSE ADDED
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, Les Leslie, Wedgwood Web Works LLC
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,23 @@
1
+ Metadata-Version: 2.5
2
+ Name: cmux-mcp
3
+ Version: 0.2.0
4
+ Summary: MCP server for cmux terminal automation (macOS only)
5
+ Author-email: Les Leslie <les@wedgwoodwebworks.com>
6
+ License-Expression: BSD-3-Clause
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.14
9
+ Requires-Dist: fastmcp<4,>=3.4
10
+ Requires-Dist: httpx>=0.27
11
+ Requires-Dist: mcp-common<0.27,>=0.26
12
+ Requires-Dist: oneiric>=0.21.0
13
+ Requires-Dist: psutil>=7.2.2
14
+ Requires-Dist: pydantic-settings>=2
15
+ Requires-Dist: pydantic>=2.13.4
16
+ Requires-Dist: pyyaml>=6
17
+ Provides-Extra: dev
18
+ Requires-Dist: crackerjack>=0.20; extra == 'dev'
19
+ Requires-Dist: mypy>=1.10; extra == 'dev'
20
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
21
+ Requires-Dist: pytest-cov>=5; extra == 'dev'
22
+ Requires-Dist: pytest>=8; extra == 'dev'
23
+ Requires-Dist: ruff>=0.6; extra == 'dev'
@@ -0,0 +1,103 @@
1
+ # cmux-mcp
2
+
3
+ [![Code style: crackerjack](https://img.shields.io/badge/code%20style-crackerjack-000042)](https://github.com/lesleslie/crackerjack)
4
+ [![Runtime: oneiric](https://img.shields.io/badge/runtime-oneiric-6e5494)](https://github.com/lesleslie/oneiric)
5
+ [![Framework: FastMCP](https://img.shields.io/badge/framework-FastMCP-0ea5e9)](https://github.com/jlowin/fastmcp)
6
+ [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
7
+ [![Python: 3.14+](https://www.python.org/downloads/)](https://www.python.org/downloads/)
8
+
9
+ Catalog and operating map for cmux-mcp.
10
+
11
+ **Status:** v0.1.0 (initial release — 12 tools, BSD-3-Clause)
12
+
13
+ ## Quick Links
14
+
15
+ - [Overview](#overview)
16
+ - [Tool Reference](#tool-reference)
17
+ - [Quick Start](#quick-start)
18
+ - [MCP Client Configuration](#mcp-client-configuration)
19
+ - [Configuration](#configuration)
20
+ - [Security Notes](#security-notes)
21
+
22
+ ## Overview
23
+
24
+ cmux-mcp exposes programmatic control of a running [cmux](https://github.com/manaflow-ai/cmux) instance (macOS-only, Ghostty-based terminal for AI coding agents) as 12 MCP tools over Streamable HTTP. The server speaks cmux's Unix-socket JSON-RPC protocol directly for orchestration and notifications, and shells out to the cmux CLI for browser automation.
25
+
26
+ ## Quick Start
27
+
28
+ ```bash
29
+ # Install
30
+ cd /Users/les/Projects/cmux-mcp && uv sync --extra dev
31
+
32
+ # Run (mock mode, for testing)
33
+ CMUX_MCP_MOCK=1 uv run cmux-mcp start
34
+
35
+ # Run (real, on macOS with cmux running)
36
+ uv run cmux-mcp start
37
+
38
+ # Lifecycle
39
+ uv run cmux-mcp start --bg
40
+ uv run cmux-mcp status
41
+ uv run cmux-mcp health
42
+ uv run cmux-mcp stop
43
+ ```
44
+
45
+ ## MCP Client Configuration
46
+
47
+ ```json
48
+ {
49
+ "mcpServers": {
50
+ "cmux": {
51
+ "command": "uv",
52
+ "args": ["run", "--project", "/Users/les/Projects/cmux-mcp", "cmux-mcp", "start"]
53
+ }
54
+ }
55
+ }
56
+ ```
57
+
58
+ ## Tool Reference
59
+
60
+ | Tool | Mode | Description |
61
+ |---|---|---|
62
+ | `cmux_list_workspaces` | Read-only | List all workspaces with panes and surfaces |
63
+ | `cmux_list_notifications` | Read-only | List pending cmux notifications |
64
+ | `cmux_identify` | Read-only | Return focused window/workspace/pane/surface |
65
+ | `cmux_send_keys` | Destructive | Send text or special key to terminal surface (fire-and-forget) |
66
+ | `cmux_notify` | Mutation | Dispatch OS notification that rings pane and lights sidebar |
67
+ | `cmux_browser_navigate` | Destructive | Navigate browser surface to URL |
68
+ | `cmux_browser_snapshot` | Read-only | A11y tree with Playwright-style refs |
69
+ | `cmux_browser_evaluate` | Destructive | Execute JS in browser (arbitrary; trust model documented) |
70
+ | `cmux_browser_click` | Destructive | Click element by CSS selector |
71
+ | `cmux_browser_type` | Destructive | Type into input (uses fill semantics) |
72
+ | `cmux_browser_tabs` | Read-only | List open tabs |
73
+ | `cmux_browser_console` | Read-only | Read console + JS errors (aggregates 2 sub-calls) |
74
+
75
+ ## Configuration
76
+
77
+ All settings via `CMUX_MCP_*` env vars. See `settings/cmux-mcp.yaml` for committed defaults.
78
+
79
+ | Setting | Default | Description |
80
+ |---|---|---|
81
+ | `CMUX_MCP_HOST` | `127.0.0.1` | HTTP bind address |
82
+ | `CMUX_MCP_PORT` | `3061` | HTTP port |
83
+ | `CMUX_MCP_SOCKET_PATH` | `/tmp/cmux.sock` | cmux Unix socket |
84
+ | `CMUX_MCP_CLI_PATH` | auto-discover | cmux CLI binary path |
85
+ | `CMUX_MCP_MOCK` | unset | `1`/`true` enables mock mode |
86
+ | `CMUX_MCP_AUTH_ENABLED` | `false` | Enable JWT auth for non-loopback deployments |
87
+
88
+ ## Security Notes
89
+
90
+ - macOS-only; non-macOS hosts must use `CMUX_MCP_MOCK=1` (auto-flipped with WARN banner)
91
+ - cmux's `cmuxOnly` access mode restricts socket connections to processes spawned inside cmux terminals
92
+ - PII redaction helpers (`redact_url_query_string`, `redact_expression`) exist in `logging_setup.py` for future log-site use; v0.1.x ships with no PII-logging call sites by default (deferred)
93
+ - Subprocess env filtered to `SUBPROCESS_ENV_ALLOWLIST` to prevent leaking API keys
94
+ - Default loopback bind; non-loopback requires `CMUX_MCP_AUTH_ENABLED=true`
95
+
96
+ ## Development Commands
97
+
98
+ ```bash
99
+ uv run pytest # Run all tests
100
+ uv run pytest -m unit # Unit tests only
101
+ uv run pytest -m integration # Integration tests (require live cmux)
102
+ uv run crackerjack run # Full quality gate
103
+ ```
@@ -0,0 +1,11 @@
1
+ """cmux-mcp: MCP server for cmux terminal automation (macOS only)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.metadata
6
+
7
+ __version__ = importlib.metadata.version("cmux-mcp")
8
+
9
+ DEFAULT_PORT: int = 3061 # per spec decision log row 2 + 8
10
+
11
+ __all__ = ["DEFAULT_PORT", "__version__"]
@@ -0,0 +1,24 @@
1
+ """cmux-mcp entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from mcp_common.cli import MCPServerCLIFactory
6
+
7
+ from cmux_mcp.config import CmuxMCPConfig
8
+ from cmux_mcp.server import CmuxMCPServer
9
+
10
+
11
+ def main() -> None:
12
+ """Entry point for `cmux-mcp` console script."""
13
+ factory = MCPServerCLIFactory.create_server_cli(
14
+ server_class=CmuxMCPServer,
15
+ config_class=CmuxMCPConfig,
16
+ name="cmux-mcp",
17
+ _description="MCP server for cmux terminal automation (macOS only).",
18
+ )
19
+ app = factory.create_app()
20
+ app()
21
+
22
+
23
+ if __name__ == "__main__":
24
+ main()
@@ -0,0 +1,49 @@
1
+ """cmux_mcp._tools — programmatic tool registration.
2
+
3
+ Per spec §"_tools.py — registration pattern":
4
+ Per-instance FastMCP (in CmuxMCPServer.__init__) means @mcp.tool() decorators
5
+ can't bind at module-load time. Instead, register_tools() registers all 12
6
+ tools programmatically in CmuxMCPServer.startup().
7
+
8
+ Each tool body follows the try/except/else/return pattern per spec §"/health
9
+ envelope wiring → Tool feed placement".
10
+
11
+ FastMCP 3.4+ API used: `FunctionTool.from_function(fn, name=..., description=...,
12
+ annotations=ToolAnnotations(...), output_schema=...)` → `mcp.add_tool(tool)`.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import TYPE_CHECKING
18
+
19
+ from cmux_mcp.health import TOOL_NAMES
20
+
21
+ if TYPE_CHECKING:
22
+ from fastmcp import FastMCP
23
+
24
+ from cmux_mcp.client import CmuxCliTransportProtocol, CmuxSocketTransportProtocol
25
+ from cmux_mcp.health import ToolFeedComponent
26
+
27
+
28
+ def build_tool_feed_components() -> list[ToolFeedComponent]:
29
+ """Delegate to health.build_tool_feed_components (single source of truth)."""
30
+ from cmux_mcp.health import build_tool_feed_components as _impl
31
+
32
+ return _impl()
33
+
34
+
35
+ def register_tools(
36
+ mcp: FastMCP,
37
+ socket_transport: CmuxSocketTransportProtocol,
38
+ cli_transport: CmuxCliTransportProtocol,
39
+ tool_feeds: dict[str, ToolFeedComponent],
40
+ ) -> None:
41
+ """Register all 12 tools on the FastMCP instance. Idempotent."""
42
+ from cmux_mcp.tools.browser_tools import register_browser_tools
43
+ from cmux_mcp.tools.socket_tools import register_socket_tools
44
+
45
+ register_socket_tools(mcp, socket_transport, tool_feeds)
46
+ register_browser_tools(mcp, cli_transport, tool_feeds)
47
+
48
+
49
+ __all__ = ["TOOL_NAMES", "build_tool_feed_components", "register_tools"]
@@ -0,0 +1,3 @@
1
+ """cli.py — placeholder; lifecycle CLI is wired via __main__.py + mcp-common."""
2
+
3
+ from __future__ import annotations
@@ -0,0 +1,98 @@
1
+ """cmux_mcp.cli_discovery — locate the cmux CLI binary.
2
+
3
+ Per spec §"cmux CLI binary discovery":
4
+ 1. CMUX_MCP_CLI_PATH env var (operator override)
5
+ 2. which cmux from $PATH
6
+ 3. /Applications/cmux.app/Contents/Resources/bin/cmux
7
+ 4. /opt/homebrew/Caskroom/cmux/*/cmux.app/.../bin/cmux (latest version)
8
+ 5. /usr/local/Caskroom/cmux/*/cmux.app/.../bin/cmux (Intel macs)
9
+ 6. ~/Library/Developer/Xcode/DerivedData/cmux-*/Build/Products/{Debug,Release}/cmux.app/.../bin/cmux
10
+ 7. fail with CmuxBinaryNotFoundError listing probed paths
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import shutil
17
+ from pathlib import Path
18
+
19
+ from cmux_mcp.errors import CmuxBinaryNotFoundError
20
+
21
+ # Module-level so tests can monkeypatch (see test_cli_discovery.py).
22
+ _DEFAULT_APP_BUNDLE = Path("/Applications/cmux.app")
23
+ _CASKROOM_PATHS: list[Path] = [
24
+ Path("/opt/homebrew/Caskroom/cmux"),
25
+ Path("/usr/local/Caskroom/cmux"),
26
+ ]
27
+
28
+
29
+ def _candidate_paths() -> list[Path]:
30
+ candidates: list[Path] = []
31
+ if "PATH" in os.environ:
32
+ which_result = shutil.which("cmux")
33
+ if which_result:
34
+ candidates.append(Path(which_result))
35
+ candidates.append(_DEFAULT_APP_BUNDLE / "Contents" / "Resources" / "bin" / "cmux")
36
+ for caskroom in _CASKROOM_PATHS:
37
+ if caskroom.exists() and caskroom.is_dir():
38
+ # Numeric tuple sort so "10.0.0" sorts after "2.0.0".
39
+ versions = sorted(
40
+ (p for p in caskroom.iterdir() if p.is_dir()),
41
+ key=lambda p: tuple(
42
+ int(x) if x.isdigit() else 0 for x in p.name.split(".")
43
+ ),
44
+ reverse=True,
45
+ )
46
+ for v in versions:
47
+ candidates.append(
48
+ v / "cmux.app" / "Contents" / "Resources" / "bin" / "cmux"
49
+ )
50
+ derived = Path.home() / "Library" / "Developer" / "Xcode" / "DerivedData"
51
+ if derived.exists():
52
+ # PERF402: candidates.extend(glob(...)) beats `for d in glob: append`
53
+ # — single C-level call instead of N Python appends.
54
+ candidates.extend(
55
+ derived.glob(
56
+ "cmux-*/Build/Products/Debug/cmux.app/Contents/Resources/bin/cmux"
57
+ ),
58
+ )
59
+ candidates.extend(
60
+ derived.glob(
61
+ "cmux-*/Build/Products/Release/cmux.app/Contents/Resources/bin/cmux"
62
+ ),
63
+ )
64
+ return candidates
65
+
66
+
67
+ def discover_cmux_cli(explicit_path: Path | None = None) -> Path:
68
+ """Resolve the cmux CLI binary path.
69
+
70
+ Args:
71
+ explicit_path: if set (e.g., via CMUX_MCP_CLI_PATH env), use directly.
72
+
73
+ Returns:
74
+ Path to a verified cmux binary.
75
+
76
+ Raises:
77
+ CmuxBinaryNotFoundError: when no candidate is found.
78
+ """
79
+ env_override = os.environ.get("CMUX_MCP_CLI_PATH")
80
+ candidates: list[Path] = []
81
+ if explicit_path is not None:
82
+ candidates.append(explicit_path)
83
+ elif env_override:
84
+ candidates.append(Path(env_override))
85
+ candidates.extend(_candidate_paths())
86
+
87
+ for path in candidates:
88
+ if path.exists() and path.is_file() and os.access(path, os.X_OK):
89
+ return path
90
+
91
+ probed = "\n ".join(str(p) for p in candidates)
92
+ raise CmuxBinaryNotFoundError(
93
+ f"cmux CLI binary not found. Probed paths:\n {probed}\n"
94
+ "Set CMUX_MCP_CLI_PATH or install cmux from https://cmux.com/"
95
+ )
96
+
97
+
98
+ __all__ = ["discover_cmux_cli"]