diff --git a/FPLAN-0006_framework_skills_system_2026-03-07.md b/FPLAN-0006_framework_skills_system_2026-03-07.md new file mode 100644 index 00000000..8c6cbe01 --- /dev/null +++ b/FPLAN-0006_framework_skills_system_2026-03-07.md @@ -0,0 +1,116 @@ +# FPLAN-0006 - [framework] Skills System Build + +**Created**: 2026-03-07 +**Branch**: /home/aipass/aipass_business/AIPass +**Status**: Complete +**Type**: Master Plan + +--- + +## Planning Phase + +### Goal +Build the Skills system for AIPass framework. Skills are documented capabilities that any AI can use — from simple markdown SOPs to full 3-layer code implementations. Lives at `src/skills/` (peer to `src/aipass/`, not inside it — skills are separate from infrastructure). + +### Approach +Three-tier skill architecture: +- **Tier 1 (Markdown):** SKILL.md only — instructions an LLM reads and follows +- **Tier 2 (Markdown + Handler):** SKILL.md + handler.py — code does the work +- **Tier 3 (Full 3-layer):** SKILL.md + apps/modules/handlers — complete implementation + +Follows seedgo's pack pattern: auto-discovery via SKILL.md manifests, drone-routable via `handle_command()`. + +OpenClaw SKILL.md format compatibility: their 52 skills (MIT license) can be adapted with minor metadata changes. + +### Key Decisions +- **Location**: `src/skills/` — outside `src/aipass/` (skills are capabilities, not infrastructure) +- **Format**: SKILL.md with YAML frontmatter (compatible with OpenClaw format) +- **Discovery**: Glob + importlib + duck typing (same pattern as Nexus skills, seedgo packs) +- **Drone integration**: `drone @skills list|info|run|create|validate` +- **Handler contract**: `run(action, args, config) -> {"success": bool, "output": str, "error": str|None}` +- **No branch manager build** — directory structure only (like FPLAN-0003/0004/0005 pattern) + +### Reference Documents +- Design: `vera/projects/framework/skills_system_design.md` +- Research: `vera/projects/framework/nexus_and_skills_design.md` +- Seedgo pattern: `src/aipass/seedgo/apps/standards/aipass/` (pack architecture) +- Telegram reference: `/home/aipass/aipass_core/api/apps/handlers/telegram/` (complex skill example) +- OpenClaw skills: `/home/aipass/external_repos/openclaw/skills/` (SKILL.md format reference) +- Architecture: `vera/projects/framework/architecture_reference.md` + +--- + +## Execution Log + +### Phase 1: Foundation — Directory Structure + Core +- [x] Create `src/skills/` directory structure (3-layer: apps/skills.py → modules/ → handlers/) +- [x] Create entry point: `apps/skills.py` with `handle_command()` for drone routing +- [x] Create discovery module: `apps/modules/discovery.py` (scan search paths, parse SKILL.md frontmatter) +- [x] Create loader module: `apps/modules/loader.py` (load full SKILL.md, import handler if present) +- [x] Create runner module: `apps/modules/runner.py` (execute handler or display instructions) +- [x] Create creator module: `apps/modules/creator.py` (scaffold new skills from templates) +- [x] Create registry handler: `apps/handlers/registry.py` (skill registry management) +- [x] Create validator handler: `apps/handlers/validator.py` (check requirements — bins, pip, config) +- [x] Create template handler: `apps/handlers/template.py` (skill templates for scaffolding) +- [x] Create Trinity files: `.trinity/passport.json`, `.trinity/local.json`, `.trinity/observations.json` +- [x] Create `__init__.py` files at all levels +- [x] Create README.md + +### Phase 2: Templates +- [x] Markdown-only skill template (`templates/markdown_only/SKILL.md`) +- [x] Markdown + handler skill template (`templates/with_handler/SKILL.md` + `handler.py`) +- [x] Full 3-layer skill template (`templates/full/SKILL.md` + `apps/` structure) + +### Phase 3: Test Skills (One Per Tier) +- [x] Tier 1: Adapt OpenClaw GitHub skill → `catalog/github/SKILL.md` +- [x] Tier 2: Build system_status skill → `catalog/system_status/SKILL.md` + `handler.py` +- [x] Tier 3: Create drone_commands skill skeleton → `catalog/drone_commands/` with apps structure + +### Phase 4: Seedgo Skills Standard +- [x] Create skills standard pack: `src/aipass/seedgo/apps/standards/skills/` +- [x] Create `pack.json` manifest +- [x] Create `pack_entry.py` entry point +- [x] Create standard modules (skill_format, skill_structure, skill_handler) +- [x] Create standard handlers (content + check pairs) + +### Phase 5: Testing +- [x] Unit tests for discovery engine (32 tests) +- [x] Unit tests for loader (7 tests) +- [x] Unit tests for runner (11 tests) +- [x] Unit tests for validator (10 tests) +- [x] Integration test: full skill lifecycle create → discover → load → run (14 tests) +- [x] Test OpenClaw skill adaptation works (github skill loads and runs correctly) + +--- + +## Notes + +- Same citizen-branch pattern as drone, ai_mail, prax — but directory structure only, no branch manager +- Skills are separate from aipass infrastructure (sit at `src/skills/`, not `src/aipass/skills/`) +- Patrick wants all three tiers supported: markdown SOPs, markdown+handler, full 3-layer +- Code skills cost zero tokens to run — markdown skills cost tokens every time. Both have a place. +- OpenClaw SKILL.md compatibility lets users adapt 52 existing MIT-licensed skills +- Seedgo standard for skills ensures all skills meet quality bar + +--- + +## Completion Checklist + +### Definition of Done +- Skills branch exists at `src/skills/` with full 3-layer architecture +- `drone @skills list|info|run|create|validate` commands work +- All three skill tiers supported (markdown, markdown+handler, full 3-layer) +- At least one test skill per tier working +- OpenClaw GitHub skill successfully adapted +- Seedgo skills standard created with at least 3 checks +- Tests passing +- Trinity files in place + +--- + +## Close Command + +When all boxes checked: +```bash +drone @flow close FPLAN-0006 +``` diff --git a/src/aipass/seedgo/apps/standards/skills/__init__.py b/src/aipass/seedgo/apps/standards/skills/__init__.py new file mode 100644 index 00000000..d729647d --- /dev/null +++ b/src/aipass/seedgo/apps/standards/skills/__init__.py @@ -0,0 +1 @@ +# Skills Standards Pack - Standards for AIPass Skills system diff --git a/src/aipass/seedgo/apps/standards/skills/extensions/__init__.py b/src/aipass/seedgo/apps/standards/skills/extensions/__init__.py new file mode 100644 index 00000000..fac53ac0 --- /dev/null +++ b/src/aipass/seedgo/apps/standards/skills/extensions/__init__.py @@ -0,0 +1 @@ +# Extensions package - Drop-in extensions for skills functionality diff --git a/src/aipass/seedgo/apps/standards/skills/handlers/__init__.py b/src/aipass/seedgo/apps/standards/skills/handlers/__init__.py new file mode 100644 index 00000000..87778101 --- /dev/null +++ b/src/aipass/seedgo/apps/standards/skills/handlers/__init__.py @@ -0,0 +1,6 @@ +""" +Skills Standards Pack - Handlers + +Implementation layer for Skills standards checking. +Organized by domain: standards/ +""" diff --git a/src/aipass/seedgo/apps/standards/skills/handlers/standards/__init__.py b/src/aipass/seedgo/apps/standards/skills/handlers/standards/__init__.py new file mode 100644 index 00000000..7dcf141c --- /dev/null +++ b/src/aipass/seedgo/apps/standards/skills/handlers/standards/__init__.py @@ -0,0 +1,8 @@ +""" +Skills Standards Checkers + +Handlers for validating skill compliance with AIPass Skills standards. +Each handler checks one specific standard domain. +""" + +__version__ = "0.1.0" diff --git a/src/aipass/seedgo/apps/standards/skills/handlers/standards/skill_format_check.py b/src/aipass/seedgo/apps/standards/skills/handlers/standards/skill_format_check.py new file mode 100644 index 00000000..19463e64 --- /dev/null +++ b/src/aipass/seedgo/apps/standards/skills/handlers/standards/skill_format_check.py @@ -0,0 +1,302 @@ +""" +Skill Format Standards Checker Handler + +Validates SKILL.md format compliance for AIPass skills. +Checks frontmatter parsing, required fields, version format, handler consistency. +""" + +# =================== META ==================== +# Name: skill_format_check.py +# Description: Skill Format Standards Checker Handler +# Version: 1.0.0 +# Created: 2026-03-07 +# Modified: 2026-03-07 +# ============================================= + + +import re +from pathlib import Path +from typing import Dict, List + + +def _parse_frontmatter(content: str) -> Dict: + """Parse YAML frontmatter from SKILL.md content. + + Handles simple key: value pairs without requiring PyYAML. + Supports strings, booleans, and lists. + + Args: + content: Full file content of SKILL.md + + Returns: + dict with parsed frontmatter, or empty dict if invalid + """ + lines = content.split("\n") + + # Find frontmatter delimiters + if not lines or lines[0].strip() != "---": + return {} + + end_index = None + for i in range(1, len(lines)): + if lines[i].strip() == "---": + end_index = i + break + + if end_index is None: + return {} + + # Parse simple YAML key: value pairs + frontmatter = {} + current_key = None + for line in lines[1:end_index]: + stripped = line.strip() + + if not stripped or stripped.startswith("#"): + continue + + # List item (continuation of previous key) + if stripped.startswith("- ") and current_key is not None: + if not isinstance(frontmatter[current_key], list): + frontmatter[current_key] = [] + frontmatter[current_key].append(stripped[2:].strip()) + continue + + # Key: value pair + if ":" in stripped: + key, _, value = stripped.partition(":") + key = key.strip() + value = value.strip() + current_key = key + + # Parse booleans + if value.lower() in ("true", "yes"): + frontmatter[key] = True + elif value.lower() in ("false", "no"): + frontmatter[key] = False + elif value == "": + # Could be a list starting on next line + frontmatter[key] = "" + else: + frontmatter[key] = value + + return frontmatter + + +def _is_valid_semver(version: str) -> bool: + """Check if version string follows semver format. + + Args: + version: Version string to validate + + Returns: + bool: True if valid semver + """ + pattern = r"^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?(\+[a-zA-Z0-9.]+)?$" + return bool(re.match(pattern, version)) + + +def check_skill_format(skill_path: str) -> Dict: + """ + Check if skill has valid SKILL.md format + + Args: + skill_path: Path to skill directory + + Returns: + dict: { + 'passed': bool, + 'checks': list, + 'score': float, + 'standard': str + } + """ + checks: List[Dict] = [] + path = Path(skill_path) + + # Validate skill directory exists + if not path.exists() or not path.is_dir(): + return { + "passed": False, + "checks": [ + { + "name": "Skill directory", + "passed": False, + "message": f"Skill directory not found: {skill_path}", + } + ], + "score": 0.0, + "standard": "SKILL_FORMAT", + } + + # Check 1: SKILL.md exists + skill_md = path / "SKILL.md" + if not skill_md.exists(): + checks.append( + { + "name": "SKILL.md exists", + "passed": False, + "message": "SKILL.md not found in skill directory", + } + ) + # Cannot continue without SKILL.md + return { + "passed": False, + "checks": checks, + "score": 0.0, + "standard": "SKILL_FORMAT", + } + + checks.append( + { + "name": "SKILL.md exists", + "passed": True, + "message": "SKILL.md found", + } + ) + + # Read SKILL.md + try: + content = skill_md.read_text(encoding="utf-8") + except Exception as e: + checks.append( + { + "name": "SKILL.md readable", + "passed": False, + "message": f"Error reading SKILL.md: {e}", + } + ) + return { + "passed": False, + "checks": checks, + "score": 0.0, + "standard": "SKILL_FORMAT", + } + + # Check 2: YAML frontmatter is parseable + frontmatter = _parse_frontmatter(content) + if not frontmatter: + checks.append( + { + "name": "YAML frontmatter", + "passed": False, + "message": "No valid YAML frontmatter found (must start with --- and end with ---)", + } + ) + # Cannot check fields without frontmatter + passed_count = sum(1 for c in checks if c["passed"]) + total = len(checks) + score = (passed_count / total * 100) if total > 0 else 0.0 + return { + "passed": score >= 75, + "checks": checks, + "score": score, + "standard": "SKILL_FORMAT", + } + + checks.append( + { + "name": "YAML frontmatter", + "passed": True, + "message": "Frontmatter parsed successfully", + } + ) + + # Check 3: name field present and non-empty + name_val = frontmatter.get("name", "") + if name_val and isinstance(name_val, str) and name_val.strip(): + checks.append( + { + "name": "name field", + "passed": True, + "message": f"name: {name_val}", + } + ) + else: + checks.append( + { + "name": "name field", + "passed": False, + "message": "name field missing or empty in frontmatter", + } + ) + + # Check 4: description field present and non-empty + desc_val = frontmatter.get("description", "") + if desc_val and isinstance(desc_val, str) and desc_val.strip(): + checks.append( + { + "name": "description field", + "passed": True, + "message": f"description: {desc_val[:60]}", + } + ) + else: + checks.append( + { + "name": "description field", + "passed": False, + "message": "description field missing or empty in frontmatter", + } + ) + + # Check 5: has_handler consistency + has_handler = frontmatter.get("has_handler") + if has_handler is True: + handler_py = path / "handler.py" + if handler_py.exists(): + checks.append( + { + "name": "has_handler consistency", + "passed": True, + "message": "has_handler: true and handler.py exists", + } + ) + else: + checks.append( + { + "name": "has_handler consistency", + "passed": False, + "message": "has_handler: true but handler.py not found", + } + ) + elif has_handler is not None: + checks.append( + { + "name": "has_handler consistency", + "passed": True, + "message": "has_handler: false (no handler expected)", + } + ) + + # Check 6: version format (semver) if present + version_val = frontmatter.get("version", "") + if version_val and isinstance(version_val, str): + if _is_valid_semver(version_val): + checks.append( + { + "name": "version format", + "passed": True, + "message": f"version: {version_val} (valid semver)", + } + ) + else: + checks.append( + { + "name": "version format", + "passed": False, + "message": f"version: {version_val} (invalid semver - expected X.Y.Z)", + } + ) + + # Calculate score + passed_count = sum(1 for c in checks if c["passed"]) + total = len(checks) + score = (passed_count / total * 100) if total > 0 else 0.0 + + return { + "passed": score >= 75, + "checks": checks, + "score": score, + "standard": "SKILL_FORMAT", + } diff --git a/src/aipass/seedgo/apps/standards/skills/handlers/standards/skill_format_content.py b/src/aipass/seedgo/apps/standards/skills/handlers/standards/skill_format_content.py new file mode 100644 index 00000000..84fbf29a --- /dev/null +++ b/src/aipass/seedgo/apps/standards/skills/handlers/standards/skill_format_content.py @@ -0,0 +1,70 @@ +""" +Skill Format Standards Content Handler + +Provides formatted SKILL.md format standards content. +Module orchestrates, handler implements. +""" + +# =================== META ==================== +# Name: skill_format_content.py +# Description: Skill Format Standards Content Handler +# Version: 1.0.0 +# Created: 2026-03-07 +# Modified: 2026-03-07 +# ============================================= + + +def get_skill_format_standards() -> str: + """Return formatted skill format standards content with Rich markup + + Returns: + str: Formatted standards text with Rich styling + """ + lines = [ + "[bold cyan]CORE PRINCIPLE:[/bold cyan]", + " Every skill MUST have a SKILL.md with valid YAML frontmatter", + " The frontmatter is the skill's identity - without it, the skill is invisible", + "", + "[bold cyan]REQUIRED FIELDS:[/bold cyan]", + "", + " [yellow]name:[/yellow] Skill name (non-empty string)", + " [yellow]description:[/yellow] What the skill does (non-empty string)", + "", + "[bold cyan]OPTIONAL FIELDS:[/bold cyan]", + "", + " [dim]version:[/dim] Semver format (e.g., 1.0.0, 0.2.1)", + " [dim]has_handler:[/dim] Boolean - if true, handler.py must exist", + " [dim]author:[/dim] Who created the skill", + " [dim]tags:[/dim] List of categorization tags", + "", + "[bold cyan]VALID SKILL.md FORMAT:[/bold cyan]", + "", + " [dim]---[/dim]", + " [dim]name: my_skill[/dim]", + " [dim]description: Does something useful[/dim]", + " [dim]version: 1.0.0[/dim]", + " [dim]has_handler: true[/dim]", + " [dim]---[/dim]", + "", + " [dim]# My Skill[/dim]", + " [dim]Detailed documentation below the frontmatter...[/dim]", + "", + "[yellow]CHECKS PERFORMED:[/yellow]", + "", + " 1. [bold]SKILL.md exists[/bold] in skill directory", + " 2. [bold]YAML frontmatter parseable[/bold] (valid --- delimited block)", + " 3. [bold]name field[/bold] present and non-empty", + " 4. [bold]description field[/bold] present and non-empty", + " 5. [bold]has_handler consistency[/bold] - if true, handler.py must exist", + " 6. [bold]version format[/bold] - semver if present", + "", + "[yellow]WARNINGS:[/yellow]", + " - Missing SKILL.md = [red]skill is invisible to the system[/red]", + " - Invalid YAML = [red]skill metadata cannot be loaded[/red]", + " - has_handler: true without handler.py = [red]broken skill[/red]", + "", + "[bold cyan]REFERENCE:[/bold cyan]", + " [dim]See: seedgo standards pack (skills)[/dim]", + ] + + return "\n".join(lines) diff --git a/src/aipass/seedgo/apps/standards/skills/handlers/standards/skill_handler_check.py b/src/aipass/seedgo/apps/standards/skills/handlers/standards/skill_handler_check.py new file mode 100644 index 00000000..4f0c7088 --- /dev/null +++ b/src/aipass/seedgo/apps/standards/skills/handlers/standards/skill_handler_check.py @@ -0,0 +1,297 @@ +""" +Skill Handler Standards Checker Handler + +Validates skill handler contract compliance for AIPass skills. +Checks run() function signature, return annotations, print usage, get_actions(). +""" + +# =================== META ==================== +# Name: skill_handler_check.py +# Description: Skill Handler Standards Checker Handler +# Version: 1.0.0 +# Created: 2026-03-07 +# Modified: 2026-03-07 +# ============================================= + + +import ast +from pathlib import Path +from typing import Dict, List, Optional + + +def _find_function(tree: ast.Module, name: str) -> Optional[ast.FunctionDef]: + """Find a top-level function by name in an AST tree. + + Args: + tree: Parsed AST module + name: Function name to find + + Returns: + FunctionDef node or None + """ + for node in ast.iter_child_nodes(tree): + if isinstance(node, ast.FunctionDef) and node.name == name: + return node + return None + + +def _get_param_names(func_node: ast.FunctionDef) -> List[str]: + """Extract parameter names from a function definition. + + Args: + func_node: AST FunctionDef node + + Returns: + List of parameter name strings (excludes 'self') + """ + params = [] + for arg in func_node.args.args: + if arg.arg != "self": + params.append(arg.arg) + return params + + +def _has_print_calls(tree: ast.Module) -> List[int]: + """Find all print() calls in the AST and return their line numbers. + + Args: + tree: Parsed AST module + + Returns: + List of line numbers where print() is called + """ + print_lines = [] + + for node in ast.walk(tree): + if isinstance(node, ast.Call): + # print(...) + if isinstance(node.func, ast.Name) and node.func.id == "print": + print_lines.append(node.lineno) + # builtins.print(...) + elif ( + isinstance(node.func, ast.Attribute) + and node.func.attr == "print" + and isinstance(node.func.value, ast.Name) + and node.func.value.id == "builtins" + ): + print_lines.append(node.lineno) + + return print_lines + + +def _get_return_annotation(func_node: ast.FunctionDef) -> Optional[str]: + """Get the return type annotation of a function as a string. + + Args: + func_node: AST FunctionDef node + + Returns: + String representation of return annotation, or None + """ + if func_node.returns is None: + return None + + if isinstance(func_node.returns, ast.Name): + return func_node.returns.id + elif isinstance(func_node.returns, ast.Constant): + return str(func_node.returns.value) + elif isinstance(func_node.returns, ast.Attribute): + return func_node.returns.attr + + return "complex" + + +def check_skill_handler(handler_path: str) -> Dict: + """ + Check if skill handler implements the standard contract + + Args: + handler_path: Path to handler.py file + + Returns: + dict: { + 'passed': bool, + 'checks': list, + 'score': float, + 'standard': str + } + """ + checks: List[Dict] = [] + path = Path(handler_path) + + # Validate file exists + if not path.exists(): + return { + "passed": False, + "checks": [ + { + "name": "handler.py exists", + "passed": False, + "message": f"File not found: {handler_path}", + } + ], + "score": 0.0, + "standard": "SKILL_HANDLER", + } + + # Parse the handler file + try: + source = path.read_text(encoding="utf-8") + tree = ast.parse(source, filename=str(path)) + except SyntaxError as e: + return { + "passed": False, + "checks": [ + { + "name": "Syntax valid", + "passed": False, + "message": f"Syntax error in handler: {e}", + } + ], + "score": 0.0, + "standard": "SKILL_HANDLER", + } + except (UnicodeDecodeError, OSError) as e: + return { + "passed": False, + "checks": [ + { + "name": "File readable", + "passed": False, + "message": f"Error reading handler: {e}", + } + ], + "score": 0.0, + "standard": "SKILL_HANDLER", + } + + # Check 1: handler.py has a run() function + run_func = _find_function(tree, "run") + if run_func is None: + checks.append( + { + "name": "run() function", + "passed": False, + "message": "run() function not found at module level", + } + ) + # Cannot check parameters without run() + passed_count = sum(1 for c in checks if c["passed"]) + total = len(checks) + score = (passed_count / total * 100) if total > 0 else 0.0 + return { + "passed": False, + "checks": checks, + "score": score, + "standard": "SKILL_HANDLER", + } + + checks.append( + { + "name": "run() function", + "passed": True, + "message": "run() function found", + } + ) + + # Check 2: run() accepts action, args, config parameters + param_names = _get_param_names(run_func) + required_params = ["action", "args", "config"] + missing_params = [p for p in required_params if p not in param_names] + + if not missing_params: + checks.append( + { + "name": "run() parameters", + "passed": True, + "message": f"run() accepts: {', '.join(param_names)}", + } + ) + else: + checks.append( + { + "name": "run() parameters", + "passed": False, + "message": f"run() missing parameters: {', '.join(missing_params)} (has: {', '.join(param_names)})", + } + ) + + # Check 3: run() return type annotation is dict (if present) + return_annotation = _get_return_annotation(run_func) + if return_annotation is None: + checks.append( + { + "name": "run() return annotation", + "passed": True, + "message": "No return annotation (acceptable - annotation is optional)", + } + ) + elif return_annotation.lower() == "dict": + checks.append( + { + "name": "run() return annotation", + "passed": True, + "message": "run() -> dict (correct)", + } + ) + else: + checks.append( + { + "name": "run() return annotation", + "passed": False, + "message": f"run() -> {return_annotation} (expected dict)", + } + ) + + # Check 4: No print() calls in handler code + print_lines = _has_print_calls(tree) + if print_lines: + lines_str = ", ".join(str(ln) for ln in print_lines[:5]) + suffix = f" (and {len(print_lines) - 5} more)" if len(print_lines) > 5 else "" + checks.append( + { + "name": "No print() calls", + "passed": False, + "message": f"print() found on lines: {lines_str}{suffix} (handlers should return dicts, not print)", + } + ) + else: + checks.append( + { + "name": "No print() calls", + "passed": True, + "message": "No print() calls found (handlers return structured data)", + } + ) + + # Check 5: get_actions() function exists (WARNING level - still passes) + get_actions_func = _find_function(tree, "get_actions") + if get_actions_func is not None: + checks.append( + { + "name": "get_actions() function", + "passed": True, + "message": "get_actions() found (enables introspection)", + } + ) + else: + # WARNING level - counts as passed but notes the recommendation + checks.append( + { + "name": "get_actions() function", + "passed": True, + "message": "WARNING: get_actions() not found (recommended for discoverability)", + } + ) + + # Calculate score + passed_count = sum(1 for c in checks if c["passed"]) + total = len(checks) + score = (passed_count / total * 100) if total > 0 else 0.0 + + return { + "passed": score >= 75, + "checks": checks, + "score": score, + "standard": "SKILL_HANDLER", + } diff --git a/src/aipass/seedgo/apps/standards/skills/handlers/standards/skill_handler_content.py b/src/aipass/seedgo/apps/standards/skills/handlers/standards/skill_handler_content.py new file mode 100644 index 00000000..e5ac4f6a --- /dev/null +++ b/src/aipass/seedgo/apps/standards/skills/handlers/standards/skill_handler_content.py @@ -0,0 +1,78 @@ +""" +Skill Handler Standards Content Handler + +Provides formatted handler contract standards content. +Module orchestrates, handler implements. +""" + +# =================== META ==================== +# Name: skill_handler_content.py +# Description: Skill Handler Standards Content Handler +# Version: 1.0.0 +# Created: 2026-03-07 +# Modified: 2026-03-07 +# ============================================= + + +def get_skill_handler_standards() -> str: + """Return formatted skill handler standards content with Rich markup + + Returns: + str: Formatted standards text with Rich styling + """ + lines = [ + "[bold cyan]CORE PRINCIPLE:[/bold cyan]", + " Skill handlers implement a standard contract for predictable execution", + " Handlers never print, always return structured dicts", + "", + "[bold cyan]REQUIRED CONTRACT:[/bold cyan]", + "", + " [yellow]run(action, args, config) -> dict[/yellow]", + "", + " Parameters:", + " [dim]action[/dim] - str: The action to perform", + " [dim]args[/dim] - dict: Arguments for the action", + " [dim]config[/dim] - dict: Configuration/context", + "", + " Return keys:", + " [dim]success[/dim] - bool: Whether the action succeeded", + " [dim]output[/dim] - any: Result data", + " [dim]error[/dim] - str or None: Error message if failed", + "", + "[bold cyan]RECOMMENDED:[/bold cyan]", + "", + " [yellow]get_actions() -> list[/yellow]", + "", + " Returns list of supported action names", + " Enables introspection and auto-discovery", + "", + "[bold cyan]EXAMPLE HANDLER:[/bold cyan]", + "", + ' [dim]def run(action, args, config):[/dim]', + ' [dim] if action == "greet":[/dim]', + ' [dim] name = args.get("name", "world")[/dim]', + ' [dim] return {"success": True, "output": f"Hello {name}", "error": None}[/dim]', + ' [dim] return {"success": False, "output": None, "error": f"Unknown action: {action}"}[/dim]', + "", + ' [dim]def get_actions():[/dim]', + ' [dim] return ["greet"][/dim]', + "", + "[yellow]CHECKS PERFORMED:[/yellow]", + "", + " 1. [bold]handler.py has run() function[/bold]", + " 2. [bold]run() accepts action, args, config[/bold] parameters", + " 3. [bold]run() return annotation[/bold] is dict (if present)", + " 4. [bold]No print() calls[/bold] in handler code", + " 5. [bold]get_actions() exists[/bold] (WARNING level, not ERROR)", + "", + "[yellow]WARNINGS:[/yellow]", + " - print() in handlers = [red]breaks structured output[/red]", + " - Missing run() = [red]handler cannot be invoked[/red]", + " - Wrong parameters = [red]caller will crash[/red]", + " - Missing get_actions() = [yellow]reduced discoverability[/yellow]", + "", + "[bold cyan]REFERENCE:[/bold cyan]", + " [dim]See: seedgo standards pack (skills)[/dim]", + ] + + return "\n".join(lines) diff --git a/src/aipass/seedgo/apps/standards/skills/handlers/standards/skill_structure_check.py b/src/aipass/seedgo/apps/standards/skills/handlers/standards/skill_structure_check.py new file mode 100644 index 00000000..adfd4cb8 --- /dev/null +++ b/src/aipass/seedgo/apps/standards/skills/handlers/standards/skill_structure_check.py @@ -0,0 +1,236 @@ +""" +Skill Structure Standards Checker Handler + +Validates skill directory structure compliance for AIPass skills. +Checks tier detection, required files, handler contract, 3-layer subdirs, stray files. +""" + +# =================== META ==================== +# Name: skill_structure_check.py +# Description: Skill Structure Standards Checker Handler +# Version: 1.0.0 +# Created: 2026-03-07 +# Modified: 2026-03-07 +# ============================================= + + +import ast +from pathlib import Path +from typing import Dict, List + + +# Valid items allowed in a skill root directory +VALID_ROOT_ITEMS = { + "SKILL.md", + "handler.py", + "__init__.py", + "apps", + "tests", + "docs", + "requirements.txt", + ".gitignore", + "__pycache__", +} + + +def _handler_has_run(handler_path: Path) -> bool: + """Check if handler.py has a run() function using AST parsing. + + Args: + handler_path: Path to handler.py + + Returns: + bool: True if run() function found at module level + """ + try: + source = handler_path.read_text(encoding="utf-8") + tree = ast.parse(source, filename=str(handler_path)) + except (SyntaxError, UnicodeDecodeError, OSError): + return False + + for node in ast.iter_child_nodes(tree): + if isinstance(node, ast.FunctionDef) and node.name == "run": + return True + + return False + + +def check_skill_structure(skill_path: str) -> Dict: + """ + Check if skill follows valid directory structure + + Args: + skill_path: Path to skill directory + + Returns: + dict: { + 'passed': bool, + 'checks': list, + 'score': float, + 'standard': str + } + """ + checks: List[Dict] = [] + path = Path(skill_path) + + # Check 1: Skill directory exists and is a directory + if not path.exists(): + return { + "passed": False, + "checks": [ + { + "name": "Skill directory exists", + "passed": False, + "message": f"Directory not found: {skill_path}", + } + ], + "score": 0.0, + "standard": "SKILL_STRUCTURE", + } + + if not path.is_dir(): + return { + "passed": False, + "checks": [ + { + "name": "Skill directory exists", + "passed": False, + "message": f"Path is not a directory: {skill_path}", + } + ], + "score": 0.0, + "standard": "SKILL_STRUCTURE", + } + + checks.append( + { + "name": "Skill directory exists", + "passed": True, + "message": f"Directory found: {path.name}/", + } + ) + + # Check 2: SKILL.md exists (required for all tiers) + skill_md = path / "SKILL.md" + if skill_md.exists(): + checks.append( + { + "name": "SKILL.md exists", + "passed": True, + "message": "SKILL.md found (required for all tiers)", + } + ) + else: + checks.append( + { + "name": "SKILL.md exists", + "passed": False, + "message": "SKILL.md missing (required for all tiers)", + } + ) + + # Detect tier + has_handler = (path / "handler.py").exists() + has_apps = (path / "apps").exists() + + # Check 3: If handler.py exists, it must have a run() function + if has_handler: + handler_path = path / "handler.py" + if _handler_has_run(handler_path): + checks.append( + { + "name": "handler.py has run()", + "passed": True, + "message": "handler.py contains run() function", + } + ) + else: + checks.append( + { + "name": "handler.py has run()", + "passed": False, + "message": "handler.py missing run() function (required for Tier 2+)", + } + ) + + # Check 4: If apps/ exists, it must have modules/ and handlers/ subdirs + if has_apps: + apps_path = path / "apps" + has_modules = (apps_path / "modules").exists() + has_handlers = (apps_path / "handlers").exists() + + if has_modules and has_handlers: + checks.append( + { + "name": "apps/ 3-layer structure", + "passed": True, + "message": "apps/ has both modules/ and handlers/ (Tier 3)", + } + ) + else: + missing = [] + if not has_modules: + missing.append("modules/") + if not has_handlers: + missing.append("handlers/") + checks.append( + { + "name": "apps/ 3-layer structure", + "passed": False, + "message": f"apps/ missing: {', '.join(missing)} (required for Tier 3)", + } + ) + + # Check 5: No stray files outside the structure + stray_items = [] + try: + for item in path.iterdir(): + if item.name not in VALID_ROOT_ITEMS: + stray_items.append(item.name) + except OSError: + pass + + if stray_items: + checks.append( + { + "name": "No stray files", + "passed": False, + "message": f"Unexpected items in skill root: {', '.join(sorted(stray_items))}", + } + ) + else: + checks.append( + { + "name": "No stray files", + "passed": True, + "message": "All items match valid skill structure", + } + ) + + # Determine detected tier for reporting + if has_apps: + tier = "Tier 3 (3-layer)" + elif has_handler: + tier = "Tier 2 (executable)" + else: + tier = "Tier 1 (minimal)" + + checks.append( + { + "name": "Tier detection", + "passed": True, + "message": f"Detected: {tier}", + } + ) + + # Calculate score + passed_count = sum(1 for c in checks if c["passed"]) + total = len(checks) + score = (passed_count / total * 100) if total > 0 else 0.0 + + return { + "passed": score >= 75, + "checks": checks, + "score": score, + "standard": "SKILL_STRUCTURE", + } diff --git a/src/aipass/seedgo/apps/standards/skills/handlers/standards/skill_structure_content.py b/src/aipass/seedgo/apps/standards/skills/handlers/standards/skill_structure_content.py new file mode 100644 index 00000000..09de116d --- /dev/null +++ b/src/aipass/seedgo/apps/standards/skills/handlers/standards/skill_structure_content.py @@ -0,0 +1,74 @@ +""" +Skill Structure Standards Content Handler + +Provides formatted directory structure standards content. +Module orchestrates, handler implements. +""" + +# =================== META ==================== +# Name: skill_structure_content.py +# Description: Skill Structure Standards Content Handler +# Version: 1.0.0 +# Created: 2026-03-07 +# Modified: 2026-03-07 +# ============================================= + + +def get_skill_structure_standards() -> str: + """Return formatted skill structure standards content with Rich markup + + Returns: + str: Formatted standards text with Rich styling + """ + lines = [ + "[bold cyan]CORE PRINCIPLE:[/bold cyan]", + " Skills follow a tiered structure based on complexity", + " Simple skills need only a SKILL.md; complex skills use the 3-layer pattern", + "", + "[bold cyan]TIER 1 - MINIMAL (documentation only):[/bold cyan]", + "", + " [dim]skill_name/[/dim]", + " [dim] SKILL.md[/dim]", + "", + " Use when: skill is a prompt, reference, or static content", + "", + "[bold cyan]TIER 2 - EXECUTABLE (single handler):[/bold cyan]", + "", + " [dim]skill_name/[/dim]", + " [dim] SKILL.md[/dim]", + " [dim] handler.py[/dim]", + "", + " Use when: skill has executable logic but is self-contained", + "", + "[bold cyan]TIER 3 - FULL (3-layer architecture):[/bold cyan]", + "", + " [dim]skill_name/[/dim]", + " [dim] SKILL.md[/dim]", + " [dim] apps/[/dim]", + " [dim] modules/[/dim]", + " [dim] handlers/[/dim]", + "", + " Use when: skill has complex logic requiring orchestration", + "", + "[yellow]CHECKS PERFORMED:[/yellow]", + "", + " 1. [bold]Skill directory exists[/bold] and is a directory", + " 2. [bold]SKILL.md exists[/bold] (required for all tiers)", + " 3. [bold]handler.py has run()[/bold] if handler.py exists", + " 4. [bold]apps/ has modules/ and handlers/[/bold] if apps/ exists", + " 5. [bold]No stray files[/bold] outside the valid structure", + "", + "[yellow]VALID FILES IN SKILL ROOT:[/yellow]", + " SKILL.md, handler.py, __init__.py, apps/, tests/, docs/,", + " requirements.txt, .gitignore", + "", + "[yellow]WARNINGS:[/yellow]", + " - Tier 3 without modules/ or handlers/ = [red]incomplete 3-layer[/red]", + " - handler.py without run() = [red]broken contract[/red]", + " - Stray files = [yellow]possible misplaced code[/yellow]", + "", + "[bold cyan]REFERENCE:[/bold cyan]", + " [dim]See: seedgo standards pack (skills)[/dim]", + ] + + return "\n".join(lines) diff --git a/src/aipass/seedgo/apps/standards/skills/modules/__init__.py b/src/aipass/seedgo/apps/standards/skills/modules/__init__.py new file mode 100644 index 00000000..c3fce998 --- /dev/null +++ b/src/aipass/seedgo/apps/standards/skills/modules/__init__.py @@ -0,0 +1,6 @@ +""" +Skills Modules - Orchestration Layer + +Modules coordinate workflows by calling handlers. +Each module implements handle_command() for auto-discovery. +""" diff --git a/src/aipass/seedgo/apps/standards/skills/modules/skill_format_standard.py b/src/aipass/seedgo/apps/standards/skills/modules/skill_format_standard.py new file mode 100644 index 00000000..d4b3d050 --- /dev/null +++ b/src/aipass/seedgo/apps/standards/skills/modules/skill_format_standard.py @@ -0,0 +1,102 @@ +""" +Skill Format Standards Module + +Provides SKILL.md format compliance standards for AIPass skills. +Run directly or via: seedgo skill_format +""" + +# =================== META ==================== +# Name: skill_format_standard.py +# Description: Skill Format Standards Module +# Version: 1.0.0 +# Created: 2026-03-07 +# Modified: 2026-03-07 +# ============================================= + + +import sys +from pathlib import Path +from typing import List + +from aipass.prax import logger +from aipass.seedgo.apps.standards.skills.handlers.standards.skill_format_content import get_skill_format_standards + +from aipass.cli import console, header + + +def print_introspection(): + """Display module info and connected handlers""" + console.print() + console.print("[bold cyan]Skill Format Standards Module[/bold cyan]") + console.print() + + console.print("[yellow]Connected Handlers:[/yellow]") + console.print() + + handlers_dir = Path(__file__).parent.parent / "handlers" / "standards" + + if handlers_dir.exists(): + console.print(" [cyan]handlers/standards/[/cyan]") + console.print(" [dim]- skill_format_content.py[/dim]") + console.print(" [dim]- skill_format_check.py[/dim]") + console.print() + + console.print("[dim]Run 'python3 skill_format_standard.py --help' for usage[/dim]") + console.print() + + +def print_help(): + """Print drone-compliant help output""" + console.print() + console.print("[bold cyan]Skill Format Standards Module[/bold cyan]") + console.print("Validates SKILL.md format compliance") + console.print() + + console.print("[yellow]COMMANDS:[/yellow]") + console.print(" Commands: skill_format, --help") + console.print() + console.print(" [cyan]skill_format[/cyan] - Display skill format standards") + console.print() + + console.print("[yellow]USAGE:[/yellow]") + console.print(" seedgo skill_format") + console.print(" python3 skill_format_standard.py") + console.print(" python3 skill_format_standard.py --help") + console.print() + + console.print("[yellow]REFERENCE:[/yellow]") + console.print(" See: seedgo standards pack (skills)") + console.print() + + +def handle_command(command: str, args: List[str]) -> bool: + """Handle 'skill_format' command""" + if command != "skill_format": + return False + + print_standard() + return True + + +def print_standard(): + """Print skill format standards - orchestrates handler call""" + console.print() + header("Skill Format Standards") + console.print() + console.print(get_skill_format_standards()) + console.print() + console.print("-" * 70) + console.print() + + +if __name__ == "__main__": + if len(sys.argv) == 1: + print_introspection() + sys.exit(0) + + if sys.argv[1] in ['--help', '-h', 'help']: + print_help() + sys.exit(0) + + logger.info("Prax logger connected to skill_format_standard") + print_standard() diff --git a/src/aipass/seedgo/apps/standards/skills/modules/skill_handler_standard.py b/src/aipass/seedgo/apps/standards/skills/modules/skill_handler_standard.py new file mode 100644 index 00000000..16a2e89e --- /dev/null +++ b/src/aipass/seedgo/apps/standards/skills/modules/skill_handler_standard.py @@ -0,0 +1,102 @@ +""" +Skill Handler Standards Module + +Provides handler contract compliance standards for AIPass skills. +Run directly or via: seedgo skill_handler +""" + +# =================== META ==================== +# Name: skill_handler_standard.py +# Description: Skill Handler Standards Module +# Version: 1.0.0 +# Created: 2026-03-07 +# Modified: 2026-03-07 +# ============================================= + + +import sys +from pathlib import Path +from typing import List + +from aipass.prax import logger +from aipass.seedgo.apps.standards.skills.handlers.standards.skill_handler_content import get_skill_handler_standards + +from aipass.cli import console, header + + +def print_introspection(): + """Display module info and connected handlers""" + console.print() + console.print("[bold cyan]Skill Handler Standards Module[/bold cyan]") + console.print() + + console.print("[yellow]Connected Handlers:[/yellow]") + console.print() + + handlers_dir = Path(__file__).parent.parent / "handlers" / "standards" + + if handlers_dir.exists(): + console.print(" [cyan]handlers/standards/[/cyan]") + console.print(" [dim]- skill_handler_content.py[/dim]") + console.print(" [dim]- skill_handler_check.py[/dim]") + console.print() + + console.print("[dim]Run 'python3 skill_handler_standard.py --help' for usage[/dim]") + console.print() + + +def print_help(): + """Print drone-compliant help output""" + console.print() + console.print("[bold cyan]Skill Handler Standards Module[/bold cyan]") + console.print("Validates skill handler contract compliance") + console.print() + + console.print("[yellow]COMMANDS:[/yellow]") + console.print(" Commands: skill_handler, --help") + console.print() + console.print(" [cyan]skill_handler[/cyan] - Display skill handler standards") + console.print() + + console.print("[yellow]USAGE:[/yellow]") + console.print(" seedgo skill_handler") + console.print(" python3 skill_handler_standard.py") + console.print(" python3 skill_handler_standard.py --help") + console.print() + + console.print("[yellow]REFERENCE:[/yellow]") + console.print(" See: seedgo standards pack (skills)") + console.print() + + +def handle_command(command: str, args: List[str]) -> bool: + """Handle 'skill_handler' command""" + if command != "skill_handler": + return False + + print_standard() + return True + + +def print_standard(): + """Print skill handler standards - orchestrates handler call""" + console.print() + header("Skill Handler Standards") + console.print() + console.print(get_skill_handler_standards()) + console.print() + console.print("-" * 70) + console.print() + + +if __name__ == "__main__": + if len(sys.argv) == 1: + print_introspection() + sys.exit(0) + + if sys.argv[1] in ['--help', '-h', 'help']: + print_help() + sys.exit(0) + + logger.info("Prax logger connected to skill_handler_standard") + print_standard() diff --git a/src/aipass/seedgo/apps/standards/skills/modules/skill_structure_standard.py b/src/aipass/seedgo/apps/standards/skills/modules/skill_structure_standard.py new file mode 100644 index 00000000..635c7ff3 --- /dev/null +++ b/src/aipass/seedgo/apps/standards/skills/modules/skill_structure_standard.py @@ -0,0 +1,102 @@ +""" +Skill Structure Standards Module + +Provides directory structure compliance standards for AIPass skills. +Run directly or via: seedgo skill_structure +""" + +# =================== META ==================== +# Name: skill_structure_standard.py +# Description: Skill Structure Standards Module +# Version: 1.0.0 +# Created: 2026-03-07 +# Modified: 2026-03-07 +# ============================================= + + +import sys +from pathlib import Path +from typing import List + +from aipass.prax import logger +from aipass.seedgo.apps.standards.skills.handlers.standards.skill_structure_content import get_skill_structure_standards + +from aipass.cli import console, header + + +def print_introspection(): + """Display module info and connected handlers""" + console.print() + console.print("[bold cyan]Skill Structure Standards Module[/bold cyan]") + console.print() + + console.print("[yellow]Connected Handlers:[/yellow]") + console.print() + + handlers_dir = Path(__file__).parent.parent / "handlers" / "standards" + + if handlers_dir.exists(): + console.print(" [cyan]handlers/standards/[/cyan]") + console.print(" [dim]- skill_structure_content.py[/dim]") + console.print(" [dim]- skill_structure_check.py[/dim]") + console.print() + + console.print("[dim]Run 'python3 skill_structure_standard.py --help' for usage[/dim]") + console.print() + + +def print_help(): + """Print drone-compliant help output""" + console.print() + console.print("[bold cyan]Skill Structure Standards Module[/bold cyan]") + console.print("Validates skill directory structure compliance") + console.print() + + console.print("[yellow]COMMANDS:[/yellow]") + console.print(" Commands: skill_structure, --help") + console.print() + console.print(" [cyan]skill_structure[/cyan] - Display skill structure standards") + console.print() + + console.print("[yellow]USAGE:[/yellow]") + console.print(" seedgo skill_structure") + console.print(" python3 skill_structure_standard.py") + console.print(" python3 skill_structure_standard.py --help") + console.print() + + console.print("[yellow]REFERENCE:[/yellow]") + console.print(" See: seedgo standards pack (skills)") + console.print() + + +def handle_command(command: str, args: List[str]) -> bool: + """Handle 'skill_structure' command""" + if command != "skill_structure": + return False + + print_standard() + return True + + +def print_standard(): + """Print skill structure standards - orchestrates handler call""" + console.print() + header("Skill Structure Standards") + console.print() + console.print(get_skill_structure_standards()) + console.print() + console.print("-" * 70) + console.print() + + +if __name__ == "__main__": + if len(sys.argv) == 1: + print_introspection() + sys.exit(0) + + if sys.argv[1] in ['--help', '-h', 'help']: + print_help() + sys.exit(0) + + logger.info("Prax logger connected to skill_structure_standard") + print_standard() diff --git a/src/aipass/seedgo/apps/standards/skills/pack_entry.py b/src/aipass/seedgo/apps/standards/skills/pack_entry.py new file mode 100644 index 00000000..cdefcb2e --- /dev/null +++ b/src/aipass/seedgo/apps/standards/skills/pack_entry.py @@ -0,0 +1,109 @@ +""" +Skills Standards Pack - Entry Point + +Auto-discovery architecture: +- Scans modules/ directory for .py files with handle_command() +- Routes commands to discovered modules automatically +- No manual imports or routing needed +""" + +# =================== META ==================== +# Name: pack_entry.py +# Description: Skills Standards Pack - Entry Point +# Version: 1.0.0 +# Created: 2026-03-07 +# Modified: 2026-03-07 +# ============================================= + + +import sys +import importlib.util +from pathlib import Path +from typing import List, Any + +from aipass.prax import logger +from aipass.cli import console, header + +# ============================================================================= +# MODULE DISCOVERY +# ============================================================================= + +MODULES_DIR = Path(__file__).parent / "modules" +VERSION = "1.0.0" + + +def discover_modules() -> List[Any]: + """Auto-discover modules in modules/ directory.""" + modules = [] + + if not MODULES_DIR.exists(): + return modules + + for file_path in sorted(MODULES_DIR.glob("*.py")): + if file_path.name.startswith("_"): + continue + + try: + spec = importlib.util.spec_from_file_location(file_path.stem, file_path) + if spec is None or spec.loader is None: + continue + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + if hasattr(module, "handle_command"): + modules.append(module) + except Exception as e: + logger.error(f"[SKILLS] Failed to load module {file_path.stem}: {e}") + + return modules + + +def route_command(command: str, args: List[str], modules: List[Any]) -> bool: + """Route command to appropriate module.""" + for module in modules: + try: + if module.handle_command(command, args): + return True + except Exception as e: + logger.error(f"[SKILLS] Module {module.__name__} error: {e}") + return False + + +# ============================================================================= +# MAIN ENTRY POINT +# ============================================================================= + +def main(): + """Main entry point - routes commands or shows help.""" + modules = discover_modules() + args = sys.argv[1:] + + if not args or args[0] in ["--help", "-h", "help"]: + header("Skills Standards Pack") + console.print() + console.print(f" {len(modules)} modules discovered") + console.print() + for module in modules: + name = getattr(module, "__name__", "unknown").split(".")[-1] + desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description" + console.print(f" {name:20} {desc}") + console.print() + return 0 + + if args[0] in ["--version", "-V"]: + console.print(f"skills-standards v{VERSION}") + return 0 + + command = args[0] + remaining = args[1:] if len(args) > 1 else [] + + if route_command(command, remaining, modules): + return 0 + + console.print(f"Unknown command: {command}") + console.print("Run 'skills --help' for available commands") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/aipass/seedgo/apps/standards/skills/plugins/__init__.py b/src/aipass/seedgo/apps/standards/skills/plugins/__init__.py new file mode 100644 index 00000000..9b1a9132 --- /dev/null +++ b/src/aipass/seedgo/apps/standards/skills/plugins/__init__.py @@ -0,0 +1 @@ +# Plugins package - Pluggable components for skills capabilities diff --git a/src/skills/README.md b/src/skills/README.md new file mode 100644 index 00000000..693d02fb --- /dev/null +++ b/src/skills/README.md @@ -0,0 +1,119 @@ +# Skills + +Capability framework for AI agents in AIPass. Skills are discoverable, validatable, and executable units of capability that any AI agent can use. + +## Three Tiers + +### 1. Markdown Only +A `SKILL.md` file with instructions. The AI reads the instructions and follows them. No code required. +``` +my-skill/ + SKILL.md +``` + +### 2. With Handler +A `SKILL.md` plus a `handler.py` that the system can execute programmatically. +``` +my-skill/ + SKILL.md + handler.py +``` + +### 3. Full 3-Layer +A `SKILL.md` plus a full AIPass 3-layer app structure for complex skills. +``` +my-skill/ + SKILL.md + apps/ + __init__.py + modules/ + __init__.py + handlers/ + __init__.py +``` + +## Creating a Skill + +```bash +# Markdown only (default) +drone @skills create my-skill + +# With handler +drone @skills create my-skill --with-handler + +# Full 3-layer +drone @skills create my-skill --full +``` + +Skills are created in `.aipass/skills/` in the current project directory. + +## Running a Skill + +```bash +# Run a handler-based skill +drone @skills run my-skill action-name key=value + +# Run a markdown skill (displays instructions) +drone @skills run my-skill + +# List all available skills +drone @skills list + +# Get details about a skill +drone @skills info my-skill + +# Check requirements +drone @skills validate my-skill +``` + +## SKILL.md Format + +```yaml +--- +name: skill-name +description: One-line description +version: 1.0.0 +tags: [category1, category2] +requires: + pip: [] # Python packages needed + bins: [] # CLI tools needed + config: [] # Env vars / config keys needed +has_handler: false +--- +# Skill Name + +## What This Does +... + +## Steps +... +``` + +## Search Paths + +Skills are discovered in this order (first match wins for same name): + +1. **Project**: `.aipass/skills/` in the current working directory +2. **Global**: `~/.aipass/skills/` in the user's home directory +3. **Built-in**: `src/skills/catalog/` in the AIPass codebase + +## Directory Structure + +``` +src/skills/ + apps/ + skills.py # Entry point (handle_command) + modules/ + discovery.py # Find skills across search paths + loader.py # Load SKILL.md + handlers + runner.py # Execute skills + creator.py # Scaffold new skills + handlers/ + registry.py # Skill registry management + validator.py # Check requirements + template.py # Skill templates + catalog/ # Built-in skills + templates/ # Skill creation templates + .trinity/ # Branch identity and memory + tests/ # Test suite +``` diff --git a/src/skills/__init__.py b/src/skills/__init__.py new file mode 100644 index 00000000..42c29217 --- /dev/null +++ b/src/skills/__init__.py @@ -0,0 +1,13 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: __init__.py - Skills package root +# Date: 2026-03-07 +# Version: 1.0.0 +# Category: skills +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-03-07): Initial implementation +# +# CODE STANDARDS: +# - Package root for the Skills system +# ============================================= diff --git a/src/skills/apps/__init__.py b/src/skills/apps/__init__.py new file mode 100644 index 00000000..e0c8fe76 --- /dev/null +++ b/src/skills/apps/__init__.py @@ -0,0 +1,13 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: __init__.py - Skills apps package +# Date: 2026-03-07 +# Version: 1.0.0 +# Category: skills/apps +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-03-07): Initial implementation +# +# CODE STANDARDS: +# - Apps layer: entry points and command routing +# ============================================= diff --git a/src/skills/apps/handlers/__init__.py b/src/skills/apps/handlers/__init__.py new file mode 100644 index 00000000..88097fcb --- /dev/null +++ b/src/skills/apps/handlers/__init__.py @@ -0,0 +1,13 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: __init__.py - Skills handlers package +# Date: 2026-03-07 +# Version: 1.0.0 +# Category: skills/apps/handlers +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-03-07): Initial implementation +# +# CODE STANDARDS: +# - Handlers layer: implementation details (returns dicts, NEVER prints) +# ============================================= diff --git a/src/skills/apps/handlers/registry.py b/src/skills/apps/handlers/registry.py new file mode 100644 index 00000000..415aa64c --- /dev/null +++ b/src/skills/apps/handlers/registry.py @@ -0,0 +1,74 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: registry.py - Skill registry management +# Date: 2026-03-07 +# Version: 1.0.0 +# Category: skills/apps/handlers +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-03-07): Initial implementation +# +# CODE STANDARDS: +# - Handler layer: returns dicts, NEVER prints +# - Manages discovered skills cache +# ============================================= + +from pathlib import Path + + +def build_registry(search_paths, discover_fn): + """Discover and cache all skills from search paths. + + Args: + search_paths: List of (path, source_label) tuples to scan. + discover_fn: Callable that takes a path and source label, + returns list of skill dicts. + + Returns: + list[dict]: All discovered skills across all search paths. + Each dict has: name, description, path, has_handler, source, tags. + """ + registry = [] + seen_names = set() + + for search_path, source_label in search_paths: + path = Path(search_path) + if not path.exists(): + continue + + skills = discover_fn(path, source_label) + for skill in skills: + # First match wins for same name + if skill["name"] not in seen_names: + seen_names.add(skill["name"]) + registry.append(skill) + + return registry + + +def get_skill(name, registry): + """Look up a skill by name in the registry. + + Args: + name: Skill name to find. + registry: List of skill dicts from build_registry. + + Returns: + dict or None: The matching skill dict, or None if not found. + """ + for skill in registry: + if skill["name"] == name: + return skill + return None + + +def get_skill_names(registry): + """Get all skill names from the registry. + + Args: + registry: List of skill dicts from build_registry. + + Returns: + list[str]: Sorted list of skill names. + """ + return sorted(skill["name"] for skill in registry) diff --git a/src/skills/apps/handlers/template.py b/src/skills/apps/handlers/template.py new file mode 100644 index 00000000..4416a1c8 --- /dev/null +++ b/src/skills/apps/handlers/template.py @@ -0,0 +1,105 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: template.py - Skill template management +# Date: 2026-03-07 +# Version: 1.0.0 +# Category: skills/apps/handlers +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-03-07): Initial implementation +# +# CODE STANDARDS: +# - Handler layer: returns dicts, NEVER prints +# - Resolves template paths and copies template content +# ============================================= + +import shutil +from pathlib import Path + + +# Template directory lives at src/skills/templates/ +TEMPLATES_DIR = Path(__file__).resolve().parent.parent.parent / "templates" + +VALID_TYPES = ("markdown_only", "with_handler", "full") + + +def get_template(template_type): + """Get the path to a template directory. + + Args: + template_type: One of "markdown_only", "with_handler", "full". + + Returns: + dict: {"success": bool, "path": Path|None, "error": str|None} + """ + if template_type not in VALID_TYPES: + return { + "success": False, + "path": None, + "error": f"Unknown template type: {template_type}. Valid types: {', '.join(VALID_TYPES)}", + } + + template_path = TEMPLATES_DIR / template_type + if not template_path.exists(): + return { + "success": False, + "path": None, + "error": f"Template directory not found: {template_path}", + } + + return {"success": True, "path": template_path, "error": None} + + +def copy_template(template_path, target_path, skill_name): + """Copy a template directory to a target location, replacing placeholders. + + Args: + template_path: Path to the source template directory. + target_path: Path to the destination directory for the new skill. + skill_name: Name to replace {{SKILL_NAME}} with in all files. + + Returns: + dict: {"success": bool, "created_files": list[str], "error": str|None} + """ + target = Path(target_path) + + if target.exists(): + return { + "success": False, + "created_files": [], + "error": f"Target directory already exists: {target}", + } + + try: + # Copy the entire template tree + shutil.copytree(str(template_path), str(target)) + + # Replace placeholders in all files + created_files = [] + for file_path in target.rglob("*"): + if file_path.is_file(): + created_files.append(str(file_path.relative_to(target))) + try: + content = file_path.read_text(encoding="utf-8") + if "{{SKILL_NAME}}" in content: + content = content.replace("{{SKILL_NAME}}", skill_name) + file_path.write_text(content, encoding="utf-8") + except UnicodeDecodeError: + # Skip binary files + pass + + return { + "success": True, + "created_files": sorted(created_files), + "error": None, + } + + except Exception as e: + # Clean up on failure + if target.exists(): + shutil.rmtree(str(target)) + return { + "success": False, + "created_files": [], + "error": f"Failed to create skill: {e}", + } diff --git a/src/skills/apps/handlers/validator.py b/src/skills/apps/handlers/validator.py new file mode 100644 index 00000000..60c3b3fe --- /dev/null +++ b/src/skills/apps/handlers/validator.py @@ -0,0 +1,109 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: validator.py - Check skill requirements +# Date: 2026-03-07 +# Version: 1.0.0 +# Category: skills/apps/handlers +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-03-07): Initial implementation +# +# CODE STANDARDS: +# - Handler layer: returns dicts, NEVER prints +# - Checks pip packages, CLI bins, and config/env vars +# ============================================= + +import importlib.util +import os +import shutil + + +def validate_skill(skill_metadata): + """Check if a skill's requirements are met. + + Args: + skill_metadata: Dict with 'requires' key containing: + - pip: list of Python package names + - bins: list of CLI tool names + - config: list of env var / config key names + + Returns: + dict: { + "valid": bool, + "missing_pip": list[str], + "missing_bins": list[str], + "missing_config": list[str] + } + """ + requires = skill_metadata.get("requires", {}) + + pip_packages = requires.get("pip", []) or [] + bins = requires.get("bins", []) or [] + config_keys = requires.get("config", []) or [] + + missing_pip = _check_pip(pip_packages) + missing_bins = _check_bins(bins) + missing_config = _check_config(config_keys) + + valid = not (missing_pip or missing_bins or missing_config) + + return { + "valid": valid, + "missing_pip": missing_pip, + "missing_bins": missing_bins, + "missing_config": missing_config, + } + + +def _check_pip(packages): + """Check which pip packages are missing. + + Args: + packages: List of Python package names. + + Returns: + list[str]: Names of packages that are not installed. + """ + missing = [] + for pkg in packages: + # Normalize package name for import (e.g., some-pkg -> some_pkg) + import_name = pkg.replace("-", "_") + try: + spec = importlib.util.find_spec(import_name) + if spec is None: + missing.append(pkg) + except (ModuleNotFoundError, ValueError): + missing.append(pkg) + return missing + + +def _check_bins(bins): + """Check which CLI binaries are missing from PATH. + + Args: + bins: List of CLI tool names. + + Returns: + list[str]: Names of binaries not found in PATH. + """ + missing = [] + for binary in bins: + if shutil.which(binary) is None: + missing.append(binary) + return missing + + +def _check_config(config_keys): + """Check which config/env vars are missing. + + Args: + config_keys: List of environment variable names. + + Returns: + list[str]: Names of env vars that are not set. + """ + missing = [] + for key in config_keys: + if os.environ.get(key) is None: + missing.append(key) + return missing diff --git a/src/skills/apps/modules/__init__.py b/src/skills/apps/modules/__init__.py new file mode 100644 index 00000000..fcda6ee4 --- /dev/null +++ b/src/skills/apps/modules/__init__.py @@ -0,0 +1,13 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: __init__.py - Skills modules package +# Date: 2026-03-07 +# Version: 1.0.0 +# Category: skills/apps/modules +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-03-07): Initial implementation +# +# CODE STANDARDS: +# - Modules layer: business logic orchestration (can print) +# ============================================= diff --git a/src/skills/apps/modules/creator.py b/src/skills/apps/modules/creator.py new file mode 100644 index 00000000..87de17bf --- /dev/null +++ b/src/skills/apps/modules/creator.py @@ -0,0 +1,107 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: creator.py - Scaffold new skills from templates +# Date: 2026-03-07 +# Version: 1.0.0 +# Category: skills/apps/modules +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-03-07): Initial implementation +# +# CODE STANDARDS: +# - Module layer: orchestration (can print) +# - Creates new skills from template directories +# ============================================= + +"""Skill creator module. + +Scaffolds new skills from templates into a target location. +Supports three tiers: markdown_only, with_handler, full. +""" + +from pathlib import Path + +from ..handlers.template import copy_template, get_template + + +def create_skill(name, template_type="markdown_only", target_dir=None): + """Create a new skill from a template. + + Args: + name: Name for the new skill (used as directory name and placeholder). + template_type: Template tier - "markdown_only", "with_handler", or "full". + target_dir: Directory to create the skill in. Defaults to + .aipass/skills/ in the current working directory. + + Returns: + dict: {"success": bool, "path": str|None, "files": list[str], "error": str|None} + """ + # Validate skill name + if not name: + return { + "success": False, + "path": None, + "files": [], + "error": "Skill name is required.", + } + + if not _is_valid_name(name): + return { + "success": False, + "path": None, + "files": [], + "error": f"Invalid skill name: '{name}'. Use lowercase letters, numbers, and hyphens only.", + } + + # Resolve template + template_result = get_template(template_type) + if not template_result["success"]: + return { + "success": False, + "path": None, + "files": [], + "error": template_result["error"], + } + + # Determine target directory + if target_dir is None: + target_dir = Path.cwd() / ".aipass" / "skills" + + target_path = Path(target_dir) / name + + # Ensure parent directory exists + target_path.parent.mkdir(parents=True, exist_ok=True) + + # Copy template + result = copy_template(template_result["path"], target_path, name) + + if result["success"]: + print(f" Created skill '{name}' at {target_path}") + print(f" Template: {template_type}") + print(f" Files: {len(result['created_files'])}") + for f in result["created_files"]: + print(f" - {f}") + + return { + "success": result["success"], + "path": str(target_path) if result["success"] else None, + "files": result["created_files"], + "error": result["error"], + } + + +def _is_valid_name(name): + """Check if a skill name is valid. + + Valid names contain only lowercase letters, numbers, and hyphens. + Must start with a letter. + + Args: + name: The skill name to validate. + + Returns: + bool: True if valid. + """ + if not name or not name[0].isalpha(): + return False + return all(c.isalnum() or c == "-" for c in name) and name == name.lower() diff --git a/src/skills/apps/modules/discovery.py b/src/skills/apps/modules/discovery.py new file mode 100644 index 00000000..a347aedc --- /dev/null +++ b/src/skills/apps/modules/discovery.py @@ -0,0 +1,264 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: discovery.py - Find skills across search paths +# Date: 2026-03-07 +# Version: 1.0.0 +# Category: skills/apps/modules +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-03-07): Initial implementation +# +# CODE STANDARDS: +# - Module layer: orchestration (can print) +# - Discovers skills by scanning for SKILL.md files +# - Falls back to simple frontmatter parser if yaml unavailable +# ============================================= + +"""Skill discovery module. + +Scans search paths for directories containing SKILL.md files and +extracts metadata from YAML frontmatter. +""" + +from pathlib import Path + +# Try yaml, fall back to simple parser +try: + import yaml + HAS_YAML = True +except ImportError: + HAS_YAML = False + + +def get_search_paths(): + """Return the ordered list of skill search paths. + + Search order (first match wins for same name): + 1. Current project: .aipass/skills/ + 2. Global user: ~/.aipass/skills/ + 3. Built-in: src/skills/catalog/ + + Returns: + list[tuple[Path, str]]: List of (path, source_label) tuples. + """ + paths = [] + + # 1. Current project + project_path = Path.cwd() / ".aipass" / "skills" + paths.append((project_path, "project")) + + # 2. Global user + global_path = Path.home() / ".aipass" / "skills" + paths.append((global_path, "global")) + + # 3. Built-in catalog + builtin_path = Path(__file__).resolve().parent.parent.parent / "catalog" + paths.append((builtin_path, "builtin")) + + return paths + + +def discover_skills_in_path(search_path, source_label): + """Scan a directory for skill directories containing SKILL.md. + + Args: + search_path: Path to scan for skill directories. + source_label: Label for the source (project, global, builtin). + + Returns: + list[dict]: List of skill dicts with keys: + name, description, path, has_handler, source, tags. + """ + path = Path(search_path) + if not path.exists() or not path.is_dir(): + return [] + + skills = [] + for item in sorted(path.iterdir()): + if not item.is_dir(): + continue + skill_md = item / "SKILL.md" + if not skill_md.exists(): + continue + + metadata = parse_frontmatter(skill_md) + if metadata is None: + continue + + skills.append({ + "name": metadata.get("name", item.name), + "description": metadata.get("description", "No description"), + "path": item, + "has_handler": metadata.get("has_handler", False), + "source": source_label, + "tags": metadata.get("tags", []), + }) + + return skills + + +def discover_all(): + """Discover all skills across all search paths. + + Returns: + list[dict]: All discovered skills, deduplicated by name + (first match wins). + """ + from ..handlers.registry import build_registry + + search_paths = get_search_paths() + return build_registry(search_paths, discover_skills_in_path) + + +def parse_frontmatter(skill_md_path): + """Parse YAML frontmatter from a SKILL.md file. + + Frontmatter must be delimited by '---' lines at the top of the file. + + Args: + skill_md_path: Path to the SKILL.md file. + + Returns: + dict or None: Parsed frontmatter metadata, or None if invalid. + """ + try: + content = Path(skill_md_path).read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + return None + + return _extract_frontmatter(content) + + +def _extract_frontmatter(content): + """Extract and parse YAML frontmatter from file content. + + Args: + content: Full text content of a SKILL.md file. + + Returns: + dict or None: Parsed frontmatter, or None if not found. + """ + lines = content.strip().splitlines() + if not lines or lines[0].strip() != "---": + return None + + # Find closing --- + end_idx = None + for i in range(1, len(lines)): + if lines[i].strip() == "---": + end_idx = i + break + + if end_idx is None: + return None + + frontmatter_text = "\n".join(lines[1:end_idx]) + + if HAS_YAML: + try: + return yaml.safe_load(frontmatter_text) + except yaml.YAMLError: + return _simple_frontmatter_parse(frontmatter_text) + else: + return _simple_frontmatter_parse(frontmatter_text) + + +def _simple_frontmatter_parse(text): + """Simple YAML-like frontmatter parser (no yaml dependency). + + Handles flat key: value pairs, simple lists with [] syntax, + and nested keys one level deep (e.g., requires.pip). + + Args: + text: Raw frontmatter text (without --- delimiters). + + Returns: + dict: Parsed key-value pairs. + """ + result = {} + current_key = None + current_list = None + + for line in text.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + + # Check for list item under a nested key + if stripped.startswith("- ") and current_list is not None: + value = stripped[2:].strip().strip("'\"") + if value: + current_list.append(value) + continue + + # Check for key: value + if ":" in stripped: + # Reset list tracking + current_list = None + + colon_idx = stripped.index(":") + key = stripped[:colon_idx].strip() + value = stripped[colon_idx + 1:].strip() + + # Detect indentation for nested keys + indent = len(line) - len(line.lstrip()) + + if indent > 0 and current_key is not None: + # Nested key (e.g., pip: [] under requires:) + if not isinstance(result.get(current_key), dict): + result[current_key] = {} + parsed_value = _parse_simple_value(value) + result[current_key][key] = parsed_value + if isinstance(parsed_value, list): + current_list = parsed_value + # Store reference so appending works + result[current_key][key] = current_list + else: + # Top-level key + current_key = key + if value: + result[key] = _parse_simple_value(value) + else: + # Could be a nested block or empty value + result[key] = {} + + return result + + +def _parse_simple_value(value): + """Parse a simple YAML value string. + + Args: + value: Raw value string. + + Returns: + Parsed value (str, bool, int, float, or list). + """ + # Empty brackets = empty list + if value == "[]": + return [] + + # Inline list: [item1, item2] + if value.startswith("[") and value.endswith("]"): + inner = value[1:-1].strip() + if not inner: + return [] + items = [item.strip().strip("'\"") for item in inner.split(",")] + return [item for item in items if item] + + # Boolean + if value.lower() == "true": + return True + if value.lower() == "false": + return False + + # Numeric + try: + if "." in value: + return float(value) + return int(value) + except ValueError: + pass + + # String (strip quotes) + return value.strip("'\"") diff --git a/src/skills/apps/modules/loader.py b/src/skills/apps/modules/loader.py new file mode 100644 index 00000000..7a0afee4 --- /dev/null +++ b/src/skills/apps/modules/loader.py @@ -0,0 +1,167 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: loader.py - Load SKILL.md and handlers +# Date: 2026-03-07 +# Version: 1.0.0 +# Category: skills/apps/modules +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-03-07): Initial implementation +# +# CODE STANDARDS: +# - Module layer: orchestration (can print) +# - Loads skill metadata, body, and optional handler module +# ============================================= + +"""Skill loader module. + +Loads a skill by name: finds it via discovery, parses the full SKILL.md +(frontmatter + body), and imports the handler if present. +""" + +import importlib.util +import sys +from pathlib import Path + +from .discovery import discover_all, parse_frontmatter + + +def load_skill(name): + """Load a skill by name. + + Steps: + 1. Find skill via discovery + 2. Parse full SKILL.md (frontmatter + body) + 3. If has_handler is true, import handler.py from skill directory + 4. Return loaded skill dict + + Args: + name: The skill name to load. + + Returns: + dict: { + "success": bool, + "metadata": dict or None, + "body": str or None, + "handler": module or None, + "path": Path or None, + "error": str or None + } + """ + # Step 1: Find via discovery + registry = discover_all() + skill_entry = None + for skill in registry: + if skill["name"] == name: + skill_entry = skill + break + + if skill_entry is None: + return { + "success": False, + "metadata": None, + "body": None, + "handler": None, + "path": None, + "error": f"Skill not found: {name}", + } + + skill_path = Path(skill_entry["path"]) + skill_md = skill_path / "SKILL.md" + + # Step 2: Parse full SKILL.md + metadata, body = _parse_full_skill_md(skill_md) + if metadata is None: + return { + "success": False, + "metadata": None, + "body": None, + "handler": None, + "path": skill_path, + "error": f"Failed to parse SKILL.md at {skill_md}", + } + + # Step 3: Import handler if present + handler = None + if metadata.get("has_handler", False): + handler = _load_handler(skill_path, name) + if handler is None: + print(f" Warning: has_handler is true but handler.py not found at {skill_path}") + + return { + "success": True, + "metadata": metadata, + "body": body, + "handler": handler, + "path": skill_path, + "error": None, + } + + +def _parse_full_skill_md(skill_md_path): + """Parse a SKILL.md file into frontmatter metadata and body text. + + Args: + skill_md_path: Path to the SKILL.md file. + + Returns: + tuple: (metadata_dict, body_string) or (None, None) on failure. + """ + try: + content = Path(skill_md_path).read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + return None, None + + lines = content.strip().splitlines() + if not lines or lines[0].strip() != "---": + return None, None + + # Find closing --- + end_idx = None + for i in range(1, len(lines)): + if lines[i].strip() == "---": + end_idx = i + break + + if end_idx is None: + return None, None + + # Parse frontmatter + metadata = parse_frontmatter(skill_md_path) + if metadata is None: + return None, None + + # Body is everything after the closing --- + body_lines = lines[end_idx + 1:] + body = "\n".join(body_lines).strip() + + return metadata, body + + +def _load_handler(skill_path, skill_name): + """Dynamically import a handler.py from a skill directory. + + Args: + skill_path: Path to the skill directory. + skill_name: Name of the skill (used for module naming). + + Returns: + module or None: The imported handler module, or None on failure. + """ + handler_file = Path(skill_path) / "handler.py" + if not handler_file.exists(): + return None + + module_name = f"skills_handler_{skill_name.replace('-', '_')}" + + try: + spec = importlib.util.spec_from_file_location(module_name, str(handler_file)) + if spec is None or spec.loader is None: + return None + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module + except Exception as exc: + print(f" Warning: Failed to load handler for {skill_name}: {exc}") + return None diff --git a/src/skills/apps/modules/runner.py b/src/skills/apps/modules/runner.py new file mode 100644 index 00000000..3f7efeff --- /dev/null +++ b/src/skills/apps/modules/runner.py @@ -0,0 +1,154 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: runner.py - Execute skills +# Date: 2026-03-07 +# Version: 1.0.0 +# Category: skills/apps/modules +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-03-07): Initial implementation +# +# CODE STANDARDS: +# - Module layer: orchestration (can print) +# - Runs skill handlers or displays markdown instructions +# ============================================= + +"""Skill runner module. + +Executes a skill: if it has a handler, calls handler.run(); +if no handler, prints the SKILL.md body for the LLM to read. +""" + +from .loader import load_skill + + +def run_skill(name, action=None, args=None, config=None): + """Execute a skill by name. + + Args: + name: The skill name to run. + action: The action to perform (required for handler-based skills). + args: Dict of action arguments. + config: Dict of resolved config values. + + Returns: + dict: {"success": bool, "output": str, "error": str|None} + """ + args = args or {} + config = config or {} + + # Load the skill + loaded = load_skill(name) + if not loaded["success"]: + return { + "success": False, + "output": "", + "error": loaded["error"], + } + + handler = loaded["handler"] + metadata = loaded["metadata"] + body = loaded["body"] + + # If skill has a handler, call it + if handler is not None: + return _run_handler(handler, name, action, args, config) + + # No handler: print the SKILL.md body (LLM reads instructions) + return _run_markdown(name, metadata, body) + + +def _run_handler(handler, name, action, args, config): + """Run a skill's handler module. + + Args: + handler: The imported handler module. + name: Skill name (for error messages). + action: Action to perform. + args: Dict of action arguments. + config: Dict of config values. + + Returns: + dict: {"success": bool, "output": str, "error": str|None} + """ + if action is None: + # List available actions if no action specified + if hasattr(handler, "get_actions"): + try: + actions = handler.get_actions() + action_list = ", ".join(actions) + return { + "success": True, + "output": f"Available actions for {name}: {action_list}", + "error": None, + } + except Exception as exc: + return { + "success": False, + "output": "", + "error": f"Failed to list actions for {name}: {exc}", + } + return { + "success": False, + "output": "", + "error": f"No action specified for {name}. Provide an action to run.", + } + + if not hasattr(handler, "run"): + return { + "success": False, + "output": "", + "error": f"Skill {name} handler has no run() function.", + } + + try: + result = handler.run(action, args=args, config=config) + if isinstance(result, dict): + return { + "success": result.get("success", False), + "output": result.get("output", ""), + "error": result.get("error"), + } + # If handler returns a non-dict, wrap it + return { + "success": True, + "output": str(result), + "error": None, + } + except Exception as exc: + return { + "success": False, + "output": "", + "error": f"Skill {name} action '{action}' failed: {exc}", + } + + +def _run_markdown(name, metadata, body): + """Run a markdown-only skill by returning its body content. + + Args: + name: Skill name. + metadata: Skill metadata dict. + body: Markdown body text. + + Returns: + dict: {"success": bool, "output": str, "error": str|None} + """ + if not body: + return { + "success": True, + "output": f"Skill '{name}' has no instructions body.", + "error": None, + } + + description = metadata.get("description", "") + header = f"=== Skill: {name} ===" + if description: + header += f"\n{description}" + header += "\n" + + return { + "success": True, + "output": f"{header}\n{body}", + "error": None, + } diff --git a/src/skills/apps/skills.py b/src/skills/apps/skills.py new file mode 100644 index 00000000..c8d8a91a --- /dev/null +++ b/src/skills/apps/skills.py @@ -0,0 +1,267 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: skills.py - Skills system entry point +# Date: 2026-03-07 +# Version: 1.0.0 +# Category: skills/apps +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-03-07): Initial implementation +# +# CODE STANDARDS: +# - Entry point with handle_command() for drone routing +# - Imports from modules layer only +# - Formats and prints output +# ============================================= + +"""Skills system entry point. + +Provides handle_command(command, args) for drone routing. +Commands: list, info, run, create, validate, --help. +""" + + +def handle_command(command, args=None): + """Route a skills command to the appropriate module. + + Args: + command: The subcommand to execute. + args: List of additional arguments. + + Returns: + bool: True if command was handled, False otherwise. + """ + args = args or [] + + if command in ("--help", "help", None): + _print_help() + return True + + if command == "list": + return _cmd_list() + + if command == "info": + if not args: + print(" Error: skill name required. Usage: skills info ") + return False + return _cmd_info(args[0]) + + if command == "run": + if not args: + print(" Error: skill name required. Usage: skills run [action] [args...]") + return False + name = args[0] + action = args[1] if len(args) > 1 else None + extra_args = _parse_extra_args(args[2:]) if len(args) > 2 else {} + return _cmd_run(name, action, extra_args) + + if command == "create": + if not args: + print(" Error: skill name required. Usage: skills create [--with-handler|--full]") + return False + return _cmd_create(args) + + if command == "validate": + if not args: + print(" Error: skill name required. Usage: skills validate ") + return False + return _cmd_validate(args[0]) + + print(f" Unknown command: {command}") + print(" Run 'skills --help' for available commands.") + return False + + +def _print_help(): + """Print skills help text.""" + print("Skills - Capability framework for AI agents") + print() + print("Usage:") + print(" drone @skills [args]") + print() + print("Commands:") + print(" list Show all discovered skills") + print(" info Display SKILL.md contents") + print(" run [action] [args] Execute a skill's handler") + print(" create Scaffold new skill (markdown only)") + print(" create --with-handler Scaffold with handler.py") + print(" create --full Scaffold with full 3-layer structure") + print(" validate Check if skill requirements are met") + print(" --help Show this help") + print() + print("Search paths (first match wins):") + print(" 1. .aipass/skills/ Project-local skills") + print(" 2. ~/.aipass/skills/ Global user skills") + print(" 3. src/skills/catalog/ Built-in skills") + + +def _cmd_list(): + """List all discovered skills.""" + from .modules.discovery import discover_all + + skills = discover_all() + + if not skills: + print(" No skills found.") + print(" Create one with: drone @skills create ") + return True + + print(f" Found {len(skills)} skill(s):") + print() + + # Group by source + sources = {} + for skill in skills: + source = skill["source"] + if source not in sources: + sources[source] = [] + sources[source].append(skill) + + source_labels = {"project": "Project", "global": "Global", "builtin": "Built-in"} + + for source, source_skills in sources.items(): + label = source_labels.get(source, source) + print(f" [{label}]") + for skill in source_skills: + handler_tag = " [handler]" if skill["has_handler"] else "" + tags = "" + if skill.get("tags"): + tags = f" ({', '.join(skill['tags'])})" + print(f" {skill['name']:<25} {skill['description']}{handler_tag}{tags}") + print() + + return True + + +def _cmd_info(name): + """Display full SKILL.md contents for a skill.""" + from .modules.loader import load_skill + + loaded = load_skill(name) + if not loaded["success"]: + print(f" Error: {loaded['error']}") + return False + + metadata = loaded["metadata"] + body = loaded["body"] + path = loaded["path"] + + print(f" Skill: {metadata.get('name', name)}") + print(f" Version: {metadata.get('version', 'unknown')}") + print(f" Description: {metadata.get('description', 'No description')}") + print(f" Path: {path}") + print(f" Has Handler: {metadata.get('has_handler', False)}") + + tags = metadata.get("tags", []) + if tags: + print(f" Tags: {', '.join(tags)}") + + requires = metadata.get("requires", {}) + if requires: + pip_pkgs = requires.get("pip", []) + bins = requires.get("bins", []) + config = requires.get("config", []) + if pip_pkgs: + print(f" Requires pip: {', '.join(pip_pkgs)}") + if bins: + print(f" Requires bins: {', '.join(bins)}") + if config: + print(f" Requires config: {', '.join(config)}") + + if body: + print() + print(" --- SKILL.md Body ---") + for line in body.splitlines(): + print(f" {line}") + + return True + + +def _cmd_run(name, action, extra_args): + """Execute a skill.""" + from .modules.runner import run_skill + + result = run_skill(name, action=action, args=extra_args) + + if result["success"]: + if result["output"]: + for line in result["output"].splitlines(): + print(f" {line}") + else: + error = result.get("error", "Unknown error") + print(f" Error: {error}") + + return result["success"] + + +def _cmd_create(args): + """Create a new skill from a template.""" + from .modules.creator import create_skill + + name = args[0] + + # Determine template type from flags + template_type = "markdown_only" + if "--with-handler" in args: + template_type = "with_handler" + elif "--full" in args: + template_type = "full" + + result = create_skill(name, template_type=template_type) + + if not result["success"]: + print(f" Error: {result['error']}") + return False + + return True + + +def _cmd_validate(name): + """Validate a skill's requirements.""" + from .modules.loader import load_skill + from .handlers.validator import validate_skill + + loaded = load_skill(name) + if not loaded["success"]: + print(f" Error: {loaded['error']}") + return False + + result = validate_skill(loaded["metadata"]) + + if result["valid"]: + print(f" Skill '{name}' - all requirements met.") + else: + print(f" Skill '{name}' - requirements NOT met:") + if result["missing_pip"]: + print(f" Missing pip packages: {', '.join(result['missing_pip'])}") + if result["missing_bins"]: + print(f" Missing CLI tools: {', '.join(result['missing_bins'])}") + if result["missing_config"]: + print(f" Missing config/env: {', '.join(result['missing_config'])}") + + return result["valid"] + + +def _parse_extra_args(arg_list): + """Parse extra arguments into a dict. + + Supports key=value pairs and positional arguments. + + Args: + arg_list: List of argument strings. + + Returns: + dict: Parsed arguments. + """ + result = {} + positional_idx = 0 + + for arg in arg_list: + if "=" in arg: + key, value = arg.split("=", 1) + result[key] = value + else: + result[f"arg{positional_idx}"] = arg + positional_idx += 1 + + return result diff --git a/src/skills/catalog/.gitkeep b/src/skills/catalog/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/skills/catalog/drone_commands/SKILL.md b/src/skills/catalog/drone_commands/SKILL.md new file mode 100644 index 00000000..08cb8a0f --- /dev/null +++ b/src/skills/catalog/drone_commands/SKILL.md @@ -0,0 +1,76 @@ +--- +name: drone_commands +description: Execute drone commands -- the AIPass CLI interface for all module operations +version: 1.0.0 +tags: [system, cli, drone, aipass] +requires: + pip: [] + bins: [] + config: [] +has_handler: true +--- + +# Drone Commands Skill + +Execute drone commands programmatically. Drone is the AIPass CLI router that dispatches commands to system modules. + +## Available Actions + +| Action | Description | +|----------|-----------------------------------------------------| +| `run` | Execute an arbitrary drone command string | +| `list` | List all available drone modules (`drone systems`) | +| `help` | Get help for a specific module (`drone @module --help`) | + +## Usage + +```bash +drone @skills run drone_commands run --args '{"command": "drone @ai_mail inbox"}' +drone @skills run drone_commands list +drone @skills run drone_commands help --args '{"module": "ai_mail"}' +``` + +## How Drone Routing Works + +Drone uses `@module` syntax to route commands to the correct system module: + +``` +drone @ai_mail inbox -> routes to ai_mail module +drone @skills list -> routes to skills module +drone @devpulse dashboard -> routes to devpulse module +drone commons feed -> special case (no @ prefix) +drone systems -> lists all registered modules +``` + +## Architecture + +This skill follows the AIPass 3-layer pattern: + +``` +drone_commands/ + SKILL.md # This file + handler.py # Top-level handler (delegates to apps/) + apps/ + modules/ + command_runner.py # Orchestrates drone command execution + handlers/ + executor.py # Runs commands via subprocess + parser.py # Parses drone output +``` + +## Output Format + +All actions return structured dicts: + +```python +{"success": True, "output": "...", "error": None} +``` + +The `run` action returns the full stdout/stderr from the drone command. + +## Notes + +- Commands execute in the AIPASS_ROOT directory by default +- Timeout defaults to 30 seconds (configurable) +- Never runs commands that modify system state without explicit action +- All output is captured, never printed directly diff --git a/src/skills/catalog/drone_commands/apps/__init__.py b/src/skills/catalog/drone_commands/apps/__init__.py new file mode 100644 index 00000000..1db46e55 --- /dev/null +++ b/src/skills/catalog/drone_commands/apps/__init__.py @@ -0,0 +1,13 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: __init__.py - drone_commands apps package +# Date: 2026-03-07 +# Version: 1.0.0 +# Category: skills/catalog/drone_commands/apps +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-03-07): Initial implementation +# +# CODE STANDARDS: +# - Apps layer entry point +# ============================================= diff --git a/src/skills/catalog/drone_commands/apps/handlers/__init__.py b/src/skills/catalog/drone_commands/apps/handlers/__init__.py new file mode 100644 index 00000000..a38b5733 --- /dev/null +++ b/src/skills/catalog/drone_commands/apps/handlers/__init__.py @@ -0,0 +1,13 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: __init__.py - drone_commands handlers package +# Date: 2026-03-07 +# Version: 1.0.0 +# Category: skills/catalog/drone_commands/apps/handlers +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-03-07): Initial implementation +# +# CODE STANDARDS: +# - Handlers layer: returns dicts, NEVER prints +# ============================================= diff --git a/src/skills/catalog/drone_commands/apps/handlers/executor.py b/src/skills/catalog/drone_commands/apps/handlers/executor.py new file mode 100644 index 00000000..a25910ff --- /dev/null +++ b/src/skills/catalog/drone_commands/apps/handlers/executor.py @@ -0,0 +1,104 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: executor.py - Runs drone commands via subprocess +# Date: 2026-03-07 +# Version: 1.0.0 +# Category: skills/catalog/drone_commands/apps/handlers +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-03-07): Initial implementation +# +# CODE STANDARDS: +# - Handlers layer: returns dicts, NEVER prints +# - stdlib only (no external deps) +# - Graceful error handling +# ============================================= + +""" +Executor handler for drone commands. + +Runs shell commands via subprocess and captures output. +Never prints -- always returns structured dicts. +""" + +import os +import subprocess +from pathlib import Path + +AIPASS_ROOT = Path(os.environ.get("AIPASS_ROOT", str(Path.home()))) +DEFAULT_TIMEOUT = 30 + + +def execute(command, cwd=None, timeout=None): + """Run a command via subprocess and capture output. + + Args: + command: The command string to execute. + cwd: Working directory for the command. Defaults to AIPASS_ROOT. + timeout: Timeout in seconds. Defaults to DEFAULT_TIMEOUT. + + Returns: + { + "success": bool, + "stdout": str, + "stderr": str, + "returncode": int + } + """ + if cwd is None: + cwd = str(AIPASS_ROOT) + if timeout is None: + timeout = DEFAULT_TIMEOUT + + # Validate command is not empty + if not command or not command.strip(): + return { + "success": False, + "stdout": "", + "stderr": "Empty command", + "returncode": -1, + } + + try: + result = subprocess.run( + command, + shell=True, + capture_output=True, + text=True, + cwd=cwd, + timeout=timeout, + ) + return { + "success": result.returncode == 0, + "stdout": result.stdout, + "stderr": result.stderr, + "returncode": result.returncode, + } + except subprocess.TimeoutExpired: + return { + "success": False, + "stdout": "", + "stderr": f"Command timed out after {timeout}s: {command}", + "returncode": -1, + } + except FileNotFoundError as exc: + return { + "success": False, + "stdout": "", + "stderr": f"File not found (bad cwd or shell?): {exc}", + "returncode": -1, + } + except OSError as exc: + return { + "success": False, + "stdout": "", + "stderr": f"OS error running command: {exc}", + "returncode": -1, + } + except Exception as exc: + return { + "success": False, + "stdout": "", + "stderr": f"Unexpected error: {exc}", + "returncode": -1, + } diff --git a/src/skills/catalog/drone_commands/apps/handlers/parser.py b/src/skills/catalog/drone_commands/apps/handlers/parser.py new file mode 100644 index 00000000..2e30b0f6 --- /dev/null +++ b/src/skills/catalog/drone_commands/apps/handlers/parser.py @@ -0,0 +1,122 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: parser.py - Parses drone command output +# Date: 2026-03-07 +# Version: 1.0.0 +# Category: skills/catalog/drone_commands/apps/handlers +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-03-07): Initial implementation +# +# CODE STANDARDS: +# - Handlers layer: returns dicts, NEVER prints +# - stdlib only (no external deps) +# - Pure functions, no side effects +# ============================================= + +""" +Parser handler for drone command output. + +Cleans up and structures raw drone output into usable data. +Never prints -- always returns structured results. +""" + +import re + + +def parse_output(raw_output): + """Clean up raw drone command output. + + Strips ANSI escape codes, trims whitespace, and normalizes line endings. + + Args: + raw_output: Raw string output from a drone command. + + Returns: + str: Cleaned output string. + """ + if not raw_output: + return "" + + # Strip ANSI escape sequences (color codes, cursor movements, etc.) + ansi_pattern = re.compile(r"\x1b\[[0-9;]*[a-zA-Z]") + cleaned = ansi_pattern.sub("", raw_output) + + # Normalize line endings + cleaned = cleaned.replace("\r\n", "\n").replace("\r", "\n") + + # Strip trailing whitespace from each line, remove excess blank lines + lines = cleaned.split("\n") + lines = [line.rstrip() for line in lines] + + # Collapse multiple consecutive blank lines into one + result_lines = [] + prev_blank = False + for line in lines: + is_blank = len(line.strip()) == 0 + if is_blank and prev_blank: + continue + result_lines.append(line) + prev_blank = is_blank + + # Strip leading/trailing blank lines from result + result = "\n".join(result_lines).strip() + + return result + + +def extract_modules(systems_output): + """Parse `drone systems` output into a list of module names. + + Expects output where each line contains a module name, possibly with + status indicators or descriptions. Extracts the module name from each + non-empty, non-header line. + + Args: + systems_output: Raw output from `drone systems` command. + + Returns: + list[str]: List of module name strings. + """ + if not systems_output: + return [] + + cleaned = parse_output(systems_output) + lines = cleaned.split("\n") + + modules = [] + for line in lines: + line = line.strip() + + # Skip empty lines + if not line: + continue + + # Skip header/separator lines (dashes, equals, common headers) + if line.startswith("---") or line.startswith("==="): + continue + if line.lower().startswith("registered") or line.lower().startswith("available"): + continue + + # Extract module name -- could be first word, or prefixed with indicators + # Common formats: + # module_name - plain name + # [OK] module_name - with status + # * module_name - with bullet + # @module_name - with @ prefix + + # Remove common prefixes + cleaned_line = line + cleaned_line = re.sub(r"^\[.*?\]\s*", "", cleaned_line) # [OK], [ERR], etc. + cleaned_line = re.sub(r"^[*\-+]\s*", "", cleaned_line) # bullet points + cleaned_line = cleaned_line.lstrip("@") # @ prefix + + # Take first word as module name + parts = cleaned_line.split() + if parts: + module_name = parts[0].strip() + # Validate it looks like a module name (alphanumeric + underscores) + if re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", module_name): + modules.append(module_name) + + return modules diff --git a/src/skills/catalog/drone_commands/apps/modules/__init__.py b/src/skills/catalog/drone_commands/apps/modules/__init__.py new file mode 100644 index 00000000..310625a8 --- /dev/null +++ b/src/skills/catalog/drone_commands/apps/modules/__init__.py @@ -0,0 +1,13 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: __init__.py - drone_commands modules package +# Date: 2026-03-07 +# Version: 1.0.0 +# Category: skills/catalog/drone_commands/apps/modules +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-03-07): Initial implementation +# +# CODE STANDARDS: +# - Modules layer: orchestration (can print) +# ============================================= diff --git a/src/skills/catalog/drone_commands/apps/modules/command_runner.py b/src/skills/catalog/drone_commands/apps/modules/command_runner.py new file mode 100644 index 00000000..ba34f1dd --- /dev/null +++ b/src/skills/catalog/drone_commands/apps/modules/command_runner.py @@ -0,0 +1,180 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: command_runner.py - Orchestrates drone command execution +# Date: 2026-03-07 +# Version: 1.0.0 +# Category: skills/catalog/drone_commands/apps/modules +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-03-07): Initial implementation +# +# CODE STANDARDS: +# - Modules layer: orchestration +# - Delegates to handlers for execution and parsing +# - Returns dicts for skill handler contract +# - stdlib only (no external deps) +# ============================================= + +""" +Command runner module for drone_commands skill. + +Orchestrates drone command execution by coordinating between +the executor (subprocess) and parser (output cleanup) handlers. +""" + +import os +import sys + +# Resolve imports relative to this skill's package +_THIS_DIR = os.path.dirname(os.path.abspath(__file__)) +_APPS_DIR = os.path.dirname(_THIS_DIR) +_HANDLERS_DIR = os.path.join(_APPS_DIR, "handlers") + +# Add handlers to path if not already there +if _HANDLERS_DIR not in sys.path: + sys.path.insert(0, _HANDLERS_DIR) +if _APPS_DIR not in sys.path: + sys.path.insert(0, _APPS_DIR) + +from handlers import executor, parser # noqa: E402 + + +AIPASS_ROOT = os.environ.get("AIPASS_ROOT", os.path.expanduser("~")) +DRONE_BIN = os.path.join(AIPASS_ROOT, "drone") + + +def run_command(command_string, timeout=None): + """Run an arbitrary drone command. + + Args: + command_string: The full drone command to execute + (e.g., "drone @ai_mail inbox"). + timeout: Optional timeout in seconds. + + Returns: + {"success": bool, "output": str, "error": str|None} + """ + if not command_string or not command_string.strip(): + return { + "success": False, + "output": "", + "error": "No command provided", + } + + # Ensure command starts with "drone" if not already + cmd = command_string.strip() + if not cmd.startswith("drone"): + cmd = f"drone {cmd}" + + # Execute via handler + result = executor.execute(cmd, cwd=AIPASS_ROOT, timeout=timeout) + + # Parse and clean output + stdout_clean = parser.parse_output(result.get("stdout", "")) + stderr_clean = parser.parse_output(result.get("stderr", "")) + + if result["success"]: + return { + "success": True, + "output": stdout_clean, + "error": None, + } + + # Command failed -- include both stdout and stderr + error_parts = [] + if stderr_clean: + error_parts.append(stderr_clean) + error_msg = "\n".join(error_parts) if error_parts else f"Command failed with exit code {result['returncode']}" + + output = stdout_clean if stdout_clean else "" + + return { + "success": False, + "output": output, + "error": error_msg, + } + + +def list_modules(timeout=None): + """List all available drone modules via `drone systems`. + + Args: + timeout: Optional timeout in seconds. + + Returns: + {"success": bool, "output": str, "error": str|None} + """ + result = executor.execute("drone systems", cwd=AIPASS_ROOT, timeout=timeout) + + if not result["success"]: + stderr_clean = parser.parse_output(result.get("stderr", "")) + return { + "success": False, + "output": "", + "error": stderr_clean or f"'drone systems' failed with exit code {result['returncode']}", + } + + stdout_clean = parser.parse_output(result.get("stdout", "")) + modules = parser.extract_modules(result.get("stdout", "")) + + if modules: + module_list = "\n".join(f" - {m}" for m in modules) + output = f"Registered modules ({len(modules)}):\n{module_list}" + else: + # Fallback: show raw cleaned output if parsing found nothing + output = stdout_clean if stdout_clean else "No modules found" + + return { + "success": True, + "output": output, + "error": None, + } + + +def module_help(module_name, timeout=None): + """Get help for a specific drone module. + + Args: + module_name: The module to get help for (e.g., "ai_mail"). + timeout: Optional timeout in seconds. + + Returns: + {"success": bool, "output": str, "error": str|None} + """ + if not module_name or not module_name.strip(): + return { + "success": False, + "output": "", + "error": "No module name provided", + } + + module_name = module_name.strip().lstrip("@") + cmd = f"drone @{module_name} --help" + + result = executor.execute(cmd, cwd=AIPASS_ROOT, timeout=timeout) + + stdout_clean = parser.parse_output(result.get("stdout", "")) + stderr_clean = parser.parse_output(result.get("stderr", "")) + + if result["success"]: + output = stdout_clean if stdout_clean else f"No help output for module '{module_name}'" + return { + "success": True, + "output": output, + "error": None, + } + + # Some modules output help to stderr + if stderr_clean and ("usage" in stderr_clean.lower() or "help" in stderr_clean.lower()): + return { + "success": True, + "output": stderr_clean, + "error": None, + } + + error_msg = stderr_clean or f"Failed to get help for module '{module_name}'" + return { + "success": False, + "output": stdout_clean, + "error": error_msg, + } diff --git a/src/skills/catalog/drone_commands/handler.py b/src/skills/catalog/drone_commands/handler.py new file mode 100644 index 00000000..88f679e4 --- /dev/null +++ b/src/skills/catalog/drone_commands/handler.py @@ -0,0 +1,101 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: handler.py - Drone Commands skill handler +# Date: 2026-03-07 +# Version: 1.0.0 +# Category: skills/catalog/drone_commands +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-03-07): Initial implementation +# +# CODE STANDARDS: +# - Top-level handler: delegates to apps/modules/ +# - Returns dicts, NEVER prints +# - stdlib only (no external deps) +# - Graceful error handling +# ============================================= + +""" +Drone Commands skill handler. + +Top-level entry point that delegates to the command_runner module +in the 3-layer apps/ structure. + +Called by: drone @skills run drone_commands [args] +""" + +import os +import sys + +# Set up import path for this skill's apps package +_THIS_DIR = os.path.dirname(os.path.abspath(__file__)) +_APPS_DIR = os.path.join(_THIS_DIR, "apps") +_MODULES_DIR = os.path.join(_APPS_DIR, "modules") + +if _MODULES_DIR not in sys.path: + sys.path.insert(0, _MODULES_DIR) +if _APPS_DIR not in sys.path: + sys.path.insert(0, _APPS_DIR) + +from modules import command_runner # noqa: E402 + + +def run(action, args=None, config=None): + """Execute a drone commands action. + + Args: + action: One of: run, list, help + args: Dict of action arguments: + - run: {"command": "drone @module action"} + - list: {} (no args needed) + - help: {"module": "module_name"} + config: Dict of resolved config values (unused) + + Returns: + {"success": bool, "output": str, "error": str|None} + """ + args = args or {} + config = config or {} + + timeout = args.get("timeout") + if timeout is not None: + try: + timeout = int(timeout) + except (ValueError, TypeError): + timeout = None + + if action == "run": + command = args.get("command", "") + if not command: + return { + "success": False, + "output": "", + "error": "Missing 'command' argument. Usage: --args '{\"command\": \"drone @module action\"}'", + } + return command_runner.run_command(command, timeout=timeout) + + elif action == "list": + return command_runner.list_modules(timeout=timeout) + + elif action == "help": + module_name = args.get("module", "") + if not module_name: + return { + "success": False, + "output": "", + "error": "Missing 'module' argument. Usage: --args '{\"module\": \"module_name\"}'", + } + return command_runner.module_help(module_name, timeout=timeout) + + else: + available = ", ".join(get_actions()) + return { + "success": False, + "output": "", + "error": f"Unknown action: {action}. Available: {available}", + } + + +def get_actions(): + """List available actions for this skill.""" + return ["run", "list", "help"] diff --git a/src/skills/catalog/github/SKILL.md b/src/skills/catalog/github/SKILL.md new file mode 100644 index 00000000..780616cc --- /dev/null +++ b/src/skills/catalog/github/SKILL.md @@ -0,0 +1,145 @@ +--- +name: github +description: "GitHub operations via gh CLI: issues, PRs, CI runs, code review, API queries." +version: 1.0.0 +tags: [dev, git, ci, github] +requires: + bins: [gh] + pip: [] + config: [] +has_handler: false +--- + +# GitHub Skill + +Use the `gh` CLI to interact with GitHub repositories, issues, PRs, and CI. + +## When to Use + +**USE this skill when:** + +- Checking PR status, reviews, or merge readiness +- Viewing CI/workflow run status and logs +- Creating, closing, or commenting on issues +- Creating or merging pull requests +- Querying GitHub API for repository data +- Listing repos, releases, or collaborators + +## When NOT to Use + +**DON'T use this skill when:** + +- Local git operations (commit, push, pull, branch) -> use `git` directly +- Non-GitHub repos (GitLab, Bitbucket, self-hosted) -> different CLIs +- Cloning repositories -> use `git clone` +- Reviewing actual code changes -> use `coding-agent` skill +- Complex multi-file diffs -> use `coding-agent` or read files directly + +## Setup + +```bash +# Authenticate (one-time) +gh auth login + +# Verify +gh auth status +``` + +## Common Commands + +### Pull Requests + +```bash +# List PRs +gh pr list --repo owner/repo + +# Check CI status +gh pr checks 55 --repo owner/repo + +# View PR details +gh pr view 55 --repo owner/repo + +# Create PR +gh pr create --title "feat: add feature" --body "Description" + +# Merge PR +gh pr merge 55 --squash --repo owner/repo +``` + +### Issues + +```bash +# List issues +gh issue list --repo owner/repo --state open + +# Create issue +gh issue create --title "Bug: something broken" --body "Details..." + +# Close issue +gh issue close 42 --repo owner/repo +``` + +### CI/Workflow Runs + +```bash +# List recent runs +gh run list --repo owner/repo --limit 10 + +# View specific run +gh run view --repo owner/repo + +# View failed step logs only +gh run view --repo owner/repo --log-failed + +# Re-run failed jobs +gh run rerun --failed --repo owner/repo +``` + +### API Queries + +```bash +# Get PR with specific fields +gh api repos/owner/repo/pulls/55 --jq '.title, .state, .user.login' + +# List all labels +gh api repos/owner/repo/labels --jq '.[].name' + +# Get repo stats +gh api repos/owner/repo --jq '{stars: .stargazers_count, forks: .forks_count}' +``` + +## JSON Output + +Most commands support `--json` for structured output with `--jq` filtering: + +```bash +gh issue list --repo owner/repo --json number,title --jq '.[] | "\(.number): \(.title)"' +gh pr list --json number,title,state,mergeable --jq '.[] | select(.mergeable == "MERGEABLE")' +``` + +## Templates + +### PR Review Summary + +```bash +# Get PR overview for review +PR=55 REPO=owner/repo +echo "## PR #$PR Summary" +gh pr view $PR --repo $REPO --json title,body,author,additions,deletions,changedFiles \ + --jq '"**\(.title)** by @\(.author.login)\n\n\(.body)\n\n+\(.additions) -\(.deletions) across \(.changedFiles) files"' +gh pr checks $PR --repo $REPO +``` + +### Issue Triage + +```bash +# Quick issue triage view +gh issue list --repo owner/repo --state open --json number,title,labels,createdAt \ + --jq '.[] | "[\(.number)] \(.title) - \([.labels[].name] | join(", ")) (\(.createdAt[:10]))"' +``` + +## Notes + +- Always specify `--repo owner/repo` when not in a git directory +- Use URLs directly: `gh pr view https://github.com/owner/repo/pull/55` +- Rate limits apply; use `gh api --cache 1h` for repeated queries diff --git a/src/skills/catalog/system_status/SKILL.md b/src/skills/catalog/system_status/SKILL.md new file mode 100644 index 00000000..dfd6fb49 --- /dev/null +++ b/src/skills/catalog/system_status/SKILL.md @@ -0,0 +1,58 @@ +--- +name: system_status +description: Check system health -- disk usage, memory, running processes, uptime +version: 1.0.0 +tags: [system, monitoring, health] +requires: + pip: [] + bins: [] + config: [] +has_handler: true +--- + +# System Status Skill + +Check system health metrics without leaving your workflow. Returns structured data about disk usage, memory, running processes, and system uptime. + +## Available Actions + +| Action | Description | +|-------------|------------------------------------------------| +| `disk` | Disk usage for the root filesystem | +| `memory` | Memory usage from /proc/meminfo (Linux) | +| `uptime` | System uptime from /proc/uptime | +| `processes` | Count of currently running processes | +| `summary` | All of the above combined into one report | + +## Usage + +```bash +drone @skills run system_status disk +drone @skills run system_status memory +drone @skills run system_status uptime +drone @skills run system_status processes +drone @skills run system_status summary +``` + +## Output Format + +All actions return structured dicts: + +```python +{"success": True, "output": "...", "error": None} +``` + +## When to Use + +- Quick health check before resource-intensive operations +- Diagnosing slow performance (memory pressure, disk full) +- Monitoring system state during long-running tasks +- Getting a snapshot of system health for reports + +## Notes + +- All data comes from stdlib / procfs -- no external dependencies +- Memory info reads from `/proc/meminfo` (Linux only) +- Uptime reads from `/proc/uptime` (Linux only) +- Disk usage uses `shutil.disk_usage()` (cross-platform) +- Process count uses `/proc` directory listing (Linux only) diff --git a/src/skills/catalog/system_status/handler.py b/src/skills/catalog/system_status/handler.py new file mode 100644 index 00000000..c446c2bc --- /dev/null +++ b/src/skills/catalog/system_status/handler.py @@ -0,0 +1,256 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: handler.py - System Status skill handler +# Date: 2026-03-07 +# Version: 1.0.0 +# Category: skills/catalog/system_status +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-03-07): Initial implementation +# +# CODE STANDARDS: +# - Handler layer: returns dicts, NEVER prints +# - stdlib only (no external deps) +# - Graceful error handling on all actions +# ============================================= + +""" +System Status skill handler. + +Provides system health information: disk usage, memory, uptime, processes. +All data sourced from stdlib and /proc (Linux). + +Called by: drone @skills run system_status +""" + +import os +import shutil + + +def run(action, args=None, config=None): + """Execute a system status action. + + Args: + action: One of: disk, memory, uptime, processes, summary + args: Dict of action arguments (unused for this skill) + config: Dict of resolved config values (unused for this skill) + + Returns: + {"success": bool, "output": str, "error": str|None} + """ + args = args or {} + config = config or {} + + dispatch = { + "disk": _disk_usage, + "memory": _memory_info, + "uptime": _system_uptime, + "processes": _process_count, + "summary": _summary, + } + + handler_fn = dispatch.get(action) + if handler_fn is None: + available = ", ".join(dispatch.keys()) + return { + "success": False, + "output": "", + "error": f"Unknown action: {action}. Available: {available}", + } + + try: + return handler_fn() + except Exception as exc: + return { + "success": False, + "output": "", + "error": f"Action '{action}' failed: {exc}", + } + + +def get_actions(): + """List available actions for this skill.""" + return ["disk", "memory", "uptime", "processes", "summary"] + + +# --------------------------------------------------------------------------- +# Action implementations +# --------------------------------------------------------------------------- + + +def _format_bytes(num_bytes): + """Format bytes into human-readable string.""" + for unit in ("B", "KB", "MB", "GB", "TB"): + if abs(num_bytes) < 1024.0: + return f"{num_bytes:.1f} {unit}" + num_bytes /= 1024.0 + return f"{num_bytes:.1f} PB" + + +def _disk_usage(): + """Get disk usage for the root filesystem.""" + usage = shutil.disk_usage("/") + total = _format_bytes(usage.total) + used = _format_bytes(usage.used) + free = _format_bytes(usage.free) + percent = (usage.used / usage.total) * 100 + + output = ( + f"Disk Usage (/)\n" + f" Total: {total}\n" + f" Used: {used} ({percent:.1f}%)\n" + f" Free: {free}" + ) + return {"success": True, "output": output, "error": None} + + +def _memory_info(): + """Get memory info from /proc/meminfo (Linux).""" + meminfo_path = "/proc/meminfo" + if not os.path.exists(meminfo_path): + return { + "success": False, + "output": "", + "error": "/proc/meminfo not available (non-Linux system?)", + } + + data = {} + with open(meminfo_path, "r", encoding="utf-8") as f: + for line in f: + parts = line.split(":") + if len(parts) == 2: + key = parts[0].strip() + # Value is in kB typically, e.g. "8045264 kB" + val_str = parts[1].strip() + # Extract numeric part + val_parts = val_str.split() + if val_parts: + try: + data[key] = int(val_parts[0]) + except ValueError: + data[key] = val_str + + mem_total = data.get("MemTotal", 0) + mem_free = data.get("MemFree", 0) + mem_available = data.get("MemAvailable", 0) + buffers = data.get("Buffers", 0) + cached = data.get("Cached", 0) + swap_total = data.get("SwapTotal", 0) + swap_free = data.get("SwapFree", 0) + + # Values from /proc/meminfo are in kB + mem_used = mem_total - mem_available + mem_percent = (mem_used / mem_total * 100) if mem_total > 0 else 0 + swap_used = swap_total - swap_free + swap_percent = (swap_used / swap_total * 100) if swap_total > 0 else 0 + + output = ( + f"Memory\n" + f" Total: {_format_bytes(mem_total * 1024)}\n" + f" Used: {_format_bytes(mem_used * 1024)} ({mem_percent:.1f}%)\n" + f" Available: {_format_bytes(mem_available * 1024)}\n" + f" Buffers: {_format_bytes(buffers * 1024)}\n" + f" Cached: {_format_bytes(cached * 1024)}\n" + f"Swap\n" + f" Total: {_format_bytes(swap_total * 1024)}\n" + f" Used: {_format_bytes(swap_used * 1024)} ({swap_percent:.1f}%)\n" + f" Free: {_format_bytes(swap_free * 1024)}" + ) + return {"success": True, "output": output, "error": None} + + +def _system_uptime(): + """Get system uptime from /proc/uptime (Linux).""" + uptime_path = "/proc/uptime" + if not os.path.exists(uptime_path): + return { + "success": False, + "output": "", + "error": "/proc/uptime not available (non-Linux system?)", + } + + with open(uptime_path, "r", encoding="utf-8") as f: + content = f.read().strip() + + parts = content.split() + if not parts: + return { + "success": False, + "output": "", + "error": "Could not parse /proc/uptime", + } + + uptime_seconds = float(parts[0]) + days = int(uptime_seconds // 86400) + hours = int((uptime_seconds % 86400) // 3600) + minutes = int((uptime_seconds % 3600) // 60) + seconds = int(uptime_seconds % 60) + + parts_list = [] + if days > 0: + parts_list.append(f"{days}d") + if hours > 0: + parts_list.append(f"{hours}h") + if minutes > 0: + parts_list.append(f"{minutes}m") + parts_list.append(f"{seconds}s") + + formatted = " ".join(parts_list) + + output = f"Uptime: {formatted} ({uptime_seconds:.0f} seconds total)" + return {"success": True, "output": output, "error": None} + + +def _process_count(): + """Count running processes via /proc directory.""" + proc_path = "/proc" + if not os.path.exists(proc_path): + return { + "success": False, + "output": "", + "error": "/proc not available (non-Linux system?)", + } + + count = 0 + try: + for entry in os.listdir(proc_path): + # Process directories are numeric PIDs + if entry.isdigit(): + count += 1 + except OSError as exc: + return { + "success": False, + "output": "", + "error": f"Failed to read /proc: {exc}", + } + + output = f"Running processes: {count}" + return {"success": True, "output": output, "error": None} + + +def _summary(): + """Combine all status checks into one report.""" + sections = [] + errors = [] + + for action_name, action_fn in [ + ("disk", _disk_usage), + ("memory", _memory_info), + ("uptime", _system_uptime), + ("processes", _process_count), + ]: + try: + result = action_fn() + if result["success"]: + sections.append(result["output"]) + else: + errors.append(f"{action_name}: {result['error']}") + except Exception as exc: + errors.append(f"{action_name}: {exc}") + + output = "\n---\n".join(sections) + + if errors: + output += "\n---\nErrors:\n " + "\n ".join(errors) + + return {"success": True, "output": output, "error": None} diff --git a/src/skills/templates/full/SKILL.md b/src/skills/templates/full/SKILL.md new file mode 100644 index 00000000..6b2a1026 --- /dev/null +++ b/src/skills/templates/full/SKILL.md @@ -0,0 +1,27 @@ +--- +name: {{SKILL_NAME}} +description: TODO — describe what this skill does +version: 1.0.0 +tags: [] +requires: + pip: [] + bins: [] + config: [] +has_handler: true +--- + +# {{SKILL_NAME}} + +## What This Does +TODO + +## When to Use +TODO + +## Steps +1. TODO + +## Example +``` +TODO +``` diff --git a/src/skills/templates/full/apps/__init__.py b/src/skills/templates/full/apps/__init__.py new file mode 100644 index 00000000..9b290563 --- /dev/null +++ b/src/skills/templates/full/apps/__init__.py @@ -0,0 +1,13 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: __init__.py - {{SKILL_NAME}} apps package +# Date: 2026-03-07 +# Version: 1.0.0 +# Category: skills/catalog/{{SKILL_NAME}}/apps +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-03-07): Initial scaffold +# +# CODE STANDARDS: +# - Apps layer entry point +# ============================================= diff --git a/src/skills/templates/full/apps/handlers/__init__.py b/src/skills/templates/full/apps/handlers/__init__.py new file mode 100644 index 00000000..00bdc3c8 --- /dev/null +++ b/src/skills/templates/full/apps/handlers/__init__.py @@ -0,0 +1,13 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: __init__.py - {{SKILL_NAME}} handlers package +# Date: 2026-03-07 +# Version: 1.0.0 +# Category: skills/catalog/{{SKILL_NAME}}/apps/handlers +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-03-07): Initial scaffold +# +# CODE STANDARDS: +# - Handlers layer: returns dicts, NEVER prints +# ============================================= diff --git a/src/skills/templates/full/apps/modules/__init__.py b/src/skills/templates/full/apps/modules/__init__.py new file mode 100644 index 00000000..37ce0633 --- /dev/null +++ b/src/skills/templates/full/apps/modules/__init__.py @@ -0,0 +1,13 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: __init__.py - {{SKILL_NAME}} modules package +# Date: 2026-03-07 +# Version: 1.0.0 +# Category: skills/catalog/{{SKILL_NAME}}/apps/modules +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-03-07): Initial scaffold +# +# CODE STANDARDS: +# - Modules layer: orchestration (can print) +# ============================================= diff --git a/src/skills/templates/markdown_only/SKILL.md b/src/skills/templates/markdown_only/SKILL.md new file mode 100644 index 00000000..d51bd04e --- /dev/null +++ b/src/skills/templates/markdown_only/SKILL.md @@ -0,0 +1,27 @@ +--- +name: {{SKILL_NAME}} +description: TODO — describe what this skill does +version: 1.0.0 +tags: [] +requires: + pip: [] + bins: [] + config: [] +has_handler: false +--- + +# {{SKILL_NAME}} + +## What This Does +TODO + +## When to Use +TODO + +## Steps +1. TODO + +## Example +``` +TODO +``` diff --git a/src/skills/templates/with_handler/SKILL.md b/src/skills/templates/with_handler/SKILL.md new file mode 100644 index 00000000..6b2a1026 --- /dev/null +++ b/src/skills/templates/with_handler/SKILL.md @@ -0,0 +1,27 @@ +--- +name: {{SKILL_NAME}} +description: TODO — describe what this skill does +version: 1.0.0 +tags: [] +requires: + pip: [] + bins: [] + config: [] +has_handler: true +--- + +# {{SKILL_NAME}} + +## What This Does +TODO + +## When to Use +TODO + +## Steps +1. TODO + +## Example +``` +TODO +``` diff --git a/src/skills/templates/with_handler/handler.py b/src/skills/templates/with_handler/handler.py new file mode 100644 index 00000000..d130dd51 --- /dev/null +++ b/src/skills/templates/with_handler/handler.py @@ -0,0 +1,30 @@ +""" +{{SKILL_NAME}} skill handler + +Called by: drone @skills run {{SKILL_NAME}} [args] +""" + + +def run(action, args=None, config=None): + """Execute a skill action. + + Args: + action: What to do + args: Dict of action arguments + config: Dict of resolved config values + + Returns: + {"success": bool, "output": str, "error": str|None} + """ + args = args or {} + config = config or {} + + if action == "example": + return {"success": True, "output": "It works!", "error": None} + + return {"success": False, "output": "", "error": f"Unknown action: {action}"} + + +def get_actions(): + """List available actions for this skill.""" + return ["example"] diff --git a/src/skills/tests/__init__.py b/src/skills/tests/__init__.py new file mode 100644 index 00000000..fe9c92ef --- /dev/null +++ b/src/skills/tests/__init__.py @@ -0,0 +1,13 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: __init__.py - Skills tests package +# Date: 2026-03-07 +# Version: 1.0.0 +# Category: skills/tests +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-03-07): Initial implementation +# +# CODE STANDARDS: +# - Test package for the Skills system +# ============================================= diff --git a/src/skills/tests/conftest.py b/src/skills/tests/conftest.py new file mode 100644 index 00000000..f3aff3cf --- /dev/null +++ b/src/skills/tests/conftest.py @@ -0,0 +1,23 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: conftest.py - Skills test configuration +# Date: 2026-03-07 +# Version: 1.0.0 +# Category: skills/tests +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-03-07): Initial implementation +# +# CODE STANDARDS: +# - Adds skills root to sys.path for test imports +# ============================================= + +"""Skills test configuration.""" + +import sys +from pathlib import Path + +# Add skills root to path for imports +skills_root = Path(__file__).parent.parent +if str(skills_root) not in sys.path: + sys.path.insert(0, str(skills_root)) diff --git a/src/skills/tests/test_discovery.py b/src/skills/tests/test_discovery.py new file mode 100644 index 00000000..fb3c4ff4 --- /dev/null +++ b/src/skills/tests/test_discovery.py @@ -0,0 +1,212 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: test_discovery.py - Unit tests for skills discovery +# Date: 2026-03-07 +# Version: 1.0.0 +# Category: skills/tests +# ============================================= + +"""Tests for the skills discovery module.""" + +import sys +import tempfile +from pathlib import Path + +import pytest + +# Ensure skills package is importable +skills_root = Path(__file__).resolve().parent.parent.parent +if str(skills_root) not in sys.path: + sys.path.insert(0, str(skills_root)) + +from skills.apps.modules.discovery import ( + _extract_frontmatter, + _parse_simple_value, + _simple_frontmatter_parse, + discover_skills_in_path, + get_search_paths, + parse_frontmatter, +) + + +class TestGetSearchPaths: + def test_returns_three_paths(self): + paths = get_search_paths() + assert len(paths) == 3 + + def test_path_order(self): + paths = get_search_paths() + labels = [label for _, label in paths] + assert labels == ["project", "global", "builtin"] + + def test_builtin_path_exists(self): + paths = get_search_paths() + builtin_path = paths[2][0] + assert builtin_path.exists() + + +class TestExtractFrontmatter: + def test_valid_frontmatter(self): + content = "---\nname: test\ndescription: A test skill\n---\n\n# Body" + result = _extract_frontmatter(content) + assert result is not None + assert result["name"] == "test" + assert result["description"] == "A test skill" + + def test_no_frontmatter(self): + content = "# Just a markdown file\nNo frontmatter here." + result = _extract_frontmatter(content) + assert result is None + + def test_unclosed_frontmatter(self): + content = "---\nname: test\nno closing delimiter" + result = _extract_frontmatter(content) + assert result is None + + def test_empty_content(self): + result = _extract_frontmatter("") + assert result is None + + def test_boolean_values(self): + content = "---\nname: test\nhas_handler: true\n---\n" + result = _extract_frontmatter(content) + assert result["has_handler"] is True + + def test_list_values(self): + content = "---\nname: test\ntags: [dev, git, ci]\n---\n" + result = _extract_frontmatter(content) + assert result["tags"] == ["dev", "git", "ci"] + + +class TestSimpleFrontmatterParse: + def test_flat_key_value(self): + text = "name: my-skill\ndescription: Does a thing" + result = _simple_frontmatter_parse(text) + assert result["name"] == "my-skill" + assert result["description"] == "Does a thing" + + def test_inline_list(self): + text = "tags: [a, b, c]" + result = _simple_frontmatter_parse(text) + assert result["tags"] == ["a", "b", "c"] + + def test_empty_list(self): + text = "tags: []" + result = _simple_frontmatter_parse(text) + assert result["tags"] == [] + + def test_boolean_true(self): + text = "has_handler: true" + result = _simple_frontmatter_parse(text) + assert result["has_handler"] is True + + def test_boolean_false(self): + text = "has_handler: false" + result = _simple_frontmatter_parse(text) + assert result["has_handler"] is False + + def test_nested_keys(self): + text = "requires:\n pip: [praw]\n bins: [gh]\n config: [MY_TOKEN]" + result = _simple_frontmatter_parse(text) + assert result["requires"]["pip"] == ["praw"] + assert result["requires"]["bins"] == ["gh"] + assert result["requires"]["config"] == ["MY_TOKEN"] + + def test_integer_value(self): + text = "version: 42" + result = _simple_frontmatter_parse(text) + assert result["version"] == 42 + + def test_quoted_string(self): + text = "description: \"A quoted value\"" + result = _simple_frontmatter_parse(text) + assert result["description"] == "A quoted value" + + +class TestParseSimpleValue: + def test_empty_list(self): + assert _parse_simple_value("[]") == [] + + def test_inline_list(self): + assert _parse_simple_value("[a, b]") == ["a", "b"] + + def test_true(self): + assert _parse_simple_value("true") is True + + def test_false(self): + assert _parse_simple_value("false") is False + + def test_integer(self): + assert _parse_simple_value("42") == 42 + + def test_float(self): + assert _parse_simple_value("3.14") == 3.14 + + def test_string(self): + assert _parse_simple_value("hello") == "hello" + + +class TestDiscoverSkillsInPath: + def test_finds_catalog_skills(self): + catalog_path = Path(__file__).resolve().parent.parent / "catalog" + skills = discover_skills_in_path(catalog_path, "builtin") + names = {s["name"] for s in skills} + assert "github" in names + assert "system_status" in names + assert "drone_commands" in names + + def test_nonexistent_path(self): + skills = discover_skills_in_path("/nonexistent/path", "test") + assert skills == [] + + def test_empty_dir(self): + with tempfile.TemporaryDirectory() as tmpdir: + skills = discover_skills_in_path(tmpdir, "test") + assert skills == [] + + def test_skill_dict_structure(self): + catalog_path = Path(__file__).resolve().parent.parent / "catalog" + skills = discover_skills_in_path(catalog_path, "builtin") + for skill in skills: + assert "name" in skill + assert "description" in skill + assert "path" in skill + assert "has_handler" in skill + assert "source" in skill + assert "tags" in skill + + def test_has_handler_flag(self): + catalog_path = Path(__file__).resolve().parent.parent / "catalog" + skills = discover_skills_in_path(catalog_path, "builtin") + skill_map = {s["name"]: s for s in skills} + assert skill_map["github"]["has_handler"] is False + assert skill_map["system_status"]["has_handler"] is True + assert skill_map["drone_commands"]["has_handler"] is True + + def test_custom_skill_discovery(self): + """Test that a custom skill directory is discovered correctly.""" + with tempfile.TemporaryDirectory() as tmpdir: + skill_dir = Path(tmpdir) / "my-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text( + "---\nname: my-skill\ndescription: A test\n---\n\n# Test\n" + ) + skills = discover_skills_in_path(tmpdir, "project") + assert len(skills) == 1 + assert skills[0]["name"] == "my-skill" + assert skills[0]["source"] == "project" + + +class TestParseFrontmatter: + def test_valid_file(self): + with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f: + f.write("---\nname: test\ndescription: Hello\n---\n\n# Body\n") + f.flush() + result = parse_frontmatter(f.name) + assert result is not None + assert result["name"] == "test" + Path(f.name).unlink() + + def test_invalid_file(self): + result = parse_frontmatter("/nonexistent/file.md") + assert result is None diff --git a/src/skills/tests/test_lifecycle.py b/src/skills/tests/test_lifecycle.py new file mode 100644 index 00000000..b731b058 --- /dev/null +++ b/src/skills/tests/test_lifecycle.py @@ -0,0 +1,180 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: test_lifecycle.py - Integration test for full skill lifecycle +# Date: 2026-03-07 +# Version: 1.0.0 +# Category: skills/tests +# ============================================= + +"""Integration tests for the full skill lifecycle: create -> discover -> load -> run.""" + +import shutil +import sys +import tempfile +from pathlib import Path + +import pytest + +skills_root = Path(__file__).resolve().parent.parent.parent +if str(skills_root) not in sys.path: + sys.path.insert(0, str(skills_root)) + +from skills.apps.handlers.template import copy_template, get_template +from skills.apps.modules.creator import create_skill +from skills.apps.modules.discovery import discover_skills_in_path, parse_frontmatter +from skills.apps.modules.loader import _load_handler, _parse_full_skill_md +from skills.apps.modules.runner import run_skill + + +class TestFullLifecycle: + """Test the complete create -> discover -> load -> run cycle.""" + + def setup_method(self): + self.tmpdir = tempfile.mkdtemp() + + def teardown_method(self): + shutil.rmtree(self.tmpdir) + + def test_create_discover_load_markdown_skill(self): + """Tier 1: Create a markdown skill, discover it, load it, run it.""" + # Create + result = create_skill("test-md", template_type="markdown_only", target_dir=self.tmpdir) + assert result["success"] is True + skill_path = Path(result["path"]) + assert (skill_path / "SKILL.md").exists() + + # Verify placeholder replacement + content = (skill_path / "SKILL.md").read_text() + assert "test-md" in content + assert "{{SKILL_NAME}}" not in content + + # Discover + skills = discover_skills_in_path(self.tmpdir, "test") + assert len(skills) == 1 + assert skills[0]["name"] == "test-md" + assert skills[0]["has_handler"] is False + + # Load (parse full SKILL.md) + metadata, body = _parse_full_skill_md(skill_path / "SKILL.md") + assert metadata is not None + assert metadata["name"] == "test-md" + assert body is not None + + def test_create_discover_load_handler_skill(self): + """Tier 2: Create a handler skill, discover it, load handler.""" + # Create + result = create_skill("test-handler", template_type="with_handler", target_dir=self.tmpdir) + assert result["success"] is True + skill_path = Path(result["path"]) + assert (skill_path / "SKILL.md").exists() + assert (skill_path / "handler.py").exists() + + # Discover + skills = discover_skills_in_path(self.tmpdir, "test") + handler_skill = [s for s in skills if s["name"] == "test-handler"] + assert len(handler_skill) == 1 + + # Load handler + handler = _load_handler(skill_path, "test-handler") + assert handler is not None + assert hasattr(handler, "run") + assert hasattr(handler, "get_actions") + + # Execute handler + actions = handler.get_actions() + assert isinstance(actions, list) + assert len(actions) > 0 + + # Run an action + result = handler.run(actions[0], args={}, config={}) + assert isinstance(result, dict) + assert "success" in result + + def test_create_full_structure(self): + """Tier 3: Create a full 3-layer skill and verify structure.""" + result = create_skill("test-full", template_type="full", target_dir=self.tmpdir) + assert result["success"] is True + skill_path = Path(result["path"]) + assert (skill_path / "SKILL.md").exists() + assert (skill_path / "apps").is_dir() + assert (skill_path / "apps" / "modules").is_dir() + assert (skill_path / "apps" / "handlers").is_dir() + + +class TestCatalogSkillsLifecycle: + """Test that built-in catalog skills work through the full lifecycle.""" + + def test_github_skill_full_cycle(self): + """GitHub (Tier 1): discover -> load -> run returns instructions.""" + catalog = Path(__file__).resolve().parent.parent / "catalog" + skills = discover_skills_in_path(catalog, "builtin") + github = [s for s in skills if s["name"] == "github"] + assert len(github) == 1 + assert github[0]["has_handler"] is False + + # Parse full SKILL.md + metadata, body = _parse_full_skill_md(github[0]["path"] / "SKILL.md") + assert metadata["name"] == "github" + assert body is not None + assert "gh" in body.lower() + + def test_system_status_full_cycle(self): + """System status (Tier 2): discover -> load -> run handler.""" + result = run_skill("system_status", action="disk") + assert result["success"] is True + assert "Disk Usage" in result["output"] + + def test_drone_commands_full_cycle(self): + """Drone commands (Tier 3): discover -> load -> run handler.""" + result = run_skill("drone_commands") + assert result["success"] is True + assert "Available actions" in result["output"] + + +class TestTemplates: + """Test template resolution and copying.""" + + def test_get_markdown_template(self): + result = get_template("markdown_only") + assert result["success"] is True + assert result["path"].exists() + + def test_get_handler_template(self): + result = get_template("with_handler") + assert result["success"] is True + assert result["path"].exists() + + def test_get_full_template(self): + result = get_template("full") + assert result["success"] is True + assert result["path"].exists() + + def test_invalid_template_type(self): + result = get_template("nonexistent") + assert result["success"] is False + assert result["error"] is not None + + def test_copy_template_replaces_placeholders(self): + tmpdir = tempfile.mkdtemp() + try: + template = get_template("markdown_only") + target = Path(tmpdir) / "my-skill" + result = copy_template(template["path"], target, "my-skill") + assert result["success"] is True + content = (target / "SKILL.md").read_text() + assert "my-skill" in content + assert "{{SKILL_NAME}}" not in content + finally: + shutil.rmtree(tmpdir) + + def test_copy_template_rejects_existing_target(self): + tmpdir = tempfile.mkdtemp() + try: + template = get_template("markdown_only") + target = Path(tmpdir) / "exists" + target.mkdir() + result = copy_template(template["path"], target, "exists") + assert result["success"] is False + assert "already exists" in result["error"] + finally: + shutil.rmtree(tmpdir) diff --git a/src/skills/tests/test_loader.py b/src/skills/tests/test_loader.py new file mode 100644 index 00000000..75b10a23 --- /dev/null +++ b/src/skills/tests/test_loader.py @@ -0,0 +1,75 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: test_loader.py - Unit tests for skills loader +# Date: 2026-03-07 +# Version: 1.0.0 +# Category: skills/tests +# ============================================= + +"""Tests for the skills loader module.""" + +import sys +from pathlib import Path + +import pytest + +skills_root = Path(__file__).resolve().parent.parent.parent +if str(skills_root) not in sys.path: + sys.path.insert(0, str(skills_root)) + +from skills.apps.modules.loader import load_skill + + +class TestLoadSkill: + def test_load_github_markdown_only(self): + result = load_skill("github") + assert result["success"] is True + assert result["metadata"]["name"] == "github" + assert result["handler"] is None + assert result["body"] is not None + assert len(result["body"]) > 0 + + def test_load_system_status_with_handler(self): + result = load_skill("system_status") + assert result["success"] is True + assert result["metadata"]["name"] == "system_status" + assert result["handler"] is not None + assert hasattr(result["handler"], "run") + assert hasattr(result["handler"], "get_actions") + + def test_load_drone_commands_full(self): + result = load_skill("drone_commands") + assert result["success"] is True + assert result["handler"] is not None + assert hasattr(result["handler"], "run") + + def test_load_nonexistent(self): + result = load_skill("nonexistent_skill_xyz") + assert result["success"] is False + assert result["error"] is not None + assert "not found" in result["error"].lower() + assert result["metadata"] is None + assert result["handler"] is None + + def test_metadata_has_expected_keys(self): + result = load_skill("github") + metadata = result["metadata"] + assert "name" in metadata + assert "description" in metadata + + def test_body_is_markdown_content(self): + result = load_skill("github") + body = result["body"] + assert "# GitHub" in body or "## " in body + + def test_handler_contract(self): + """Verify handler follows the run(action, args, config) contract.""" + result = load_skill("system_status") + handler = result["handler"] + # Must have run() and get_actions() + assert callable(handler.run) + assert callable(handler.get_actions) + # get_actions returns a list + actions = handler.get_actions() + assert isinstance(actions, list) + assert len(actions) > 0 diff --git a/src/skills/tests/test_runner.py b/src/skills/tests/test_runner.py new file mode 100644 index 00000000..c76c4137 --- /dev/null +++ b/src/skills/tests/test_runner.py @@ -0,0 +1,98 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: test_runner.py - Unit tests for skills runner +# Date: 2026-03-07 +# Version: 1.0.0 +# Category: skills/tests +# ============================================= + +"""Tests for the skills runner module.""" + +import sys +from pathlib import Path + +import pytest + +skills_root = Path(__file__).resolve().parent.parent.parent +if str(skills_root) not in sys.path: + sys.path.insert(0, str(skills_root)) + +from skills.apps.modules.runner import run_skill + + +class TestRunSkillHandler: + def test_run_system_status_disk(self): + result = run_skill("system_status", action="disk") + assert result["success"] is True + assert "Disk Usage" in result["output"] + assert result["error"] is None + + def test_run_system_status_memory(self): + result = run_skill("system_status", action="memory") + assert result["success"] is True + assert "Memory" in result["output"] + + def test_run_system_status_uptime(self): + result = run_skill("system_status", action="uptime") + assert result["success"] is True + assert "Uptime" in result["output"] + + def test_run_system_status_processes(self): + result = run_skill("system_status", action="processes") + assert result["success"] is True + assert "processes" in result["output"].lower() + + def test_run_system_status_summary(self): + result = run_skill("system_status", action="summary") + assert result["success"] is True + assert "Disk Usage" in result["output"] + assert "Memory" in result["output"] + + def test_invalid_action(self): + result = run_skill("system_status", action="nonexistent") + assert result["success"] is False + assert result["error"] is not None + + def test_no_action_lists_actions(self): + result = run_skill("system_status") + assert result["success"] is True + assert "Available actions" in result["output"] + + def test_nonexistent_skill(self): + result = run_skill("nonexistent_skill_xyz") + assert result["success"] is False + assert result["error"] is not None + + +class TestRunSkillMarkdown: + def test_run_github_returns_body(self): + result = run_skill("github") + assert result["success"] is True + assert result["output"] is not None + assert len(result["output"]) > 100 + assert "github" in result["output"].lower() + assert result["error"] is None + + def test_output_format(self): + result = run_skill("github") + assert result["output"].startswith("=== Skill: github ===") + + +class TestRunSkillReturnContract: + def test_return_has_required_keys(self): + result = run_skill("system_status", action="disk") + assert "success" in result + assert "output" in result + assert "error" in result + + def test_success_result_types(self): + result = run_skill("system_status", action="disk") + assert isinstance(result["success"], bool) + assert isinstance(result["output"], str) + assert result["error"] is None + + def test_failure_result_types(self): + result = run_skill("nonexistent_skill_xyz") + assert isinstance(result["success"], bool) + assert isinstance(result["output"], str) + assert isinstance(result["error"], str) diff --git a/src/skills/tests/test_validator.py b/src/skills/tests/test_validator.py new file mode 100644 index 00000000..67b543ad --- /dev/null +++ b/src/skills/tests/test_validator.py @@ -0,0 +1,89 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: test_validator.py - Unit tests for skills validator +# Date: 2026-03-07 +# Version: 1.0.0 +# Category: skills/tests +# ============================================= + +"""Tests for the skills validator handler.""" + +import sys +from pathlib import Path + +import pytest + +skills_root = Path(__file__).resolve().parent.parent.parent +if str(skills_root) not in sys.path: + sys.path.insert(0, str(skills_root)) + +from skills.apps.handlers.validator import validate_skill + + +class TestValidateSkill: + def test_no_requirements(self): + result = validate_skill({}) + assert result["valid"] is True + assert result["missing_pip"] == [] + assert result["missing_bins"] == [] + assert result["missing_config"] == [] + + def test_empty_requirements(self): + result = validate_skill({"requires": {"pip": [], "bins": [], "config": []}}) + assert result["valid"] is True + + def test_installed_pip_package(self): + # sys is always available + result = validate_skill({"requires": {"pip": ["sys"]}}) + assert result["valid"] is True + assert result["missing_pip"] == [] + + def test_missing_pip_package(self): + result = validate_skill({"requires": {"pip": ["nonexistent_pkg_xyz_123"]}}) + assert result["valid"] is False + assert "nonexistent_pkg_xyz_123" in result["missing_pip"] + + def test_available_binary(self): + # python3 should be on PATH + result = validate_skill({"requires": {"bins": ["python3"]}}) + assert result["valid"] is True + assert result["missing_bins"] == [] + + def test_missing_binary(self): + result = validate_skill({"requires": {"bins": ["nonexistent_bin_xyz"]}}) + assert result["valid"] is False + assert "nonexistent_bin_xyz" in result["missing_bins"] + + def test_missing_config(self): + result = validate_skill({"requires": {"config": ["NONEXISTENT_VAR_XYZ"]}}) + assert result["valid"] is False + assert "NONEXISTENT_VAR_XYZ" in result["missing_config"] + + def test_set_config(self): + import os + os.environ["_TEST_SKILLS_VAR"] = "value" + try: + result = validate_skill({"requires": {"config": ["_TEST_SKILLS_VAR"]}}) + assert result["valid"] is True + assert result["missing_config"] == [] + finally: + del os.environ["_TEST_SKILLS_VAR"] + + def test_mixed_pass_fail(self): + result = validate_skill({ + "requires": { + "pip": ["sys"], + "bins": ["nonexistent_bin_xyz"], + "config": [], + } + }) + assert result["valid"] is False + assert result["missing_pip"] == [] + assert "nonexistent_bin_xyz" in result["missing_bins"] + + def test_return_structure(self): + result = validate_skill({}) + assert "valid" in result + assert "missing_pip" in result + assert "missing_bins" in result + assert "missing_config" in result