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:
@@ -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"
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
262
src/skill_seekers/cli/enhance_command.py
Normal file
262
src/skill_seekers/cli/enhance_command.py
Normal 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())
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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!")
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
367
tests/test_enhance_command.py
Normal file
367
tests/test_enhance_command.py
Normal 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"
|
||||||
Reference in New Issue
Block a user