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())