diff --git a/mureo/byod/runtime.py b/mureo/byod/runtime.py index bd939ab..02f1f99 100644 --- a/mureo/byod/runtime.py +++ b/mureo/byod/runtime.py @@ -30,10 +30,30 @@ _USER_DIR_NAME = ".mureo" _BYOD_SUBDIR = "byod" _MANIFEST_NAME = "manifest.json" +_BYOD_DIR_ENV = "MUREO_BYOD_DIR" def byod_data_dir() -> Path: - """Return ``~/.mureo/byod/`` (does not create it).""" + """Return the BYOD data directory (does not create it). + + Resolution precedence: + 1. ``MUREO_BYOD_DIR`` environment variable. Set by the + install-desktop wrapper to ``/byod`` for the MCP + runtime, and also temporarily set by ``install_desktop()`` + itself during ``--with-demo`` seeding. Each Claude Desktop + workspace sees its own BYOD store and demo data does not + leak across workspaces. + 2. Legacy ``~/.mureo/byod/`` for existing CLI / Claude Code + users — preserves backward compatibility. + + An empty or whitespace-only env var is treated as 'unset' so a + stray ``MUREO_BYOD_DIR=`` (or `` `` from a malformed shell rc) + cannot silently redirect BYOD into the cwd or a path named + ``" "``. ``~`` in the value is expanded. + """ + override = os.environ.get(_BYOD_DIR_ENV) + if override and override.strip(): + return Path(override.strip()).expanduser() return Path.home() / _USER_DIR_NAME / _BYOD_SUBDIR diff --git a/mureo/cli/install_desktop_cmd.py b/mureo/cli/install_desktop_cmd.py new file mode 100644 index 0000000..b77a86b --- /dev/null +++ b/mureo/cli/install_desktop_cmd.py @@ -0,0 +1,91 @@ +"""``mureo install-desktop`` — onboarding for Claude Desktop chat users. + +This top-level command exists separately from the ``mureo setup *`` +group because it targets non-engineer users. The discoverability of +``install-desktop`` matters more than consistency with the existing +host-specific setup commands. +""" + +from __future__ import annotations + +from pathlib import Path + +import typer + +from mureo.desktop_installer import ( + DesktopConfigCorruptError, + DesktopInstallExistsError, + DesktopInstallUnsupportedPlatformError, + format_next_steps, + install_desktop, +) + +install_desktop_app = typer.Typer( + name="install-desktop", + help="Wire mureo into Claude Desktop chat (macOS).", + invoke_without_command=True, + no_args_is_help=False, +) + + +@install_desktop_app.callback() # type: ignore[untyped-decorator, unused-ignore] +def install_desktop_cmd( + workspace: Path | None = typer.Option( # noqa: B008 + None, + "--workspace", + "-w", + help="Directory the MCP server will use as its working " + "directory. STRATEGY.md / STATE.json will live here. " + "Defaults to ~/mureo.", + ), + with_demo: str | None = typer.Option( + None, + "--with-demo", + help="Seed the workspace with a demo scenario " + "(seasonality-trap | halo-effect | hidden-champion | strategy-drift).", + ), + force: bool = typer.Option( + False, + "--force", + "-f", + help="Overwrite an existing 'mureo' MCP entry without prompting " + "(a timestamped backup is always saved first).", + ), + dry_run: bool = typer.Option( + False, + "--dry-run", + help="Show what would happen without writing anything.", + ), +) -> None: + """Wire mureo into Claude Desktop's MCP config. + + Creates the workspace, generates a wrapper shell script, and + merges a 'mureo' entry into ~/Library/Application + Support/Claude/claude_desktop_config.json. + """ + resolved_workspace = workspace if workspace is not None else Path.home() / "mureo" + try: + result = install_desktop( + workspace=resolved_workspace, + with_demo=with_demo, + force=force, + dry_run=dry_run, + ) + except DesktopInstallUnsupportedPlatformError as exc: + typer.echo(f"Error: {exc}", err=True) + raise typer.Exit(code=2) from exc + except DesktopInstallExistsError as exc: + typer.echo(f"Error: {exc}", err=True) + raise typer.Exit(code=1) from exc + except DesktopConfigCorruptError as exc: + typer.echo(f"Error: {exc}", err=True) + raise typer.Exit(code=1) from exc + + if result.dry_run: + typer.echo("Dry-run — no changes written.") + typer.echo(f" Would create workspace: {result.workspace}") + typer.echo(f" Would write wrapper: {result.wrapper_path}") + typer.echo(f" Would update config: {result.config_path}") + return + + typer.echo(format_next_steps(result)) diff --git a/mureo/cli/main.py b/mureo/cli/main.py index 67dcab1..26f5655 100644 --- a/mureo/cli/main.py +++ b/mureo/cli/main.py @@ -11,6 +11,7 @@ from mureo.cli.auth_cmd import auth_app from mureo.cli.byod_cmd import byod_app from mureo.cli.demo_cmd import demo_app +from mureo.cli.install_desktop_cmd import install_desktop_app from mureo.cli.rollback_cmd import rollback_app from mureo.cli.setup_cmd import setup_app @@ -22,6 +23,7 @@ app.add_typer(auth_app) app.add_typer(setup_app) +app.add_typer(install_desktop_app) app.add_typer(rollback_app) app.add_typer(byod_app) app.add_typer(demo_app) diff --git a/mureo/desktop_installer.py b/mureo/desktop_installer.py new file mode 100644 index 0000000..b57166c --- /dev/null +++ b/mureo/desktop_installer.py @@ -0,0 +1,382 @@ +"""Onboarding helper that wires mureo into Claude Desktop. + +The Desktop chat host has no ``Read`` / ``Write`` tools, so the +``mureo_*`` MCP surface added in PR #74 is the only way for users to +read or update STRATEGY.md / STATE.json from the chat tab. Getting +that wired up by hand is the single biggest barrier to non-engineer +adoption — three steps are needed (workspace dir, wrapper script, +edit ``claude_desktop_config.json``), and each has its own footgun. + +This module exposes ``install_desktop`` so ``mureo install-desktop`` +(and any future GUI installer) can perform all three atomically. + +Why a wrapper script? Claude Desktop's MCP runtime ignores the +``cwd`` field in its config — the spawned MCP process inherits +``cwd=/`` instead. ``/`` is read-only on macOS, which causes +``mureo_strategy_set`` to fail with ``[Errno 30] Read-only file +system`` while writing the temp file for atomic rename. The wrapper +sidesteps the bug by ``cd``-ing into the workspace before exec'ing +the Python interpreter. +""" + +from __future__ import annotations + +import contextlib +import json +import os +import shlex +import shutil +import sys +import tempfile +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +class DesktopInstallError(Exception): + """Base class for install-desktop failures.""" + + +class DesktopInstallUnsupportedPlatformError(DesktopInstallError): + """Raised when running on a non-macOS host (Phase 1 scope).""" + + +class DesktopInstallExistsError(DesktopInstallError): + """Raised when ``mureo`` is already configured and ``--force`` was not given.""" + + +class DesktopConfigCorruptError(DesktopInstallError): + """Raised when ``claude_desktop_config.json`` is not valid JSON. + + We refuse to overwrite a corrupt config because the user may have + hand-edited it and we don't want to silently destroy work. + """ + + +@dataclass(frozen=True) +class InstallResult: + """Summary of what ``install_desktop`` did (or would do, in dry-run).""" + + workspace: Path + wrapper_path: Path + config_path: Path + backup_path: Path | None + dry_run: bool + + +def _macos_config_path() -> Path: + return ( + Path.home() + / "Library" + / "Application Support" + / "Claude" + / "claude_desktop_config.json" + ) + + +def _wrapper_path() -> Path: + return Path.home() / ".local" / "bin" / "mureo-mcp-wrapper.sh" + + +def _wrapper_body(workspace: Path, python_executable: str) -> str: + """Generate the wrapper shell script body. + + ``exec`` replaces the shell with the Python process so signals + propagate cleanly back to Claude Desktop. Both interpolations go + through ``shlex.quote`` so paths containing spaces, ``$``, ``;``, + or other shell metacharacters cannot break out of the command — + a non-engineer's HOME may well include any of those. + + ``MUREO_BYOD_DIR`` is exported so each workspace's MCP server + reads/writes BYOD data inside that workspace (``/byod/``) + rather than the global ``~/.mureo/byod/``. This lets demo and real + workspaces coexist without ``rm -rf`` between them. + """ + quoted_workspace = shlex.quote(str(workspace)) + quoted_byod_dir = shlex.quote(str(workspace / "byod")) + quoted_python = shlex.quote(python_executable) + return ( + "#!/bin/bash\n" + "# mureo MCP wrapper (auto-generated by `mureo install-desktop`).\n" + "#\n" + "# Forces cwd to the mureo workspace because Claude Desktop\n" + "# does not honour the `cwd` field in its MCP config — without\n" + "# this, the MCP process inherits cwd=/ which is read-only on\n" + "# macOS and breaks atomic writes inside `mureo_strategy_set`.\n" + f"cd {quoted_workspace}\n" + "# Workspace-local BYOD so demo and real setups can coexist.\n" + f"export MUREO_BYOD_DIR={quoted_byod_dir}\n" + f'exec {quoted_python} -m mureo.mcp "$@"\n' + ) + + +def _load_config(path: Path) -> dict[str, Any]: + """Read the Claude Desktop config, returning ``{}`` when absent. + + Raises: + DesktopConfigCorruptError: when the file is not valid JSON or + its top-level value is not an object. Refusing on a + non-object payload is what stops a downstream + ``existing.get(...)`` from raising ``AttributeError`` and + leaking a stack trace to a non-engineer. + """ + if not path.exists(): + return {} + try: + parsed: Any = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise DesktopConfigCorruptError( + f"Existing config at {path} is not valid JSON: {exc}. " + "Fix or remove it manually before re-running install-desktop." + ) from exc + if not isinstance(parsed, dict): + raise DesktopConfigCorruptError( + f"Existing config at {path} parses to {type(parsed).__name__}, " + "not an object. Claude Desktop expects a JSON object at the top " + "level. Fix or remove it before re-running install-desktop." + ) + return parsed + + +def _atomic_write_config(path: Path, data: dict[str, Any]) -> None: + """Write the config atomically: tempfile in same dir + ``os.replace``. + + Why this matters: ``Path.write_text`` is not atomic — a power loss + or disk-full mid-write would leave a truncated config and Claude + Desktop loses every MCP entry. Writing to a sibling tempfile and + then ``os.replace``-ing makes the swap atomic on the same + filesystem. + + Symlink-following is also blocked: if the user's config is a + symlink (Dropbox / iCloud sync setups commonly do this), the + tempfile is created next to the symlink itself, ``os.replace`` + overwrites the symlink, and the original target is not modified. + Refusing the install if the config is a symlink — for now — + surfaces that constraint loudly instead of silently doing the + surprising thing. + """ + if path.is_symlink(): + raise DesktopConfigCorruptError( + f"Existing config at {path} is a symlink. install-desktop " + "refuses to follow it. Resolve the symlink and re-run." + ) + path.parent.mkdir(parents=True, exist_ok=True) + payload = json.dumps(data, indent=2, ensure_ascii=False) + "\n" + fd, tmp_name = tempfile.mkstemp(prefix=path.name + ".tmp.", dir=str(path.parent)) + try: + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(payload) + fh.flush() + os.fsync(fh.fileno()) + os.replace(tmp_name, path) + except Exception: + with contextlib.suppress(OSError): + os.unlink(tmp_name) + raise + + +def _backup_config(path: Path) -> Path: + """Snapshot the existing config to ``.bak.``. + + UTC keeps timestamps stable across DST shifts. Microsecond + precision avoids backup collisions on rapid retries (CI loops, + interactive ``--force`` re-runs); falling back to a numeric + suffix if even that collides keeps the operation deterministic. + """ + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S-%f") + backup = path.with_name(f"{path.name}.bak.{timestamp}") + counter = 1 + while backup.exists(): + backup = path.with_name(f"{path.name}.bak.{timestamp}-{counter}") + counter += 1 + shutil.copy2(path, backup) + return backup + + +def _validate_demo_scenario(scenario: str) -> None: + """Reject unknown demo names *before* any filesystem mutation. + + Without this guard a typo (``--with-demo seasonality_trip``) only + surfaces inside ``materialize`` after the workspace has been + created and the config rewritten — leaving the user with a + half-populated state and a confusing error. + """ + from mureo.demo.scenarios import get_scenario + + try: + get_scenario(scenario) + except ValueError as exc: + raise DesktopInstallError(f"Unknown demo scenario {scenario!r}: {exc}") from exc + + +def _run_demo_init(workspace: Path, scenario: str) -> None: + """Seed the workspace with a demo scenario. + + Wrapped in a private helper so tests can ``monkeypatch`` it without + needing the demo bundle data on disk. Forces overwrite because the + workspace was just created (or the user explicitly re-installed). + """ + from mureo.demo.installer import materialize + + materialize(target_dir=workspace, force=True, scenario_name=scenario) + + +def install_desktop( + workspace: Path | None = None, + *, + with_demo: str | None = None, + force: bool = False, + dry_run: bool = False, +) -> InstallResult: + """Wire mureo into Claude Desktop (macOS). + + Args: + workspace: Directory to use as the MCP server's cwd. Defaults + to ``~/mureo``. + with_demo: Name of a demo scenario to seed the workspace with + (``seasonality-trap``, ``halo-effect``, ``hidden-champion``, + ``strategy-drift``). ``None`` skips demo seeding. + force: When ``True``, replace any existing ``mureo`` entry + without prompting. When ``False`` and an entry already + exists, raises ``DesktopInstallExistsError``. + dry_run: When ``True``, return what would be done without + mutating the filesystem or config. + + Raises: + DesktopInstallUnsupportedPlatformError: when not on macOS. + DesktopInstallExistsError: when an existing ``mureo`` entry + would be overwritten without ``force``. + DesktopConfigCorruptError: when the existing config is not + valid JSON. + """ + if sys.platform != "darwin": + raise DesktopInstallUnsupportedPlatformError( + f"install-desktop currently only supports macOS; " + f"detected sys.platform={sys.platform!r}. Linux and Windows " + "support is tracked as a follow-up." + ) + + if with_demo is not None: + _validate_demo_scenario(with_demo) + + workspace = (workspace or (Path.home() / "mureo")).resolve() + wrapper = _wrapper_path() + config_path = _macos_config_path() + + existing = _load_config(config_path) + servers_raw = existing.get("mcpServers") or {} + if not isinstance(servers_raw, dict): + raise DesktopConfigCorruptError( + f"'mcpServers' in {config_path} is not an object. " + "Fix or remove it before re-running install-desktop." + ) + has_existing_mureo = "mureo" in servers_raw + if has_existing_mureo and not force: + raise DesktopInstallExistsError( + f"Claude Desktop already has a 'mureo' MCP entry at " + f"{config_path}. Re-run with --force to overwrite " + "(a timestamped backup will be saved)." + ) + + if dry_run: + return InstallResult( + workspace=workspace, + wrapper_path=wrapper, + config_path=config_path, + backup_path=None, + dry_run=True, + ) + + workspace.mkdir(parents=True, exist_ok=True) + + wrapper.parent.mkdir(parents=True, exist_ok=True) + wrapper.write_text(_wrapper_body(workspace, sys.executable), encoding="utf-8") + # Explicit 0o755 — don't inherit looser bits from a prior wrapper + # that may have been written world-writable. + wrapper.chmod(0o755) + + # Seed the demo BEFORE rewriting the config so a demo-side failure + # cannot leave the user with a wired-up MCP pointing at an empty + # workspace. ``_validate_demo_scenario`` already caught typos. + # + # The wrapper exports ``MUREO_BYOD_DIR=/byod`` for the + # MCP runtime, but the install-desktop *process itself* is what + # calls ``materialize`` here — and that lookup also goes through + # ``byod_data_dir()``. So we temporarily set the same env var in + # this process for the duration of the seed, then restore the + # original value (or unset if it was unset) so we don't leak + # state into anything that imported install_desktop as a library. + # + # Not thread-safe: ``os.environ`` is process-global, so two + # concurrent ``install_desktop`` calls would race the env mutation. + # Acceptable today because the CLI invokes this once per process; + # if a future GUI installer parallelises across workspaces we + # should refactor ``materialize`` to take an explicit ``byod_dir`` + # parameter and drop the env-var dance. + # + # ``str(workspace / "byod")`` matches what the wrapper will export + # (``str(workspace / "byod")`` quoted, no extra ``.resolve()``). + # ``workspace`` is already resolved above, so the two paths agree. + if with_demo is not None: + byod_target = str(workspace / "byod") + previous_byod = os.environ.get("MUREO_BYOD_DIR") + os.environ["MUREO_BYOD_DIR"] = byod_target + try: + _run_demo_init(workspace=workspace, scenario=with_demo) + finally: + if previous_byod is None: + os.environ.pop("MUREO_BYOD_DIR", None) + else: + os.environ["MUREO_BYOD_DIR"] = previous_byod + + backup_path: Path | None = None + if config_path.exists(): + backup_path = _backup_config(config_path) + + merged = dict(existing) + servers = dict(servers_raw) + servers["mureo"] = {"command": str(wrapper)} + merged["mcpServers"] = servers + _atomic_write_config(config_path, merged) + + return InstallResult( + workspace=workspace, + wrapper_path=wrapper, + config_path=config_path, + backup_path=backup_path, + dry_run=False, + ) + + +def format_next_steps(result: InstallResult) -> str: + """Return a human-friendly post-install message for the CLI/GUI to render.""" + backup_line = ( + f" Config backup: {result.backup_path}\n" + if result.backup_path is not None + else "" + ) + return ( + "Installed.\n" + f" Workspace: {result.workspace}\n" + f" Wrapper: {result.wrapper_path}\n" + f" Config: {result.config_path}\n" + f"{backup_line}" + "\n" + "Next steps:\n" + " 1. Quit Claude Desktop completely (Cmd+Q)\n" + " 2. Reopen Claude Desktop\n" + " 3. Open the connector list — you should see 5 mureo_* tools\n" + ' 4. Try in chat: "Call mureo_state_get and show me the result"\n' + ) + + +__all__ = [ + "DesktopConfigCorruptError", + "DesktopInstallError", + "DesktopInstallExistsError", + "DesktopInstallUnsupportedPlatformError", + "InstallResult", + "format_next_steps", + "install_desktop", +] diff --git a/tests/test_byod_runtime_env.py b/tests/test_byod_runtime_env.py new file mode 100644 index 0000000..7fb345c --- /dev/null +++ b/tests/test_byod_runtime_env.py @@ -0,0 +1,109 @@ +"""Tests for ``MUREO_BYOD_DIR`` env-var override of ``byod_data_dir``. + +The env var lets the install-desktop wrapper point each Claude +Desktop workspace at its own BYOD directory (``/byod/``), +so demo and real-data setups can coexist without ``rm -rf +~/.mureo/byod/`` between them. The default (no env var set) keeps +the legacy ``~/.mureo/byod/`` location so existing CLI users see no +behaviour change. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.unit + + +def test_default_is_home_dot_mureo(monkeypatch: pytest.MonkeyPatch) -> None: + """Without the env var, the legacy ``~/.mureo/byod/`` path stands. + + Existing users (CLI, Claude Code) must not see any behaviour + change after this PR; only install-desktop callers opt in. + """ + from mureo.byod.runtime import byod_data_dir + + fake_home = Path("/tmp/fake_home_for_byod_default") + monkeypatch.setattr(Path, "home", lambda: fake_home) + monkeypatch.delenv("MUREO_BYOD_DIR", raising=False) + + assert byod_data_dir() == fake_home / ".mureo" / "byod" + + +def test_env_var_overrides_home( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """``MUREO_BYOD_DIR=`` redirects BYOD reads/writes.""" + from mureo.byod.runtime import byod_data_dir + + target = tmp_path / "workspace_byod" + monkeypatch.setenv("MUREO_BYOD_DIR", str(target)) + + assert byod_data_dir() == target + + +@pytest.mark.skipif( + sys.platform == "win32", + reason="Windows uses USERPROFILE/HOMEDRIVE, not HOME, for ~ expansion", +) +def test_env_var_expanduser(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """``~`` in the env var is expanded so users can point at + ``~/mureo/byod/`` without resolving manually in their shell rc.""" + from mureo.byod.runtime import byod_data_dir + + # ``Path.expanduser`` reads the ``HOME`` env var, not ``Path.home()``, + # so we must redirect both to keep the assertion stable. + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("MUREO_BYOD_DIR", "~/custom_byod") + + assert byod_data_dir() == tmp_path / "custom_byod" + + +def test_empty_env_var_falls_back_to_default( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """An empty string in the env var must be treated as 'unset'. + + Otherwise a stray ``MUREO_BYOD_DIR=`` (e.g. from a malformed + shell rc) silently writes BYOD into the cwd, which would surprise + everyone. + """ + from mureo.byod.runtime import byod_data_dir + + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("MUREO_BYOD_DIR", "") + + assert byod_data_dir() == tmp_path / ".mureo" / "byod" + + +def test_whitespace_only_env_var_falls_back_to_default( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A whitespace-only env var (`` ``, ``\\t``) must also fall back — + otherwise the BYOD directory ends up as a literal ``" "`` path, + which is even harder to debug than the empty-string case.""" + from mureo.byod.runtime import byod_data_dir + + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("MUREO_BYOD_DIR", " \t ") + + assert byod_data_dir() == tmp_path / ".mureo" / "byod" + + +def test_manifest_path_follows_env_var( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Downstream helpers like ``manifest_path`` must compose on top of + the env-aware ``byod_data_dir`` — verifying this prevents a + regression where a future refactor reintroduces a hard-coded path.""" + from mureo.byod.runtime import byod_data_dir, manifest_path + + target = tmp_path / "ws_byod" + monkeypatch.setenv("MUREO_BYOD_DIR", str(target)) + + assert manifest_path() == byod_data_dir() / "manifest.json" + assert manifest_path() == target / "manifest.json" diff --git a/tests/test_desktop_installer.py b/tests/test_desktop_installer.py new file mode 100644 index 0000000..d45b100 --- /dev/null +++ b/tests/test_desktop_installer.py @@ -0,0 +1,544 @@ +"""Tests for the ``mureo install-desktop`` command. + +This command is the primary onboarding path for non-engineer users +who want to run mureo from Claude Desktop chat. It must: + - create the workspace directory if absent + - generate a wrapper script that ``cd``s into the workspace + (Claude Desktop ignores the ``cwd`` field in its MCP config, so + we encode cwd in a shell wrapper instead) + - merge a ``mureo`` entry into ``claude_desktop_config.json`` + without clobbering other MCP servers + - back up the existing config before mutation + +Each test runs against a fake ``$HOME`` via monkeypatch so we never +touch the user's real Claude Desktop config or ``~/.local/bin``. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import patch + +import pytest + +pytestmark = pytest.mark.unit + + +@pytest.fixture +def fake_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Redirect ``$HOME`` and ``Path.home()`` to a temp directory. + + The installer reads several paths off ``Path.home()`` (workspace, + wrapper directory, Claude Desktop config). Pointing them all at a + sandbox keeps the user's real environment untouched. + """ + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + # Force macOS path even if tests run on Linux CI + monkeypatch.setattr("sys.platform", "darwin") + return tmp_path + + +def _config_path(home: Path) -> Path: + return ( + home + / "Library" + / "Application Support" + / "Claude" + / "claude_desktop_config.json" + ) + + +def _read_config(home: Path) -> dict: + cfg = _config_path(home) + if not cfg.exists(): + return {} + return json.loads(cfg.read_text(encoding="utf-8")) + + +# --------------------------------------------------------------------------- +# Fresh install +# --------------------------------------------------------------------------- + + +def test_install_creates_workspace(fake_home: Path) -> None: + from mureo.desktop_installer import install_desktop + + result = install_desktop(workspace=fake_home / "mureo") + + assert (fake_home / "mureo").is_dir() + assert result.workspace == fake_home / "mureo" + + +def test_install_generates_executable_wrapper(fake_home: Path) -> None: + from mureo.desktop_installer import install_desktop + + result = install_desktop(workspace=fake_home / "mureo") + + wrapper = result.wrapper_path + assert wrapper.exists() + assert wrapper.stat().st_mode & 0o111 + body = wrapper.read_text(encoding="utf-8") + assert body.startswith("#!/bin/bash") + assert f"cd {fake_home / 'mureo'}" in body + assert "-m mureo.mcp" in body + + +def test_wrapper_exports_workspace_local_byod_dir(fake_home: Path) -> None: + """The wrapper must ``export MUREO_BYOD_DIR=/byod`` so + the MCP process reads/writes BYOD data inside the workspace. + Without this, demo and real workspaces silently share the global + ``~/.mureo/byod/`` directory.""" + from mureo.desktop_installer import install_desktop + + result = install_desktop(workspace=fake_home / "mureo") + body = result.wrapper_path.read_text(encoding="utf-8") + expected = f"export MUREO_BYOD_DIR={fake_home / 'mureo' / 'byod'}" + assert expected in body + + +def test_install_writes_config_with_mureo_entry(fake_home: Path) -> None: + from mureo.desktop_installer import install_desktop + + result = install_desktop(workspace=fake_home / "mureo") + + cfg = _read_config(fake_home) + assert "mcpServers" in cfg + assert "mureo" in cfg["mcpServers"] + assert cfg["mcpServers"]["mureo"]["command"] == str(result.wrapper_path) + + +def test_install_creates_config_dir_if_missing(fake_home: Path) -> None: + from mureo.desktop_installer import install_desktop + + install_desktop(workspace=fake_home / "mureo") + + cfg_dir = fake_home / "Library" / "Application Support" / "Claude" + assert cfg_dir.is_dir() + + +# --------------------------------------------------------------------------- +# Existing config preservation +# --------------------------------------------------------------------------- + + +def test_install_preserves_other_mcp_servers(fake_home: Path) -> None: + from mureo.desktop_installer import install_desktop + + cfg_path = _config_path(fake_home) + cfg_path.parent.mkdir(parents=True, exist_ok=True) + cfg_path.write_text( + json.dumps( + { + "mcpServers": { + "filesystem": {"command": "/usr/local/bin/mcp-fs"}, + "github": {"command": "npx", "args": ["@github/mcp"]}, + } + } + ), + encoding="utf-8", + ) + + install_desktop(workspace=fake_home / "mureo") + + cfg = _read_config(fake_home) + assert "filesystem" in cfg["mcpServers"] + assert "github" in cfg["mcpServers"] + assert "mureo" in cfg["mcpServers"] + + +def test_install_preserves_top_level_preferences(fake_home: Path) -> None: + from mureo.desktop_installer import install_desktop + + cfg_path = _config_path(fake_home) + cfg_path.parent.mkdir(parents=True, exist_ok=True) + cfg_path.write_text( + json.dumps({"preferences": {"sidebarMode": "chat"}, "mcpServers": {}}), + encoding="utf-8", + ) + + install_desktop(workspace=fake_home / "mureo") + + cfg = _read_config(fake_home) + assert cfg["preferences"]["sidebarMode"] == "chat" + + +# --------------------------------------------------------------------------- +# Existing mureo entry — confirm vs --force +# --------------------------------------------------------------------------- + + +def test_install_refuses_overwrite_without_force(fake_home: Path) -> None: + from mureo.desktop_installer import ( + DesktopInstallExistsError, + install_desktop, + ) + + cfg_path = _config_path(fake_home) + cfg_path.parent.mkdir(parents=True, exist_ok=True) + cfg_path.write_text( + json.dumps({"mcpServers": {"mureo": {"command": "/old/path"}}}), + encoding="utf-8", + ) + + with pytest.raises(DesktopInstallExistsError): + install_desktop(workspace=fake_home / "mureo", force=False) + + +def test_install_overwrites_with_force(fake_home: Path) -> None: + from mureo.desktop_installer import install_desktop + + cfg_path = _config_path(fake_home) + cfg_path.parent.mkdir(parents=True, exist_ok=True) + cfg_path.write_text( + json.dumps({"mcpServers": {"mureo": {"command": "/old/path"}}}), + encoding="utf-8", + ) + + result = install_desktop(workspace=fake_home / "mureo", force=True) + + cfg = _read_config(fake_home) + assert cfg["mcpServers"]["mureo"]["command"] == str(result.wrapper_path) + assert cfg["mcpServers"]["mureo"]["command"] != "/old/path" + + +def test_install_creates_backup_when_overwriting(fake_home: Path) -> None: + from mureo.desktop_installer import install_desktop + + cfg_path = _config_path(fake_home) + cfg_path.parent.mkdir(parents=True, exist_ok=True) + original = json.dumps({"mcpServers": {"mureo": {"command": "/old/path"}}}) + cfg_path.write_text(original, encoding="utf-8") + + result = install_desktop(workspace=fake_home / "mureo", force=True) + + assert result.backup_path is not None + assert result.backup_path.exists() + assert result.backup_path.read_text(encoding="utf-8") == original + + +# --------------------------------------------------------------------------- +# --dry-run +# --------------------------------------------------------------------------- + + +def test_dry_run_does_not_create_files(fake_home: Path) -> None: + from mureo.desktop_installer import install_desktop + + result = install_desktop(workspace=fake_home / "mureo", dry_run=True) + + assert not (fake_home / "mureo").exists() + assert not result.wrapper_path.exists() + assert not _config_path(fake_home).exists() + assert result.dry_run is True + + +# --------------------------------------------------------------------------- +# Platform support +# --------------------------------------------------------------------------- + + +def test_install_refuses_non_macos( + fake_home: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from mureo.desktop_installer import ( + DesktopInstallUnsupportedPlatformError, + install_desktop, + ) + + monkeypatch.setattr("sys.platform", "linux") + with pytest.raises(DesktopInstallUnsupportedPlatformError): + install_desktop(workspace=fake_home / "mureo") + + +# --------------------------------------------------------------------------- +# Demo seeding +# --------------------------------------------------------------------------- + + +def test_install_with_demo_seeds_workspace(fake_home: Path) -> None: + from mureo import desktop_installer + + with patch.object(desktop_installer, "_run_demo_init") as mock_demo: + desktop_installer.install_desktop( + workspace=fake_home / "mureo", with_demo="seasonality-trap" + ) + + mock_demo.assert_called_once_with( + workspace=(fake_home / "mureo").resolve(), scenario="seasonality-trap" + ) + + +def test_install_with_demo_sets_byod_env_during_seed(fake_home: Path) -> None: + """During ``_run_demo_init`` the install process must temporarily + set ``MUREO_BYOD_DIR=/byod`` so the demo's BYOD bundle + lands inside the workspace — not in the global ``~/.mureo/byod/``. + Without this, the wrapper points at the workspace but the demo + seed wrote elsewhere, and the user sees an empty BYOD.""" + import os + + from mureo import desktop_installer + + seen_env: dict[str, str | None] = {"value": ""} + + def capture_env(workspace: Path, scenario: str) -> None: + seen_env["value"] = os.environ.get("MUREO_BYOD_DIR") + + with patch.object(desktop_installer, "_run_demo_init", side_effect=capture_env): + desktop_installer.install_desktop( + workspace=fake_home / "mureo", with_demo="seasonality-trap" + ) + + assert seen_env["value"] == str((fake_home / "mureo").resolve() / "byod") + + +def test_install_restores_byod_env_after_seed( + fake_home: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The temporary ``MUREO_BYOD_DIR`` must be reverted after demo + seed completes — leaking it into the calling process would change + the install-desktop caller's environment in unexpected ways.""" + from mureo import desktop_installer + + monkeypatch.setenv("MUREO_BYOD_DIR", "/preexisting/path") + + with patch.object(desktop_installer, "_run_demo_init"): + desktop_installer.install_desktop( + workspace=fake_home / "mureo", with_demo="seasonality-trap" + ) + + import os + + assert os.environ.get("MUREO_BYOD_DIR") == "/preexisting/path" + + +def test_install_unsets_byod_env_after_seed_when_unset_initially( + fake_home: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """If ``MUREO_BYOD_DIR`` was unset before install, it must be unset + after install — not left as the workspace path that we used for + the demo seed.""" + import os + + from mureo import desktop_installer + + monkeypatch.delenv("MUREO_BYOD_DIR", raising=False) + + with patch.object(desktop_installer, "_run_demo_init"): + desktop_installer.install_desktop( + workspace=fake_home / "mureo", with_demo="seasonality-trap" + ) + + assert "MUREO_BYOD_DIR" not in os.environ + + +def test_install_rejects_unknown_demo_scenario(fake_home: Path) -> None: + """A typo'd scenario must abort *before* any filesystem mutation + so the user is not left with a half-set-up workspace.""" + from mureo.desktop_installer import DesktopInstallError, install_desktop + + with pytest.raises(DesktopInstallError, match="Unknown demo scenario"): + install_desktop(workspace=fake_home / "mureo", with_demo="not_a_scenario") + # Nothing should have been written + assert not (fake_home / "mureo").exists() + assert not _config_path(fake_home).exists() + + +def test_install_without_demo_does_not_seed(fake_home: Path) -> None: + from mureo.desktop_installer import install_desktop + + install_desktop(workspace=fake_home / "mureo") + + assert not (fake_home / "mureo" / "STRATEGY.md").exists() + assert not (fake_home / "mureo" / "STATE.json").exists() + + +# --------------------------------------------------------------------------- +# Idempotence +# --------------------------------------------------------------------------- + + +def test_install_is_idempotent_with_force(fake_home: Path) -> None: + from mureo.desktop_installer import install_desktop + + install_desktop(workspace=fake_home / "mureo", force=True) + result = install_desktop(workspace=fake_home / "mureo", force=True) + + cfg = _read_config(fake_home) + assert cfg["mcpServers"]["mureo"]["command"] == str(result.wrapper_path) + assert result.wrapper_path.exists() + + +# --------------------------------------------------------------------------- +# Malformed config +# --------------------------------------------------------------------------- + + +def test_install_refuses_malformed_config(fake_home: Path) -> None: + from mureo.desktop_installer import ( + DesktopConfigCorruptError, + install_desktop, + ) + + cfg_path = _config_path(fake_home) + cfg_path.parent.mkdir(parents=True, exist_ok=True) + cfg_path.write_text("{ not valid json", encoding="utf-8") + + with pytest.raises(DesktopConfigCorruptError): + install_desktop(workspace=fake_home / "mureo") + + +def test_install_refuses_non_object_top_level(fake_home: Path) -> None: + """A valid-JSON-but-wrong-shape config (array, string, ...) must + raise cleanly rather than crash with AttributeError when we try to + call ``.get('mcpServers')`` on it.""" + from mureo.desktop_installer import ( + DesktopConfigCorruptError, + install_desktop, + ) + + cfg_path = _config_path(fake_home) + cfg_path.parent.mkdir(parents=True, exist_ok=True) + cfg_path.write_text('"hello"', encoding="utf-8") + + with pytest.raises(DesktopConfigCorruptError, match="object"): + install_desktop(workspace=fake_home / "mureo") + + +def test_install_handles_null_mcp_servers(fake_home: Path) -> None: + """``{"mcpServers": null}`` is wrong but mild — be permissive and + treat it as an empty servers map (we'd otherwise crash with + ``'NoneType' object has no attribute 'get'``).""" + from mureo.desktop_installer import install_desktop + + cfg_path = _config_path(fake_home) + cfg_path.parent.mkdir(parents=True, exist_ok=True) + cfg_path.write_text(json.dumps({"mcpServers": None}), encoding="utf-8") + + install_desktop(workspace=fake_home / "mureo") + + cfg = _read_config(fake_home) + assert "mureo" in cfg["mcpServers"] + + +def test_install_refuses_non_object_mcp_servers(fake_home: Path) -> None: + """``{"mcpServers": "oops"}`` is corrupt enough that we should + refuse rather than silently overwrite — the user clearly hand- + edited it and may not want us flattening their work.""" + from mureo.desktop_installer import ( + DesktopConfigCorruptError, + install_desktop, + ) + + cfg_path = _config_path(fake_home) + cfg_path.parent.mkdir(parents=True, exist_ok=True) + cfg_path.write_text(json.dumps({"mcpServers": "oops"}), encoding="utf-8") + + with pytest.raises(DesktopConfigCorruptError, match="mcpServers"): + install_desktop(workspace=fake_home / "mureo") + + +def test_install_refuses_symlinked_config(fake_home: Path) -> None: + """Refuse to follow a symlinked config — Dropbox/iCloud sync + setups commonly symlink it, and writing through a symlink can land + the change in an unexpected location.""" + import os + + from mureo.desktop_installer import ( + DesktopConfigCorruptError, + install_desktop, + ) + + real_target = fake_home / "real_config.json" + real_target.write_text(json.dumps({"mcpServers": {}}), encoding="utf-8") + cfg_path = _config_path(fake_home) + cfg_path.parent.mkdir(parents=True, exist_ok=True) + os.symlink(real_target, cfg_path) + + with pytest.raises(DesktopConfigCorruptError, match="symlink"): + install_desktop(workspace=fake_home / "mureo") + + +def test_wrapper_quotes_workspace_with_spaces(fake_home: Path) -> None: + """A workspace path containing spaces must produce a wrapper that + runs correctly under bash — verified by checking the cd/exec lines + use shlex-style single quoting.""" + from mureo.desktop_installer import install_desktop + + workspace = fake_home / "my mureo workspace" + result = install_desktop(workspace=workspace) + body = result.wrapper_path.read_text(encoding="utf-8") + # shlex.quote on a path with spaces produces single-quoted form + expected_cd = f"cd '{workspace.resolve()}'" + assert expected_cd in body + + +def test_install_idempotent_does_not_duplicate_mureo_entry(fake_home: Path) -> None: + """Re-running with --force must keep exactly one ``mureo`` key, + not append a duplicate.""" + from mureo.desktop_installer import install_desktop + + install_desktop(workspace=fake_home / "mureo", force=True) + install_desktop(workspace=fake_home / "mureo", force=True) + + cfg = _read_config(fake_home) + server_keys = list(cfg["mcpServers"].keys()) + assert server_keys.count("mureo") == 1 + + +# --------------------------------------------------------------------------- +# End-to-end: real materialize + workspace isolation +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +def test_install_with_demo_lands_byod_in_workspace_not_global( + fake_home: Path, +) -> None: + """End-to-end: ``--with-demo`` must produce a manifest under + ``/byod/`` and **not** under ``~/.mureo/byod/``. + + All earlier tests mock ``_run_demo_init``, so this is the only + test that proves the env-var dance actually flows through to the + real ``materialize`` call. Without this guard a future refactor + that drops the env-var around the seed would silently regress to + writing the demo bundle into the global directory. + """ + from mureo.desktop_installer import install_desktop + + workspace = fake_home / "mureo" + install_desktop(workspace=workspace, with_demo="seasonality-trap") + + workspace_manifest = workspace / "byod" / "manifest.json" + global_manifest = fake_home / ".mureo" / "byod" / "manifest.json" + assert ( + workspace_manifest.exists() + ), f"Expected demo BYOD manifest at {workspace_manifest}; got nothing" + assert ( + not global_manifest.exists() + ), f"Demo bundle leaked into global {global_manifest}" + + +@pytest.mark.integration +def test_two_workspaces_keep_byod_isolated(fake_home: Path) -> None: + """The headline guarantee: a demo workspace and a real workspace + can coexist with independent BYOD directories. Switching between + them is just ``install-desktop --workspace ... --force``.""" + from mureo.desktop_installer import install_desktop + + demo_ws = fake_home / "mureo-demo" + real_ws = fake_home / "mureo-real" + + install_desktop(workspace=demo_ws, with_demo="seasonality-trap") + install_desktop(workspace=real_ws, force=True) + + assert (demo_ws / "byod" / "manifest.json").exists() + # The real workspace was installed without --with-demo, so its + # byod/ should be empty (or absent). The wrapper, however, must + # point at it so future imports land there. + real_wrapper = (fake_home / ".local" / "bin" / "mureo-mcp-wrapper.sh").read_text() + assert f"export MUREO_BYOD_DIR={real_ws.resolve() / 'byod'}" in real_wrapper + # And the demo workspace's byod/ must still be intact after the + # second install — switching workspaces does not touch siblings. + assert (demo_ws / "byod" / "manifest.json").exists()