fix: smart enhancement dispatcher — Gemini/API mode + root/Docker detection

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 <noreply@anthropic.com>
This commit is contained in:
yusyus
2026-02-22 01:26:19 +03:00
parent 2e2941e0d4
commit fee89d5897
8 changed files with 750 additions and 13 deletions

View File

@@ -185,7 +185,7 @@ skill-seekers-scrape = "skill_seekers.cli.doc_scraper:main"
skill-seekers-github = "skill_seekers.cli.github_scraper:main" skill-seekers-github = "skill_seekers.cli.github_scraper:main"
skill-seekers-pdf = "skill_seekers.cli.pdf_scraper:main" skill-seekers-pdf = "skill_seekers.cli.pdf_scraper:main"
skill-seekers-unified = "skill_seekers.cli.unified_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-enhance-status = "skill_seekers.cli.enhance_status:main"
skill-seekers-package = "skill_seekers.cli.package_skill:main" skill-seekers-package = "skill_seekers.cli.package_skill:main"
skill-seekers-upload = "skill_seekers.cli.upload_skill:main" skill-seekers-upload = "skill_seekers.cli.upload_skill:main"

View File

@@ -1,8 +1,8 @@
"""Enhance command argument definitions. """Enhance command argument definitions.
This module defines ALL arguments for the enhance command in ONE place. 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) Both enhance_command.py (dispatcher), enhance_skill_local.py (standalone),
import and use these definitions. and parsers/enhance_parser.py (unified CLI) import and use these definitions.
""" """
import argparse import argparse
@@ -17,7 +17,40 @@ ENHANCE_ARGUMENTS: dict[str, dict[str, Any]] = {
"help": "Skill directory path", "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": { "agent": {
"flags": ("--agent",), "flags": ("--agent",),
"kwargs": { "kwargs": {
@@ -35,7 +68,14 @@ ENHANCE_ARGUMENTS: dict[str, dict[str, Any]] = {
"metavar": "CMD", "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": { "background": {
"flags": ("--background",), "flags": ("--background",),
"kwargs": { "kwargs": {

View File

@@ -50,6 +50,7 @@ class ConfigManager:
"api_keys": {"anthropic": None, "google": None, "openai": None}, "api_keys": {"anthropic": None, "google": None, "openai": None},
"ai_enhancement": { "ai_enhancement": {
"default_enhance_level": 1, # Default AI enhancement level (0-3) "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_batch_size": 20, # Patterns per Claude CLI call (default was 5)
"local_parallel_workers": 3, # Concurrent Claude CLI calls "local_parallel_workers": 3, # Concurrent Claude CLI calls
}, },
@@ -438,6 +439,25 @@ class ConfigManager:
self.config["ai_enhancement"]["local_parallel_workers"] = workers self.config["ai_enhancement"]["local_parallel_workers"] = workers
self.save_config() 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 # First Run Experience
def is_first_run(self) -> bool: def is_first_run(self) -> bool:

View File

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

View File

@@ -889,7 +889,22 @@ rm {prompt_file}
else: else:
print(f"{self.agent_display} returned error (exit code: {result.returncode})") print(f"{self.agent_display} returned error (exit code: {result.returncode})")
if result.stderr: 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 return False
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:

View File

@@ -14,7 +14,7 @@ Commands:
pdf Extract from PDF file pdf Extract from PDF file
unified Multi-source scraping (docs + GitHub + PDF) unified Multi-source scraping (docs + GitHub + PDF)
analyze Analyze local codebase and extract code knowledge 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) enhance-status Check enhancement status (for background/daemon modes)
package Package skill into .zip file package Package skill into .zip file
upload Upload skill to Claude upload Upload skill to Claude
@@ -48,7 +48,7 @@ COMMAND_MODULES = {
"github": "skill_seekers.cli.github_scraper", "github": "skill_seekers.cli.github_scraper",
"pdf": "skill_seekers.cli.pdf_scraper", "pdf": "skill_seekers.cli.pdf_scraper",
"unified": "skill_seekers.cli.unified_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", "enhance-status": "skill_seekers.cli.enhance_status",
"package": "skill_seekers.cli.package_skill", "package": "skill_seekers.cli.package_skill",
"upload": "skill_seekers.cli.upload_skill", "upload": "skill_seekers.cli.upload_skill",
@@ -320,10 +320,39 @@ def _handle_analyze_command(args: argparse.Namespace) -> int:
print("=" * 60 + "\n") print("=" * 60 + "\n")
try: 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) _fake_args = _ap.Namespace(
success = enhancer.run(headless=True, timeout=600) 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: if success:
print("\n✅ SKILL.md enhancement complete!") print("\n✅ SKILL.md enhancement complete!")

View File

@@ -17,11 +17,15 @@ class EnhanceParser(SubcommandParser):
@property @property
def help(self) -> str: def help(self) -> str:
return "AI-powered enhancement (local, no API key)" return "AI-powered enhancement (auto: API or LOCAL mode)"
@property @property
def description(self) -> str: 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): def add_arguments(self, parser):
"""Add enhance-specific arguments. """Add enhance-specific arguments.

View File

@@ -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"