From 401b3e1f32bb0f2a9d01f2679ca42ed547b26382 Mon Sep 17 00:00:00 2001 From: hyoshi <4027404+hyoshi@users.noreply.github.com> Date: Fri, 1 May 2026 12:47:19 +0900 Subject: [PATCH 1/3] feat(cli): add `mureo install-desktop` for one-step Claude Desktop onboarding Phase 2-1 of the non-engineer onboarding roadmap. Wires mureo into Claude Desktop's MCP config in a single command so users no longer need to: 1. find ~/Library/Application Support/Claude/claude_desktop_config.json 2. edit JSON by hand 3. work around Claude Desktop ignoring the `cwd` field 4. write a shell wrapper The command takes care of all four: - creates the workspace (default ~/mureo) - generates ~/.local/bin/mureo-mcp-wrapper.sh that `cd`s into the workspace before exec'ing python -m mureo.mcp (with shlex-quoted paths so HOMEs with spaces or metacharacters do not break) - merges a `mureo` entry into the Desktop config without clobbering other MCP servers (filesystem, github, ...) or top-level prefs - writes the config atomically (tempfile + os.replace + fsync) so a disk-full or power loss mid-write cannot leave Claude Desktop without an mcpServers section - timestamped backup before any mutation Flags: --workspace, --with-demo, --force, --dry-run. Refuses cleanly on: - non-macOS (Phase 1 scope, Linux/Windows tracked as follow-up) - existing `mureo` entry without --force - corrupt JSON / non-object top-level / non-object mcpServers - symlinked config (Dropbox/iCloud sync setups -- surfaces the constraint instead of silently writing through the link) - unknown --with-demo scenario (validated before any mutation, so a typo cannot leave a half-populated workspace) 22 unit tests cover fresh install, config preservation, force overwrite + backup, dry-run, idempotence (including key-count check), all corrupt-config flavours, symlinked config, and shell quoting of spaces in workspace paths. --- mureo/cli/install_desktop_cmd.py | 91 +++++++ mureo/cli/main.py | 2 + mureo/desktop_installer.py | 346 ++++++++++++++++++++++++++ tests/test_desktop_installer.py | 411 +++++++++++++++++++++++++++++++ 4 files changed, 850 insertions(+) create mode 100644 mureo/cli/install_desktop_cmd.py create mode 100644 mureo/desktop_installer.py create mode 100644 tests/test_desktop_installer.py 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..5287e31 --- /dev/null +++ b/mureo/desktop_installer.py @@ -0,0 +1,346 @@ +"""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. + """ + quoted_workspace = shlex.quote(str(workspace)) + 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" + 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. + if with_demo is not None: + _run_demo_init(workspace=workspace, scenario=with_demo) + + 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_desktop_installer.py b/tests/test_desktop_installer.py new file mode 100644 index 0000000..153b38c --- /dev/null +++ b/tests/test_desktop_installer.py @@ -0,0 +1,411 @@ +"""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_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_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 From 52152426e2ead9754865c3b9d2099d514d97ffd2 Mon Sep 17 00:00:00 2001 From: hyoshi <4027404+hyoshi@users.noreply.github.com> Date: Fri, 1 May 2026 15:47:53 +0900 Subject: [PATCH 2/3] feat(byod): support workspace-local BYOD via MUREO_BYOD_DIR env var Phase 2-1.5 of the non-engineer onboarding roadmap. Lets each Claude Desktop workspace have its own BYOD directory so demo and real-data setups can coexist without manual cleanup of ~/.mureo/byod/ between them. Changes: - byod_data_dir() now consults MUREO_BYOD_DIR env var first; legacy ~/.mureo/byod/ remains the default when the var is unset or empty (existing CLI / Claude Code users see no change). - mureo install-desktop wrapper now exports MUREO_BYOD_DIR=/byod so the MCP runtime spawned by Claude Desktop reads/writes BYOD inside the workspace. - During --with-demo seeding the install process itself temporarily sets the same env var so materialize writes the demo bundle into the workspace, then restores the prior value (or unsets it if it was unset) to keep install_desktop clean when called as a library. User-visible effect: mureo install-desktop --workspace ~/mureo-demo --with-demo seasonality-trap mureo install-desktop --workspace ~/mureo-real --force Both workspaces now have independent byod/ directories. Tests: - 5 new tests in test_byod_runtime_env.py covering precedence (env > home), expanduser, empty-string fallback, and composability through manifest_path. - 3 new tests in test_desktop_installer.py for the wrapper export line, env var being set during demo seed, and proper restoration after seed (preserving prior value, leaving unset when initially unset). The 4 production callers of byod_data_dir() (_client_factory, byod/installer, byod/bundle, cli/byod_cmd) and 25 test sites all call it dynamically so they pick up the override transparently. --- mureo/byod/runtime.py | 19 ++++++- mureo/desktop_installer.py | 27 +++++++++- tests/test_byod_runtime_env.py | 90 +++++++++++++++++++++++++++++++++ tests/test_desktop_installer.py | 76 ++++++++++++++++++++++++++++ 4 files changed, 210 insertions(+), 2 deletions(-) create mode 100644 tests/test_byod_runtime_env.py diff --git a/mureo/byod/runtime.py b/mureo/byod/runtime.py index bd939ab..13227df 100644 --- a/mureo/byod/runtime.py +++ b/mureo/byod/runtime.py @@ -30,10 +30,27 @@ _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`` so 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 env var is treated as 'unset' so a stray + ``MUREO_BYOD_DIR=`` from a malformed shell rc cannot silently + redirect BYOD into the cwd. ``~`` in the value is expanded. + """ + override = os.environ.get(_BYOD_DIR_ENV) + if override: + return Path(override).expanduser() return Path.home() / _USER_DIR_NAME / _BYOD_SUBDIR diff --git a/mureo/desktop_installer.py b/mureo/desktop_installer.py index 5287e31..f69838b 100644 --- a/mureo/desktop_installer.py +++ b/mureo/desktop_installer.py @@ -87,8 +87,14 @@ def _wrapper_body(workspace: Path, python_executable: str) -> str: 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" @@ -99,6 +105,8 @@ def _wrapper_body(workspace: Path, python_executable: str) -> str: "# 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' ) @@ -291,8 +299,25 @@ def install_desktop( # 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. if with_demo is not None: - _run_demo_init(workspace=workspace, scenario=with_demo) + byod_target = str((workspace / "byod").resolve()) + 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(): diff --git a/tests/test_byod_runtime_env.py b/tests/test_byod_runtime_env.py new file mode 100644 index 0000000..cc552ec --- /dev/null +++ b/tests/test_byod_runtime_env.py @@ -0,0 +1,90 @@ +"""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 + +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 + + +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_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 index 153b38c..3fef0a0 100644 --- a/tests/test_desktop_installer.py +++ b/tests/test_desktop_installer.py @@ -85,6 +85,19 @@ def test_install_generates_executable_wrapper(fake_home: Path) -> None: 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 @@ -257,6 +270,69 @@ def test_install_with_demo_seeds_workspace(fake_home: Path) -> None: ) +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" / "byod").resolve()) + + +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 b1244292f6501849172151223192e3ad3be3c31a Mon Sep 17 00:00:00 2001 From: hyoshi <4027404+hyoshi@users.noreply.github.com> Date: Fri, 1 May 2026 15:52:39 +0900 Subject: [PATCH 3/3] fixup(byod): address code-review feedback (whitespace, asymmetric resolve, e2e tests) - runtime.py: reject whitespace-only MUREO_BYOD_DIR (was only rejecting empty) - desktop_installer.py: drop redundant .resolve() (workspace is already resolved); add not-thread-safe note - runtime.py docstring: mention install_desktop() also sets the env var - test_byod_runtime_env.py: add whitespace-only fallback test, skip expanduser test on win32 - test_desktop_installer.py: align byod_target assertion with new unresolved path - test_desktop_installer.py: add 2 integration tests covering real materialize and two-workspace isolation (the headline guarantee of the env-var feature) --- mureo/byod/runtime.py | 21 +++++++----- mureo/desktop_installer.py | 13 +++++++- tests/test_byod_runtime_env.py | 19 +++++++++++ tests/test_desktop_installer.py | 59 ++++++++++++++++++++++++++++++++- 4 files changed, 101 insertions(+), 11 deletions(-) diff --git a/mureo/byod/runtime.py b/mureo/byod/runtime.py index 13227df..02f1f99 100644 --- a/mureo/byod/runtime.py +++ b/mureo/byod/runtime.py @@ -37,20 +37,23 @@ def byod_data_dir() -> Path: """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`` so each - Claude Desktop workspace sees its own BYOD store and demo - data does not leak across workspaces). + 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 env var is treated as 'unset' so a stray - ``MUREO_BYOD_DIR=`` from a malformed shell rc cannot silently - redirect BYOD into the cwd. ``~`` in the value is expanded. + 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: - return Path(override).expanduser() + if override and override.strip(): + return Path(override.strip()).expanduser() return Path.home() / _USER_DIR_NAME / _BYOD_SUBDIR diff --git a/mureo/desktop_installer.py b/mureo/desktop_installer.py index f69838b..b57166c 100644 --- a/mureo/desktop_installer.py +++ b/mureo/desktop_installer.py @@ -307,8 +307,19 @@ def install_desktop( # 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").resolve()) + byod_target = str(workspace / "byod") previous_byod = os.environ.get("MUREO_BYOD_DIR") os.environ["MUREO_BYOD_DIR"] = byod_target try: diff --git a/tests/test_byod_runtime_env.py b/tests/test_byod_runtime_env.py index cc552ec..7fb345c 100644 --- a/tests/test_byod_runtime_env.py +++ b/tests/test_byod_runtime_env.py @@ -10,6 +10,7 @@ from __future__ import annotations +import sys from pathlib import Path import pytest @@ -44,6 +45,10 @@ def test_env_var_overrides_home( 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.""" @@ -75,6 +80,20 @@ def test_empty_env_var_falls_back_to_default( 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: diff --git a/tests/test_desktop_installer.py b/tests/test_desktop_installer.py index 3fef0a0..d45b100 100644 --- a/tests/test_desktop_installer.py +++ b/tests/test_desktop_installer.py @@ -290,7 +290,7 @@ def capture_env(workspace: Path, scenario: str) -> None: workspace=fake_home / "mureo", with_demo="seasonality-trap" ) - assert seen_env["value"] == str((fake_home / "mureo" / "byod").resolve()) + assert seen_env["value"] == str((fake_home / "mureo").resolve() / "byod") def test_install_restores_byod_env_after_seed( @@ -485,3 +485,60 @@ def test_install_idempotent_does_not_duplicate_mureo_entry(fake_home: Path) -> N 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()