/ pyod / cli.py
cli.py
  1  # -*- coding: utf-8 -*-
  2  """Unified command-line interface for PyOD.
  3  
  4  Single entry point for the three agentic activation paths:
  5  
  6  - ``pyod install skill``: copy the od-expert Claude Code skill into
  7    ``~/.claude/skills/od-expert/``.
  8  - ``pyod info``: print version, detector counts, and install state for
  9    each activation path. Self-diagnostic.
 10  - ``pyod mcp serve``: launch the MCP server (alias for
 11    ``python -m pyod.mcp_server``).
 12  - ``pyod --help``: show all subcommands.
 13  
 14  The legacy ``pyod-install-skill`` console script is kept as a
 15  backward-compat alias. It shares the same ``_run_install`` helper as
 16  ``pyod install skill`` so their output is identical.
 17  """
 18  from __future__ import annotations
 19  
 20  import argparse
 21  import importlib.util
 22  import sys
 23  from collections import Counter
 24  from pathlib import Path
 25  
 26  
 27  def _cmd_install_skill(args: argparse.Namespace) -> int:
 28      """Dispatch for `pyod install skill`. Delegates to the shared helper."""
 29      from pyod.skills import _run_install
 30      return _run_install(
 31          target=args.target,
 32          project=args.project,
 33          skill=args.skill,
 34          list_skills=args.list_skills,
 35      )
 36  
 37  
 38  def _cmd_info(args: argparse.Namespace) -> int:
 39      """Dispatch for `pyod info`. Self-diagnostic; returns 0 in core installs."""
 40      import pyod
 41  
 42      # Real detector counts: iterate the KB instead of hardcoding buckets.
 43      # The KB stores a `data_types` list per algorithm (note the plural);
 44      # an algorithm may appear in multiple modalities.
 45      try:
 46          from pyod.utils.ad_engine import ADEngine
 47          engine = ADEngine()
 48          counts: Counter = Counter()
 49          for algo in engine.kb.algorithms.values():
 50              for dt in algo.get("data_types", []):
 51                  counts[dt] += 1
 52          total = len(engine.kb.algorithms)
 53          ad_ok = True
 54      except Exception:
 55          counts = Counter()
 56          total = 0
 57          ad_ok = False
 58  
 59      # Classic API reachable?
 60      try:
 61          from pyod.models.iforest import IForest  # noqa: F401
 62          classic_ok = True
 63      except Exception:
 64          classic_ok = False
 65  
 66      # MCP extra availability — probe via find_spec, NEVER import the
 67      # server module. Importing pyod.mcp_server would execute its
 68      # module-level FastMCP check, which in v3.0.0 called sys.exit(1)
 69      # when mcp was missing. Task 1 fixes that upstream, but probing
 70      # via find_spec is still the right abstraction.
 71      #
 72      # Gotcha: find_spec("mcp.server.fastmcp") RAISES ModuleNotFoundError
 73      # when the parent `mcp` package is missing (it does not return None).
 74      # Probe the parent first.
 75      if importlib.util.find_spec("mcp") is None:
 76          mcp_available = False
 77      else:
 78          try:
 79              mcp_available = importlib.util.find_spec("mcp.server.fastmcp") is not None
 80          except ModuleNotFoundError:
 81              mcp_available = False
 82  
 83      # od-expert skill install state — check BOTH install paths that
 84      # `pyod install skill` supports: the user-global Claude Code
 85      # directory and the project-local `./skills/` directory used by
 86      # `--project`. Also detect sibling agent stacks (Codex) so the
 87      # output gives actionable guidance for non-Claude-Code users.
 88      # Claude Code has a user-global skill directory at ~/.claude/skills/;
 89      # Codex does not have an equivalent and instead reads project-local
 90      # skills from ./skills/<name>/ (the same path used by `--project`).
 91      claude_dir = Path.home() / ".claude"
 92      codex_dir = Path.home() / ".codex"
 93      user_skill_path = claude_dir / "skills" / "od-expert" / "SKILL.md"
 94      project_skill_path = Path.cwd() / "skills" / "od-expert" / "SKILL.md"
 95      user_installed = user_skill_path.is_file()
 96      project_installed = project_skill_path.is_file()
 97      agents_detected: list[str] = []
 98      if claude_dir.is_dir():
 99          agents_detected.append("Claude Code")
