test_setup_cmd.py
1 """Tests for mureo setup commands (install_commands, install_skills).""" 2 3 from __future__ import annotations 4 5 from typing import TYPE_CHECKING 6 from unittest.mock import patch 7 8 import pytest 9 10 if TYPE_CHECKING: 11 from pathlib import Path 12 13 14 @pytest.mark.unit 15 def test_install_commands(tmp_path: Path) -> None: 16 """install_commands copies all .md files to target directory.""" 17 from mureo.cli.setup_cmd import install_commands 18 19 count, dest = install_commands(target_dir=tmp_path / "commands") 20 21 assert count == 10 22 assert dest == tmp_path / "commands" 23 assert (dest / "daily-check.md").exists() 24 assert (dest / "onboard.md").exists() 25 assert (dest / "rescue.md").exists() 26 27 28 @pytest.mark.unit 29 def test_install_commands_creates_directory(tmp_path: Path) -> None: 30 """install_commands creates target directory if it doesn't exist.""" 31 from mureo.cli.setup_cmd import install_commands 32 33 target = tmp_path / "deep" / "nested" / "commands" 34 count, dest = install_commands(target_dir=target) 35 36 assert count == 10 37 assert dest.exists() 38 39 40 @pytest.mark.unit 41 def test_install_commands_overwrites_existing(tmp_path: Path) -> None: 42 """install_commands overwrites existing files (idempotent).""" 43 from mureo.cli.setup_cmd import install_commands 44 45 target = tmp_path / "commands" 46 target.mkdir() 47 (target / "daily-check.md").write_text("old content") 48 49 count, _ = install_commands(target_dir=target) 50 51 assert count == 10 52 content = (target / "daily-check.md").read_text() 53 assert content != "old content" 54 55 56 @pytest.mark.unit 57 def test_install_commands_preserves_extra_files(tmp_path: Path) -> None: 58 """install_commands does not delete extra files in target directory.""" 59 from mureo.cli.setup_cmd import install_commands 60 61 target = tmp_path / "commands" 62 target.mkdir() 63 (target / "my-custom-command.md").write_text("custom") 64 65 install_commands(target_dir=target) 66 67 assert (target / "my-custom-command.md").read_text() == "custom" 68 69 70 @pytest.mark.unit 71 def test_install_commands_replaces_symlink_without_touching_target( 72 tmp_path: Path, 73 ) -> None: 74 """A symlink in the destination must be replaced with a real copy. 75 76 ``shutil.copy2`` follows destination symlinks by default — opening 77 ``dst`` for writing transparently writes to the symlink's target, 78 leaving the symlink itself in place. That matters for sandboxed 79 clients (Claude Desktop on macOS) that try to READ the command 80 file: following a symlink into ``~/Documents`` hits TCC and the 81 read silently fails, so the slash command appears to do nothing. 82 83 This regression locks in the fix: detect the symlink, unlink it, 84 then lay down a real file in its place. The external target file 85 the symlink used to point at is not touched. 86 """ 87 from mureo.cli.setup_cmd import install_commands 88 89 external_target = tmp_path / "external" / "daily-check.md" 90 external_target.parent.mkdir(parents=True) 91 external_target.write_text("dev-link content — must survive") 92 93 target = tmp_path / "commands" 94 target.mkdir() 95 link_path = target / "daily-check.md" 96 link_path.symlink_to(external_target) 97 98 install_commands(target_dir=target) 99 100 # The dev symlink was replaced with a real file. 101 assert not link_path.is_symlink() 102 assert link_path.is_file() 103 # Body is now the bundled command, not the dev copy. 104 assert link_path.read_text() != "dev-link content — must survive" 105 # The external target the symlink used to point at is intact. 106 assert external_target.exists() 107 assert external_target.read_text() == "dev-link content — must survive" 108 109 110 @pytest.mark.unit 111 def test_install_commands_replaces_broken_symlink(tmp_path: Path) -> None: 112 """A dangling symlink at the destination must still be replaced 113 with a real file. ``Path.is_symlink()`` returns True for a broken 114 link, and ``Path.unlink()`` removes the link without caring that 115 the target is missing, so the subsequent ``shutil.copy2`` writes 116 a clean real file. Locks in the "don't care about the target" 117 contract. 118 """ 119 from mureo.cli.setup_cmd import install_commands 120 121 target = tmp_path / "commands" 122 target.mkdir() 123 broken = target / "daily-check.md" 124 broken.symlink_to(tmp_path / "does-not-exist.md") 125 126 install_commands(target_dir=target) 127 128 assert not broken.is_symlink() 129 assert broken.is_file() 130 assert broken.read_text() # Non-empty real bundled content. 131 132 133 @pytest.mark.unit 134 def test_install_skills(tmp_path: Path) -> None: 135 """install_skills copies all skill directories to target.""" 136 from mureo.cli.setup_cmd import install_skills 137 138 count, dest = install_skills(target_dir=tmp_path / "skills") 139 140 assert count == 6 141 assert dest == tmp_path / "skills" 142 assert (dest / "mureo-google-ads" / "SKILL.md").exists() 143 assert (dest / "mureo-meta-ads" / "SKILL.md").exists() 144 assert (dest / "mureo-shared" / "SKILL.md").exists() 145 assert (dest / "mureo-strategy" / "SKILL.md").exists() 146 assert (dest / "mureo-workflows" / "SKILL.md").exists() 147 assert (dest / "mureo-learning" / "SKILL.md").exists() 148 149 150 @pytest.mark.unit 151 def test_install_skills_creates_directory(tmp_path: Path) -> None: 152 """install_skills creates target directory if it doesn't exist.""" 153 from mureo.cli.setup_cmd import install_skills 154 155 target = tmp_path / "deep" / "nested" / "skills" 156 count, dest = install_skills(target_dir=target) 157 158 assert count == 6 159 assert dest.exists() 160 161 162 @pytest.mark.unit 163 def test_install_skills_overwrites_existing(tmp_path: Path) -> None: 164 """install_skills replaces existing skill directories (idempotent).""" 165 from mureo.cli.setup_cmd import install_skills 166 167 target = tmp_path / "skills" 168 target.mkdir() 169 old_skill = target / "mureo-shared" 170 old_skill.mkdir() 171 (old_skill / "SKILL.md").write_text("old content") 172 173 install_skills(target_dir=target) 174 175 content = (target / "mureo-shared" / "SKILL.md").read_text() 176 assert content != "old content" 177 178 179 @pytest.mark.unit 180 def test_install_skills_preserves_extra_skills(tmp_path: Path) -> None: 181 """install_skills does not delete extra skill directories.""" 182 from mureo.cli.setup_cmd import install_skills 183 184 target = tmp_path / "skills" 185 target.mkdir() 186 custom = target / "my-custom-skill" 187 custom.mkdir() 188 (custom / "SKILL.md").write_text("custom") 189 190 install_skills(target_dir=target) 191 192 assert (target / "my-custom-skill" / "SKILL.md").read_text() == "custom" 193 194 195 @pytest.mark.unit 196 def test_install_skills_replaces_symlink_without_touching_target( 197 tmp_path: Path, 198 ) -> None: 199 """A symlink in the destination must be replaced with a real copy. 200 201 Developers often symlink `~/.claude/skills/<bundled>/` at their repo's 202 dev copy. Re-running `mureo setup claude-code` used to crash with 203 ``OSError: Cannot call rmtree on a symbolic link`` because 204 ``shutil.rmtree`` refuses symlinks by design (to avoid blowing away 205 the link's target). This regression pins the corrected behavior: 206 the symlink itself is removed, then a real copy of the bundled skill 207 lands in its place. The external target the symlink used to point 208 at is left untouched. 209 """ 210 from mureo.cli.setup_cmd import install_skills 211 212 external_target = tmp_path / "external" / "mureo-workflows" 213 external_target.mkdir(parents=True) 214 (external_target / "SKILL.md").write_text("dev-link content") 215 (external_target / "precious.txt").write_text("do not delete") 216 217 target = tmp_path / "skills" 218 target.mkdir() 219 link_path = target / "mureo-workflows" 220 link_path.symlink_to(external_target, target_is_directory=True) 221 222 install_skills(target_dir=target) 223 224 assert not link_path.is_symlink() 225 assert link_path.is_dir() 226 assert (link_path / "SKILL.md").exists() 227 assert external_target.exists() 228 assert (external_target / "SKILL.md").read_text() == "dev-link content" 229 assert (external_target / "precious.txt").read_text() == "do not delete" 230 231 232 @pytest.mark.unit 233 def test_install_commands_default_path(tmp_path: Path) -> None: 234 """install_commands uses ~/.claude/commands as default.""" 235 from mureo.cli.setup_cmd import install_commands 236 237 with patch("mureo.cli.setup_cmd.Path.home", return_value=tmp_path): 238 count, dest = install_commands() 239 240 assert dest == tmp_path / ".claude" / "commands" 241 assert count == 10 242 243 244 @pytest.mark.unit 245 def test_install_skills_default_path(tmp_path: Path) -> None: 246 """install_skills uses ~/.claude/skills as default.""" 247 from mureo.cli.setup_cmd import install_skills 248 249 with patch("mureo.cli.setup_cmd.Path.home", return_value=tmp_path): 250 count, dest = install_skills() 251 252 assert dest == tmp_path / ".claude" / "skills" 253 assert count == 6 254 255 256 # --------------------------------------------------------------------------- 257 # Non-interactive behavior — regression tests for TTY-safe setup commands 258 # --------------------------------------------------------------------------- 259 260 261 @pytest.mark.unit 262 def test_should_skip_auth_when_flag_true() -> None: 263 """--skip-auth always wins regardless of TTY state.""" 264 from mureo.cli.setup_cmd import _should_skip_auth 265 266 skip, banner = _should_skip_auth(skip_auth_flag=True) 267 assert skip is True 268 assert banner is not None 269 assert "--skip-auth" in banner 270 271 272 @pytest.mark.unit 273 def test_should_skip_auth_when_no_tty( 274 monkeypatch: pytest.MonkeyPatch, 275 ) -> None: 276 """Missing TTY (AI-agent subprocess, CI) auto-implies skip-auth.""" 277 from mureo.cli.setup_cmd import _should_skip_auth 278 279 monkeypatch.setattr("mureo.cli.setup_cmd.is_tty", lambda: False) 280 skip, banner = _should_skip_auth(skip_auth_flag=False) 281 assert skip is True 282 assert banner is not None 283 assert "No TTY" in banner 284 assert "mureo auth setup" in banner 285 286 287 @pytest.mark.unit 288 def test_should_not_skip_auth_when_tty_and_no_flag( 289 monkeypatch: pytest.MonkeyPatch, 290 ) -> None: 291 """Interactive TTY preserves the existing prompting flow.""" 292 from mureo.cli.setup_cmd import _should_skip_auth 293 294 monkeypatch.setattr("mureo.cli.setup_cmd.is_tty", lambda: True) 295 skip, banner = _should_skip_auth(skip_auth_flag=False) 296 assert skip is False 297 assert banner is None 298 299 300 @pytest.mark.unit 301 def test_setup_claude_code_runs_without_tty( 302 monkeypatch: pytest.MonkeyPatch, tmp_path: Path 303 ) -> None: 304 """`mureo setup claude-code` must complete non-interactively under 305 an AI agent (no TTY) AND still perform every non-auth install step. 306 307 Regression lock: a future refactor that silently no-ops everything 308 in non-TTY mode would still print "Setup complete" — so we also 309 assert the MCP config file, the credential guard hook, the workflow 310 commands directory, and the skills directory were all actually 311 created on disk. 312 """ 313 import json 314 315 import typer.testing 316 317 from mureo.cli.main import app 318 319 monkeypatch.setattr("mureo.cli.setup_cmd.is_tty", lambda: False) 320 monkeypatch.setattr("mureo.cli.setup_cmd.Path.home", lambda: tmp_path) 321 monkeypatch.setattr("mureo.auth_setup.Path.home", lambda: tmp_path) 322 monkeypatch.setattr("mureo.cli.setup_codex.Path.home", lambda: tmp_path) 323 # Guard: if any code path tries to reach OAuth, fail loudly. 324 from mureo import auth_setup 325 326 def _boom(*_: object, **__: object) -> None: 327 raise AssertionError("OAuth must not be invoked in non-TTY mode") 328 329 monkeypatch.setattr(auth_setup, "setup_google_ads", _boom) 330 monkeypatch.setattr(auth_setup, "setup_meta_ads", _boom) 331 332 runner = typer.testing.CliRunner() 333 result = runner.invoke(app, ["setup", "claude-code"]) 334 assert result.exit_code == 0, result.output 335 assert "No TTY detected" in result.output 336 assert "Setup complete" in result.output 337 338 # MCP config must exist with mureo registered. 339 settings_path = tmp_path / ".claude" / "settings.json" 340 assert settings_path.exists() 341 settings = json.loads(settings_path.read_text(encoding="utf-8")) 342 assert "mureo" in settings.get("mcpServers", {}) 343 # Credential guard hook installed (same settings.json). 344 hooks = settings.get("hooks", {}).get("PreToolUse", []) 345 assert any( 346 "[mureo-credential-guard]" in h.get("command", "") 347 for entry in hooks 348 for h in entry.get("hooks", []) 349 ) 350 # Workflow commands + skills were copied. 351 assert (tmp_path / ".claude" / "commands" / "onboard.md").exists() 352 assert (tmp_path / ".claude" / "skills" / "mureo-workflows").exists()