/ tests / test_setup_cmd.py
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()