diff --git a/agentcli/agent/registry.py b/agentcli/agent/registry.py index ffd3d32..ff1c9b3 100644 --- a/agentcli/agent/registry.py +++ b/agentcli/agent/registry.py @@ -31,6 +31,7 @@ from ..subagents.file_ops import FileOpsAgent from ..subagents.grep_search import GrepSearchAgent from ..subagents.shell import ShellExecutionAgent +from ..subagents.skill_runner import SkillRunnerAgent from ..subagents.task_manager import TaskManagerAgent from ..subagents.web_fetch import WebFetchAgent from ..subagents.web_search import WebSearchAgent @@ -230,6 +231,10 @@ def _register_defaults(self) -> None: self.register( SubAgentType.DIAGNOSTICS_CHECK.value, lambda: DiagnosticsAgent(config=diag_cfg) ) + self.register( + SubAgentType.SKILL_RUNNER.value, lambda: SkillRunnerAgent() + ) + @staticmethod def _safe_type(agent_type: str) -> SubAgentType: diff --git a/agentcli/cli.py b/agentcli/cli.py index 9769935..3596fc5 100644 --- a/agentcli/cli.py +++ b/agentcli/cli.py @@ -922,7 +922,102 @@ async def run_chat(args: argparse.Namespace, config: Config) -> int: print("Usage: /tasks [list | status | logs | kill ]") continue + if user_input.startswith(("/skill", "/skills")): + skill_parts = user_input.split(maxsplit=2) + skill_subcmd = skill_parts[1].lower() if len(skill_parts) > 1 else "list" + target_skill = skill_parts[2].strip() if len(skill_parts) > 2 else "" + + if skill_subcmd in ("list", "ls") or len(skill_parts) == 1: + skills_list = session.skill_engine.list_available_skills() + if not skills_list: + print("No skills discovered in workspace or user directory.") + else: + print(f"\n--- Available Skills ({len(skills_list)}) ---") + for s in skills_list: + source_badge = f"[{s['source_type'].upper()}]" + print(f" {source_badge} {s['name']} (v{s['version']}): {s['description']}") + print("------------------------------------\n") + print("Run a skill with: /skill run or inspect with /skill info \n") + elif skill_subcmd == "info": + if not target_skill: + print("Usage: /skill info ") + else: + manifest = session.skill_engine.loader.get_skill(target_skill) + if not manifest: + print(f"Skill '{target_skill}' not found. Run '/skill list' to see available skills.") + else: + print(f"\nSkill [{manifest.name}] (v{manifest.version})") + print(f" Description: {manifest.description}") + print(f" Source: {manifest.source_type} ({manifest.skill_dir or 'builtin'})") + print(f" Execution Mode: {manifest.execution_mode} (max {manifest.max_iterations} iterations)") + if manifest.parameters: + print(" Parameters:") + for p_name, p in manifest.parameters.items(): + req_str = " (required)" if p.required else f" (default: {p.default})" + print(f" - {p_name} [{p.type}]{req_str}: {p.description}") + if manifest.required_tools: + print(f" Required Tools: {', '.join(manifest.required_tools)}") + print() + elif skill_subcmd == "reload": + session.skill_engine.loader.reload() + total = len(session.skill_engine.loader.list_skills()) + print(f"Reloaded skills. Found {total} skill(s).") + elif skill_subcmd in ("run", "exec") or session.skill_engine.loader.has_skill(skill_subcmd): + if skill_subcmd in ("run", "exec"): + actual_name = target_skill.split(maxsplit=1)[0] if target_skill else "" + raw_args_str = target_skill.split(maxsplit=1)[1] if len(target_skill.split(maxsplit=1)) > 1 else "" + else: + actual_name = skill_subcmd + raw_args_str = target_skill + + if not actual_name: + print("Usage: /skill run [param=value ...]") + continue + + parsed_args: dict[str, Any] = {} + if raw_args_str: + for token in raw_args_str.split(): + if "=" in token: + k, v = token.split("=", 1) + parsed_args[k.strip()] = v.strip() + else: + parsed_args["target"] = token + + try: + manifest, rendered = session.skill_engine.prepare_skill( + skill_name=actual_name, + arguments=parsed_args, + ) + print(f"\nExecuting skill '{manifest.name}' ({manifest.execution_mode})...\n") + + if manifest.execution_mode == "loop" or session.should_use_loop(rendered): + await session.async_add_user_message(rendered) + loop_summary = None + try: + with renderer.status_spinner(f"Running skill {manifest.name}..."): + async for event in session.run_loop(rendered): + renderer.render_loop_event(event, verbose=verbose) + if isinstance(event, FinishEvent): + loop_summary = event.summary + if getattr(event, "output", None): + loop_summary = f"{event.summary}\n\n{event.output}" + elif isinstance(event, LoopErrorEvent): + loop_summary = f"[loop error] {event.error}" + await session.async_add_assistant_message( + f"[Skill: {manifest.name}]\n{loop_summary or '(completed)'}" + ) + except LoopIterationLimitError as exc: + print(f"\n[skill-runner] Iteration limit reached: {exc}") + except KeyboardInterrupt: + print("\n[interrupted]") + except KeyError as k_err: + print(f"Error: {k_err}") + else: + print("Usage: /skill [list | info | run [args] | reload]") + continue + if user_input in {"/clear", "/cls"}: + renderer.clear() continue @@ -938,7 +1033,7 @@ async def run_chat(args: argparse.Namespace, config: Config) -> int: print("Usage: /goal ") continue goal_text = parts[1].strip() - loop_summary: str | None = None + loop_summary = None try: with renderer.status_spinner("Running autonomous goal loop..."): async for event in session.run_loop(goal_text): diff --git a/agentcli/session.py b/agentcli/session.py index 9290c8f..6f45d3a 100644 --- a/agentcli/session.py +++ b/agentcli/session.py @@ -25,10 +25,12 @@ from .routing.classifier import classify from .routing.registry import ModelRegistry from .routing.router import Router +from .skills.engine import SkillEngine logger = logging.getLogger(__name__) + @dataclass class SessionReply: stream: AsyncIterator[str] @@ -111,6 +113,8 @@ def __init__( self.mcp_manager: MCPClientManager = MCPClientManager(config=self.config) self.checkpoint_manager: CheckpointManager = CheckpointManager() self.task_manager: TaskManager = TaskManager() + self.skill_engine: SkillEngine = SkillEngine() + if config.routing.enabled: self.registry = ModelRegistry(config.routing) diff --git a/agentcli/skills/__init__.py b/agentcli/skills/__init__.py new file mode 100644 index 0000000..e8d787a --- /dev/null +++ b/agentcli/skills/__init__.py @@ -0,0 +1,15 @@ +"""Skills and custom workflow recipe system for agentcli (Phase 33).""" + +from .engine import SkillEngine, SkillExecutionResult +from .loader import SkillLoader +from .manifest import SkillManifest, SkillParameter, SkillStep, parse_skill_markdown + +__all__ = [ + "SkillEngine", + "SkillExecutionResult", + "SkillLoader", + "SkillManifest", + "SkillParameter", + "SkillStep", + "parse_skill_markdown", +] diff --git a/agentcli/skills/builtin/code_review/SKILL.md b/agentcli/skills/builtin/code_review/SKILL.md new file mode 100644 index 0000000..dbeae7b --- /dev/null +++ b/agentcli/skills/builtin/code_review/SKILL.md @@ -0,0 +1,40 @@ +--- +name: code-review +description: Comprehensive code review analyzing logic, security vulnerabilities, edge cases, and design patterns +version: 1.0.0 +author: agentcli +execution_mode: loop +max_iterations: 5 +parameters: + target: + type: string + default: "." + description: File or directory path to review + focus: + type: enum + options: [all, security, performance, correctness, architecture, style] + default: all + description: Specific review focus area +required_tools: [grep_search, file_ops, diagnostics_check] +--- +# Autonomous Code Review + +Please perform a thorough, actionable code review for the target: `{{target}}`. + +## Review Focus +- **Active Focus Area**: `{{focus}}` +- **Active Git Branch**: `{{active_branch}}` +- **Workspace Directory**: `{{workspace_dir}}` + +## Step-by-Step Review Instructions +1. Inspect the target path `{{target}}` using `file_ops` and `grep_search`. +2. Run any project diagnostics using `diagnostics_check` to detect underlying linter or type defects. +3. Review logic for: + - **Correctness**: Off-by-one errors, unhandled edge cases, null/none dereferences. + - **Security**: Injection risks, sensitive credential leaks, insecure deserialization, unsafe shell executions. + - **Performance**: High algorithmic complexity, unindexed searches, unclosed handles or locks. + - **Architecture & Modularity**: Adherence to project conventions and single-responsibility principles. +4. Output a structured findings report: + - **Critical Issues** (Immediate attention required) + - **Warnings & Improvements** (Maintainability & performance enhancements) + - **Code Examples** (Precise before/after refactor snippets) diff --git a/agentcli/skills/builtin/security_audit/SKILL.md b/agentcli/skills/builtin/security_audit/SKILL.md new file mode 100644 index 0000000..9c92fd4 --- /dev/null +++ b/agentcli/skills/builtin/security_audit/SKILL.md @@ -0,0 +1,37 @@ +--- +name: security-audit +description: Autonomous vulnerability, secret leak, and dependency supply-chain security audit +version: 1.0.0 +author: agentcli +execution_mode: loop +max_iterations: 5 +parameters: + target_dir: + type: string + default: "." + description: Directory path to audit + scan_secrets: + type: bool + default: true + description: Scan for hardcoded API keys, JWT tokens, and private credentials +required_tools: [grep_search, file_ops, diagnostics_check] +--- +# Security & Vulnerability Audit + +Perform an end-to-end security review of directory: `{{target_dir}}`. + +## Parameters +- **Target Directory**: `{{target_dir}}` +- **Scan Secrets**: `{{scan_secrets}}` +- **Active Branch**: `{{active_branch}}` + +## Audit Checklist +1. **Secret & Key Leaks**: + - Search for API keys, private keys, JWT secrets, passwords, or tokens in committed source code or configuration. +2. **Command & SQL/NoSQL Injection**: + - Inspect all raw shell commands, formatted strings in subprocess execution, or unsanitized DB queries. +3. **Authentication & Authorization**: + - Verify proper JWT token verification, RBAC scoping, and tenant isolation boundaries. +4. **Input Sanitization & Path Traversal**: + - Check file read/write paths for `../` path traversal vulnerabilities. +5. Provide a prioritized markdown vulnerability table (Severity, File, Line, Vulnerability, Remediation). diff --git a/agentcli/skills/builtin/test_generator/SKILL.md b/agentcli/skills/builtin/test_generator/SKILL.md new file mode 100644 index 0000000..6ba4000 --- /dev/null +++ b/agentcli/skills/builtin/test_generator/SKILL.md @@ -0,0 +1,41 @@ +--- +name: test-generator +description: Autonomous unit and integration test generator for target modules with edge-case coverage +version: 1.0.0 +author: agentcli +execution_mode: loop +max_iterations: 6 +parameters: + target_file: + type: string + default: "." + description: Target source file or module to generate tests for + framework: + type: enum + options: [pytest, unittest, vitest, jest, cargo] + default: pytest + description: Testing framework + edge_cases: + type: bool + default: true + description: Whether to explicitly generate extreme edge-case and boundary tests +required_tools: [grep_search, file_ops, shell_execution, diagnostics_check] +--- +# Autonomous Test Generator + +Generate comprehensive, hermetic tests for target: `{{target_file}}` using framework: `{{framework}}`. + +## Parameters +- **Target File**: `{{target_file}}` +- **Framework**: `{{framework}}` +- **Include Edge Cases**: `{{edge_cases}}` +- **Active Workspace**: `{{workspace_dir}}` + +## Instructions +1. Read `{{target_file}}` and understand all public methods, classes, signatures, and internal error branches. +2. Locate existing project test suites to follow naming conventions, fixtures, and directory structures. +3. Write isolated unit and integration test functions covering: + - Happy path behaviors. + - Boundary values, empty inputs, invalid types, and unexpected formats. + - Async coroutines, exception handling, and teardown cleanup. +4. Execute test suite and verify 100% pass rate using `shell_execution` or `diagnostics_check`. diff --git a/agentcli/skills/engine.py b/agentcli/skills/engine.py new file mode 100644 index 0000000..1210651 --- /dev/null +++ b/agentcli/skills/engine.py @@ -0,0 +1,82 @@ +"""Skill execution engine and recipe orchestrator for Phase 33.""" + +from __future__ import annotations + +import logging +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from .loader import SkillLoader +from .manifest import SkillManifest + +logger = logging.getLogger(__name__) + + +@dataclass +class SkillExecutionResult: + """Result emitted after executing a skill.""" + + success: bool + skill_name: str + rendered_prompt: str + output: Any = None + error: str | None = None + execution_mode: str = "loop" + tools_used: list[str] | None = None + + +class SkillEngine: + """Coordinates skill discovery, validation, context injection, and execution.""" + + def __init__( + self, + loader: SkillLoader | None = None, + workspace_dir: str | Path | None = None, + ) -> None: + self.workspace_dir = Path(workspace_dir).resolve() if workspace_dir else Path.cwd() + self.loader = loader or SkillLoader(workspace_dir=self.workspace_dir) + + def _get_git_branch(self) -> str: + """Get the current active git branch name, if any.""" + try: + res = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + cwd=str(self.workspace_dir), + capture_output=True, + text=True, + check=False, + ) + if res.returncode == 0: + return res.stdout.strip() + except Exception as exc: # noqa: BLE001 + logger.debug("Could not determine git branch for workspace %s: %s", self.workspace_dir, exc) + return "main" + + def prepare_skill( + self, + skill_name: str, + arguments: dict[str, Any] | None = None, + extra_context: dict[str, Any] | None = None, + ) -> tuple[SkillManifest, str]: + """Validate arguments and render prompt for a skill.""" + manifest = self.loader.get_skill(skill_name) + if not manifest: + raise KeyError(f"Skill '{skill_name}' not found. Run '/skill list' to see available skills.") + + args = arguments or {} + context: dict[str, Any] = { + "workspace_dir": str(self.workspace_dir), + "workspace_name": self.workspace_dir.name, + "active_branch": self._get_git_branch(), + } + if extra_context: + context.update(extra_context) + + rendered_prompt = manifest.render_prompt(args, context=context) + return manifest, rendered_prompt + + def list_available_skills(self) -> list[dict[str, Any]]: + """Return a serializable list of all loaded skill summaries.""" + return [skill.to_dict() for skill in self.loader.list_skills()] diff --git a/agentcli/skills/loader.py b/agentcli/skills/loader.py new file mode 100644 index 0000000..a0ae841 --- /dev/null +++ b/agentcli/skills/loader.py @@ -0,0 +1,101 @@ +"""Skill discovery and loading module for Phase 33.""" + +from __future__ import annotations + +import logging +from collections.abc import Sequence +from pathlib import Path + +from .manifest import SkillManifest, parse_skill_markdown + +logger = logging.getLogger(__name__) + +BUILTIN_SKILLS_DIR = Path(__file__).parent / "builtin" + + +class SkillLoader: + """Discovers, parses, and indexes skills from workspace and system directories.""" + + def __init__( + self, + workspace_dir: str | Path | None = None, + user_skills_dir: str | Path | None = None, + extra_paths: Sequence[str | Path] | None = None, + include_builtin: bool = True, + ) -> None: + self.workspace_dir = Path(workspace_dir).resolve() if workspace_dir else Path.cwd() + self.user_skills_dir = ( + Path(user_skills_dir).resolve() + if user_skills_dir + else (Path.home() / ".agentcli" / "skills").resolve() + ) + self.extra_paths = [Path(p).resolve() for p in (extra_paths or [])] + self.include_builtin = include_builtin + self._skills: dict[str, SkillManifest] = {} + self.reload() + + def reload(self) -> dict[str, SkillManifest]: + """Re-scan all skill search directories and refresh indexed manifests.""" + self._skills.clear() + + # 1. Load Builtin Skills (Lowest priority) + if self.include_builtin and BUILTIN_SKILLS_DIR.exists(): + self._scan_directory(BUILTIN_SKILLS_DIR, source_type="builtin") + + # 2. Load User Home Skills (Medium priority) + if self.user_skills_dir.exists(): + self._scan_directory(self.user_skills_dir, source_type="user") + + # 3. Load Extra Custom Paths + for p in self.extra_paths: + if p.exists(): + self._scan_directory(p, source_type="user") + + # 4. Load Workspace Skills (Highest priority, overrides global/builtin) + workspace_candidates = [ + self.workspace_dir / ".agentcli" / "skills", + self.workspace_dir / ".skills", + self.workspace_dir / "skills", + ] + for w_dir in workspace_candidates: + if w_dir.exists(): + self._scan_directory(w_dir, source_type="project") + + return self._skills + + def _scan_directory(self, root: Path, source_type: str) -> None: + """Scan a directory for SKILL.md files.""" + try: + for item in root.iterdir(): + if item.is_dir(): + skill_file = item / "SKILL.md" + if skill_file.is_file(): + self._load_skill_file(skill_file, source_type=source_type) + elif item.is_file() and item.name.lower() == "skill.md": + self._load_skill_file(item, source_type=source_type) + except Exception as exc: # noqa: BLE001 + logger.debug("Error scanning directory '%s' for skills: %s", root, exc) + + def _load_skill_file(self, skill_file: Path, source_type: str) -> None: + """Load and parse an individual SKILL.md file.""" + try: + content = skill_file.read_text(encoding="utf-8", errors="replace") + manifest = parse_skill_markdown(content, source_path=skill_file, source_type=source_type) + # Store normalized name (lowercase, stripped) + norm_name = manifest.name.strip().lower() + self._skills[norm_name] = manifest + logger.debug("Loaded skill '%s' (%s) from %s", norm_name, source_type, skill_file) + except Exception as exc: # noqa: BLE001 + logger.warning("Failed to load skill file '%s': %s", skill_file, exc) + + def get_skill(self, name: str) -> SkillManifest | None: + """Retrieve a loaded skill manifest by name.""" + return self._skills.get(name.strip().lower()) + + def list_skills(self) -> list[SkillManifest]: + """Return a sorted list of all currently loaded skill manifests.""" + return sorted(self._skills.values(), key=lambda s: s.name) + + def has_skill(self, name: str) -> bool: + """Check if a skill exists by name.""" + return name.strip().lower() in self._skills diff --git a/agentcli/skills/manifest.py b/agentcli/skills/manifest.py new file mode 100644 index 0000000..b52be51 --- /dev/null +++ b/agentcli/skills/manifest.py @@ -0,0 +1,296 @@ +"""Skill manifest and frontmatter parsing models for Phase 33.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + + +@dataclass +class SkillParameter: + """Definition of a skill input parameter.""" + + name: str + type: str = "string" # "string", "int", "float", "bool", "enum" + default: Any = None + description: str = "" + options: list[str] = field(default_factory=list) + required: bool = False + + def validate_and_cast(self, value: Any) -> Any: + """Validate and cast an input value according to parameter type.""" + if value is None: + if self.required and self.default is None: + raise ValueError(f"Required parameter '{self.name}' is missing") + return self.default + + if self.type == "int": + try: + return int(value) + except (ValueError, TypeError) as err: + raise ValueError(f"Parameter '{self.name}' must be an integer, got: {value}") from err + + if self.type == "float": + try: + return float(value) + except (ValueError, TypeError) as err: + raise ValueError(f"Parameter '{self.name}' must be a float, got: {value}") from err + + if self.type == "bool": + if isinstance(value, bool): + return value + val_str = str(value).strip().lower() + if val_str in ("true", "1", "yes", "y", "t"): + return True + if val_str in ("false", "0", "no", "n", "f"): + return False + raise ValueError(f"Parameter '{self.name}' must be a boolean, got: {value}") + + if self.type == "enum": + val_str = str(value).strip() + if self.options and val_str not in self.options: + raise ValueError( + f"Parameter '{self.name}' must be one of {self.options}, got: {val_str}" + ) + return val_str + + return str(value) + + +@dataclass +class SkillStep: + """Definition of an individual step in a multi-stage skill recipe.""" + + id: str + name: str + goal: str + tools: list[str] = field(default_factory=list) + condition: str = "" # Optional gating expression + + +@dataclass +class SkillManifest: + """Complete structured definition of a skill loaded from a SKILL.md file.""" + + name: str + description: str + version: str = "1.0.0" + author: str = "" + execution_mode: str = "loop" # "chat", "loop", "subagent" + max_iterations: int = 5 + parameters: dict[str, SkillParameter] = field(default_factory=dict) + required_tools: list[str] = field(default_factory=list) + prompt_template: str = "" + steps: list[SkillStep] = field(default_factory=list) + skill_dir: Path | None = None + source_type: str = "project" # "builtin", "project", "user" + + def render_prompt(self, arguments: dict[str, Any], context: dict[str, Any] | None = None) -> str: + """Render skill prompt template with validated arguments and dynamic context.""" + merged: dict[str, Any] = {} + + # 1. Fill parameter defaults & validated values + for param_name, param_def in self.parameters.items(): + user_val = arguments.get(param_name, param_def.default) + merged[param_name] = param_def.validate_and_cast(user_val) + + # 2. Inject extra arguments not defined in parameters + for k, v in arguments.items(): + if k not in merged: + merged[k] = v + + # 3. Inject context variables (e.g. workspace, active branch, date) + if context: + for ck, cv in context.items(): + if ck not in merged: + merged[ck] = cv + + rendered = self.prompt_template + for key, val in merged.items(): + pattern = re.compile(rf"\{{\{{\s*{re.escape(key)}\s*\}}\}}") + val_str = str(val) + + def _make_repl(text: str) -> Any: + return lambda _m: text + + rendered = pattern.sub(_make_repl(val_str), rendered) + + return rendered.strip() + + def to_dict(self) -> dict[str, Any]: + """Convert manifest to serializable summary dictionary.""" + return { + "name": self.name, + "description": self.description, + "version": self.version, + "author": self.author, + "execution_mode": self.execution_mode, + "max_iterations": self.max_iterations, + "parameters": { + k: { + "type": p.type, + "default": p.default, + "description": p.description, + "options": p.options, + "required": p.required, + } + for k, p in self.parameters.items() + }, + "required_tools": self.required_tools, + "source_type": self.source_type, + "skill_dir": str(self.skill_dir) if self.skill_dir else None, + } + + +def parse_simple_yaml(text: str) -> dict[str, Any]: + """Lightweight zero-dependency YAML parser for skill frontmatter.""" + result: dict[str, Any] = {} + lines = text.splitlines() + i = 0 + current_key: str | None = None + sub_key: str | None = None + + while i < len(lines): + raw_line = lines[i] + line = raw_line.strip() + i += 1 + + if not line or line.startswith("#"): + continue + + indent = len(raw_line) - len(raw_line.lstrip()) + + # Top-level key + if indent == 0 and ":" in line: + parts = line.split(":", 1) + key = parts[0].strip() + val_str = parts[1].strip() + current_key = key + sub_key = None + + if not val_str: + result[key] = {} + elif val_str.startswith("[") and val_str.endswith("]"): + items = [x.strip().strip("'\"") for x in val_str[1:-1].split(",") if x.strip()] + result[key] = items + else: + result[key] = _cast_scalar(val_str) + # Indented dict or parameter child + elif indent == 2 and current_key is not None: + if line.startswith("- "): + if not isinstance(result.get(current_key), list): + result[current_key] = [] + result[current_key].append(_cast_scalar(line[2:].strip())) + else: + if not isinstance(result.get(current_key), dict): + result[current_key] = {} + parts = line.split(":", 1) + sub_name = parts[0].strip() + sub_val_str = parts[1].strip() if len(parts) > 1 else "" + + if not sub_val_str: + result[current_key][sub_name] = {} + sub_key = sub_name + elif sub_val_str.startswith("[") and sub_val_str.endswith("]"): + items = [x.strip().strip("'\"") for x in sub_val_str[1:-1].split(",") if x.strip()] + result[current_key][sub_name] = items + sub_key = None + else: + result[current_key][sub_name] = _cast_scalar(sub_val_str) + sub_key = None + elif indent == 4 and current_key is not None and sub_key is not None: + if line.startswith("- "): + if not isinstance(result[current_key].get(sub_key), list): + result[current_key][sub_key] = [] + result[current_key][sub_key].append(_cast_scalar(line[2:].strip())) + elif isinstance(result[current_key], dict) and isinstance(result[current_key].get(sub_key), dict): + parts = line.split(":", 1) + field_name = parts[0].strip() + field_val_str = parts[1].strip() if len(parts) > 1 else "" + if field_val_str.startswith("[") and field_val_str.endswith("]"): + items = [x.strip().strip("'\"") for x in field_val_str[1:-1].split(",") if x.strip()] + result[current_key][sub_key][field_name] = items + else: + result[current_key][sub_key][field_name] = _cast_scalar(field_val_str) + + return result + + +def _cast_scalar(val: str) -> Any: + cleaned = val.strip().strip("'\"") + if cleaned.lower() == "true": + return True + if cleaned.lower() == "false": + return False + if cleaned.lower() == "null" or cleaned.lower() == "none" or cleaned == "": + return None + try: + return int(cleaned) + except ValueError: + pass + try: + return float(cleaned) + except ValueError: + pass + return cleaned + + +def parse_skill_markdown(content: str, source_path: Path | None = None, source_type: str = "project") -> SkillManifest: + """Parse a SKILL.md file with YAML frontmatter into a SkillManifest.""" + frontmatter_match = re.match(r"^---\s*\r?\n(.*?)\r?\n---\s*\r?\n(.*)$", content, re.DOTALL) + if not frontmatter_match: + # Fallback: entire file is prompt template + name = source_path.parent.name if source_path else "custom-skill" + return SkillManifest( + name=name, + description=f"Custom skill '{name}'", + prompt_template=content.strip(), + skill_dir=source_path.parent if source_path else None, + source_type=source_type, + ) + + fm_raw, body_raw = frontmatter_match.group(1), frontmatter_match.group(2) + meta = parse_simple_yaml(fm_raw) + + name = str(meta.get("name") or (source_path.parent.name if source_path else "unnamed-skill")) + description = str(meta.get("description") or "") + version = str(meta.get("version") or "1.0.0") + author = str(meta.get("author") or "") + execution_mode = str(meta.get("execution_mode") or "loop") + max_iterations = int(meta.get("max_iterations") or 5) + required_tools = list(meta.get("required_tools") or []) + + parameters: dict[str, SkillParameter] = {} + params_dict = meta.get("parameters") or {} + if isinstance(params_dict, dict): + for p_name, p_data in params_dict.items(): + if isinstance(p_data, dict): + parameters[p_name] = SkillParameter( + name=p_name, + type=str(p_data.get("type", "string")), + default=p_data.get("default"), + description=str(p_data.get("description", "")), + options=list(p_data.get("options", [])), + required=bool(p_data.get("required", False)), + ) + else: + parameters[p_name] = SkillParameter( + name=p_name, + default=p_data, + ) + + return SkillManifest( + name=name, + description=description, + version=version, + author=author, + execution_mode=execution_mode, + max_iterations=max_iterations, + parameters=parameters, + required_tools=required_tools, + prompt_template=body_raw.strip(), + skill_dir=source_path.parent if source_path else None, + source_type=source_type, + ) diff --git a/agentcli/subagents/__init__.py b/agentcli/subagents/__init__.py index e22677d..38ad29a 100644 --- a/agentcli/subagents/__init__.py +++ b/agentcli/subagents/__init__.py @@ -14,6 +14,7 @@ from .grep_search import GrepSearchAgent from .planner import PlannerAgent from .shell import ShellExecutionAgent +from .skill_runner import SkillRunnerAgent from .spawner import SubAgentPool, SubAgentSpawner from .task_manager import TaskManagerAgent from .web_fetch import HTMLToMarkdownConverter, WebFetchAgent, html_to_markdown @@ -38,7 +39,9 @@ "MessageType", "PlannerAgent", "ShellExecutionAgent", + "SkillRunnerAgent", "SubAgent", + "SubAgentPool", "SubAgentResult", "SubAgentSpawner", diff --git a/agentcli/subagents/base.py b/agentcli/subagents/base.py index 20ba286..e97c66b 100644 --- a/agentcli/subagents/base.py +++ b/agentcli/subagents/base.py @@ -40,6 +40,8 @@ class SubAgentType(str, Enum): TASK_MANAGER = "task_manager" ASK_QUESTION = "ask_question" DIAGNOSTICS_CHECK = "diagnostics_check" + SKILL_RUNNER = "skill_runner" + class SubAgentStatus(str, Enum): diff --git a/agentcli/subagents/skill_runner.py b/agentcli/subagents/skill_runner.py new file mode 100644 index 0000000..70352b5 --- /dev/null +++ b/agentcli/subagents/skill_runner.py @@ -0,0 +1,109 @@ +"""Skill runner subagent for Phase 33.""" + +from __future__ import annotations + +import logging + +from ..skills.engine import SkillEngine +from ..skills.loader import SkillLoader +from .base import SubAgent, SubAgentResult, SubAgentTask + +logger = logging.getLogger(__name__) + + +class SkillRunnerAgent(SubAgent): + """Subagent handling skill discovery, inspection, and execution.""" + + def __init__( + self, + engine: SkillEngine | None = None, + loader: SkillLoader | None = None, + workspace_dir: str | None = None, + ) -> None: + self.loader = loader or SkillLoader(workspace_dir=workspace_dir) + self.engine = engine or SkillEngine(loader=self.loader, workspace_dir=workspace_dir) + + async def run(self, task: SubAgentTask) -> SubAgentResult: + """Execute skill runner actions: list, info, reload, run.""" + payload = task.payload or {} + action = payload.get("action", "list").lower() + + try: + if action == "list": + skills = self.engine.list_available_skills() + return SubAgentResult( + task_id=task.id, + agent_type=task.agent_type, + success=True, + output={"skills": skills, "total": len(skills)}, + ) + + if action == "info": + name = str(payload.get("name", payload.get("skill_name", ""))).strip() + manifest = self.loader.get_skill(name) + if not manifest: + return SubAgentResult( + task_id=task.id, + agent_type=task.agent_type, + success=False, + error=f"Skill '{name}' not found", + output={"error": f"Skill '{name}' not found"}, + ) + return SubAgentResult( + task_id=task.id, + agent_type=task.agent_type, + success=True, + output={"skill": manifest.to_dict(), "prompt_template": manifest.prompt_template}, + ) + + if action == "reload": + self.loader.reload() + skills = self.engine.list_available_skills() + return SubAgentResult( + task_id=task.id, + agent_type=task.agent_type, + success=True, + output={"reloaded": True, "total": len(skills)}, + ) + + if action in ("run", "execute"): + name = str(payload.get("name", payload.get("skill_name", ""))).strip() + args = payload.get("args", payload.get("arguments", {})) + context = payload.get("context", {}) + + manifest, rendered_prompt = self.engine.prepare_skill( + skill_name=name, + arguments=args, + extra_context=context, + ) + + return SubAgentResult( + task_id=task.id, + agent_type=task.agent_type, + success=True, + output={ + "skill_name": manifest.name, + "execution_mode": manifest.execution_mode, + "max_iterations": manifest.max_iterations, + "required_tools": manifest.required_tools, + "rendered_prompt": rendered_prompt, + }, + ) + + return SubAgentResult( + task_id=task.id, + agent_type=task.agent_type, + success=False, + error=f"Unknown skill_runner action: '{action}'", + output={"error": f"Unknown action: '{action}'"}, + ) + + except Exception as exc: + logger.exception("Error executing skill runner action '%s'", action) + return SubAgentResult( + task_id=task.id, + agent_type=task.agent_type, + success=False, + error=str(exc), + output={"error": str(exc)}, + ) diff --git a/agentcli/tools_schema.py b/agentcli/tools_schema.py index 18fc530..dc3b0cd 100644 --- a/agentcli/tools_schema.py +++ b/agentcli/tools_schema.py @@ -395,9 +395,36 @@ }, }, }, + SubAgentType.SKILL_RUNNER.value: { + "type": "function", + "function": { + "name": "skill_runner", + "description": "Discover, inspect, and execute custom skills and multi-stage workflow recipes from .agentcli/skills/", + "parameters": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["list", "info", "run", "reload"], + "description": "Action to perform: 'list' available skills, 'info' on a skill, 'run' a skill recipe, or 'reload' skills from disk", + }, + "name": { + "type": "string", + "description": "Skill name (required for 'info' and 'run' actions)", + }, + "args": { + "type": "object", + "description": "Input parameters dict passed to the skill prompt template", + }, + }, + "required": ["action"], + }, + }, + }, } + def get_tool_definitions( allowed_types: Iterable[SubAgentType | str] | None = None, ) -> list[dict[str, Any]]: diff --git a/agentcli/ui/prompt.py b/agentcli/ui/prompt.py index 4c99ab4..1ed7490 100644 --- a/agentcli/ui/prompt.py +++ b/agentcli/ui/prompt.py @@ -53,6 +53,7 @@ def resolve_slash_command(text: str) -> str: "/revert": "/undo", "/task": "/tasks", "/bg": "/tasks", + "/skills": "/skill", } if raw_cmd in aliases: @@ -68,6 +69,7 @@ def resolve_slash_command(text: str) -> str: "/diff", "/undo", "/tasks", + "/skill", "/tokens", "/cost", "/clear", @@ -86,7 +88,7 @@ def resolve_slash_command(text: str) -> str: class SlashAndFileCompleter(Completer): - """Completer for slash commands (/models, /model, /undo, /tasks, /budget, /history, /exit, etc.), model arguments, and @file references.""" + """Completer for slash commands (/models, /model, /undo, /tasks, /skill, /budget, /history, /exit, etc.), model arguments, and @file references.""" SLASH_COMMANDS: ClassVar[list[tuple[str, str]]] = [ ("/help", "Show help, slash commands, and shortcuts"), @@ -98,6 +100,7 @@ class SlashAndFileCompleter(Completer): ("/diff", "Inspect file diffs generated during session"), ("/undo", "Revert latest file changes or inspect turn rollback (/undo diff)"), ("/tasks", "List or manage background tasks (/tasks, /tasks kill )"), + ("/skill", "Run or inspect custom skills and recipes (/skill list, /skill run )"), ("/tokens", "Show current session token usage breakdown"), ("/cost", "Show current session estimated cost"), ("/clear", "Clear terminal screen"), @@ -114,8 +117,10 @@ class SlashAndFileCompleter(Completer): "/h": "/help", "/task": "/tasks", "/bg": "/tasks", + "/skills": "/skill", } + def __init__(self) -> None: self.path_completer = PathCompleter(expanduser=True) @@ -167,6 +172,37 @@ def get_completions(self, document: Document, complete_event: CompleteEvent) -> yield Completion(opt, start_position=-len(arg), display_meta=desc) return + # Complete skill arguments after /skill + if text.startswith(("/skill ", "\\skill ")): + from ..skills.loader import SkillLoader + + arg = text.split(maxsplit=1)[1] if len(text.split(maxsplit=1)) > 1 else "" + arg_lower = arg.lower() + + skill_actions = [ + ("list", "[ACTION] List all available skills"), + ("info", "[ACTION] Show skill details and parameters"), + ("run", "[ACTION] Execute a skill recipe (/skill run )"), + ("reload", "[ACTION] Reload skills from disk"), + ] + for act, desc in skill_actions: + if act.startswith(arg_lower): + yield Completion(act, start_position=-len(arg), display_meta=desc) + + if arg_lower.startswith(("run ", "info ")): + sub_parts = arg.split(maxsplit=1) + sub_arg = sub_parts[1] if len(sub_parts) > 1 else "" + loader = SkillLoader() + for skill in loader.list_skills(): + if skill.name.lower().startswith(sub_arg.lower()): + yield Completion( + skill.name, + start_position=-len(sub_arg), + display_meta=f"[{skill.source_type.upper()}] {skill.description[:35]}", + ) + return + + # Complete slash commands at the start of input (support both / and \) if text.startswith(("/", "\\")): normalized_text = "/" + text[1:] if text.startswith("\\") else text diff --git a/agentcli/ui/tui_app.py b/agentcli/ui/tui_app.py index 46730c5..b35ed2d 100644 --- a/agentcli/ui/tui_app.py +++ b/agentcli/ui/tui_app.py @@ -490,7 +490,97 @@ async def _handle_slash_command(self, text: str, timestamp: str, event: KeyPress self.add_message("system", "No active session task manager available.", timestamp) return + if cmd in {"/skill", "/skills"}: + if self.session and hasattr(self.session, "skill_engine"): + parts = text.split(maxsplit=2) + sub = parts[1].lower() if len(parts) > 1 else "list" + target_name = parts[2].strip() if len(parts) > 2 else "" + + if sub in ("list", "ls") or (len(parts) == 1): + skills = self.session.skill_engine.list_available_skills() + if not skills: + self.add_message( + "system", "No skills discovered in workspace or user directory.", timestamp + ) + else: + lines = [f"Available Skills ({len(skills)}):"] + for s in skills: + lines.append( + f" [{s['source_type'].upper()}] {s['name']} (v{s['version']}): {s['description']}" + ) + self.add_message("system", "\n".join(lines), timestamp) + elif sub == "info": + if not target_name: + self.add_message("system", "Usage: /skill info ", timestamp) + else: + manifest = self.session.skill_engine.loader.get_skill(target_name) + if not manifest: + self.add_message( + "system", f"Skill '{target_name}' not found.", timestamp + ) + else: + lines = [ + f"Skill [{manifest.name}] (v{manifest.version})", + f" Description: {manifest.description}", + f" Source: {manifest.source_type}", + f" Execution Mode: {manifest.execution_mode}", + ] + if manifest.parameters: + lines.append(" Parameters:") + for p_name, p in manifest.parameters.items(): + lines.append(f" - {p_name} [{p.type}]: {p.description}") + self.add_message("system", "\n".join(lines), timestamp) + elif sub == "reload": + self.session.skill_engine.loader.reload() + total = len(self.session.skill_engine.loader.list_skills()) + self.add_message( + "system", f"Reloaded skills. Found {total} skill(s).", timestamp + ) + elif sub in ("run", "exec") or self.session.skill_engine.loader.has_skill(sub): + actual_name = ( + target_name.split(maxsplit=1)[0] if sub in ("run", "exec") else sub + ) + raw_args = ( + target_name.split(maxsplit=1)[1] + if (sub in ("run", "exec") and len(target_name.split(maxsplit=1)) > 1) + else target_name + ) + if not actual_name: + self.add_message( + "system", "Usage: /skill run [param=value ...]", timestamp + ) + else: + parsed_args = {} + if raw_args: + for token in raw_args.split(): + if "=" in token: + k, v = token.split("=", 1) + parsed_args[k.strip()] = v.strip() + else: + parsed_args["target"] = token + try: + manifest, rendered = self.session.skill_engine.prepare_skill( + skill_name=actual_name, + arguments=parsed_args, + ) + self.add_message("user", f"/skill run {actual_name}", timestamp) + self._current_task = asyncio.create_task( + self._process_goal_query(rendered) + ) + except Exception as exc: # noqa: BLE001 + self.add_message("system", f"Error: {exc}", timestamp) + else: + self.add_message( + "system", + "Usage: /skill [list | info | run [args] | reload]", + timestamp, + ) + else: + self.add_message("system", "No active session skill engine available.", timestamp) + return + if cmd == "/goal": + parts = text.split(maxsplit=1) if len(parts) < 2 or not parts[1].strip(): self.add_message("system", "Usage: /goal ", timestamp) diff --git a/docs/roadmap_phase33_36.md b/docs/roadmap_phase33_36.md new file mode 100644 index 0000000..9ee364d --- /dev/null +++ b/docs/roadmap_phase33_36.md @@ -0,0 +1,112 @@ +# Implementation Plan: Phases 33 – 36 – Enterprise Extensibility, Interactive Review & Multi-Provider Architecture + +## Overview +This roadmap establishes the next generation of enterprise agentic capabilities for `agentcli`. Building upon Phase 31 (Grep, Web Fetch, Checkpoints) and Phase 32 (Background Tasks, Clarification, Diagnostics), Phases 33–36 introduce custom skills/recipes, granular hunk-by-hunk patch review, persistent codebase knowledge graphs, and native multi-provider LLM gateways. + +--- + +## 🧩 Phase 33: Custom Skills, Workflow Recipes & Dynamic Slash Commands (P0) + +### 1. Repository & User Skill Manifests (`.agentcli/skills/`, `~/.agentcli/skills/`) +- **Module**: `agentcli/skills/loader.py` & `agentcli/skills/manifest.py` +- **Capabilities**: + - Discover and load structured `SKILL.md` skill definitions from project workspace (`.agentcli/skills//SKILL.md`) and user home directory (`~/.agentcli/skills//SKILL.md`). + - YAML frontmatter parser extracting metadata: `name`, `description`, `version`, `author`, `parameters`, `required_tools`, and `execution_mode` (chat, loop, subagent). + - Jinja2/f-string template variable substitution injecting session context, active git branch, touched files, and user arguments. + +### 2. Custom Workflow Recipe Engine & SubAgent Routing +- **Module**: `agentcli/skills/engine.py` & `agentcli/subagents/skill_runner.py` +- **Capabilities**: + - Multi-step declarative workflow execution (e.g. `audit-security`, `generate-e2e-tests`, `refactor-module`, `pr-review`). + - Step gating: execute step -> evaluate reflection criteria -> proceed or halt. + - SubAgent isolation: runs skills in constrained sandboxes with configurable tool permission whitelists (`allow_write`, `allow_shell`, `network_allowed`). + +### 3. Dynamic Slash Command Registration & Autocompletion +- **Module**: `agentcli/ui/prompt.py` & `agentcli/ui/tui_app.py` +- **Capabilities**: + - Automatically registers loaded skills as first-class slash commands (e.g. `/audit`, `/refactor`, `/gen-tests`). + - Interactive `/skill` management: `/skill list`, `/skill info `, `/skill reload`, `/skill run [args]`. + - Dynamic dropdown completion in CLI prompt and Textual TUI dashboard. + +--- + +## 🔍 Phase 34: Interactive Hunk-by-Hunk Patch Reviewer & Guided Diff Inspector (P1) + +### 1. Granular Diff Parser & Hunk Splitter +- **Module**: `agentcli/diff/parser.py` & `agentcli/diff/hunk.py` +- **Capabilities**: + - Split unified diffs into independent logical hunks with precise line range offsets (`@@ -start,len +start,len @@`). + - Detect intra-hunk whitespace, syntax changes, and cross-file rename/deletion events. + - Compute hunk collision and dependency graphs. + +### 2. Interactive Terminal Hunk Reviewer & Approval Modal +- **Module**: `agentcli/ui/diff_reviewer.py` & `agentcli/ui/tui_app.py` +- **Capabilities**: + - Visual color-coded hunk-by-hunk review in both interactive terminal chat and TUI modal. + - Single-key actions per hunk: + - `[y]` **Accept hunk**: Stage hunk for workspace application. + - `[n]` **Reject hunk**: Drop hunk and record rejection rationale. + - `[e]` **Edit hunk**: Open hunk in `$EDITOR` / internal editor for manual adjustment. + - `[a]` **Accept all remaining**: Apply all pending hunks. + - `[d]` **Discard all**: Reject entire turn changeset and trigger `/undo`. + - `[?]` **Help**: Display keybinding guide. + +### 3. Reflector Feedback Loop Integration +- **Module**: `agentcli/agent/loop.py` & `agentcli/agent/reflector.py` +- **Capabilities**: + - Rejected hunks and user comments feed directly into the `LLMReflector` context. + - The model autonomously revises rejected changes without redoing accepted modifications. + +--- + +## 🧠 Phase 35: Persistent Codebase Knowledge Graph & Cross-Session Memory (P1) + +### 1. SQLite Codebase Knowledge Graph (`.agentcli/knowledge.db`) +- **Module**: `agentcli/knowledge/graph.py` & `agentcli/knowledge/indexer.py` +- **Capabilities**: + - Persistent relational index of codebase architecture: modules, classes, functions, imports, and call graphs. + - Cross-file symbol resolution and dependency impact analysis (e.g. "what breaks if `AuthService.login()` signature changes?"). + - Background incremental updating triggered by `FileWatcher`. + +### 2. Cross-Session Architectural Memory & Decision Vault +- **Module**: `agentcli/memory/knowledge_store.py` +- **Capabilities**: + - Automatic extraction of codebase rules, architecture decisions, and bug-fix patterns into durable long-term memory. + - Queryable memory recall across terminal restarts (`@rule`, `@arch`, `@decision`). + - Explicit user memory controls: `/memory list`, `/memory add `, `/memory clear`. + +### 3. Session Branching, Diffing & Merging +- **Module**: `agentcli/session_branching.py` +- **Capabilities**: + - Branch conversation history: `/session branch `, `/session switch `. + - Compare session branches: `/session diff `. + - Export and import session transcripts as portable markdown bundles. + +--- + +## 🔌 Phase 36: Multi-Provider LLM Gateway & Native Streaming Fallbacks (P2) + +### 1. Native Provider Adapters (Direct SDK-Free HTTP/SSE) +- **Module**: `agentcli/providers/` (`anthropic.py`, `openai.py`, `gemini.py`, `ollama.py`, `openrouter.py`) +- **Capabilities**: + - Direct HTTP/2 Server-Sent Events (SSE) streaming clients without bloated heavy SDKs. + - Native support for **Anthropic** (Claude 3.5 Sonnet/Opus), **OpenAI** (GPT-4o/o3-mini), **Google Gemini** (Gemini 2.0 Flash/Pro), **DeepSeek**, and **Local Ollama/vLLM** (`localhost:11434`). + +### 2. Real-Time Token-Level Interruption & Telemetry +- **Module**: `agentcli/providers/stream.py` +- **Capabilities**: + - Clean `ESC` / `Ctrl+C` generation interruption without hanging sockets or corrupted memory state. + - Live token generation speed (tokens/sec), time-to-first-token (TTFT), and latency profiling. + +### 3. Automatic Cross-Provider Fallback Chaining +- **Module**: `agentcli/routing/gateway.py` +- **Capabilities**: + - Tiered fallback cascades across providers: e.g. OpenRouter Primary -> Direct Anthropic Secondary -> Local Ollama Fallback. + - Automatic error classification: HTTP 429 (rate limit), 503 (overloaded), 401 (auth failure), and connection timeouts. + +--- + +## 🛡️ Core Constraints & Development Safeguards +1. **Single-Worker Laptop Profile (`peregrine001` 15W TDP)**: Strict `maxWorkers: 1`, non-parallel command execution, and bounded memory buffers. +2. **Windows & PowerShell First**: Strict backslash safety for paths in shell commands, forward slashes in code, and process tree termination (`taskkill /T /F`). +3. **Strict Quality Gates**: Zero `ruff` errors, zero `mypy` errors across all files, >= 85% test coverage, and green CI across Python 3.11–3.14 on Ubuntu and Windows. diff --git a/tests/test_phase33_skills.py b/tests/test_phase33_skills.py new file mode 100644 index 0000000..08ee653 --- /dev/null +++ b/tests/test_phase33_skills.py @@ -0,0 +1,442 @@ +"""Tests for Phase 33: Custom Skills, Workflow Recipes & Dynamic Slash Commands.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +import pytest + +from agentcli.agent.registry import ToolRegistry +from agentcli.skills.engine import SkillEngine +from agentcli.skills.loader import SkillLoader +from agentcli.skills.manifest import ( + SkillManifest, + SkillParameter, + parse_simple_yaml, + parse_skill_markdown, +) +from agentcli.subagents.base import SubAgentTask, SubAgentType +from agentcli.subagents.skill_runner import SkillRunnerAgent +from agentcli.tools_schema import TOOL_DEFINITIONS, get_tool_definitions + +# --------------------------------------------------------------------------- +# 1. Manifest Parsing Tests +# --------------------------------------------------------------------------- + + +def test_parse_skill_markdown_full(tmp_path: Path): + skill_content = """--- +# Comment header +name: sample-analysis +description: Performs sample analysis on codebase. +version: 1.2.0 +author: Antigravity Team +tags: [analysis, test, automated] +parameters: + target_dir: + description: Target directory to analyze + type: string + required: true + default: . + max_depth: + description: Max scanning depth + type: int + default: 3 + fast_mode: + description: Skip detailed checks + type: bool + default: false + simple_scalar: default_val +required_tools: + - file_ops + - shell_execution +--- + +# Sample Analysis Prompt + +Analyze the directory: {{target_dir}} with depth {{max_depth}}. +Fast mode is {{fast_mode}}. +Workspace: {{workspace_dir}} +Branch: {{active_branch}} +""" + skill_file = tmp_path / "SKILL.md" + skill_file.write_text(skill_content, encoding="utf-8") + + manifest = parse_skill_markdown( + skill_content, + source_path=skill_file, + source_type="project", + ) + + assert manifest is not None + assert manifest.name == "sample-analysis" + assert manifest.description == "Performs sample analysis on codebase." + assert manifest.version == "1.2.0" + assert manifest.author == "Antigravity Team" + assert manifest.source_type == "project" + assert "target_dir" in manifest.parameters + assert manifest.parameters["target_dir"].type == "string" + assert manifest.parameters["target_dir"].required is True + assert manifest.parameters["max_depth"].type == "int" + assert manifest.parameters["max_depth"].default == 3 + assert manifest.parameters["fast_mode"].type == "bool" + assert manifest.parameters["fast_mode"].default is False + assert manifest.parameters["simple_scalar"].default == "default_val" + assert manifest.required_tools == ["file_ops", "shell_execution"] + assert manifest.prompt_template.startswith("# Sample Analysis Prompt") + + +def test_parse_skill_markdown_no_frontmatter(tmp_path: Path): + skill_file = tmp_path / "custom-analysis" / "SKILL.md" + skill_file.parent.mkdir(parents=True) + raw_prompt = "# Simple Prompt with no YAML header\nDo something useful." + skill_file.write_text(raw_prompt, encoding="utf-8") + + manifest = parse_skill_markdown(raw_prompt, source_path=skill_file, source_type="project") + assert manifest is not None + assert manifest.name == "custom-analysis" + assert manifest.prompt_template == raw_prompt + + +def test_manifest_cast_parameters(): + param_int = SkillParameter(name="count", type="int", default=10) + assert param_int.validate_and_cast("42") == 42 + assert param_int.validate_and_cast(50) == 50 + with pytest.raises(ValueError): + param_int.validate_and_cast("invalid") + + param_float = SkillParameter(name="score", type="float", default=0.5) + assert param_float.validate_and_cast("0.85") == 0.85 + assert param_float.validate_and_cast(1.2) == 1.2 + with pytest.raises(ValueError): + param_float.validate_and_cast("bad") + + param_bool = SkillParameter(name="flag", type="bool", default=False) + assert param_bool.validate_and_cast(True) is True + assert param_bool.validate_and_cast(False) is False + assert param_bool.validate_and_cast("true") is True + assert param_bool.validate_and_cast("yes") is True + assert param_bool.validate_and_cast("1") is True + assert param_bool.validate_and_cast("false") is False + assert param_bool.validate_and_cast("0") is False + with pytest.raises(ValueError): + param_bool.validate_and_cast("not-a-bool") + + param_enum = SkillParameter(name="choice", type="enum", options=["low", "high"], default="low") + assert param_enum.validate_and_cast("high") == "high" + with pytest.raises(ValueError): + param_enum.validate_and_cast("invalid") + + # None handling + param_optional = SkillParameter(name="opt", default="default_opt") + assert param_optional.validate_and_cast(None) == "default_opt" + + +def test_manifest_render_prompt(): + manifest = SkillManifest( + name="test-skill", + description="A test skill", + parameters={ + "query": SkillParameter(name="query", type="string", default="default query"), + "limit": SkillParameter(name="limit", type="int", default=10), + }, + prompt_template="Searching for '{{query}}' with limit {{limit}} in {{workspace_dir}}.", + ) + + rendered = manifest.render_prompt( + arguments={"query": "security bugs", "limit": 25}, + context={"workspace_dir": "/workspace"}, + ) + assert rendered == "Searching for 'security bugs' with limit 25 in /workspace." + + +def test_manifest_validation(): + manifest = SkillManifest( + name="test-validation", + description="Testing validation", + parameters={ + "required_arg": SkillParameter(name="required_arg", type="string", required=True), + }, + prompt_template="Execute with {{required_arg}}", + ) + with pytest.raises(ValueError, match="Required parameter 'required_arg' is missing"): + manifest.render_prompt(arguments={}) + + rendered = manifest.render_prompt(arguments={"required_arg": "provided_value"}) + assert "provided_value" in rendered + + +def test_parse_simple_yaml_bracket_and_nested(): + yaml_text = """ +title: Test Pipeline +tags: [fast, secure] +details: + level: 4 + flags: [a, b] +""" + res = parse_simple_yaml(yaml_text) + assert res["title"] == "Test Pipeline" + assert res["tags"] == ["fast", "secure"] + assert res["details"]["level"] == 4 + assert res["details"]["flags"] == ["a", "b"] + + +# --------------------------------------------------------------------------- +# 2. Skill Loader Tests +# --------------------------------------------------------------------------- + + +def test_skill_loader_builtin_discovery(): + loader = SkillLoader() + skills = loader.list_skills() + + assert len(skills) >= 3 + skill_names = [s.name for s in skills] + assert "code-review" in skill_names + assert "test-generator" in skill_names + assert "security-audit" in skill_names + + assert loader.has_skill("code-review") is True + assert loader.has_skill("non-existent-skill-xyz") is False + + code_review = loader.get_skill("code-review") + assert code_review is not None + assert code_review.source_type == "builtin" + assert "target" in code_review.parameters + + +def test_skill_loader_tier_override(tmp_path: Path): + # Setup mock workspace directory with .agentcli/skills/code-review/SKILL.md + workspace_dir = tmp_path / "my_project" + workspace_dir.mkdir() + project_skills = workspace_dir / ".agentcli" / "skills" / "code-review" + project_skills.mkdir(parents=True) + + override_skill = """--- +name: code-review +description: Custom workspace overridden code review. +version: 2.0.0 +--- + +Workspace custom code review template. +""" + (project_skills / "SKILL.md").write_text(override_skill, encoding="utf-8") + + loader = SkillLoader(workspace_dir=workspace_dir) + skill = loader.get_skill("code-review") + + assert skill is not None + # Workspace version must override builtin + assert skill.source_type == "project" + assert skill.description == "Custom workspace overridden code review." + assert skill.version == "2.0.0" + + +def test_skill_loader_user_and_extra_paths(tmp_path: Path): + user_skills_dir = tmp_path / "user_home_skills" + user_skills_dir.mkdir(parents=True) + extra_dir = tmp_path / "extra_skills" + extra_dir.mkdir(parents=True) + + (user_skills_dir / "user_skill").mkdir() + (user_skills_dir / "user_skill" / "SKILL.md").write_text( + "---\nname: user-skill\ndescription: User home skill\n---\nPrompt", encoding="utf-8" + ) + + (extra_dir / "extra_skill").mkdir() + (extra_dir / "extra_skill" / "SKILL.md").write_text( + "---\nname: extra-skill\ndescription: Extra skill\n---\nPrompt", encoding="utf-8" + ) + + loader = SkillLoader( + workspace_dir=tmp_path / "empty_ws", + user_skills_dir=user_skills_dir, + extra_paths=[extra_dir], + ) + + assert loader.has_skill("user-skill") is True + assert loader.has_skill("extra-skill") is True + skill = loader.get_skill("user-skill") + assert skill is not None + assert skill.source_type == "user" + + +def test_skill_loader_direct_file_and_error_handling(tmp_path: Path): + ws = tmp_path / "direct_ws" + ws.mkdir() + skills_dir = ws / ".agentcli" / "skills" + skills_dir.mkdir(parents=True) + + # Put a direct SKILL.md file inside .agentcli/skills + (skills_dir / "SKILL.md").write_text( + "---\nname: direct-skill\ndescription: Directly in root\n---\nPrompt", encoding="utf-8" + ) + + loader = SkillLoader(workspace_dir=ws) + assert loader.has_skill("direct-skill") is True + + +# --------------------------------------------------------------------------- +# 3. Skill Engine Tests +# --------------------------------------------------------------------------- + + +def test_skill_engine_list_and_prepare(tmp_path: Path): + engine = SkillEngine(workspace_dir=tmp_path) + skills = engine.list_available_skills() + assert any(s["name"] == "code-review" for s in skills) + + manifest, rendered = engine.prepare_skill( + "code-review", + arguments={"target": "src/main.py", "strict": True}, + extra_context={"custom_var": "val123"}, + ) + assert manifest.name == "code-review" + assert "src/main.py" in rendered + + +def test_skill_engine_git_branch_fallback(tmp_path: Path): + engine = SkillEngine(workspace_dir=tmp_path) + with patch("subprocess.run", side_effect=Exception("Git not found")): + branch = engine._get_git_branch() + assert branch == "main" + + +def test_skill_engine_prepare_nonexistent(tmp_path: Path): + engine = SkillEngine(workspace_dir=tmp_path) + with pytest.raises(KeyError, match="not found"): + engine.prepare_skill("non-existent-skill") + + +# --------------------------------------------------------------------------- +# 4. Skill Runner Agent Tests +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_skill_runner_agent_list(tmp_path: Path): + agent = SkillRunnerAgent(workspace_dir=str(tmp_path)) + task = SubAgentTask( + agent_type=SubAgentType.SKILL_RUNNER, + payload={"action": "list"}, + ) + result = await agent.run(task) + + assert result.success is True + data = result.output + assert "skills" in data + assert "total" in data + assert data["total"] >= 3 + assert any(s["name"] == "code-review" for s in data["skills"]) + + +@pytest.mark.asyncio +async def test_skill_runner_agent_info(tmp_path: Path): + agent = SkillRunnerAgent(workspace_dir=str(tmp_path)) + task = SubAgentTask( + agent_type=SubAgentType.SKILL_RUNNER, + payload={"action": "info", "name": "code-review"}, + ) + result = await agent.run(task) + + assert result.success is True + data = result.output + assert "skill" in data + assert data["skill"]["name"] == "code-review" + assert "parameters" in data["skill"] + assert "target" in data["skill"]["parameters"] + + +@pytest.mark.asyncio +async def test_skill_runner_agent_info_not_found(tmp_path: Path): + agent = SkillRunnerAgent(workspace_dir=str(tmp_path)) + task = SubAgentTask( + agent_type=SubAgentType.SKILL_RUNNER, + payload={"action": "info", "name": "unknown-skill"}, + ) + result = await agent.run(task) + + assert result.success is False + assert result.error is not None + assert "Skill 'unknown-skill' not found" in result.error + + +@pytest.mark.asyncio +async def test_skill_runner_agent_run(tmp_path: Path): + agent = SkillRunnerAgent(workspace_dir=str(tmp_path)) + task = SubAgentTask( + agent_type=SubAgentType.SKILL_RUNNER, + payload={ + "action": "run", + "name": "code-review", + "args": {"target": "agentcli/skills/engine.py"}, + }, + ) + result = await agent.run(task) + + assert result.success is True + data = result.output + assert data["skill_name"] == "code-review" + assert "rendered_prompt" in data + assert "agentcli/skills/engine.py" in data["rendered_prompt"] + + +@pytest.mark.asyncio +async def test_skill_runner_agent_reload(tmp_path: Path): + agent = SkillRunnerAgent(workspace_dir=str(tmp_path)) + task = SubAgentTask( + agent_type=SubAgentType.SKILL_RUNNER, + payload={"action": "reload"}, + ) + result = await agent.run(task) + + assert result.success is True + assert result.output["reloaded"] is True + + +@pytest.mark.asyncio +async def test_skill_runner_agent_unknown_action(tmp_path: Path): + agent = SkillRunnerAgent(workspace_dir=str(tmp_path)) + task = SubAgentTask( + agent_type=SubAgentType.SKILL_RUNNER, + payload={"action": "invalid_action_name"}, + ) + result = await agent.run(task) + + assert result.success is False + assert result.error is not None + assert "Unknown skill_runner action" in result.error + + +# --------------------------------------------------------------------------- +# 5. Tool Registry and Schema Tests +# --------------------------------------------------------------------------- + + +def test_tool_definitions_has_skill_runner(): + assert "skill_runner" in TOOL_DEFINITIONS + skill_tool = TOOL_DEFINITIONS["skill_runner"] + assert "action" in skill_tool["function"]["parameters"]["properties"] + assert "name" in skill_tool["function"]["parameters"]["properties"] + assert "args" in skill_tool["function"]["parameters"]["properties"] + + # Test get_tool_definitions with SubAgentType + tool_list = get_tool_definitions([SubAgentType.SKILL_RUNNER]) + assert len(tool_list) == 1 + assert tool_list[0]["function"]["name"] == "skill_runner" + + # Test get_tool_definitions with str + tool_list_str = get_tool_definitions(["skill_runner"]) + assert len(tool_list_str) == 1 + assert tool_list_str[0]["function"]["name"] == "skill_runner" + + # Test get_tool_definitions with None (all tools) + all_tools = get_tool_definitions(None) + assert len(all_tools) >= 1 + + +def test_tool_registry_includes_skill_runner(): + registry = ToolRegistry() + assert "skill_runner" in registry.registered_types() + assert SubAgentType.SKILL_RUNNER.value == "skill_runner"