/ pyod / test / test_cli.py
test_cli.py
  1  # -*- coding: utf-8 -*-
  2  """Tests for the unified pyod CLI."""
  3  import contextlib
  4  import io
  5  import os
  6  import subprocess
  7  import sys
  8  from pathlib import Path
  9  
 10  
 11  def test_pyod_cli_help():
 12      """Running `pyod --help` lists the three subcommands."""
 13      result = subprocess.run(
 14          [sys.executable, "-m", "pyod.cli", "--help"],
 15          capture_output=True, text=True,
 16      )
 17      assert result.returncode == 0
 18      assert "install" in result.stdout
 19      assert "info" in result.stdout
 20      assert "mcp" in result.stdout
 21  
 22  
 23  def test_pyod_info_runs():
 24      """`pyod info` prints version and detector counts, exit 0 in core install."""
 25      result = subprocess.run(
 26          [sys.executable, "-m", "pyod.cli", "info"],
 27          capture_output=True, text=True,
 28      )
 29      assert result.returncode == 0, f"stderr={result.stderr}"
 30      assert "PyOD version" in result.stdout
 31      assert "detectors" in result.stdout.lower() or "Detectors" in result.stdout
 32  
 33  
 34  def test_pyod_info_does_not_exit_without_mcp():
 35      """`pyod info` must not crash in a core install without the mcp extra.
 36  
 37      Regression test for the Task 1 mcp_server refactor: if
 38      pyod.mcp_server ever regresses to exiting at import time, `pyod info`
 39      would inherit the exit and this test would fail.
 40      """
 41      result = subprocess.run(
 42          [sys.executable, "-c",
 43           "import sys; sys.modules['mcp'] = None; "
 44           "from pyod.cli import main; sys.exit(main(['info']))"],
 45          capture_output=True, text=True,
 46      )
 47      assert result.returncode == 0, (
 48          f"pyod info exited non-zero with mcp blocked: "
 49          f"stdout={result.stdout!r} stderr={result.stderr!r}"
 50      )
 51  
 52  
 53  _REPO_ROOT = str(Path(__file__).resolve().parents[2])
 54  
 55  
 56  def _isolated_home_env(home: Path) -> dict:
 57      """Return an env dict with HOME/USERPROFILE pointing at a clean directory.
 58  
 59      Used by tests that need to exercise the ``pyod info`` skill-install-state
 60      branches without leaking state from the test runner's real home dir
 61      (which may have ``~/.claude`` or ``~/.codex`` installed for unrelated
 62      reasons).
 63  
 64      Also injects the repo root into ``PYTHONPATH`` so subprocesses that
 65      run ``python -m pyod.cli`` from a temp cwd can still import ``pyod``.
 66      On developer workstations with an editable install this is redundant;
 67      on CI the workflow does not install pyod, so this is load-bearing.
 68      """
 69      env = os.environ.copy()
 70      env["HOME"] = str(home)
 71      env["USERPROFILE"] = str(home)  # Windows equivalent of HOME
 72      existing_pp = env.get("PYTHONPATH", "")
 73      env["PYTHONPATH"] = (
 74          _REPO_ROOT + os.pathsep + existing_pp if existing_pp else _REPO_ROOT
 75      )
 76      return env
 77  
 78  
 79  def test_pyod_info_project_local_only(tmp_path):
 80      """`pyod info` distinguishes project-local-only from user-global install.
 81  
 82      Regression test for the expanded skill-install-state rendering in
 83      `_cmd_info`. Sets up:
 84        - a fake project-local install at `tmp_path/skills/od-expert/SKILL.md`
 85        - a clean HOME (no ~/.claude/ or ~/.codex/ under it)
 86      then runs `pyod info` with `cwd=tmp_path` and asserts the output
 87      reports the project-local install without claiming a user-global
 88      install or detecting any agent stack.
 89      """
 90      project_skill = tmp_path / "skills" / "od-expert"
 91      project_skill.mkdir(parents=True)
 92      (project_skill / "SKILL.md").write_text("---\nname: od-expert\n---\n")
 93  
 94      fake_home = tmp_path / "fake_home"
 95      fake_home.mkdir()
 96  
 97      result = subprocess.run(
 98          [sys.executable, "-m", "pyod.cli", "info"],
 99          capture_output=True, text=True,
100          cwd=str(tmp_path),
101          env=_isolated_home_env(fake_home),
102      )
103      assert result.returncode == 0, f"stderr={result.stderr}"
104      assert "INSTALLED (project)" in result.stdout
105      assert "INSTALLED (user-global)" not in result.stdout
106      # No agent dirs in fake_home → no "Active for" or "Detected agents" line
107      assert "Detected agents:" not in result.stdout
108  
109  
110  def test_pyod_info_codex_detected_no_skill(tmp_path):
111      """`pyod info` detects Codex via ~/.codex/ and advises --project install.
112  
113      Sets up a fake HOME containing only ``~/.codex/`` (no Claude Code, no
114      project-local skill). `pyod info` must report Codex as detected and
115      recommend ``pyod install skill --project`` rather than the Claude-Code-
116      specific user-global command.
117      """
118      fake_home = tmp_path / "fake_home"
119      (fake_home / ".codex").mkdir(parents=True)
120  
121      # cwd is a pristine directory with no ./skills/od-expert/ install
122      work = tmp_path / "work"
123      work.mkdir()
124  
125      result = subprocess.run(
126          [sys.executable, "-m", "pyod.cli", "info"],
127          capture_output=True, text=True,
128          cwd=str(work),
129          env=_isolated_home_env(fake_home),
130      )
131      assert result.returncode == 0, f"stderr={result.stderr}"
132      assert "NOT INSTALLED" in result.stdout
133      assert "Codex" in result.stdout
134      assert "pyod install skill --project" in result.stdout
135      # Must NOT fall into the Claude-Code-specific branch
136      assert "Claude Code (user-global)" not in result.stdout
137      assert "no agent stacks detected" not in result.stdout
138  
139  
140  def test_pyod_info_codex_and_claude_both_detected(tmp_path):
141      """Both ~/.claude/ and ~/.codex/ present, neither skill installed.
142  
143      Output must list both agents and show both install commands so the
144      user knows which option fits their workflow.
145      """
146      fake_home = tmp_path / "fake_home"
147      (fake_home / ".claude").mkdir(parents=True)
148      (fake_home / ".codex").mkdir(parents=True)
149  
150      work = tmp_path / "work"
151      work.mkdir()
152  
153      result = subprocess.run(
154          [sys.executable, "-m", "pyod.cli", "info"],
155          capture_output=True, text=True,
156          cwd=str(work),
157          env=_isolated_home_env(fake_home),
158      )
159      assert result.returncode == 0, f"stderr={result.stderr}"
160      assert "NOT INSTALLED" in result.stdout
161      # Pin the exact recommendation lines so a regression that drops one
162      # branch cannot pass on a substring match (``pyod install skill`` is
163      # a substring of ``pyod install skill --project``).
164      assert "Claude Code (user-global): run `pyod install skill`" in result.stdout
165      assert "Codex (project-local):" in result.stdout
166      assert "`pyod install skill --project`" in result.stdout
167  
168  
169  def test_pyod_info_user_global_claude_plus_codex_detected(tmp_path):
170      """User-global Claude install + Codex detected + no project install.
171  
172      The subtle branch flagged in Round 2 review. Claude's user-global
173      skill does not help Codex, so the output must recommend `--project`
174      for Codex even though Claude Code is already satisfied.
175      """
176      fake_home = tmp_path / "fake_home"
177      # Pre-install od-expert user-globally for Claude Code
178      user_skill = fake_home / ".claude" / "skills" / "od-expert"
179      user_skill.mkdir(parents=True)
180      (user_skill / "SKILL.md").write_text("---\nname: od-expert\n---\n")
181      # Codex also present
182      (fake_home / ".codex").mkdir(parents=True)
183  
184      # Pristine cwd (no project-local install)
185      work = tmp_path / "work"
186      work.mkdir()
187  
188      result = subprocess.run(
189          [sys.executable, "-m", "pyod.cli", "info"],
190          capture_output=True, text=True,
191          cwd=str(work),
192          env=_isolated_home_env(fake_home),
193      )
194      assert result.returncode == 0, f"stderr={result.stderr}"
195      assert "INSTALLED (user-global)" in result.stdout
196      # Must explicitly flag the Codex gap and the --project remedy.
197      assert "Codex detected but does not read the user-global path" in result.stdout
198      assert "`pyod install skill --project`" in result.stdout
199  
200  
201  def test_install_skill_project_message_is_agent_neutral(tmp_path):
202      """`pyod install skill --project` must not claim Claude-only activation.
203  
204      Regression test for the Round 2 finding that `_run_install` hardcoded
205      a Claude-specific success message. For a project-local install, the
206      output should use agent-neutral wording that covers both Claude Code
207      and Codex.
208  
209      Uses explicit ``cwd`` + ``_isolated_home_env`` so the subprocess
210      imports ``pyod`` via PYTHONPATH even on CI (no editable install).
211      """
212      fake_home = tmp_path / "fake_home"
213      fake_home.mkdir()
214      work = tmp_path / "work"
215      work.mkdir()
216  
217      result = subprocess.run(
218          [sys.executable, "-m", "pyod.cli", "install", "skill", "--project"],
219          capture_output=True, text=True,
220          cwd=str(work),
221          env=_isolated_home_env(fake_home),
222      )
223      assert result.returncode == 0, f"stderr={result.stderr}"
224      assert (work / "skills" / "od-expert" / "SKILL.md").is_file()
225      # The Claude-only restart hint must NOT appear for project-local installs.
226      assert "Restart your Claude Code session" not in result.stdout
227      assert "Claude Code will auto-activate" not in result.stdout
228      # The agent-neutral wording must appear.
229      assert "project-local skill" in result.stdout
230      assert "Codex" in result.stdout
231  
232  
233  def test_pyod_install_skill_to_target(tmp_path):
234      """`pyod install skill --target <path>` writes od-expert/SKILL.md."""
235      result = subprocess.run(
236          [sys.executable, "-m", "pyod.cli", "install", "skill",
237           "--target", str(tmp_path)],
238          capture_output=True, text=True,
239      )
240      assert result.returncode == 0
241      assert (tmp_path / "od-expert" / "SKILL.md").is_file()
242      # Output should use the canonical hyphenated name.
243      assert "od-expert" in result.stdout
244  
245  
246  def test_pyod_install_skill_canonical_name_on_underscore_input(tmp_path):
247      """Passing `--skill od_expert` still prints the canonical `od-expert`."""
248      result = subprocess.run(
249          [sys.executable, "-m", "pyod.cli", "install", "skill",
250           "--skill", "od_expert", "--target", str(tmp_path)],
251          capture_output=True, text=True,
252      )
253      assert result.returncode == 0
254      assert "Installed od-expert skill" in result.stdout
255      assert "Installed od_expert skill" not in result.stdout
256  
257  
258  def test_pyod_install_skill_list():
259      """`pyod install skill --list` prints available skills with canonical names."""
260      result = subprocess.run(
261          [sys.executable, "-m", "pyod.cli", "install", "skill", "--list"],
262          capture_output=True, text=True,
263      )
264      assert result.returncode == 0
265      assert "od-expert" in result.stdout
266  
267  
268  def test_legacy_and_unified_install_match_in_process(tmp_path):
269      """`pyod-install-skill ...` and `pyod install skill ...` match in-process.
270  
271      Compares return code, stdout (with the path line scrubbed), and
272      stderr. An entry-point regression that changes exit behavior would
273      fail here.
274      """
275      from pyod.skills import install_cli
276      from pyod.cli import main as cli_main
277  
278      tmp_legacy = tmp_path / "legacy"
279      tmp_unified = tmp_path / "unified"
280  
281      buf_legacy_out = io.StringIO()
282      buf_legacy_err = io.StringIO()
283      with contextlib.redirect_stdout(buf_legacy_out), \
284           contextlib.redirect_stderr(buf_legacy_err):
285          rc_legacy = install_cli(["--target", str(tmp_legacy)])
286  
287      buf_unified_out = io.StringIO()
288      buf_unified_err = io.StringIO()
289      with contextlib.redirect_stdout(buf_unified_out), \
290           contextlib.redirect_stderr(buf_unified_err):
291          rc_unified = cli_main(["install", "skill", "--target", str(tmp_unified)])
292  
293      assert rc_legacy == 0
294      assert rc_unified == 0
295      assert rc_legacy == rc_unified
296  
297      def _scrub(text):
298          return "\n".join(
299              line for line in text.splitlines()
300              if "Installed od-expert skill to:" not in line
301          )
302  
303      assert _scrub(buf_legacy_out.getvalue()) == _scrub(buf_unified_out.getvalue())
304      assert buf_legacy_err.getvalue() == buf_unified_err.getvalue()
305  
306  
307  def test_legacy_and_unified_install_match_subprocess(tmp_path):
308      """Subprocess parity test: real console scripts produce matching output.
309  
310      Runs the real `pyod` and `pyod-install-skill` commands through the
311      entry-point shims rather than importing the functions directly.
312      This catches wiring regressions the in-process test would miss
313      (e.g., a console_scripts entry pointing at the wrong function).
314      Skipped if either executable is not on PATH.
315      """
316      import shutil
317  
318      pyod_exe = shutil.which("pyod")
319      legacy_exe = shutil.which("pyod-install-skill")
320      if not pyod_exe or not legacy_exe:
321          import pytest
322          pytest.skip("pyod and/or pyod-install-skill not on PATH (editable install not wired up)")
323  
324      tmp_legacy = tmp_path / "legacy"
325      tmp_unified = tmp_path / "unified"
326  
327      r_legacy = subprocess.run(
328          [legacy_exe, "--target", str(tmp_legacy)],
329          capture_output=True, text=True,
330      )
331      r_unified = subprocess.run(
332          [pyod_exe, "install", "skill", "--target", str(tmp_unified)],
333          capture_output=True, text=True,
334      )
335  
336      assert r_legacy.returncode == 0, r_legacy.stderr
337      assert r_unified.returncode == 0, r_unified.stderr
338      assert r_legacy.returncode == r_unified.returncode
339  
340      def _scrub(text):
341          return "\n".join(
342              line for line in text.splitlines()
343              if "Installed od-expert skill to:" not in line
344          )
345  
346      assert _scrub(r_legacy.stdout) == _scrub(r_unified.stdout)
347      assert r_legacy.stderr == r_unified.stderr
348      assert (tmp_legacy / "od-expert" / "SKILL.md").is_file()
349      assert (tmp_unified / "od-expert" / "SKILL.md").is_file()
350  
351  
352  def test_pyod_install_skill_copies_references_tree(tmp_path):
353      """`pyod install skill --target <path>` must copy references/ subdir too.
354  
355      Regression test for the v3.2.0 tree-aware installer. The skill ships
356      with a references/ subdirectory containing depth files; the installer
357      must copy them alongside SKILL.md.
358  
359      Skipped automatically until the references/ subdir exists in the
360      package source tree (Phase 2 of the v3.2.0 plan, Tasks 6-12). Once
361      workflow.md lands, the skip evaporates and this becomes a hard
362      regression check.
363      """
364      source_refs = (
365          Path(__file__).resolve().parents[1]
366          / "skills" / "od_expert" / "references"
367      )
368      if not (source_refs / "workflow.md").is_file():
369          import pytest
370          pytest.skip(
371              "pyod/skills/od_expert/references/workflow.md not yet shipped "
372              "(v3.2.0 Phase 2 deliverable). Test auto-promotes once it exists."
373          )
374      fake_home = tmp_path / "fake_home"
375      fake_home.mkdir(exist_ok=True)
376      result = subprocess.run(
377          [sys.executable, "-m", "pyod.cli", "install", "skill",
378           "--target", str(tmp_path)],
379          capture_output=True, text=True,
380          env=_isolated_home_env(fake_home),
381      )
382      assert result.returncode == 0, f"stderr={result.stderr}"
383      skill_dir = tmp_path / "od-expert"
384      assert (skill_dir / "SKILL.md").is_file()
385      references_dir = skill_dir / "references"
386      assert references_dir.is_dir(), (
387          f"references/ subdir not copied to {references_dir}"
388      )
389      # Spot-check at least one expected reference file exists
390      assert (references_dir / "workflow.md").is_file(), (
391          "workflow.md not copied — installer is not tree-aware"
392      )