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

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

View File

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

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:
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:

View File

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

View File

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