100      if codex_dir.is_dir():
101          agents_detected.append("Codex")
102  
103      # --- Output ---
104      print(f"PyOD version:          {pyod.__version__}")
105  
106      if ad_ok:
107          # Render in a stable order; unknown modalities fall through at the end.
108          preferred = ["tabular", "time_series", "graph", "text", "image",
109                       "multimodal"]
110          parts = []
111          for key in preferred:
112              if counts.get(key):
113                  label = key.replace("_", "-")
114                  parts.append(f"{counts[key]} {label}")
115          for key, val in counts.items():
116              if key not in preferred and val:
117                  parts.append(f"{val} {key}")
118          breakdown = ", ".join(parts) if parts else "none"
119          print(f"Detectors (ADEngine):  {total} total ({breakdown})")
120      else:
121          print("Detectors (ADEngine):  ERROR (ADEngine did not load)")
122  
123      print(f"Classic API:           {'OK' if classic_ok else 'ERROR'}")
124      print(f"ADEngine (Layer 2):    {'OK' if ad_ok else 'ERROR'}")
125      if mcp_available:
126          print("MCP extra:             OK (run: pyod mcp serve)")
127      else:
128          print("MCP extra:             NOT INSTALLED "
129                "(install: pip install pyod[mcp])")
130  
131      if user_installed and project_installed:
132          print(f"od-expert skill:       INSTALLED (user-global) at {user_skill_path}")
133          print(f"                       INSTALLED (project)     at {project_skill_path}")
134          if agents_detected:
135              print(f"                       Detected agents: {', '.join(agents_detected)}")
136      elif user_installed:
137          print(f"od-expert skill:       INSTALLED (user-global) at {user_skill_path}")
138          if "Codex" in agents_detected:
139              print("                       Codex detected but does not read the user-global path.")
140              print("                       For Codex: run `pyod install skill --project` in the project directory.")
141      elif project_installed:
142          print(f"od-expert skill:       INSTALLED (project) at {project_skill_path}")
143          if agents_detected:
144              print(f"                       Active for: {', '.join(agents_detected)}")
145          if "Claude Code" in agents_detected:
146              print("                       For a user-global Claude Code install, run `pyod install skill`.")
147      elif agents_detected:
148          print("od-expert skill:       NOT INSTALLED")
149          print(f"                       Detected agents: {', '.join(agents_detected)}")
150          if "Claude Code" in agents_detected:
151              print("                       Claude Code (user-global): run `pyod install skill`")
152          if "Codex" in agents_detected:
153              print("                       Codex (project-local):     run `pyod install skill --project`")
154      else:
155          print("od-expert skill:       NOT INSTALLED (no agent stacks detected)")
156  
157      return 0
158  
159  
160  def _cmd_mcp_serve(args: argparse.Namespace) -> int:
161      """Dispatch for `pyod mcp serve`. Delegates to `pyod.mcp_server.main`."""
162      from pyod import mcp_server
163      return mcp_server.main()
164  
165  
166  def main(argv: list[str] | None = None) -> int:
167      """Unified `pyod` CLI entry point."""
168      parser = argparse.ArgumentParser(
169          prog="pyod",
170          description=(
171              "PyOD 3 command-line interface. Use `pyod <subcommand> --help` "
172              "for details on each subcommand."
173          ),
174      )
175      sub = parser.add_subparsers(dest="command", required=True)
176  
177      # pyod install ...
178      install_p = sub.add_parser(
179          "install", help="Install an activation-path component.",
180      )
181      # Use a non-overloaded dest name: `skill_p` below also has a
182      # `--target` option, and argparse would otherwise write both to
183      # `args.target`. The inner `--target` wins in practice, but the
184      # collision is a readability trap.
185      install_sub = install_p.add_subparsers(dest="install_component", required=True)
186  
187      # pyod install skill
188      skill_p = install_sub.add_parser(
189          "skill",
190          help="Copy the od-expert Claude Code skill into a skill directory.",
191      )
192      skill_p.add_argument(
193          "--target", type=Path, default=None,
194          help="Custom target directory. Overrides --project.",
195      )
196      skill_p.add_argument(
197          "--project", action="store_true",
198          help="Install into ./skills/ in the current working directory.",
199      )
200      skill_p.add_argument(
201          "--skill", default="od-expert",
202          help=(
203              "Name of the packaged skill to install (default: od-expert). "
204              "Both 'od-expert' and 'od_expert' are accepted."
205          ),
206      )
207      skill_p.add_argument(
208          "--list", action="store_true", dest="list_skills",
209          help="List available packaged skills and exit.",
210      )
211      skill_p.set_defaults(func=_cmd_install_skill)
212  
213      # pyod info
214      info_p = sub.add_parser(
215          "info",
216          help="Print version, detector counts, and install state.",
217      )
218      info_p.set_defaults(func=_cmd_info)
219  
220      # pyod mcp ...
221      mcp_p = sub.add_parser("mcp", help="MCP server commands.")
222      mcp_sub = mcp_p.add_subparsers(dest="mcp_command", required=True)
223      serve_p = mcp_sub.add_parser("serve", help="Run the PyOD MCP server.")
224      serve_p.set_defaults(func=_cmd_mcp_serve)
225  
226      args = parser.parse_args(argv)
227      return args.func(args)
228  
229  
230  if __name__ == "__main__":
231      sys.exit(main())