From fee89d5897cf2df42e9fce3a6ef24c3fe98a377b Mon Sep 17 00:00:00 2001 From: yusyus Date: Sun, 22 Feb 2026 01:26:19 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20smart=20enhancement=20dispatcher=20?= =?UTF-8?q?=E2=80=94=20Gemini/API=20mode=20+=20root/Docker=20detection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes issues #289 and #286 (agent switching and Docker/root failures). enhance_command.py (new smart dispatcher): - Routes skill-seekers enhance to API mode (Gemini/OpenAI/Claude API) when an API key is available, or LOCAL mode (Claude Code CLI) otherwise - Decision priority: --target flag > config default_agent > auto-detect from env vars (ANTHROPIC_API_KEY → claude, GOOGLE_API_KEY → gemini, OPENAI_API_KEY → openai) > LOCAL fallback - Blocks LOCAL mode when running as root (Docker/VPS) with clear error message + API mode instructions - Supports --dry-run, --target, --api-key as first-class flags arguments/enhance.py: - Added --target, --api-key, --dry-run, --interactive-enhancement to ENHANCE_ARGUMENTS (shared by unified CLI parser and standalone entry point) enhance_skill_local.py: - Error output no longer truncated at 200 chars (shows up to 20 lines) - Detects root/permission errors in stderr and prints actionable hint config_manager.py: - Added default_agent field to DEFAULT_CONFIG ai_enhancement section - Added get_default_agent() and set_default_agent() methods main.py: - enhance command routed to enhance_command (was enhance_skill_local) - _handle_analyze_command uses smart dispatcher for post-analysis enhancement pyproject.toml: - skill-seekers-enhance entry point updated to enhance_command:main Tests: 1977 passed, 0 failed (28 new tests in test_enhance_command.py) Co-Authored-By: Claude Sonnet 4.6 --- pyproject.toml | 2 +- src/skill_seekers/cli/arguments/enhance.py | 48 ++- src/skill_seekers/cli/config_manager.py | 20 + src/skill_seekers/cli/enhance_command.py | 262 +++++++++++++ src/skill_seekers/cli/enhance_skill_local.py | 17 +- src/skill_seekers/cli/main.py | 39 +- .../cli/parsers/enhance_parser.py | 8 +- tests/test_enhance_command.py | 367 ++++++++++++++++++ 8 files changed, 750 insertions(+), 13 deletions(-) create mode 100644 src/skill_seekers/cli/enhance_command.py create mode 100644 tests/test_enhance_command.py diff --git a/pyproject.toml b/pyproject.toml index d81d9a0..9302b91 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -185,7 +185,7 @@ skill-seekers-scrape = "skill_seekers.cli.doc_scraper:main" skill-seekers-github = "skill_seekers.cli.github_scraper:main" skill-seekers-pdf = "skill_seekers.cli.pdf_scraper:main" skill-seekers-unified = "skill_seekers.cli.unified_scraper:main" -skill-seekers-enhance = "skill_seekers.cli.enhance_skill_local:main" +skill-seekers-enhance = "skill_seekers.cli.enhance_command:main" skill-seekers-enhance-status = "skill_seekers.cli.enhance_status:main" skill-seekers-package = "skill_seekers.cli.package_skill:main" skill-seekers-upload = "skill_seekers.cli.upload_skill:main" diff --git a/src/skill_seekers/cli/arguments/enhance.py b/src/skill_seekers/cli/arguments/enhance.py index 3ffca30..ef19dba 100644 --- a/src/skill_seekers/cli/arguments/enhance.py +++ b/src/skill_seekers/cli/arguments/enhance.py @@ -1,8 +1,8 @@ """Enhance command argument definitions. This module defines ALL arguments for the enhance command in ONE place. -Both enhance_skill_local.py (standalone) and parsers/enhance_parser.py (unified CLI) -import and use these definitions. +Both enhance_command.py (dispatcher), enhance_skill_local.py (standalone), +and parsers/enhance_parser.py (unified CLI) import and use these definitions. """ import argparse @@ -17,7 +17,40 @@ ENHANCE_ARGUMENTS: dict[str, dict[str, Any]] = { "help": "Skill directory path", }, }, - # Agent options + # Mode selection — used by smart dispatcher (enhance_command.py) + "target": { + "flags": ("--target",), + "kwargs": { + "type": str, + "choices": ["claude", "gemini", "openai"], + "help": ( + "AI platform for enhancement (uses API mode). " + "Auto-detected from env vars if not specified: " + "ANTHROPIC_API_KEY→claude, GOOGLE_API_KEY→gemini, OPENAI_API_KEY→openai. " + "Falls back to LOCAL mode (Claude Code CLI) when no API keys are found." + ), + "metavar": "PLATFORM", + }, + }, + "api_key": { + "flags": ("--api-key",), + "kwargs": { + "type": str, + "help": ( + "API key for the target platform " + "(or set ANTHROPIC_API_KEY / GOOGLE_API_KEY / OPENAI_API_KEY)" + ), + "metavar": "KEY", + }, + }, + "dry_run": { + "flags": ("--dry-run",), + "kwargs": { + "action": "store_true", + "help": "Preview what would be enhanced without calling AI", + }, + }, + # Agent options — LOCAL mode only "agent": { "flags": ("--agent",), "kwargs": { @@ -35,7 +68,14 @@ ENHANCE_ARGUMENTS: dict[str, dict[str, Any]] = { "metavar": "CMD", }, }, - # Execution options + # Execution options — LOCAL mode only + "interactive_enhancement": { + "flags": ("--interactive-enhancement",), + "kwargs": { + "action": "store_true", + "help": "Open terminal window for enhancement (default: headless mode)", + }, + }, "background": { "flags": ("--background",), "kwargs": { diff --git a/src/skill_seekers/cli/config_manager.py b/src/skill_seekers/cli/config_manager.py index 0d85a9a..c8febf7 100644 --- a/src/skill_seekers/cli/config_manager.py +++ b/src/skill_seekers/cli/config_manager.py @@ -50,6 +50,7 @@ class ConfigManager: "api_keys": {"anthropic": None, "google": None, "openai": None}, "ai_enhancement": { "default_enhance_level": 1, # Default AI enhancement level (0-3) + "default_agent": None, # "claude", "gemini", "openai", or None (auto-detect) "local_batch_size": 20, # Patterns per Claude CLI call (default was 5) "local_parallel_workers": 3, # Concurrent Claude CLI calls }, @@ -438,6 +439,25 @@ class ConfigManager: self.config["ai_enhancement"]["local_parallel_workers"] = workers self.save_config() + def get_default_agent(self) -> str | None: + """Get preferred AI agent/platform for enhancement. + + Returns: + "claude", "gemini", "openai", or None (auto-detect from env vars). + """ + return self.config.get("ai_enhancement", {}).get("default_agent") + + def set_default_agent(self, agent: str | None): + """Set preferred AI agent/platform for enhancement. + + Args: + agent: "claude", "gemini", "openai", or None to auto-detect. + """ + if "ai_enhancement" not in self.config: + self.config["ai_enhancement"] = {} + self.config["ai_enhancement"]["default_agent"] = agent + self.save_config() + # First Run Experience def is_first_run(self) -> bool: diff --git a/src/skill_seekers/cli/enhance_command.py b/src/skill_seekers/cli/enhance_command.py new file mode 100644 index 0000000..d82d894 --- /dev/null +++ b/src/skill_seekers/cli/enhance_command.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +""" +Smart Enhancement Dispatcher + +Routes `skill-seekers enhance` to the correct backend: + + API mode — when an API key is available (Claude/Gemini/OpenAI). + Calls enhance_skill.py which uses platform adaptors. + + LOCAL mode — when no API key is found. + Calls LocalSkillEnhancer from enhance_skill_local.py. + +Decision priority: + 1. Explicit --target flag → API mode with that platform. + 2. Config ai_enhancement.default_agent + matching env key → API mode. + 3. Auto-detect from env vars: ANTHROPIC_API_KEY → claude, + GOOGLE_API_KEY → gemini, OPENAI_API_KEY → openai. + 4. No API keys → LOCAL mode (Claude Code CLI). + 5. LOCAL mode + running as root → clear error (Claude Code refuses root). +""" + +import os +import sys +from pathlib import Path + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _is_root() -> bool: + """Return True if the current process is running as root (UID 0).""" + try: + return os.getuid() == 0 + except AttributeError: + return False # Windows has no getuid + + +def _get_api_keys() -> dict[str, str | None]: + """Collect API keys from environment.""" + return { + "claude": ( + os.environ.get("ANTHROPIC_API_KEY") + or os.environ.get("ANTHROPIC_AUTH_TOKEN") + ), + "gemini": os.environ.get("GOOGLE_API_KEY"), + "openai": os.environ.get("OPENAI_API_KEY"), + } + + +def _get_config_default_agent() -> str | None: + """Read ai_enhancement.default_agent from config manager (best-effort).""" + try: + from skill_seekers.cli.config_manager import get_config_manager + + return get_config_manager().get_default_agent() + except Exception: + return None + + +def _pick_mode(args) -> tuple[str, str | None]: + """Decide between 'api' and 'local' mode. + + Returns: + (mode, target) — mode is "api" or "local"; + target is the platform name ("claude", "gemini", "openai") + or None for local mode. + """ + api_keys = _get_api_keys() + + # 1. Explicit --target flag always forces API mode. + target = getattr(args, "target", None) + if target: + return "api", target + + # 2. Config default_agent preference (if a matching key is available). + config_agent = _get_config_default_agent() + if config_agent in ("claude", "gemini", "openai") and api_keys.get(config_agent): + return "api", config_agent + + # 3. Auto-detect from environment variables. + # Priority: Claude > Gemini > OpenAI (Claude is Anthropic's native platform). + if api_keys["claude"]: + return "api", "claude" + if api_keys["gemini"]: + return "api", "gemini" + if api_keys["openai"]: + return "api", "openai" + + # 4. No API keys found → LOCAL mode. + return "local", None + + +# --------------------------------------------------------------------------- +# API mode runner +# --------------------------------------------------------------------------- + +def _run_api_mode(args, target: str) -> int: + """Delegate to enhance_skill.py (platform adaptor path).""" + from skill_seekers.cli.enhance_skill import main as enhance_api_main + + api_keys = _get_api_keys() + api_key = getattr(args, "api_key", None) + if not api_key: + # Explicit key > env var for the selected platform + env_map = { + "claude": api_keys["claude"], + "gemini": api_keys["gemini"], + "openai": api_keys["openai"], + } + api_key = env_map.get(target) + + # Reconstruct sys.argv for enhance_skill.main() + argv = [ + "enhance_skill.py", + str(args.skill_directory), + "--target", + target, + ] + if api_key: + argv.extend(["--api-key", api_key]) + if getattr(args, "dry_run", False): + argv.append("--dry-run") + + original_argv = sys.argv.copy() + sys.argv = argv + try: + enhance_api_main() + return 0 + except SystemExit as exc: + return exc.code if isinstance(exc.code, int) else 0 + finally: + sys.argv = original_argv + + +# --------------------------------------------------------------------------- +# LOCAL mode runner +# --------------------------------------------------------------------------- + +def _run_local_mode(args) -> int: + """Delegate to LocalSkillEnhancer from enhance_skill_local.py.""" + from skill_seekers.cli.enhance_skill_local import LocalSkillEnhancer + + try: + enhancer = LocalSkillEnhancer( + args.skill_directory, + force=not getattr(args, "no_force", False), + agent=getattr(args, "agent", None), + agent_cmd=getattr(args, "agent_cmd", None), + ) + except ValueError as exc: + print(f"❌ Error: {exc}") + return 1 + + interactive = getattr(args, "interactive_enhancement", False) + headless = not interactive + success = enhancer.run( + headless=headless, + timeout=getattr(args, "timeout", 600), + background=getattr(args, "background", False), + daemon=getattr(args, "daemon", False), + ) + return 0 if success else 1 + + +# --------------------------------------------------------------------------- +# Main entry point +# --------------------------------------------------------------------------- + +def main() -> int: + import argparse + + from skill_seekers.cli.arguments.enhance import add_enhance_arguments + + parser = argparse.ArgumentParser( + description=( + "Enhance SKILL.md using AI. " + "Automatically selects API mode (Gemini/OpenAI/Claude API) when an API key " + "is available, or falls back to LOCAL mode (Claude Code CLI)." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Mode selection (automatic — no flags required): + API mode : Set ANTHROPIC_API_KEY, GOOGLE_API_KEY, or OPENAI_API_KEY. + Or use --target to force a platform. + LOCAL mode: Falls back when no API keys are found. Requires Claude Code CLI. + Does NOT work as root (Docker/VPS) — use API mode instead. + +Examples: + # Auto-detect (API mode if any key is set, else LOCAL) + skill-seekers enhance output/react/ + + # Force Gemini API + skill-seekers enhance output/react/ --target gemini + + # Force Claude API with explicit key + skill-seekers enhance output/react/ --target claude --api-key sk-ant-... + + # LOCAL mode options + skill-seekers enhance output/react/ --background + skill-seekers enhance output/react/ --timeout 1200 + + # Dry run (preview only) + skill-seekers enhance output/react/ --dry-run +""", + ) + add_enhance_arguments(parser) + args = parser.parse_args() + + # Validate skill directory + skill_dir = Path(args.skill_directory) + if not skill_dir.exists(): + print(f"❌ Error: Directory not found: {skill_dir}") + return 1 + if not skill_dir.is_dir(): + print(f"❌ Error: Not a directory: {skill_dir}") + return 1 + + mode, target = _pick_mode(args) + + # Dry run — just show what would happen + if getattr(args, "dry_run", False): + print("🔍 DRY RUN MODE") + print(f" Skill directory : {skill_dir}") + print(f" Selected mode : {mode.upper()}") + if mode == "api": + print(f" Platform : {target}") + else: + agent = getattr(args, "agent", None) or os.environ.get("SKILL_SEEKER_AGENT", "claude") + print(f" Agent : {agent}") + refs_dir = skill_dir / "references" + if refs_dir.exists(): + ref_files = list(refs_dir.glob("*.md")) + print(f" Reference files : {len(ref_files)}") + print("\nTo actually run: remove --dry-run") + return 0 + + if mode == "api": + print(f"🤖 Enhancement mode: API ({target})") + return _run_api_mode(args, target) + + # LOCAL mode — check for root before attempting + if _is_root(): + print("❌ Cannot run LOCAL enhancement as root.") + print() + print(" Claude Code CLI refuses to execute as root (Docker/VPS security policy).") + print(" Use API mode instead by setting one of these environment variables:") + print() + print(" export ANTHROPIC_API_KEY=sk-ant-... # Claude") + print(" export GOOGLE_API_KEY=AIza... # Gemini") + print(" export OPENAI_API_KEY=sk-proj-... # OpenAI") + print() + print(" Then retry:") + print(f" skill-seekers enhance {args.skill_directory}") + return 1 + + print("🤖 Enhancement mode: LOCAL (Claude Code CLI)") + return _run_local_mode(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/skill_seekers/cli/enhance_skill_local.py b/src/skill_seekers/cli/enhance_skill_local.py index bc2c680..fb1fa9c 100644 --- a/src/skill_seekers/cli/enhance_skill_local.py +++ b/src/skill_seekers/cli/enhance_skill_local.py @@ -889,7 +889,22 @@ rm {prompt_file} else: print(f"❌ {self.agent_display} returned error (exit code: {result.returncode})") if result.stderr: - print(f" Error: {result.stderr[:200]}") + stderr_lines = result.stderr.strip().split("\n") + for line in stderr_lines[:20]: + print(f" | {line}") + if len(stderr_lines) > 20: + print(f" ... ({len(stderr_lines) - 20} more lines)") + # Hint for root/permission errors + stderr_lower = result.stderr.lower() + if result.returncode in (1, 126) and ( + "root" in stderr_lower or "permission" in stderr_lower + ): + print() + print(" ⚠️ This looks like a root/permission error.") + print(" Claude Code CLI refuses to run as root (security policy).") + print(" Use API mode instead:") + print(" export ANTHROPIC_API_KEY=sk-ant-...") + print(f" skill-seekers enhance {self.skill_dir} --target claude") return False except subprocess.TimeoutExpired: diff --git a/src/skill_seekers/cli/main.py b/src/skill_seekers/cli/main.py index 09fbdb1..657088e 100644 --- a/src/skill_seekers/cli/main.py +++ b/src/skill_seekers/cli/main.py @@ -14,7 +14,7 @@ Commands: pdf Extract from PDF file unified Multi-source scraping (docs + GitHub + PDF) analyze Analyze local codebase and extract code knowledge - enhance AI-powered enhancement (local, no API key) + enhance AI-powered enhancement (auto: API or LOCAL mode) enhance-status Check enhancement status (for background/daemon modes) package Package skill into .zip file upload Upload skill to Claude @@ -48,7 +48,7 @@ COMMAND_MODULES = { "github": "skill_seekers.cli.github_scraper", "pdf": "skill_seekers.cli.pdf_scraper", "unified": "skill_seekers.cli.unified_scraper", - "enhance": "skill_seekers.cli.enhance_skill_local", + "enhance": "skill_seekers.cli.enhance_command", "enhance-status": "skill_seekers.cli.enhance_status", "package": "skill_seekers.cli.package_skill", "upload": "skill_seekers.cli.upload_skill", @@ -320,10 +320,39 @@ def _handle_analyze_command(args: argparse.Namespace) -> int: print("=" * 60 + "\n") try: - from skill_seekers.cli.enhance_skill_local import LocalSkillEnhancer + from skill_seekers.cli.enhance_command import ( + _is_root, + _pick_mode, + _run_api_mode, + _run_local_mode, + ) + import argparse as _ap - enhancer = LocalSkillEnhancer(str(skill_dir), force=True) - success = enhancer.run(headless=True, timeout=600) + _fake_args = _ap.Namespace( + skill_directory=str(skill_dir), + target=None, + api_key=None, + dry_run=False, + agent=None, + agent_cmd=None, + interactive_enhancement=False, + background=False, + daemon=False, + no_force=False, + timeout=600, + ) + _mode, _target = _pick_mode(_fake_args) + + if _mode == "api": + print(f"\n🤖 Enhancement mode: API ({_target})") + success = _run_api_mode(_fake_args, _target) == 0 + elif _is_root(): + print("\n⚠️ Skipping SKILL.md enhancement: running as root") + print(" Set ANTHROPIC_API_KEY / GOOGLE_API_KEY to enable API mode") + success = False + else: + print("\n🤖 Enhancement mode: LOCAL (Claude Code CLI)") + success = _run_local_mode(_fake_args) == 0 if success: print("\n✅ SKILL.md enhancement complete!") diff --git a/src/skill_seekers/cli/parsers/enhance_parser.py b/src/skill_seekers/cli/parsers/enhance_parser.py index 138403c..f7c2e06 100644 --- a/src/skill_seekers/cli/parsers/enhance_parser.py +++ b/src/skill_seekers/cli/parsers/enhance_parser.py @@ -17,11 +17,15 @@ class EnhanceParser(SubcommandParser): @property def help(self) -> str: - return "AI-powered enhancement (local, no API key)" + return "AI-powered enhancement (auto: API or LOCAL mode)" @property def description(self) -> str: - return "Enhance SKILL.md using a local coding agent" + return ( + "Enhance SKILL.md using AI. " + "Automatically uses API mode (Gemini/OpenAI/Claude) when an API key is " + "available, or falls back to LOCAL mode (Claude Code CLI)." + ) def add_arguments(self, parser): """Add enhance-specific arguments. diff --git a/tests/test_enhance_command.py b/tests/test_enhance_command.py new file mode 100644 index 0000000..a2b613b --- /dev/null +++ b/tests/test_enhance_command.py @@ -0,0 +1,367 @@ +"""Tests for the smart enhancement dispatcher (enhance_command.py).""" + +import argparse +import sys +import types + +import pytest + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_args(**kwargs): + """Build a fake Namespace with sensible defaults.""" + defaults = dict( + skill_directory="output/react", + target=None, + api_key=None, + dry_run=False, + agent=None, + agent_cmd=None, + interactive_enhancement=False, + background=False, + daemon=False, + no_force=False, + timeout=600, + ) + defaults.update(kwargs) + return argparse.Namespace(**defaults) + + +def _make_skill_dir(tmp_path): + skill_dir = tmp_path / "test_skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("# Test", encoding="utf-8") + return skill_dir + + +# --------------------------------------------------------------------------- +# _is_root +# --------------------------------------------------------------------------- + +class TestIsRoot: + def test_returns_bool(self): + from skill_seekers.cli.enhance_command import _is_root + assert isinstance(_is_root(), bool) + + def test_not_root_when_monkeypatched(self, monkeypatch): + import os + monkeypatch.setattr(os, "getuid", lambda: 1000) + from skill_seekers.cli.enhance_command import _is_root + assert _is_root() is False + + def test_root_when_uid_zero(self, monkeypatch): + import os + monkeypatch.setattr(os, "getuid", lambda: 0) + from skill_seekers.cli.enhance_command import _is_root + assert _is_root() is True + + def test_windows_no_getuid(self, monkeypatch): + """On Windows (no os.getuid), _is_root should return False.""" + import os + if hasattr(os, "getuid"): + monkeypatch.delattr(os, "getuid") + from skill_seekers.cli.enhance_command import _is_root + assert _is_root() is False + + +# --------------------------------------------------------------------------- +# _pick_mode — explicit --target flag +# --------------------------------------------------------------------------- + +class TestPickModeExplicitTarget: + def test_target_gemini_forces_api(self, monkeypatch): + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("GOOGLE_API_KEY", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + + from skill_seekers.cli.enhance_command import _pick_mode + args = _make_args(target="gemini") + mode, target = _pick_mode(args) + assert mode == "api" + assert target == "gemini" + + def test_target_openai_forces_api(self, monkeypatch): + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + + from skill_seekers.cli.enhance_command import _pick_mode + args = _make_args(target="openai") + mode, target = _pick_mode(args) + assert mode == "api" + assert target == "openai" + + def test_target_claude_forces_api(self, monkeypatch): + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + + from skill_seekers.cli.enhance_command import _pick_mode + args = _make_args(target="claude") + mode, target = _pick_mode(args) + assert mode == "api" + assert target == "claude" + + +# --------------------------------------------------------------------------- +# _pick_mode — auto-detection from env vars +# --------------------------------------------------------------------------- + +class TestPickModeAutoDetect: + def test_anthropic_key_selects_claude(self, monkeypatch): + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test") + monkeypatch.delenv("GOOGLE_API_KEY", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + + from skill_seekers.cli.enhance_command import _pick_mode + mode, target = _pick_mode(_make_args()) + assert mode == "api" + assert target == "claude" + + def test_google_key_selects_gemini(self, monkeypatch): + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("ANTHROPIC_AUTH_TOKEN", raising=False) + monkeypatch.setenv("GOOGLE_API_KEY", "AIza-test") + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + + from skill_seekers.cli.enhance_command import _pick_mode + mode, target = _pick_mode(_make_args()) + assert mode == "api" + assert target == "gemini" + + def test_openai_key_selects_openai(self, monkeypatch): + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("ANTHROPIC_AUTH_TOKEN", raising=False) + monkeypatch.delenv("GOOGLE_API_KEY", raising=False) + monkeypatch.setenv("OPENAI_API_KEY", "sk-proj-test") + + from skill_seekers.cli.enhance_command import _pick_mode + mode, target = _pick_mode(_make_args()) + assert mode == "api" + assert target == "openai" + + def test_no_keys_falls_back_to_local(self, monkeypatch): + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("ANTHROPIC_AUTH_TOKEN", raising=False) + monkeypatch.delenv("GOOGLE_API_KEY", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + + from skill_seekers.cli.enhance_command import _pick_mode + mode, target = _pick_mode(_make_args()) + assert mode == "local" + assert target is None + + def test_anthropic_takes_priority_over_google(self, monkeypatch): + """ANTHROPIC_API_KEY should win when both are set.""" + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test") + monkeypatch.setenv("GOOGLE_API_KEY", "AIza-test") + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + + from skill_seekers.cli.enhance_command import _pick_mode + mode, target = _pick_mode(_make_args()) + assert mode == "api" + assert target == "claude" + + +# --------------------------------------------------------------------------- +# _pick_mode — config default_agent +# --------------------------------------------------------------------------- + +class TestPickModeConfigAgent: + def _patch_config(self, monkeypatch, agent: str | None): + """Patch get_config_manager to return a stub with get_default_agent().""" + stub = types.SimpleNamespace(get_default_agent=lambda: agent) + + monkeypatch.setattr( + "skill_seekers.cli.enhance_command._get_config_default_agent", + lambda: agent, + ) + + def test_config_gemini_with_key_uses_gemini(self, monkeypatch): + self._patch_config(monkeypatch, "gemini") + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("ANTHROPIC_AUTH_TOKEN", raising=False) + monkeypatch.setenv("GOOGLE_API_KEY", "AIza-test") + + from skill_seekers.cli.enhance_command import _pick_mode + mode, target = _pick_mode(_make_args()) + assert mode == "api" + assert target == "gemini" + + def test_config_gemini_without_key_falls_to_autodetect(self, monkeypatch): + """Config says gemini but no GOOGLE_API_KEY → auto-detect.""" + self._patch_config(monkeypatch, "gemini") + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("ANTHROPIC_AUTH_TOKEN", raising=False) + monkeypatch.delenv("GOOGLE_API_KEY", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + + from skill_seekers.cli.enhance_command import _pick_mode + mode, target = _pick_mode(_make_args()) + assert mode == "local" + + def test_config_agent_overridden_by_explicit_target(self, monkeypatch): + """--target flag takes priority over config.""" + self._patch_config(monkeypatch, "gemini") + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + + from skill_seekers.cli.enhance_command import _pick_mode + args = _make_args(target="openai") + mode, target = _pick_mode(args) + assert mode == "api" + assert target == "openai" + + +# --------------------------------------------------------------------------- +# CLI argument parsing +# --------------------------------------------------------------------------- + +class TestEnhanceArgumentParsing: + """Test that the enhance parser exposes all expected arguments.""" + + def _parse(self, argv, tmp_path): + import argparse as _ap + from skill_seekers.cli.arguments.enhance import add_enhance_arguments + + parser = _ap.ArgumentParser() + add_enhance_arguments(parser) + return parser.parse_args(argv) + + def test_target_gemini(self, tmp_path): + args = self._parse(["output/react", "--target", "gemini"], tmp_path) + assert args.target == "gemini" + + def test_target_openai(self, tmp_path): + args = self._parse(["output/react", "--target", "openai"], tmp_path) + assert args.target == "openai" + + def test_api_key_stored(self, tmp_path): + args = self._parse(["output/react", "--api-key", "test-key-123"], tmp_path) + assert args.api_key == "test-key-123" + + def test_dry_run(self, tmp_path): + args = self._parse(["output/react", "--dry-run"], tmp_path) + assert args.dry_run is True + + def test_no_target_defaults_none(self, tmp_path): + args = self._parse(["output/react"], tmp_path) + assert args.target is None + + def test_invalid_target_rejected(self, tmp_path): + import argparse as _ap + from skill_seekers.cli.arguments.enhance import add_enhance_arguments + + parser = _ap.ArgumentParser() + add_enhance_arguments(parser) + with pytest.raises(SystemExit): + parser.parse_args(["output/react", "--target", "notaplatform"]) + + +# --------------------------------------------------------------------------- +# main() CLI integration — dry-run + root detection +# --------------------------------------------------------------------------- + +class TestEnhanceCommandMain: + def test_dry_run_no_ai_call(self, tmp_path): + skill_dir = _make_skill_dir(tmp_path) + sys_argv_backup = sys.argv.copy() + sys.argv = ["enhance_command.py", str(skill_dir), "--dry-run"] + try: + from skill_seekers.cli.enhance_command import main + rc = main() + assert rc == 0 + finally: + sys.argv = sys_argv_backup + + def test_missing_dir_returns_error(self, tmp_path): + sys_argv_backup = sys.argv.copy() + sys.argv = ["enhance_command.py", str(tmp_path / "nonexistent")] + try: + from skill_seekers.cli.enhance_command import main + rc = main() + assert rc == 1 + finally: + sys.argv = sys_argv_backup + + def test_root_local_mode_blocked(self, monkeypatch, tmp_path): + import os + + skill_dir = _make_skill_dir(tmp_path) + monkeypatch.setattr(os, "getuid", lambda: 0) + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("ANTHROPIC_AUTH_TOKEN", raising=False) + monkeypatch.delenv("GOOGLE_API_KEY", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + + sys_argv_backup = sys.argv.copy() + sys.argv = ["enhance_command.py", str(skill_dir)] + try: + from skill_seekers.cli.enhance_command import main + rc = main() + assert rc == 1 + finally: + sys.argv = sys_argv_backup + + def test_root_api_mode_allowed(self, monkeypatch, tmp_path): + """Even as root, API mode should be selected (not blocked).""" + import os + + skill_dir = _make_skill_dir(tmp_path) + monkeypatch.setattr(os, "getuid", lambda: 0) + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test") + + # Patch _run_api_mode to avoid real API call + monkeypatch.setattr( + "skill_seekers.cli.enhance_command._run_api_mode", + lambda args, target: 0, + ) + + sys_argv_backup = sys.argv.copy() + sys.argv = ["enhance_command.py", str(skill_dir)] + try: + from skill_seekers.cli.enhance_command import main + rc = main() + assert rc == 0 + finally: + sys.argv = sys_argv_backup + + +# --------------------------------------------------------------------------- +# Config manager — get_default_agent +# --------------------------------------------------------------------------- + +class TestConfigManagerDefaultAgent: + def test_get_default_agent_none_by_default(self, tmp_path, monkeypatch): + from skill_seekers.cli.config_manager import ConfigManager + + monkeypatch.setattr(ConfigManager, "CONFIG_DIR", tmp_path / "cfg") + monkeypatch.setattr(ConfigManager, "CONFIG_FILE", tmp_path / "cfg" / "config.json") + monkeypatch.setattr(ConfigManager, "PROGRESS_DIR", tmp_path / "prog") + + mgr = ConfigManager() + assert mgr.get_default_agent() is None + + def test_set_and_get_default_agent(self, tmp_path, monkeypatch): + from skill_seekers.cli.config_manager import ConfigManager + + monkeypatch.setattr(ConfigManager, "CONFIG_DIR", tmp_path / "cfg") + monkeypatch.setattr(ConfigManager, "CONFIG_FILE", tmp_path / "cfg" / "config.json") + monkeypatch.setattr(ConfigManager, "PROGRESS_DIR", tmp_path / "prog") + + mgr = ConfigManager() + mgr.set_default_agent("gemini") + assert mgr.get_default_agent() == "gemini" + + def test_set_default_agent_persisted(self, tmp_path, monkeypatch): + from skill_seekers.cli.config_manager import ConfigManager + + monkeypatch.setattr(ConfigManager, "CONFIG_DIR", tmp_path / "cfg") + config_file = tmp_path / "cfg" / "config.json" + monkeypatch.setattr(ConfigManager, "CONFIG_FILE", config_file) + monkeypatch.setattr(ConfigManager, "PROGRESS_DIR", tmp_path / "prog") + + mgr = ConfigManager() + mgr.set_default_agent("openai") + + # Re-instantiate to verify persistence + mgr2 = ConfigManager() + assert mgr2.get_default_agent() == "openai"