Merge branch 'main' of https://github.com/AIOSAI/AIPass
This commit is contained in:
@@ -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
|
||||
```
|
||||
@@ -0,0 +1 @@
|
||||
# Skills Standards Pack - Standards for AIPass Skills system
|
||||
@@ -0,0 +1 @@
|
||||
# Extensions package - Drop-in extensions for skills functionality
|
||||
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Skills Standards Pack - Handlers
|
||||
|
||||
Implementation layer for Skills standards checking.
|
||||
Organized by domain: standards/
|
||||
"""
|
||||
@@ -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"
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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)
|
||||
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Skills Modules - Orchestration Layer
|
||||
|
||||
Modules coordinate workflows by calling handlers.
|
||||
Each module implements handle_command() for auto-discovery.
|
||||
"""
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -0,0 +1 @@
|
||||
# Plugins package - Pluggable components for skills capabilities
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
# =============================================
|
||||
@@ -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
|
||||
# =============================================
|
||||
@@ -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)
|
||||
# =============================================
|
||||
@@ -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)
|
||||
@@ -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}",
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
# =============================================
|
||||
@@ -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()
|
||||
@@ -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("'\"")
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 <name>")
|
||||
return False
|
||||
return _cmd_info(args[0])
|
||||
|
||||
if command == "run":
|
||||
if not args:
|
||||
print(" Error: skill name required. Usage: skills run <name> [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 <name> [--with-handler|--full]")
|
||||
return False
|
||||
return _cmd_create(args)
|
||||
|
||||
if command == "validate":
|
||||
if not args:
|
||||
print(" Error: skill name required. Usage: skills validate <name>")
|
||||
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 <command> [args]")
|
||||
print()
|
||||
print("Commands:")
|
||||
print(" list Show all discovered skills")
|
||||
print(" info <name> Display SKILL.md contents")
|
||||
print(" run <name> [action] [args] Execute a skill's handler")
|
||||
print(" create <name> Scaffold new skill (markdown only)")
|
||||
print(" create <name> --with-handler Scaffold with handler.py")
|
||||
print(" create <name> --full Scaffold with full 3-layer structure")
|
||||
print(" validate <name> 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 <name>")
|
||||
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
|
||||
@@ -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
|
||||
@@ -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
|
||||
# =============================================
|
||||
@@ -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
|
||||
# =============================================
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
# =============================================
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 <action> [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"]
|
||||
@@ -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 <run-id> --repo owner/repo
|
||||
|
||||
# View failed step logs only
|
||||
gh run view <run-id> --repo owner/repo --log-failed
|
||||
|
||||
# Re-run failed jobs
|
||||
gh run rerun <run-id> --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
|
||||
@@ -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)
|
||||
@@ -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 <action>
|
||||
"""
|
||||
|
||||
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}
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
# =============================================
|
||||
@@ -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
|
||||
# =============================================
|
||||
@@ -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)
|
||||
# =============================================
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
{{SKILL_NAME}} skill handler
|
||||
|
||||
Called by: drone @skills run {{SKILL_NAME}} <action> [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"]
|
||||
@@ -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
|
||||
# =============================================
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user