feat(skills): revive dormant citizen — namespace skills.*→aipass.skills.* (48 imports), path-depth parents[3]→[4], happy-path logger.info() central logging (DPLAN-0203 night shift). 252 tests, seedgo 100%
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# .aipass
|
||||
|
||||
AIPass local configuration and prompts for the skills branch.
|
||||
@@ -0,0 +1,58 @@
|
||||
# SKILLS — Branch Context
|
||||
<!-- File: src/skills/.aipass/aipass_local_prompt.md — Injected on every prompt when in skills directory. -->
|
||||
|
||||
Capability framework for AI agents. Discoverable, validatable, executable skill units across three tiers: markdown-only, with handler, full 3-layer.
|
||||
|
||||
## Commands
|
||||
|
||||
```
|
||||
drone @skills list # Show all discovered skills
|
||||
drone @skills info <name> # Display SKILL.md contents
|
||||
drone @skills run <name> [action] [args] # Execute a skill's handler
|
||||
drone @skills create <name> # Scaffold new skill (markdown only)
|
||||
drone @skills create <name> --with-handler # Scaffold with handler.py
|
||||
drone @skills create <name> --full # Scaffold with full 3-layer structure
|
||||
drone @skills validate <name> # Check if skill requirements are met
|
||||
drone @skills --help # Show help
|
||||
```
|
||||
|
||||
## Apps Layout
|
||||
|
||||
```
|
||||
apps/
|
||||
├── skills.py # Entry point — command routing
|
||||
├── modules/
|
||||
│ ├── discovery.py # Orchestration: discover_all (thin, delegates to handler)
|
||||
│ ├── loader.py # Orchestration: load_skill (thin, delegates to handler)
|
||||
│ ├── runner.py # Execute skills (handler-based or markdown-only)
|
||||
│ ├── creator.py # Scaffold new skills from templates
|
||||
│ └── validator.py # Check skill requirements
|
||||
├── handlers/
|
||||
│ ├── discovery_handler.py # Core: search paths, SKILL.md scanning, frontmatter parsing
|
||||
│ ├── loader_handler.py # Core: parse full SKILL.md, dynamic handler import
|
||||
│ ├── registry.py # Build deduplicated skill registry
|
||||
│ ├── validator.py # Requirement checking (pip, bins, config)
|
||||
│ └── template.py # Template resolution and copying
|
||||
├── plugins/ # Extension point (empty)
|
||||
catalog/ # Built-in skills: drone_commands, github, system_status
|
||||
templates/ # Skill creation templates (markdown_only, with_handler, full)
|
||||
```
|
||||
|
||||
## Search Paths (first match wins)
|
||||
|
||||
1. `.aipass/skills/` — Project-local skills
|
||||
2. `~/.aipass/skills/` — Global user skills
|
||||
3. `src/skills/catalog/` — Built-in skills
|
||||
|
||||
## Three Skill Tiers
|
||||
|
||||
- **Markdown only**: SKILL.md with instructions (AI reads and follows)
|
||||
- **With handler**: SKILL.md + handler.py (programmatic execution)
|
||||
- **Full 3-layer**: SKILL.md + apps/ structure (complex skills)
|
||||
|
||||
## Memory & Tracking
|
||||
|
||||
- `.trinity/passport.json` — identity
|
||||
- `.trinity/local.json` — session history
|
||||
- `.trinity/observations.json` — collaboration patterns
|
||||
- `dev.local.md` — scratchpad for issues, todos, notes
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: another_test
|
||||
description: TODO — describe what this skill does
|
||||
version: 1.0.0
|
||||
tags: []
|
||||
requires:
|
||||
pip: []
|
||||
bins: []
|
||||
config: []
|
||||
has_handler: true
|
||||
---
|
||||
|
||||
# another_test
|
||||
|
||||
## What This Does
|
||||
TODO
|
||||
|
||||
## When to Use
|
||||
TODO
|
||||
|
||||
## Steps
|
||||
1. TODO
|
||||
|
||||
## Example
|
||||
```
|
||||
TODO
|
||||
```
|
||||
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
another_test skill handler
|
||||
|
||||
Called by: drone @skills run another_test <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,27 @@
|
||||
---
|
||||
name: full_test
|
||||
description: TODO — describe what this skill does
|
||||
version: 1.0.0
|
||||
tags: []
|
||||
requires:
|
||||
pip: []
|
||||
bins: []
|
||||
config: []
|
||||
has_handler: true
|
||||
---
|
||||
|
||||
# full_test
|
||||
|
||||
## What This Does
|
||||
TODO
|
||||
|
||||
## When to Use
|
||||
TODO
|
||||
|
||||
## Steps
|
||||
1. TODO
|
||||
|
||||
## Example
|
||||
```
|
||||
TODO
|
||||
```
|
||||
@@ -0,0 +1,7 @@
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: __init__.py - full_test apps package
|
||||
# Date: 2026-03-07
|
||||
# Version: 1.0.0
|
||||
# Category: skills/catalog/full_test/apps
|
||||
# =============================================
|
||||
@@ -0,0 +1,13 @@
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: __init__.py - full_test handlers package
|
||||
# Date: 2026-03-07
|
||||
# Version: 1.0.0
|
||||
# Category: skills/catalog/full_test/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 - full_test modules package
|
||||
# Date: 2026-03-07
|
||||
# Version: 1.0.0
|
||||
# Category: skills/catalog/full_test/apps/modules
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2026-03-07): Initial scaffold
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Modules layer: orchestration (can print)
|
||||
# =============================================
|
||||
@@ -0,0 +1,24 @@
|
||||
"""
|
||||
full_test — Full 3-layer skill handler.
|
||||
|
||||
Scaffolded by: drone @skills create full_test --full
|
||||
"""
|
||||
|
||||
|
||||
def run(action: str, args: list, config: dict) -> dict:
|
||||
"""
|
||||
Execute the skill.
|
||||
|
||||
Args:
|
||||
action: The action to perform
|
||||
args: Command arguments
|
||||
config: Skill configuration from SKILL.md
|
||||
|
||||
Returns:
|
||||
dict with keys: success (bool), output (str), error (str|None)
|
||||
"""
|
||||
return {
|
||||
"success": True,
|
||||
"output": f"full_test executed action: {action}",
|
||||
"error": None,
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: test_skill
|
||||
description: TODO — describe what this skill does
|
||||
version: 1.0.0
|
||||
tags: []
|
||||
requires:
|
||||
pip: []
|
||||
bins: []
|
||||
config: []
|
||||
has_handler: false
|
||||
---
|
||||
|
||||
# test_skill
|
||||
|
||||
## What This Does
|
||||
TODO
|
||||
|
||||
## When to Use
|
||||
TODO
|
||||
|
||||
## Steps
|
||||
1. TODO
|
||||
|
||||
## Example
|
||||
```
|
||||
TODO
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
# Claude Code Settings
|
||||
|
||||
Claude Code configuration for `Skills`.
|
||||
|
||||
Contains `settings.local.json` with permission rules. Most branches are denied raw git commands and must use `drone @git` instead.
|
||||
@@ -0,0 +1,14 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.env
|
||||
*.egg-info/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
dist/
|
||||
build/
|
||||
*.log
|
||||
*.tmp
|
||||
*.swp
|
||||
@@ -0,0 +1,3 @@
|
||||
# .seedgo
|
||||
|
||||
Seedgo audit configuration and bypass rules for the skills branch.
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"metadata": {
|
||||
"version": "1.0.0",
|
||||
"created": "2026-03-07T23:23:56.244569",
|
||||
"description": "Standards bypass configuration for this branch"
|
||||
},
|
||||
"bypass": [
|
||||
{
|
||||
"file": "apps/handlers/loader_handler.py",
|
||||
"standard": "handlers",
|
||||
"lines": [25],
|
||||
"pattern": "from aipass.skills.apps.handlers.discovery_handler import parse_frontmatter",
|
||||
"reason": "Same-branch handler utility import — parse_frontmatter is a shared parsing function needed by both discovery and loader handlers"
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/creator_handler.py",
|
||||
"standard": "handlers",
|
||||
"lines": [23],
|
||||
"pattern": "from aipass.skills.apps.handlers.template import copy_template, get_template",
|
||||
"reason": "Same-branch handler utility import — template.py provides copy/get functions used only by creator_handler"
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/discovery_handler.py",
|
||||
"standard": "naming",
|
||||
"lines": [26],
|
||||
"pattern": "yaml = None",
|
||||
"reason": "Conditional import holder — yaml is assigned via 'import yaml' on success or stays None. Not a constant, just a module reference variable"
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/registry.py",
|
||||
"standard": "unused_function",
|
||||
"lines": [52, 68],
|
||||
"pattern": "def get_skill|def get_skill_names",
|
||||
"reason": "Public API functions — used by test_registry.py and available for external callers; part of the registry module's contract"
|
||||
}
|
||||
],
|
||||
"notes": {
|
||||
"usage": "Add entries to 'bypass' list to exclude specific violations",
|
||||
"example": {
|
||||
"file": "apps/modules/logger.py",
|
||||
"standard": "cli",
|
||||
"lines": [
|
||||
146,
|
||||
177
|
||||
],
|
||||
"pattern": "if __name__ == '__main__'",
|
||||
"reason": "Circular dependency - logger cannot import CLI"
|
||||
},
|
||||
"fields": {
|
||||
"file": "Relative path from branch root (required)",
|
||||
"standard": "Standard name: cli, imports, naming, etc. (required)",
|
||||
"lines": "Optional - specific line numbers to bypass",
|
||||
"pattern": "Optional - pattern to match (e.g. 'if __name__')",
|
||||
"reason": "Required - why this bypass exists"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
[← Back to AIPass](../../../README.md)
|
||||
|
||||
# Skills
|
||||
|
||||
**Purpose:** Capability framework for AI agents in AIPass. Skills are discoverable, validatable, and executable units of capability that any AI agent can use.
|
||||
**Module:** `skills`
|
||||
**Created:** 2026-03-07
|
||||
**Last Updated:** 2026-04-07
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
## 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
|
||||
|
||||
## Commands / Usage
|
||||
|
||||
```bash
|
||||
drone @skills list # Show all discovered skills
|
||||
drone @skills info <name> # Display SKILL.md contents
|
||||
drone @skills run <name> [action] [args] # Execute a skill's handler
|
||||
drone @skills create <name> # Scaffold new skill (markdown only)
|
||||
drone @skills create <name> --with-handler # Scaffold with handler.py
|
||||
drone @skills create <name> --full # Scaffold with full 3-layer structure
|
||||
drone @skills validate <name> # Check if skill requirements are met
|
||||
drone @skills --help # Show help
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
validator.py # Check skill requirements
|
||||
handlers/
|
||||
json/ # JSON handler (three-JSON pattern)
|
||||
creator_handler.py # Skill creation logic (name validation, orchestration)
|
||||
registry.py # Skill registry management
|
||||
validator.py # Check requirements
|
||||
template.py # Skill templates
|
||||
plugins/ # Plugin extensions
|
||||
catalog/ # Built-in skills (branch_health, drone_commands, github, inbox_check, system_status)
|
||||
templates/ # Skill creation templates
|
||||
skills_json/ # JSON tracking directory
|
||||
dropbox/ # External storage sync
|
||||
.trinity/ # Branch identity and memory
|
||||
tests/ # Test suite
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration Points
|
||||
|
||||
### Depends On
|
||||
- Python stdlib (`pathlib`, `json`, `shutil`, `importlib`, `re`, `yaml`)
|
||||
- Filesystem: reads SKILL.md files from project, global, and built-in search paths
|
||||
|
||||
### Provides To
|
||||
- All modules — skill discovery, loading, validation, and execution
|
||||
- AI agents — discoverable capability units via `drone @skills`
|
||||
- Projects — local skill scaffolding via `drone @skills create`
|
||||
|
||||
---
|
||||
|
||||
*Last Updated: 2026-04-07*
|
||||
|
||||
---
|
||||
[← Back to AIPass](../../../README.md)
|
||||
@@ -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,3 @@
|
||||
# apps
|
||||
|
||||
Core application code for the skills module.
|
||||
@@ -0,0 +1,15 @@
|
||||
# ===================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
|
||||
# =============================================
|
||||
|
||||
from . import handlers # noqa: F401 — required for mock.patch resolution
|
||||
@@ -0,0 +1,3 @@
|
||||
# handlers
|
||||
|
||||
Low-level handler functions for skill operations.
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Skills handlers package - Security protected."""
|
||||
|
||||
import inspect
|
||||
from pathlib import Path
|
||||
|
||||
MY_BRANCH = "skills"
|
||||
MODULE_PATH = "aipass.skills"
|
||||
|
||||
|
||||
def _find_real_caller():
|
||||
"""
|
||||
Walk the stack to find the actual file that triggered this import.
|
||||
|
||||
Skips:
|
||||
- This file (handlers/__init__.py)
|
||||
- Python's importlib internals
|
||||
- Frozen modules
|
||||
|
||||
Returns tuple: (file_path, import_line) or (None, None)
|
||||
"""
|
||||
stack = inspect.stack()
|
||||
this_file = str(Path(__file__).resolve())
|
||||
|
||||
for frame_info in stack:
|
||||
filename = frame_info.filename
|
||||
|
||||
# Skip this file
|
||||
if this_file in str(Path(filename).resolve()):
|
||||
continue
|
||||
|
||||
# Skip Python internals
|
||||
if filename.startswith("<") or "importlib" in filename:
|
||||
continue
|
||||
|
||||
# Found a real file - try to get the import line
|
||||
import_line = None
|
||||
if frame_info.code_context:
|
||||
import_line = frame_info.code_context[0].strip()
|
||||
|
||||
return str(Path(filename).resolve()), import_line
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
def _extract_branch_name(filepath: str) -> str:
|
||||
"""Extract branch name from a file path."""
|
||||
parts = Path(filepath).parts
|
||||
for i, part in enumerate(parts):
|
||||
if part in ("aipass", "memory", "Nexus"):
|
||||
if i + 1 < len(parts):
|
||||
return parts[i + 1]
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _guard_branch_access():
|
||||
"""
|
||||
Block cross-branch handler imports.
|
||||
|
||||
Only code from within the 'skills' branch can import these handlers.
|
||||
External branches must use skills.apps.modules instead.
|
||||
"""
|
||||
caller_file, import_line = _find_real_caller()
|
||||
|
||||
import os
|
||||
|
||||
if os.environ.get("AIPASS_DEBUG_GUARD"):
|
||||
import sys
|
||||
|
||||
print(f"[GUARD DEBUG] caller_file = {caller_file}", file=sys.stderr)
|
||||
print(f"[GUARD DEBUG] import_line = {import_line}", file=sys.stderr)
|
||||
|
||||
if caller_file is None:
|
||||
stack = inspect.stack()
|
||||
for frame in stack:
|
||||
if frame.filename in ("<string>", "<stdin>"):
|
||||
target_line = "unknown"
|
||||
if frame.code_context:
|
||||
target_line = frame.code_context[0].strip()
|
||||
raise ImportError(
|
||||
f"\n{'=' * 60}\n"
|
||||
f"ACCESS DENIED: Cross-branch handler import blocked\n"
|
||||
f"{'=' * 60}\n"
|
||||
f" Caller: interactive/script\n"
|
||||
f" Blocked: {target_line}\n"
|
||||
f"\n"
|
||||
f" Handlers are internal to their branch.\n"
|
||||
f" Use the module API instead:\n"
|
||||
f" from {MODULE_PATH}.apps.modules.<module> import <function>\n"
|
||||
f"\n"
|
||||
f" For full standards guide:\n"
|
||||
f" drone @seedgo handlers\n"
|
||||
f"{'=' * 60}"
|
||||
)
|
||||
return
|
||||
|
||||
# Check if caller is from our branch
|
||||
if f"/{MY_BRANCH}/" in caller_file:
|
||||
return
|
||||
|
||||
# External caller - block access
|
||||
caller_branch = _extract_branch_name(caller_file)
|
||||
caller_filename = Path(caller_file).name
|
||||
blocked_import = import_line if import_line else "unknown"
|
||||
|
||||
raise ImportError(
|
||||
f"\n{'=' * 60}\n"
|
||||
f"ACCESS DENIED: Cross-branch handler import blocked\n"
|
||||
f"{'=' * 60}\n"
|
||||
f" Caller branch: {caller_branch}\n"
|
||||
f" Caller file: {caller_filename}\n"
|
||||
f" Blocked: {blocked_import}\n"
|
||||
f"\n"
|
||||
f" Handlers are internal to their branch.\n"
|
||||
f" Use the module API instead:\n"
|
||||
f" from {MODULE_PATH}.apps.modules.<module> import <function>\n"
|
||||
f"\n"
|
||||
f" For full standards guide:\n"
|
||||
f" drone @seedgo handlers\n"
|
||||
f"{'=' * 60}"
|
||||
)
|
||||
|
||||
|
||||
# Run guard at import time
|
||||
_guard_branch_access()
|
||||
@@ -0,0 +1,110 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: creator_handler.py
|
||||
# Description: Skill creation handler
|
||||
# Version: 1.2.0
|
||||
# Created: 2026-03-08
|
||||
# Modified: 2026-03-08
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Skill Creation Handler
|
||||
|
||||
Contains the core logic for creating new skills from templates.
|
||||
Validates skill names, resolves templates, and orchestrates the copy.
|
||||
|
||||
Purpose:
|
||||
Implementation logic for skill creation, separated from CLI/display
|
||||
layer to satisfy thin-module standard.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.skills.apps.handlers.json import json_handler
|
||||
from aipass.skills.apps.handlers.template import copy_template, get_template
|
||||
|
||||
# logger imported from aipass.prax
|
||||
|
||||
|
||||
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 in "-_" for c in name) and name == name.lower()
|
||||
|
||||
|
||||
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)
|
||||
|
||||
json_handler.log_operation(
|
||||
"skill_scaffold",
|
||||
{
|
||||
"name": name,
|
||||
"template_type": template_type,
|
||||
"success": result["success"],
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
"success": result["success"],
|
||||
"path": str(target_path) if result["success"] else None,
|
||||
"files": result["created_files"],
|
||||
"error": result["error"],
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: discovery_handler.py
|
||||
# Description: Skill discovery handler
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-03-08
|
||||
# Modified: 2026-03-08
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Skill Discovery Handler
|
||||
|
||||
Contains the core logic for discovering skills across search paths.
|
||||
Scans directories for SKILL.md files and parses YAML frontmatter.
|
||||
|
||||
Purpose:
|
||||
Implementation logic for skill discovery, separated from
|
||||
orchestration layer to satisfy thin-module standard.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.skills.apps.handlers.json import json_handler
|
||||
|
||||
# Try yaml, fall back to simple parser
|
||||
yaml = None
|
||||
try:
|
||||
import yaml
|
||||
|
||||
HAS_YAML = True
|
||||
except ImportError:
|
||||
logger.warning("yaml package not available — using simple frontmatter parser")
|
||||
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
|
||||
if not isinstance(metadata, dict):
|
||||
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", []),
|
||||
}
|
||||
)
|
||||
|
||||
json_handler.log_operation(
|
||||
"discovery_scan",
|
||||
{
|
||||
"path": str(path),
|
||||
"source": source_label,
|
||||
"found": len(skills),
|
||||
},
|
||||
)
|
||||
return skills
|
||||
|
||||
|
||||
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):
|
||||
logger.warning(f"Failed to read frontmatter from: {skill_md_path}")
|
||||
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 yaml is not None:
|
||||
try:
|
||||
return yaml.safe_load(frontmatter_text)
|
||||
except yaml.YAMLError:
|
||||
logger.warning("YAML parse failed — falling back to simple parser")
|
||||
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:
|
||||
logger.warning(f"Could not parse numeric value: {value}")
|
||||
|
||||
# String (strip quotes)
|
||||
return value.strip("'\"")
|
||||
@@ -0,0 +1 @@
|
||||
"""Skills JSON handler package."""
|
||||
@@ -0,0 +1,221 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: json_handler.py
|
||||
# Description: Auto-Creating JSON Handler
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-03-17
|
||||
# Modified: 2026-03-17
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
JSON Handler - Auto-Creating & Self-Healing JSON System
|
||||
|
||||
Handles default JSON files (config, data, log) for skills modules.
|
||||
Never manually create JSONs - they build themselves.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, Optional
|
||||
import inspect
|
||||
|
||||
from aipass.prax import logger
|
||||
|
||||
|
||||
# Infrastructure
|
||||
_BRANCH_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
# Constants
|
||||
SKILLS_JSON_DIR = _BRANCH_ROOT / "skills_json"
|
||||
|
||||
|
||||
def _get_caller_module_name() -> str:
|
||||
"""
|
||||
Auto-detect calling module name from call stack.
|
||||
|
||||
Returns:
|
||||
Module name (e.g., "discovery" from discovery.py)
|
||||
"""
|
||||
try:
|
||||
stack = inspect.stack()
|
||||
# Skip frames: [0]=this function, [1]=log_operation, [2]=actual caller
|
||||
if len(stack) > 2:
|
||||
caller_frame = stack[2]
|
||||
caller_path = Path(caller_frame.filename)
|
||||
module_name = caller_path.stem
|
||||
|
||||
# Validate module name
|
||||
if module_name and not module_name.startswith("_"):
|
||||
return module_name
|
||||
|
||||
return "unknown"
|
||||
except Exception:
|
||||
logger.warning("Failed to detect caller module name from stack")
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _get_default(json_type: str, module_name: str) -> Any:
|
||||
"""Return inline default structure for a JSON type."""
|
||||
now = datetime.now().date().isoformat()
|
||||
if json_type == "config":
|
||||
return {
|
||||
"module_name": module_name,
|
||||
"version": "1.0.0",
|
||||
"timestamp": now,
|
||||
"config": {"auto_save": True, "enabled": True},
|
||||
}
|
||||
if json_type == "data":
|
||||
return {
|
||||
"module_name": module_name,
|
||||
"created": now,
|
||||
"last_updated": now,
|
||||
"operations_total": 0,
|
||||
"operations_successful": 0,
|
||||
"operations_failed": 0,
|
||||
}
|
||||
if json_type == "log":
|
||||
return []
|
||||
return None
|
||||
|
||||
|
||||
def validate_json_structure(data: Any, json_type: str) -> bool:
|
||||
"""Validate JSON structure matches expected type."""
|
||||
if json_type == "config":
|
||||
if not isinstance(data, dict):
|
||||
return False
|
||||
required = ["module_name", "version", "config"]
|
||||
return all(key in data for key in required)
|
||||
|
||||
elif json_type == "data":
|
||||
if not isinstance(data, dict):
|
||||
return False
|
||||
required = ["created", "last_updated"]
|
||||
return all(key in data for key in required)
|
||||
|
||||
elif json_type == "log":
|
||||
return isinstance(data, list)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get_json_path(module_name: str, json_type: str) -> Path:
|
||||
"""Get path for module JSON file."""
|
||||
filename = f"{module_name}_{json_type}.json"
|
||||
return SKILLS_JSON_DIR / filename
|
||||
|
||||
|
||||
def ensure_json_exists(module_name: str, json_type: str) -> bool:
|
||||
"""Ensure JSON file exists, create from template if missing."""
|
||||
SKILLS_JSON_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
json_path = get_json_path(module_name, json_type)
|
||||
|
||||
if json_path.exists():
|
||||
try:
|
||||
with open(json_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
if validate_json_structure(data, json_type):
|
||||
return True
|
||||
except Exception:
|
||||
logger.warning(f"Corrupt JSON file, will recreate: {json_path}")
|
||||
|
||||
template = _get_default(json_type, module_name)
|
||||
if template is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
with open(json_path, "w", encoding="utf-8") as f:
|
||||
json.dump(template, f, indent=2, ensure_ascii=False)
|
||||
return True
|
||||
except Exception:
|
||||
logger.error(f"Failed to write JSON file: {json_path}")
|
||||
return False
|
||||
|
||||
|
||||
def load_json(module_name: str, json_type: str) -> Optional[Any]:
|
||||
"""Load JSON file, auto-create if missing."""
|
||||
if not ensure_json_exists(module_name, json_type):
|
||||
return None
|
||||
|
||||
json_path = get_json_path(module_name, json_type)
|
||||
|
||||
try:
|
||||
with open(json_path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
logger.warning(f"Failed to load JSON: {json_path}")
|
||||
return None
|
||||
|
||||
|
||||
def save_json(module_name: str, json_type: str, data: Any) -> bool:
|
||||
"""Save JSON file."""
|
||||
json_path = get_json_path(module_name, json_type)
|
||||
|
||||
if not validate_json_structure(data, json_type):
|
||||
return False
|
||||
|
||||
if json_type == "data" and isinstance(data, dict):
|
||||
data["last_updated"] = datetime.now().date().isoformat()
|
||||
|
||||
try:
|
||||
with open(json_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
return True
|
||||
except Exception:
|
||||
logger.error(f"Failed to save JSON for {module_name}/{json_type}")
|
||||
return False
|
||||
|
||||
|
||||
def ensure_module_jsons(module_name: str) -> bool:
|
||||
"""Ensure all 3 JSON files exist for a module."""
|
||||
ensure_json_exists(module_name, "config")
|
||||
ensure_json_exists(module_name, "data")
|
||||
ensure_json_exists(module_name, "log")
|
||||
return True
|
||||
|
||||
|
||||
def log_operation(operation: str, data: Dict[str, Any] | None = None, module_name: str | None = None) -> bool:
|
||||
"""
|
||||
Add entry to module log with automatic rotation.
|
||||
|
||||
Auto-detects calling module if module_name not provided.
|
||||
When max_log_entries is reached, removes oldest entries (FIFO).
|
||||
|
||||
Args:
|
||||
operation: Operation name to log
|
||||
data: Optional data dict
|
||||
module_name: Optional module name (auto-detected if not provided)
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
if module_name is None:
|
||||
module_name = _get_caller_module_name()
|
||||
|
||||
ensure_module_jsons(module_name)
|
||||
|
||||
# Load config to get max_log_entries
|
||||
config = load_json(module_name, "config")
|
||||
max_entries = 100
|
||||
if config and "config" in config:
|
||||
max_entries = config["config"].get("max_log_entries", 100)
|
||||
|
||||
# Load existing log
|
||||
log = load_json(module_name, "log")
|
||||
if log is None:
|
||||
log = []
|
||||
|
||||
# Create new entry
|
||||
entry: Dict[str, Any] = {"timestamp": datetime.now().isoformat(), "operation": operation}
|
||||
|
||||
if data:
|
||||
entry["data"] = data
|
||||
|
||||
log.append(entry)
|
||||
|
||||
# Rotate if exceeds max
|
||||
if len(log) > max_entries:
|
||||
log = log[-max_entries:]
|
||||
|
||||
return save_json(module_name, "log", log)
|
||||
@@ -0,0 +1,185 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: loader_handler.py
|
||||
# Description: Skill loading handler
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-03-08
|
||||
# Modified: 2026-03-08
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Skill Loading Handler
|
||||
|
||||
Contains the core logic for loading skills: parsing full SKILL.md files
|
||||
(frontmatter + body) and dynamically importing handler.py modules.
|
||||
|
||||
Purpose:
|
||||
Implementation logic for skill loading, separated from
|
||||
orchestration layer to satisfy thin-module standard.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.skills.apps.handlers.discovery_handler import parse_frontmatter
|
||||
from aipass.skills.apps.handlers.json import json_handler
|
||||
|
||||
|
||||
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):
|
||||
logger.warning(f"Failed to read SKILL.md: {skill_md_path}")
|
||||
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 import_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:
|
||||
logger.warning(f"Failed to load handler for {skill_name}")
|
||||
return None
|
||||
|
||||
|
||||
def find_skill_in_registry(name, registry):
|
||||
"""Find a skill entry in the registry by name.
|
||||
|
||||
Args:
|
||||
name: Skill name to find.
|
||||
registry: List of skill dicts.
|
||||
|
||||
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 load_skill(name, registry):
|
||||
"""Load a skill by name from a pre-built registry.
|
||||
|
||||
Steps:
|
||||
1. Find skill in registry
|
||||
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.
|
||||
registry: List of skill dicts from discovery.
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
"success": bool,
|
||||
"metadata": dict or None,
|
||||
"body": str or None,
|
||||
"handler": module or None,
|
||||
"path": Path or None,
|
||||
"error": str or None
|
||||
}
|
||||
"""
|
||||
skill_entry = find_skill_in_registry(name, registry)
|
||||
|
||||
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"
|
||||
|
||||
# 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}",
|
||||
}
|
||||
|
||||
# Import handler if present
|
||||
handler = None
|
||||
if isinstance(metadata, dict) and metadata.get("has_handler", False):
|
||||
handler = import_handler(skill_path, name)
|
||||
|
||||
json_handler.log_operation(
|
||||
"skill_load",
|
||||
{
|
||||
"name": name,
|
||||
"has_handler": handler is not None,
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"metadata": metadata,
|
||||
"body": body,
|
||||
"handler": handler,
|
||||
"path": skill_path,
|
||||
"error": None,
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: registry.py
|
||||
# Description: Skill registry management
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-03-07
|
||||
# Modified: 2026-03-07
|
||||
# =============================================
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.skills.apps.handlers.json import json_handler
|
||||
|
||||
|
||||
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)
|
||||
|
||||
json_handler.log_operation(
|
||||
"registry_built",
|
||||
{
|
||||
"paths_scanned": len(search_paths),
|
||||
"skills_found": len(registry),
|
||||
},
|
||||
)
|
||||
|
||||
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,127 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: runner_handler.py
|
||||
# Description: Skill execution handler
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-03-08
|
||||
# Modified: 2026-03-08
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Skill Execution Handler
|
||||
|
||||
Contains the core logic for executing skills: calling handler.run()
|
||||
for handler-based skills, and assembling output for markdown-only skills.
|
||||
|
||||
Purpose:
|
||||
Implementation logic for skill execution, separated from
|
||||
orchestration layer to satisfy thin-module standard.
|
||||
"""
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.skills.apps.handlers.json import json_handler
|
||||
|
||||
|
||||
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:
|
||||
logger.error(f"Failed to list actions for {name}: {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)
|
||||
json_handler.log_operation(
|
||||
"handler_executed",
|
||||
{
|
||||
"name": name,
|
||||
"action": action,
|
||||
"success": True,
|
||||
},
|
||||
)
|
||||
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:
|
||||
logger.error(f"Skill {name} action '{action}' failed: {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,121 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: template.py
|
||||
# Description: Skill template management
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-03-07
|
||||
# Modified: 2026-03-07
|
||||
# =============================================
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.skills.apps.handlers.json import json_handler
|
||||
|
||||
|
||||
# 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 _replace_placeholder_in_file(file_path, skill_name):
|
||||
"""Replace {{SKILL_NAME}} placeholder in a single file.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file to process.
|
||||
skill_name: Name to substitute for the placeholder.
|
||||
"""
|
||||
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:
|
||||
logger.warning(f"Skipping binary file during template copy: {file_path}")
|
||||
|
||||
|
||||
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, excluding __pycache__
|
||||
shutil.copytree(str(template_path), str(target), ignore=shutil.ignore_patterns("__pycache__"))
|
||||
|
||||
# Replace placeholders in all files
|
||||
created_files = []
|
||||
for file_path in target.rglob("*"):
|
||||
if not file_path.is_file():
|
||||
continue
|
||||
created_files.append(str(file_path.relative_to(target)))
|
||||
_replace_placeholder_in_file(file_path, skill_name)
|
||||
|
||||
json_handler.log_operation(
|
||||
"template_copied",
|
||||
{
|
||||
"template": str(template_path.name),
|
||||
"target": str(target),
|
||||
"files_count": len(created_files),
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"created_files": sorted(created_files),
|
||||
"error": None,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Template copy failed: {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,114 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: validator.py
|
||||
# Description: Check skill requirements
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-03-07
|
||||
# Modified: 2026-03-07
|
||||
# =============================================
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.skills.apps.handlers.json import json_handler
|
||||
|
||||
|
||||
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)
|
||||
|
||||
json_handler.log_operation(
|
||||
"validation_check",
|
||||
{
|
||||
"valid": valid,
|
||||
"missing_count": len(missing_pip) + len(missing_bins) + len(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):
|
||||
logger.warning(f"Package check failed for: {pkg}")
|
||||
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,3 @@
|
||||
# Integrations
|
||||
|
||||
Extension point for external integrations.
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"module_name": "{{MODULE_NAME}}",
|
||||
"version": "1.0.0",
|
||||
"timestamp": "{{TIMESTAMP}}",
|
||||
"config": {
|
||||
"auto_save": true,
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"module_name": "{{MODULE_NAME}}",
|
||||
"created": "{{TIMESTAMP}}",
|
||||
"last_updated": "{{TIMESTAMP}}",
|
||||
"operations_total": 0,
|
||||
"operations_successful": 0,
|
||||
"operations_failed": 0
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -0,0 +1,3 @@
|
||||
# modules
|
||||
|
||||
High-level module interfaces that orchestrate handler functions.
|
||||
@@ -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,114 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: creator.py
|
||||
# Description: Scaffold new skills from templates
|
||||
# Version: 1.2.0
|
||||
# Created: 2026-03-07
|
||||
# Modified: 2026-03-08
|
||||
# =============================================
|
||||
|
||||
"""Skill creator module.
|
||||
|
||||
Scaffolds new skills from templates into a target location.
|
||||
Supports three tiers: markdown_only, with_handler, full.
|
||||
|
||||
Thin orchestration layer - delegates to creator_handler for logic.
|
||||
"""
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.cli.apps.modules import console, error
|
||||
from aipass.skills.apps.handlers.creator_handler import create_skill as _handler_create_skill
|
||||
from aipass.skills.apps.handlers.json import json_handler
|
||||
|
||||
try:
|
||||
from aipass.trigger.apps.modules.core import trigger
|
||||
except ImportError:
|
||||
logger.warning("trigger module not available — skill events disabled")
|
||||
trigger = None
|
||||
|
||||
|
||||
def handle_command(command: str, args: list) -> bool:
|
||||
"""Handle commands routed by the entry point.
|
||||
|
||||
Args:
|
||||
command: The subcommand to execute.
|
||||
args: List of additional arguments.
|
||||
|
||||
Returns:
|
||||
bool: True if command was handled, False otherwise.
|
||||
"""
|
||||
if not args:
|
||||
print_introspection()
|
||||
return True
|
||||
if "--help" in args:
|
||||
print_introspection()
|
||||
return True
|
||||
|
||||
if command == "create":
|
||||
if not args:
|
||||
error("Error: skill name required. Usage: skills create <name> [--with-handler|--full]")
|
||||
return False
|
||||
|
||||
name = args[0]
|
||||
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)
|
||||
return result["success"]
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def create_skill(name, template_type="markdown_only", target_dir=None):
|
||||
"""Create a new skill from a template.
|
||||
|
||||
Delegates to handler for validation and creation logic,
|
||||
then renders results with Rich.
|
||||
|
||||
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}
|
||||
"""
|
||||
result = _handler_create_skill(name, template_type=template_type, target_dir=target_dir)
|
||||
|
||||
if result["success"]:
|
||||
if trigger is not None:
|
||||
trigger.fire("skill_created", name=name, template_type=template_type, path=result["path"])
|
||||
|
||||
console.print(f" Created skill '{name}' at {result['path']}")
|
||||
console.print(f" Template: {template_type}")
|
||||
console.print(f" Files: {len(result['files'])}")
|
||||
for f in result["files"]:
|
||||
console.print(f" - {f}")
|
||||
|
||||
json_handler.log_operation(
|
||||
"skill_created",
|
||||
{
|
||||
"name": name,
|
||||
"template_type": template_type,
|
||||
"success": result["success"],
|
||||
},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def print_introspection():
|
||||
"""Display module introspection info."""
|
||||
console.print()
|
||||
console.print("[bold cyan]creator Module[/bold cyan]")
|
||||
console.print("[dim]Scaffold new skills from templates into a target location[/dim]")
|
||||
console.print()
|
||||
console.print("[bold]Connected Handlers:[/bold]")
|
||||
console.print(" [cyan]handlers/[/cyan]")
|
||||
console.print(
|
||||
" [dim]- creator_handler.py (create_skill — validate name, resolve template, copy to target)[/dim]"
|
||||
)
|
||||
console.print(" [dim]- template.py (copy_template, get_template — template resolution and file copy)[/dim]")
|
||||
console.print()
|
||||
@@ -0,0 +1,105 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: discovery.py
|
||||
# Description: Find skills across search paths
|
||||
# Version: 1.1.0
|
||||
# Created: 2026-03-07
|
||||
# Modified: 2026-03-08
|
||||
# =============================================
|
||||
|
||||
"""Skill discovery module.
|
||||
|
||||
Thin orchestration layer - delegates to discovery_handler for scanning
|
||||
search paths and parsing SKILL.md frontmatter.
|
||||
"""
|
||||
|
||||
from aipass.prax import logger # noqa: F401
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.skills.apps.handlers.discovery_handler import (
|
||||
get_search_paths,
|
||||
discover_skills_in_path,
|
||||
parse_frontmatter, # noqa: F401
|
||||
)
|
||||
from aipass.skills.apps.handlers.registry import build_registry
|
||||
from aipass.skills.apps.handlers.json import json_handler
|
||||
|
||||
|
||||
def handle_command(command: str, args: list) -> bool:
|
||||
"""Handle commands routed by the entry point.
|
||||
|
||||
Args:
|
||||
command: The subcommand to execute.
|
||||
args: List of additional arguments.
|
||||
|
||||
Returns:
|
||||
bool: True if command was handled, False otherwise.
|
||||
"""
|
||||
if not args:
|
||||
print_introspection()
|
||||
return True
|
||||
if "--help" in args:
|
||||
print_introspection()
|
||||
return True
|
||||
|
||||
if command in ("discover", "list"):
|
||||
skills = discover_all()
|
||||
|
||||
if not skills:
|
||||
console.print(" No skills found.")
|
||||
console.print(" Create one with: drone @skills create <name>")
|
||||
return True
|
||||
|
||||
console.print(f" Found {len(skills)} skill(s):")
|
||||
console.print()
|
||||
|
||||
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)
|
||||
console.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'])})"
|
||||
console.print(f" {skill['name']:<25} {skill['description']}{handler_tag}{tags}")
|
||||
console.print()
|
||||
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def discover_all():
|
||||
"""Discover all skills across all search paths.
|
||||
|
||||
Returns:
|
||||
list[dict]: All discovered skills, deduplicated by name
|
||||
(first match wins).
|
||||
"""
|
||||
search_paths = get_search_paths()
|
||||
result = build_registry(search_paths, discover_skills_in_path)
|
||||
json_handler.log_operation("skills_discovered", {"count": len(result)})
|
||||
return result
|
||||
|
||||
|
||||
def print_introspection():
|
||||
"""Display module introspection info."""
|
||||
console.print()
|
||||
console.print("[bold cyan]discovery Module[/bold cyan]")
|
||||
console.print("[dim]Find skills across search paths — project, global, and built-in[/dim]")
|
||||
console.print()
|
||||
console.print("[bold]Connected Handlers:[/bold]")
|
||||
console.print(" [cyan]handlers/[/cyan]")
|
||||
console.print(
|
||||
" [dim]- discovery_handler.py (get_search_paths, discover_skills_in_path,"
|
||||
" parse_frontmatter — path scanning and SKILL.md parsing)[/dim]"
|
||||
)
|
||||
console.print(" [dim]- registry.py (build_registry — deduplicated skill registry from search paths)[/dim]")
|
||||
console.print()
|
||||
@@ -0,0 +1,98 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: loader.py
|
||||
# Description: Load SKILL.md and handlers
|
||||
# Version: 1.1.0
|
||||
# Created: 2026-03-07
|
||||
# Modified: 2026-03-08
|
||||
# =============================================
|
||||
|
||||
"""Skill loader module.
|
||||
|
||||
Thin orchestration layer - delegates to loader_handler for parsing
|
||||
SKILL.md files and dynamically importing handler modules.
|
||||
"""
|
||||
|
||||
from aipass.cli.apps.modules import console, warning
|
||||
from aipass.prax import logger
|
||||
from aipass.skills.apps.modules.discovery import discover_all
|
||||
from aipass.skills.apps.handlers.loader_handler import load_skill as _handler_load_skill
|
||||
from aipass.skills.apps.handlers.json import json_handler
|
||||
|
||||
|
||||
def handle_command(command: str, args: list) -> bool:
|
||||
"""Handle commands routed by the entry point.
|
||||
|
||||
Loader is a service module used by other modules (runner, validator, etc.).
|
||||
It does not handle any direct CLI commands.
|
||||
|
||||
Args:
|
||||
command: The subcommand to execute.
|
||||
args: List of additional arguments.
|
||||
|
||||
Returns:
|
||||
bool: Always False - loader is a service module, not a command handler.
|
||||
"""
|
||||
if not args:
|
||||
print_introspection()
|
||||
return True
|
||||
if "--help" in args:
|
||||
print_introspection()
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def load_skill(name):
|
||||
"""Load a skill by name.
|
||||
|
||||
Discovers all skills, then delegates to handler for loading logic.
|
||||
|
||||
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
|
||||
}
|
||||
"""
|
||||
registry = discover_all()
|
||||
result = _handler_load_skill(name, registry)
|
||||
|
||||
if not result["success"]:
|
||||
logger.warning(f"Failed to load skill: {result['error']}")
|
||||
|
||||
if result["success"] and result["handler"] is None and result["metadata"].get("has_handler", False):
|
||||
warning(f"Warning: has_handler is true but handler.py not found at {result['path']}")
|
||||
|
||||
json_handler.log_operation(
|
||||
"skill_loaded",
|
||||
{
|
||||
"name": name,
|
||||
"success": result["success"],
|
||||
},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def print_introspection():
|
||||
"""Display module introspection info."""
|
||||
console.print()
|
||||
console.print("[bold cyan]loader Module[/bold cyan]")
|
||||
console.print("[dim]Load SKILL.md metadata, body, and optional handler module by name[/dim]")
|
||||
console.print()
|
||||
console.print("[bold]Connected Handlers:[/bold]")
|
||||
console.print(" [cyan]handlers/[/cyan]")
|
||||
console.print(
|
||||
" [dim]- loader_handler.py (load_skill, parse_full_skill_md, import_handler"
|
||||
" — skill loading and dynamic handler import)[/dim]"
|
||||
)
|
||||
console.print()
|
||||
console.print("[bold]Connected Modules:[/bold]")
|
||||
console.print(" [cyan]modules/[/cyan]")
|
||||
console.print(" [dim]- discovery.py (discover_all — skill registry for name lookup)[/dim]")
|
||||
console.print()
|
||||
@@ -0,0 +1,137 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: runner.py
|
||||
# Description: Execute skills
|
||||
# Version: 1.1.0
|
||||
# Created: 2026-03-07
|
||||
# Modified: 2026-03-08
|
||||
# =============================================
|
||||
|
||||
"""Skill runner module.
|
||||
|
||||
Thin orchestration layer - delegates to runner_handler for executing
|
||||
skill handlers and assembling markdown output.
|
||||
"""
|
||||
|
||||
from aipass.prax import logger # noqa: F401
|
||||
from aipass.cli.apps.modules import console, error
|
||||
from aipass.skills.apps.modules.loader import load_skill
|
||||
from aipass.skills.apps.handlers.runner_handler import run_handler, run_markdown
|
||||
from aipass.skills.apps.handlers.json import json_handler
|
||||
|
||||
|
||||
def handle_command(command: str, args: list) -> bool:
|
||||
"""Handle commands routed by the entry point.
|
||||
|
||||
Args:
|
||||
command: The subcommand to execute.
|
||||
args: List of additional arguments.
|
||||
|
||||
Returns:
|
||||
bool: True if command was handled, False otherwise.
|
||||
"""
|
||||
if not args:
|
||||
print_introspection()
|
||||
return True
|
||||
if "--help" in args:
|
||||
print_introspection()
|
||||
return True
|
||||
|
||||
if command == "run":
|
||||
if not args:
|
||||
error("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_run_args(args[2:]) if len(args) > 2 else {}
|
||||
|
||||
result = run_skill(name, action=action, args=extra_args)
|
||||
|
||||
if result["success"]:
|
||||
if result["output"]:
|
||||
for line in result["output"].splitlines():
|
||||
console.print(f" {line}")
|
||||
else:
|
||||
err = result.get("error", "Unknown error")
|
||||
error(f"Error: {err}")
|
||||
|
||||
return result["success"]
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _parse_run_args(arg_list):
|
||||
"""Parse extra arguments into a dict."""
|
||||
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
|
||||
|
||||
|
||||
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"]
|
||||
|
||||
# Delegate to handler for execution
|
||||
if handler is not None:
|
||||
result = run_handler(handler, name, action, args, config)
|
||||
else:
|
||||
result = run_markdown(name, metadata, body)
|
||||
|
||||
json_handler.log_operation(
|
||||
"skill_executed",
|
||||
{
|
||||
"name": name,
|
||||
"success": result["success"],
|
||||
"has_handler": handler is not None,
|
||||
},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def print_introspection():
|
||||
"""Display module introspection info."""
|
||||
console.print()
|
||||
console.print("[bold cyan]runner Module[/bold cyan]")
|
||||
console.print("[dim]Execute skills by name — runs handler-based or markdown-only skills[/dim]")
|
||||
console.print()
|
||||
console.print("[bold]Connected Handlers:[/bold]")
|
||||
console.print(" [cyan]handlers/[/cyan]")
|
||||
console.print(
|
||||
" [dim]- runner_handler.py (run_handler, run_markdown — skill execution and markdown output)[/dim]"
|
||||
)
|
||||
console.print()
|
||||
console.print("[bold]Connected Modules:[/bold]")
|
||||
console.print(" [cyan]modules/[/cyan]")
|
||||
console.print(" [dim]- loader.py (load_skill — load skill metadata, body, and handler)[/dim]")
|
||||
console.print()
|
||||
@@ -0,0 +1,107 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: validator.py
|
||||
# Description: Skill validation module
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-03-08
|
||||
# Modified: 2026-03-08
|
||||
# =============================================
|
||||
|
||||
"""Skill validator module.
|
||||
|
||||
Thin orchestration layer - delegates to validator handler for
|
||||
checking skill requirements (pip packages, CLI bins, config/env vars).
|
||||
"""
|
||||
|
||||
from aipass.prax import logger # noqa: F401
|
||||
from aipass.cli.apps.modules import console, error
|
||||
from aipass.skills.apps.handlers.validator import validate_skill as _handler_validate
|
||||
from aipass.skills.apps.handlers.json import json_handler
|
||||
|
||||
|
||||
def handle_command(command: str, args: list) -> bool:
|
||||
"""Handle commands routed by the entry point.
|
||||
|
||||
Args:
|
||||
command: The subcommand to execute.
|
||||
args: List of additional arguments.
|
||||
|
||||
Returns:
|
||||
bool: True if command was handled, False otherwise.
|
||||
"""
|
||||
if not args:
|
||||
print_introspection()
|
||||
return True
|
||||
if "--help" in args:
|
||||
print_introspection()
|
||||
return True
|
||||
|
||||
if command == "validate":
|
||||
if not args:
|
||||
error("Error: skill name required. Usage: skills validate <name>")
|
||||
return False
|
||||
|
||||
from aipass.skills.apps.modules.loader import load_skill
|
||||
|
||||
name = args[0]
|
||||
loaded = load_skill(name)
|
||||
if not loaded["success"]:
|
||||
error(f"Error: {loaded['error']}")
|
||||
return False
|
||||
|
||||
result = validate_skill(loaded["metadata"])
|
||||
|
||||
if result["valid"]:
|
||||
console.print(f" Skill '{name}' - all requirements met.")
|
||||
else:
|
||||
console.print(f" Skill '{name}' - requirements NOT met:")
|
||||
if result["missing_pip"]:
|
||||
console.print(f" Missing pip packages: {', '.join(result['missing_pip'])}")
|
||||
if result["missing_bins"]:
|
||||
console.print(f" Missing CLI tools: {', '.join(result['missing_bins'])}")
|
||||
if result["missing_config"]:
|
||||
console.print(f" Missing config/env: {', '.join(result['missing_config'])}")
|
||||
|
||||
return result["valid"]
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def validate_skill(skill_metadata):
|
||||
"""Check if a skill's requirements are met.
|
||||
|
||||
Delegates to handler for validation logic.
|
||||
|
||||
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]
|
||||
}
|
||||
"""
|
||||
result = _handler_validate(skill_metadata)
|
||||
json_handler.log_operation(
|
||||
"skill_validated",
|
||||
{
|
||||
"valid": result["valid"],
|
||||
},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def print_introspection():
|
||||
"""Display module introspection info."""
|
||||
console.print()
|
||||
console.print("[bold cyan]validator Module[/bold cyan]")
|
||||
console.print("[dim]Check if a skill's requirements are met (pip packages, CLI bins, config/env vars)[/dim]")
|
||||
console.print()
|
||||
console.print("[bold]Connected Handlers:[/bold]")
|
||||
console.print(" [cyan]handlers/[/cyan]")
|
||||
console.print(" [dim]- validator.py (validate_skill — check pip, bins, and config requirements)[/dim]")
|
||||
console.print()
|
||||
@@ -0,0 +1,3 @@
|
||||
# plugins
|
||||
|
||||
Plugin extensions for the skills system.
|
||||
@@ -0,0 +1,7 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: __init__.py
|
||||
# Description: Skills plugins package
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-03-08
|
||||
# Modified: 2026-03-08
|
||||
# =============================================
|
||||
@@ -0,0 +1,330 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: skills.py
|
||||
# Description: Entry point CLI for drone @skills
|
||||
# Version: 1.0.1
|
||||
# Created: 2026-03-08
|
||||
# Modified: 2026-03-28
|
||||
# =============================================
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Prevent this script's parent dir from shadowing the 'skills' package
|
||||
_script_dir = str(Path(__file__).resolve().parent)
|
||||
if _script_dir in sys.path:
|
||||
sys.path.remove(_script_dir)
|
||||
|
||||
from aipass.prax import logger # noqa: E402
|
||||
from aipass.cli.apps.modules import console, error # noqa: E402
|
||||
|
||||
"""Skills system entry point.
|
||||
|
||||
Provides handle_command(command, args) for drone routing.
|
||||
Commands: list, info, run, create, validate, --help.
|
||||
"""
|
||||
|
||||
|
||||
def print_introspection():
|
||||
"""Display module introspection info."""
|
||||
console.print()
|
||||
console.print("[bold cyan]skills Entry Point[/bold cyan]")
|
||||
console.print("[dim]Capability framework for AI agents — discover, run, create, and validate skills[/dim]")
|
||||
console.print()
|
||||
console.print("[bold]Connected Modules:[/bold]")
|
||||
console.print(" [cyan]modules/[/cyan]")
|
||||
console.print(" [dim]- discovery.py (discover_all — scan search paths for skills)[/dim]")
|
||||
console.print(" [dim]- loader.py (load_skill — load SKILL.md metadata, body, and handler)[/dim]")
|
||||
console.print(" [dim]- runner.py (run_skill — execute handler-based or markdown-only skills)[/dim]")
|
||||
console.print(" [dim]- creator.py (create_skill — scaffold new skills from templates)[/dim]")
|
||||
console.print(" [dim]- validator.py (validate_skill — check skill requirements)[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
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 is None:
|
||||
print_introspection()
|
||||
return True
|
||||
|
||||
if command in ("--help", "-h", "help"):
|
||||
print_help()
|
||||
return True
|
||||
|
||||
if command in ("--version", "-V"):
|
||||
console.print("SKILLS v1.0.0")
|
||||
return True
|
||||
|
||||
if command == "list":
|
||||
return _cmd_list()
|
||||
|
||||
if command == "info":
|
||||
if not args:
|
||||
error("Error: skill name required. Usage: skills info <name>")
|
||||
return False
|
||||
return _cmd_info(args[0])
|
||||
|
||||
if command == "run":
|
||||
if not args:
|
||||
error("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:
|
||||
error("Error: skill name required. Usage: skills create <name> [--with-handler|--full]")
|
||||
return False
|
||||
if args[0] in ("--help", "-h", "help"):
|
||||
_print_create_help()
|
||||
return True
|
||||
return _cmd_create(args)
|
||||
|
||||
if command == "validate":
|
||||
if not args:
|
||||
error("Error: skill name required. Usage: skills validate <name>")
|
||||
return False
|
||||
return _cmd_validate(args[0])
|
||||
|
||||
console.print(f" Unknown command: {command}")
|
||||
console.print(" Run 'skills --help' for available commands.")
|
||||
return False
|
||||
|
||||
|
||||
def print_help():
|
||||
"""Print skills help text."""
|
||||
console.print("Skills - Capability framework for AI agents")
|
||||
console.print()
|
||||
console.print("Usage:")
|
||||
console.print(" drone @skills <command> [args]")
|
||||
console.print()
|
||||
console.print("Commands:")
|
||||
console.print(" list Show all discovered skills")
|
||||
console.print(" info <name> Display SKILL.md contents")
|
||||
console.print(" run <name> [action] [args] Execute a skill's handler")
|
||||
console.print(" create <name> Scaffold new skill (markdown only)")
|
||||
console.print(" create <name> --with-handler Scaffold with handler.py")
|
||||
console.print(" create <name> --full Scaffold with full 3-layer structure")
|
||||
console.print(" validate <name> Check if skill requirements are met")
|
||||
console.print(" --help Show this help")
|
||||
console.print(" --version, -V Show version")
|
||||
console.print()
|
||||
console.print("Search paths (first match wins):")
|
||||
console.print(" 1. .aipass/skills/ Project-local skills")
|
||||
console.print(" 2. ~/.aipass/skills/ Global user skills")
|
||||
console.print(" 3. src/skills/catalog/ Built-in skills")
|
||||
|
||||
|
||||
def _cmd_list():
|
||||
"""List all discovered skills."""
|
||||
from aipass.skills.apps.modules.discovery import discover_all
|
||||
|
||||
skills = discover_all()
|
||||
|
||||
if not skills:
|
||||
console.print(" No skills found.")
|
||||
console.print(" Create one with: drone @skills create <name>")
|
||||
return True
|
||||
|
||||
logger.info(f"list: found {len(skills)} skill(s)")
|
||||
console.print(f" Found {len(skills)} skill(s):")
|
||||
console.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)
|
||||
console.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'])})"
|
||||
console.print(f" {skill['name']:<25} {skill['description']}{handler_tag}{tags}")
|
||||
console.print()
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _cmd_info(name):
|
||||
"""Display full SKILL.md contents for a skill."""
|
||||
from aipass.skills.apps.modules.loader import load_skill
|
||||
|
||||
loaded = load_skill(name)
|
||||
if not loaded["success"]:
|
||||
error(f"Error: {loaded['error']}")
|
||||
return False
|
||||
|
||||
metadata = loaded["metadata"]
|
||||
body = loaded["body"]
|
||||
path = loaded["path"]
|
||||
|
||||
console.print(f" Skill: {metadata.get('name', name)}")
|
||||
console.print(f" Version: {metadata.get('version', 'unknown')}")
|
||||
console.print(f" Description: {metadata.get('description', 'No description')}")
|
||||
console.print(f" Path: {path}")
|
||||
console.print(f" Has Handler: {metadata.get('has_handler', False)}")
|
||||
|
||||
tags = metadata.get("tags", [])
|
||||
if tags:
|
||||
console.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:
|
||||
console.print(f" Requires pip: {', '.join(pip_pkgs)}")
|
||||
if bins:
|
||||
console.print(f" Requires bins: {', '.join(bins)}")
|
||||
if config:
|
||||
console.print(f" Requires config: {', '.join(config)}")
|
||||
|
||||
if body:
|
||||
console.print()
|
||||
console.print(" --- SKILL.md Body ---")
|
||||
for line in body.splitlines():
|
||||
console.print(f" {line}")
|
||||
|
||||
logger.info(f"info: loaded skill '{name}'")
|
||||
return True
|
||||
|
||||
|
||||
def _cmd_run(name, action, extra_args):
|
||||
"""Execute a skill."""
|
||||
from aipass.skills.apps.modules.runner import run_skill
|
||||
|
||||
result = run_skill(name, action=action, args=extra_args)
|
||||
|
||||
if result["success"]:
|
||||
logger.info(f"run: executed skill '{name}' action={action}")
|
||||
if result["output"]:
|
||||
for line in result["output"].splitlines():
|
||||
console.print(f" {line}")
|
||||
else:
|
||||
err = result.get("error", "Unknown error")
|
||||
error(f"Error: {err}")
|
||||
|
||||
return result["success"]
|
||||
|
||||
|
||||
def _print_create_help():
|
||||
"""Print help text for the create subcommand."""
|
||||
console.print("Skills Create - Scaffold a new skill from a template")
|
||||
console.print()
|
||||
console.print("Usage:")
|
||||
console.print(" drone @skills create <name> Create a markdown-only skill")
|
||||
console.print(" drone @skills create <name> --with-handler Create with handler.py")
|
||||
console.print(" drone @skills create <name> --full Create with full 3-layer structure")
|
||||
console.print()
|
||||
console.print("Templates:")
|
||||
console.print(" markdown_only SKILL.md with instructions (AI reads and follows)")
|
||||
console.print(" with_handler SKILL.md + handler.py (programmatic execution)")
|
||||
console.print(" full SKILL.md + apps/ structure (complex skills)")
|
||||
|
||||
|
||||
def _cmd_create(args):
|
||||
"""Create a new skill from a template."""
|
||||
from aipass.skills.apps.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"]:
|
||||
error(f"Error: {result['error']}")
|
||||
return False
|
||||
|
||||
logger.info(f"create: scaffolded skill '{name}' ({template_type})")
|
||||
return True
|
||||
|
||||
|
||||
def _cmd_validate(name):
|
||||
"""Validate a skill's requirements."""
|
||||
from aipass.skills.apps.modules.loader import load_skill
|
||||
from aipass.skills.apps.modules.validator import validate_skill
|
||||
|
||||
loaded = load_skill(name)
|
||||
if not loaded["success"]:
|
||||
error(f"Error: {loaded['error']}")
|
||||
return False
|
||||
|
||||
result = validate_skill(loaded["metadata"])
|
||||
|
||||
if result["valid"]:
|
||||
logger.info(f"validate: skill '{name}' passed all requirements")
|
||||
console.print(f" Skill '{name}' - all requirements met.")
|
||||
else:
|
||||
console.print(f" Skill '{name}' - requirements NOT met:")
|
||||
if result["missing_pip"]:
|
||||
console.print(f" Missing pip packages: {', '.join(result['missing_pip'])}")
|
||||
if result["missing_bins"]:
|
||||
console.print(f" Missing CLI tools: {', '.join(result['missing_bins'])}")
|
||||
if result["missing_config"]:
|
||||
console.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
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
args = sys.argv[1:]
|
||||
if not args:
|
||||
handle_command("--help")
|
||||
else:
|
||||
command = args[0]
|
||||
remaining = args[1:] if len(args) > 1 else []
|
||||
handle_command(command, remaining)
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
name: branch_health
|
||||
description: Quick health check -- test counts and file stats for AIPass branches
|
||||
version: 1.0.0
|
||||
tags: [system, monitoring, health, testing]
|
||||
requires:
|
||||
pip: []
|
||||
bins: []
|
||||
config: []
|
||||
has_handler: true
|
||||
---
|
||||
|
||||
# Branch Health Skill
|
||||
|
||||
Quick health check for AIPass branches. Counts Python source files, test files, and test functions to give a snapshot of each branch's codebase and test coverage.
|
||||
|
||||
## Available Actions
|
||||
|
||||
| Action | Description |
|
||||
|-------------|-------------------------------------------------------|
|
||||
| `summary` | Full stats for all branches (default) |
|
||||
| `tests` | Test-only stats (test files, test function counts) |
|
||||
| *branch* | Stats for a single branch by name |
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
drone @skills run branch_health summary
|
||||
drone @skills run branch_health tests
|
||||
drone @skills run branch_health flow
|
||||
```
|
||||
|
||||
## Output Format
|
||||
|
||||
All actions return structured dicts:
|
||||
|
||||
```python
|
||||
{"success": True, "output": "...", "error": None}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Scans `apps/` for source files and `tests/` for test files
|
||||
- Counts `def test_` lines as test functions
|
||||
- Missing directories are handled gracefully
|
||||
- No external dependencies -- stdlib only
|
||||
@@ -0,0 +1,226 @@
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: handler.py - Branch Health skill handler
|
||||
# Date: 2026-03-29
|
||||
# Version: 1.0.0
|
||||
# Category: skills/catalog/branch_health
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Branch Health skill handler.
|
||||
|
||||
Quick health check for AIPass branches -- counts Python source files,
|
||||
test files, and test functions per branch.
|
||||
|
||||
Called by: drone @skills run branch_health <action>
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def run(action, args=None, config=None):
|
||||
"""Execute a branch health action.
|
||||
|
||||
Args:
|
||||
action: One of: summary (default), tests, or a specific branch name
|
||||
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 {}
|
||||
|
||||
try:
|
||||
if action == "summary":
|
||||
return _full_summary()
|
||||
if action == "tests":
|
||||
return _tests_only()
|
||||
return _single_branch(action)
|
||||
except Exception as exc:
|
||||
return {
|
||||
"success": False,
|
||||
"output": "",
|
||||
"error": f"Action '{action}' failed: {exc}",
|
||||
}
|
||||
|
||||
|
||||
def get_actions():
|
||||
"""List available actions for this skill."""
|
||||
return ["summary", "tests", "<branch_name>"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _src_root():
|
||||
"""Return the src/ directory by navigating up from this handler."""
|
||||
# handler.py -> branch_health/ -> catalog/ -> skills/ -> aipass/ -> src/
|
||||
return Path(__file__).resolve().parents[4]
|
||||
|
||||
|
||||
def _find_branches():
|
||||
"""Yield (branch_name, branch_path) for all branches."""
|
||||
src = _src_root()
|
||||
|
||||
# src/aipass/*/ branches
|
||||
aipass_dir = src / "aipass"
|
||||
if aipass_dir.is_dir():
|
||||
for branch_dir in sorted(aipass_dir.iterdir()):
|
||||
if branch_dir.is_dir() and not branch_dir.name.startswith((".", "_")):
|
||||
# Only yield actual branches (have apps/ or tests/ or .trinity/)
|
||||
if (
|
||||
(branch_dir / "apps").is_dir()
|
||||
or (branch_dir / "tests").is_dir()
|
||||
or (branch_dir / ".trinity").is_dir()
|
||||
):
|
||||
yield (branch_dir.name, branch_dir)
|
||||
|
||||
# src/skills/ itself
|
||||
skills_dir = src / "skills"
|
||||
if skills_dir.is_dir():
|
||||
yield ("skills", skills_dir)
|
||||
|
||||
|
||||
def _count_py_files(directory):
|
||||
"""Count .py files recursively in a directory."""
|
||||
if not directory.is_dir():
|
||||
return 0
|
||||
return sum(1 for _ in directory.rglob("*.py"))
|
||||
|
||||
|
||||
def _count_test_files(directory):
|
||||
"""Count test_*.py files in a directory."""
|
||||
if not directory.is_dir():
|
||||
return 0
|
||||
return sum(1 for f in directory.rglob("*.py") if f.name.startswith("test_"))
|
||||
|
||||
|
||||
def _count_test_functions(directory):
|
||||
"""Count lines matching 'def test_' in test files."""
|
||||
if not directory.is_dir():
|
||||
return 0
|
||||
count = 0
|
||||
for py_file in directory.rglob("*.py"):
|
||||
if not py_file.name.startswith("test_"):
|
||||
continue
|
||||
try:
|
||||
text = py_file.read_text(encoding="utf-8")
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("def test_"):
|
||||
count += 1
|
||||
except OSError:
|
||||
continue
|
||||
return count
|
||||
|
||||
|
||||
def _branch_stats(branch_name, branch_path):
|
||||
"""Compute stats for a single branch. Returns a dict."""
|
||||
apps_dir = branch_path / "apps"
|
||||
tests_dir = branch_path / "tests"
|
||||
|
||||
return {
|
||||
"name": branch_name,
|
||||
"py_files": _count_py_files(apps_dir),
|
||||
"test_files": _count_test_files(tests_dir),
|
||||
"test_functions": _count_test_functions(tests_dir),
|
||||
"has_apps": apps_dir.is_dir(),
|
||||
"has_tests": tests_dir.is_dir(),
|
||||
}
|
||||
|
||||
|
||||
def _format_row(name, py_files, test_files, test_fns):
|
||||
"""Format a single branch stats row."""
|
||||
return f" {name:<20s} {py_files:>5d} py {test_files:>4d} tests {test_fns:>5d} fns"
|
||||
|
||||
|
||||
def _full_summary():
|
||||
"""Full stats for all branches."""
|
||||
lines = ["Branch Health Summary", " " + "-" * 55]
|
||||
total_py = 0
|
||||
total_tests = 0
|
||||
total_fns = 0
|
||||
branch_count = 0
|
||||
|
||||
for branch_name, branch_path in _find_branches():
|
||||
stats = _branch_stats(branch_name, branch_path)
|
||||
lines.append(
|
||||
_format_row(
|
||||
stats["name"],
|
||||
stats["py_files"],
|
||||
stats["test_files"],
|
||||
stats["test_functions"],
|
||||
)
|
||||
)
|
||||
total_py += stats["py_files"]
|
||||
total_tests += stats["test_files"]
|
||||
total_fns += stats["test_functions"]
|
||||
branch_count += 1
|
||||
|
||||
lines.append(" " + "-" * 55)
|
||||
lines.append(f" {'TOTAL':<20s} {total_py:>5d} py {total_tests:>4d} tests {total_fns:>5d} fns")
|
||||
lines.append(f" ({branch_count} branches)")
|
||||
|
||||
return {"success": True, "output": "\n".join(lines), "error": None}
|
||||
|
||||
|
||||
def _tests_only():
|
||||
"""Test-only stats for all branches."""
|
||||
lines = ["Branch Health -- Test Stats", " " + "-" * 45]
|
||||
total_tests = 0
|
||||
total_fns = 0
|
||||
|
||||
for branch_name, branch_path in _find_branches():
|
||||
stats = _branch_stats(branch_name, branch_path)
|
||||
if stats["test_files"] > 0 or stats["test_functions"] > 0:
|
||||
lines.append(f" {stats['name']:<20s} {stats['test_files']:>4d} tests {stats['test_functions']:>5d} fns")
|
||||
total_tests += stats["test_files"]
|
||||
total_fns += stats["test_functions"]
|
||||
|
||||
if total_tests == 0:
|
||||
lines.append(" No test files found.")
|
||||
else:
|
||||
lines.append(" " + "-" * 45)
|
||||
lines.append(f" {'TOTAL':<20s} {total_tests:>4d} tests {total_fns:>5d} fns")
|
||||
|
||||
return {"success": True, "output": "\n".join(lines), "error": None}
|
||||
|
||||
|
||||
def _single_branch(branch_name):
|
||||
"""Stats for a single branch."""
|
||||
src = _src_root()
|
||||
|
||||
# Check src/aipass/<branch_name>/ first, then src/<branch_name>/
|
||||
candidates = [
|
||||
src / "aipass" / branch_name,
|
||||
src / branch_name,
|
||||
]
|
||||
|
||||
branch_path = None
|
||||
for candidate in candidates:
|
||||
if candidate.is_dir():
|
||||
branch_path = candidate
|
||||
break
|
||||
|
||||
if branch_path is None:
|
||||
return {
|
||||
"success": True,
|
||||
"output": f"Branch Health -- {branch_name}\n Branch '{branch_name}' not found.",
|
||||
"error": None,
|
||||
}
|
||||
|
||||
stats = _branch_stats(branch_name, branch_path)
|
||||
lines = [
|
||||
f"Branch Health -- {branch_name}",
|
||||
f" Source files (apps/): {stats['py_files']}",
|
||||
f" Test files (tests/): {stats['test_files']}",
|
||||
f" Test functions: {stats['test_functions']}",
|
||||
f" Has apps/ dir: {'yes' if stats['has_apps'] else 'no'}",
|
||||
f" Has tests/ dir: {'yes' if stats['has_tests'] else 'no'}",
|
||||
]
|
||||
|
||||
return {"success": True, "output": "\n".join(lines), "error": None}
|
||||
@@ -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,46 @@
|
||||
---
|
||||
name: inbox_check
|
||||
description: Check ai_mail inbox status across AIPass branches
|
||||
version: 1.0.0
|
||||
tags: [communication, mail, status]
|
||||
requires:
|
||||
pip: []
|
||||
bins: []
|
||||
config: []
|
||||
has_handler: true
|
||||
---
|
||||
|
||||
# Inbox Check Skill
|
||||
|
||||
Scan AIPass branches for `.ai_mail.local/inbox.json` files and report unread message counts. Useful for quickly seeing which branches have pending mail without visiting each one.
|
||||
|
||||
## Available Actions
|
||||
|
||||
| Action | Description |
|
||||
|-------------|-----------------------------------------------------|
|
||||
| `summary` | Unread counts per branch (default) |
|
||||
| `all` | Full message listing for every branch |
|
||||
| *branch* | Show inbox for a specific branch by name |
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
drone @skills run inbox_check summary
|
||||
drone @skills run inbox_check all
|
||||
drone @skills run inbox_check flow
|
||||
```
|
||||
|
||||
## Output Format
|
||||
|
||||
All actions return structured dicts:
|
||||
|
||||
```python
|
||||
{"success": True, "output": "...", "error": None}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Reads `.ai_mail.local/inbox.json` from each branch directory
|
||||
- Messages with `"status": "new"` are counted as unread
|
||||
- Missing inbox files are silently skipped in summary mode
|
||||
- No external dependencies -- stdlib only
|
||||
@@ -0,0 +1,185 @@
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: handler.py - Inbox Check skill handler
|
||||
# Date: 2026-03-29
|
||||
# Version: 1.0.0
|
||||
# Category: skills/catalog/inbox_check
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Inbox Check skill handler.
|
||||
|
||||
Scan AIPass branches for .ai_mail.local/inbox.json and report
|
||||
unread message counts or full message listings.
|
||||
|
||||
Called by: drone @skills run inbox_check <action>
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def run(action, args=None, config=None):
|
||||
"""Execute an inbox check action.
|
||||
|
||||
Args:
|
||||
action: One of: summary (default), all, or a specific branch name
|
||||
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 {}
|
||||
|
||||
try:
|
||||
if action in ("summary", "all"):
|
||||
return _scan_all(detail=(action == "all"))
|
||||
return _scan_branch(action)
|
||||
except Exception as exc:
|
||||
return {
|
||||
"success": False,
|
||||
"output": "",
|
||||
"error": f"Action '{action}' failed: {exc}",
|
||||
}
|
||||
|
||||
|
||||
def get_actions():
|
||||
"""List available actions for this skill."""
|
||||
return ["summary", "all", "<branch_name>"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _src_root():
|
||||
"""Return the src/ directory by navigating up from this handler."""
|
||||
# handler.py -> inbox_check/ -> catalog/ -> skills/ -> aipass/ -> src/
|
||||
return Path(__file__).resolve().parents[4]
|
||||
|
||||
|
||||
def _find_inboxes():
|
||||
"""Yield (branch_name, inbox_path) for all branches with inbox files."""
|
||||
src = _src_root()
|
||||
|
||||
# src/aipass/*/ branches
|
||||
aipass_dir = src / "aipass"
|
||||
if aipass_dir.is_dir():
|
||||
for branch_dir in sorted(aipass_dir.iterdir()):
|
||||
if branch_dir.is_dir():
|
||||
inbox = branch_dir / ".ai_mail.local" / "inbox.json"
|
||||
if inbox.is_file():
|
||||
yield (branch_dir.name, inbox)
|
||||
|
||||
# src/skills/ itself
|
||||
skills_inbox = src / "skills" / ".ai_mail.local" / "inbox.json"
|
||||
if skills_inbox.is_file():
|
||||
yield ("skills", skills_inbox)
|
||||
|
||||
|
||||
def _read_inbox(inbox_path):
|
||||
"""Read and parse an inbox.json file. Returns list of messages."""
|
||||
try:
|
||||
text = inbox_path.read_text(encoding="utf-8").strip()
|
||||
if not text:
|
||||
return []
|
||||
data = json.loads(text)
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
if isinstance(data, dict) and "messages" in data:
|
||||
return data["messages"]
|
||||
return []
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return []
|
||||
|
||||
|
||||
def _count_new(messages):
|
||||
"""Count messages where status == 'new'."""
|
||||
return sum(1 for m in messages if isinstance(m, dict) and m.get("status") == "new")
|
||||
|
||||
|
||||
def _scan_all(detail=False):
|
||||
"""Scan all branches for inbox status."""
|
||||
lines = []
|
||||
total_new = 0
|
||||
total_messages = 0
|
||||
branch_count = 0
|
||||
|
||||
for branch_name, inbox_path in _find_inboxes():
|
||||
messages = _read_inbox(inbox_path)
|
||||
new_count = _count_new(messages)
|
||||
total_new += new_count
|
||||
total_messages += len(messages)
|
||||
branch_count += 1
|
||||
|
||||
if detail:
|
||||
lines.append(f"\n {branch_name} ({new_count} new / {len(messages)} total):")
|
||||
if messages:
|
||||
for msg in messages:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
status = msg.get("status", "unknown")
|
||||
sender = msg.get("from", msg.get("sender", "unknown"))
|
||||
subject = msg.get("subject", msg.get("message", "(no subject)"))
|
||||
marker = "*" if status == "new" else " "
|
||||
lines.append(f" {marker} [{status}] from {sender}: {subject}")
|
||||
else:
|
||||
lines.append(" (empty)")
|
||||
else:
|
||||
if new_count > 0:
|
||||
lines.append(f" {branch_name}: {new_count} new ({len(messages)} total)")
|
||||
|
||||
if not lines and not detail:
|
||||
output = "Inbox Check\n No unread messages across any branch."
|
||||
else:
|
||||
header = f"Inbox Check -- {branch_count} branches scanned"
|
||||
summary = f" Total: {total_new} new / {total_messages} messages"
|
||||
body = "\n".join(lines) if lines else " No unread messages."
|
||||
output = f"{header}\n{summary}\n{body}"
|
||||
|
||||
return {"success": True, "output": output, "error": None}
|
||||
|
||||
|
||||
def _scan_branch(branch_name):
|
||||
"""Show inbox for a specific branch."""
|
||||
src = _src_root()
|
||||
|
||||
# Check src/aipass/<branch_name>/ first, then src/<branch_name>/
|
||||
candidates = [
|
||||
src / "aipass" / branch_name / ".ai_mail.local" / "inbox.json",
|
||||
src / branch_name / ".ai_mail.local" / "inbox.json",
|
||||
]
|
||||
|
||||
inbox_path = None
|
||||
for candidate in candidates:
|
||||
if candidate.is_file():
|
||||
inbox_path = candidate
|
||||
break
|
||||
|
||||
if inbox_path is None:
|
||||
return {
|
||||
"success": True,
|
||||
"output": f"Inbox Check -- {branch_name}\n No inbox found for branch '{branch_name}'.",
|
||||
"error": None,
|
||||
}
|
||||
|
||||
messages = _read_inbox(inbox_path)
|
||||
new_count = _count_new(messages)
|
||||
|
||||
lines = [f"Inbox Check -- {branch_name} ({new_count} new / {len(messages)} total):"]
|
||||
if messages:
|
||||
for msg in messages:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
status = msg.get("status", "unknown")
|
||||
sender = msg.get("from", msg.get("sender", "unknown"))
|
||||
subject = msg.get("subject", msg.get("message", "(no subject)"))
|
||||
marker = "*" if status == "new" else " "
|
||||
lines.append(f" {marker} [{status}] from {sender}: {subject}")
|
||||
else:
|
||||
lines.append(" (empty inbox)")
|
||||
|
||||
return {"success": True, "output": "\n".join(lines), "error": None}
|
||||
@@ -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,243 @@
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: handler.py - System Status skill handler
|
||||
# Date: 2026-03-07
|
||||
# Version: 1.0.0
|
||||
# Category: skills/catalog/system_status
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
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 Total: {total}\n Used: {used} ({percent:.1f}%)\n 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,3 @@
|
||||
# docs
|
||||
|
||||
Public documentation for the skills module.
|
||||
@@ -0,0 +1,22 @@
|
||||
[pytest]
|
||||
# Test discovery paths
|
||||
testpaths = tests
|
||||
|
||||
# Test file patterns
|
||||
python_files = test_*.py
|
||||
python_functions = test_*
|
||||
python_classes = Test*
|
||||
|
||||
# Command-line options (always applied)
|
||||
# Verbose output, short traceback, strict markers, show summary of all outcomes
|
||||
addopts =
|
||||
-v
|
||||
--tb=short
|
||||
--strict-markers
|
||||
-ra
|
||||
|
||||
# Test markers (for categorizing tests)
|
||||
markers =
|
||||
unit: Unit tests
|
||||
integration: Integration tests
|
||||
slow: Tests that take significant time
|
||||
@@ -0,0 +1,3 @@
|
||||
# templates
|
||||
|
||||
Skill scaffolding templates (markdown_only, with_handler, full).
|
||||
@@ -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,7 @@
|
||||
# ===================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
|
||||
# =============================================
|
||||
@@ -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,24 @@
|
||||
"""
|
||||
{{SKILL_NAME}} — Full 3-layer skill handler.
|
||||
|
||||
Scaffolded by: drone @skills create {{SKILL_NAME}} --full
|
||||
"""
|
||||
|
||||
|
||||
def run(action: str, args: list, config: dict) -> dict:
|
||||
"""
|
||||
Execute the skill.
|
||||
|
||||
Args:
|
||||
action: The action to perform
|
||||
args: Command arguments
|
||||
config: Skill configuration from SKILL.md
|
||||
|
||||
Returns:
|
||||
dict with keys: success (bool), output (str), error (str|None)
|
||||
"""
|
||||
return {
|
||||
"success": True,
|
||||
"output": f"{{SKILL_NAME}} executed action: {action}",
|
||||
"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: 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,3 @@
|
||||
# tests
|
||||
|
||||
Test suite for the skills module.
|
||||
@@ -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,178 @@
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: conftest.py - Skills test configuration
|
||||
# Date: 2026-03-07
|
||||
# Version: 2.0.0
|
||||
# Category: skills/tests
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v2.0.0 (2026-03-28): Added temp_dir, sample_data, mock_infrastructure,
|
||||
# mock_logger, mock_json_handler fixtures for test quality compliance
|
||||
# - v1.0.0 (2026-03-07): Initial implementation
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Adds skills root to sys.path for test imports
|
||||
# =============================================
|
||||
|
||||
"""Skills test configuration."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
# Redirect prax logs to temp directory during tests
|
||||
# Must be set before any prax imports to catch logger initialization
|
||||
if "AIPASS_TEST_LOG_DIR" not in os.environ:
|
||||
os.environ["AIPASS_TEST_LOG_DIR"] = tempfile.mkdtemp(prefix="aipass_test_logs_")
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
from typing import Generator
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
# Add src/ to path so aipass.skills is importable
|
||||
skills_root = Path(__file__).resolve().parents[3]
|
||||
if str(skills_root) not in sys.path:
|
||||
sys.path.insert(0, str(skills_root))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dynamic import for json_handler isolation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
BRANCH_MODULE = "aipass.skills"
|
||||
|
||||
_handler_pkg = f"{BRANCH_MODULE}.apps.handlers"
|
||||
_json_mod_path = f"{BRANCH_MODULE}.apps.handlers.json.json_handler"
|
||||
|
||||
# Ensure the handler package is importable
|
||||
if _handler_pkg not in sys.modules:
|
||||
_stub = types.ModuleType(_handler_pkg)
|
||||
_handlers_dir = Path(__file__).resolve().parents[1] / "apps" / "handlers"
|
||||
_stub.__path__ = [str(_handlers_dir)]
|
||||
sys.modules[_handler_pkg] = _stub
|
||||
|
||||
_json_mod = importlib.import_module(_json_mod_path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# JSON_DIR variable discovery
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_JSON_DIR_ATTR: str | None = None
|
||||
_JSON_DIR_CANDIDATES = [
|
||||
"SKILLS_JSON_DIR",
|
||||
"JSON_DIR",
|
||||
"BRANCH_JSON_DIR",
|
||||
"_JSON_DIR",
|
||||
]
|
||||
|
||||
for _candidate in _JSON_DIR_CANDIDATES:
|
||||
if hasattr(_json_mod, _candidate):
|
||||
_JSON_DIR_ATTR = _candidate
|
||||
break
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def temp_dir(tmp_path: Path) -> Generator[Path, None, None]:
|
||||
"""Creates temporary directory for testing, cleans up after."""
|
||||
test_dir = tmp_path / "test_workspace"
|
||||
test_dir.mkdir(parents=True, exist_ok=True)
|
||||
yield test_dir
|
||||
for child in test_dir.iterdir():
|
||||
if child.is_file():
|
||||
child.unlink()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def sample_data() -> dict:
|
||||
"""Sample test data for JSON operations."""
|
||||
return {
|
||||
"config": {
|
||||
"module_name": "test_module",
|
||||
"version": "1.0.0",
|
||||
"config": {"max_log_entries": 50},
|
||||
"timestamp": "2026-03-28",
|
||||
},
|
||||
"data": {
|
||||
"module_name": "test_module",
|
||||
"created": "2026-03-28",
|
||||
"last_updated": "2026-03-28",
|
||||
"operations_total": 0,
|
||||
"operations_successful": 0,
|
||||
"operations_failed": 0,
|
||||
},
|
||||
"log": [{"timestamp": "2026-03-28T10:00:00", "operation": "test"}],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_infrastructure(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Autouse fixture that isolates JSON operations and silences logging.
|
||||
|
||||
This fixture:
|
||||
1. Redirects the branch's JSON_DIR to tmp_path (test isolation)
|
||||
2. Patches the branch logger to a NullHandler (no console noise)
|
||||
"""
|
||||
if _JSON_DIR_ATTR is not None:
|
||||
monkeypatch.setattr(_json_mod, _JSON_DIR_ATTR, tmp_path)
|
||||
|
||||
logger_names = [
|
||||
BRANCH_MODULE,
|
||||
f"{BRANCH_MODULE}.apps.handlers.json.json_handler",
|
||||
]
|
||||
for logger_name in logger_names:
|
||||
log = logging.getLogger(logger_name)
|
||||
monkeypatch.setattr(log, "handlers", [logging.NullHandler()])
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mock_logger() -> MagicMock:
|
||||
"""Standalone mock logger for tests that need to verify logging calls."""
|
||||
mock = MagicMock(spec=logging.Logger)
|
||||
mock.debug = MagicMock()
|
||||
mock.info = MagicMock()
|
||||
mock.warning = MagicMock()
|
||||
mock.error = MagicMock()
|
||||
mock.critical = MagicMock()
|
||||
return mock
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mock_json_handler() -> MagicMock:
|
||||
"""Standalone mock json_handler for isolating from real file I/O."""
|
||||
handler = MagicMock()
|
||||
handler.load_json = MagicMock(return_value={})
|
||||
handler.save_json = MagicMock(return_value=True)
|
||||
handler.ensure_json_exists = MagicMock(return_value=True)
|
||||
handler.ensure_module_jsons = MagicMock(return_value=True)
|
||||
handler.get_json_path = MagicMock(return_value=Path("/tmp/mock.json"))
|
||||
handler.validate_json_structure = MagicMock(return_value=True)
|
||||
handler.log_operation = MagicMock(return_value=True)
|
||||
return handler
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def reimport_after_mock(monkeypatch: pytest.MonkeyPatch) -> MagicMock:
|
||||
"""Fixture demonstrating reimport_after_mock pattern.
|
||||
|
||||
Patches sys.modules to inject a mock, then reimports the handler module
|
||||
so it picks up the mocked dependency. Useful for testing import-time behavior.
|
||||
"""
|
||||
mock_mod = MagicMock()
|
||||
monkeypatch.setitem(sys.modules, f"{BRANCH_MODULE}.apps.handlers.json.json_handler", mock_mod)
|
||||
reimported = importlib.import_module(_json_mod_path)
|
||||
importlib.reload(reimported)
|
||||
return mock_mod
|
||||
@@ -0,0 +1,205 @@
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: test_cli_routing.py - Unit tests for skills.py CLI routing
|
||||
# Date: 2026-03-10
|
||||
# Version: 1.0.0
|
||||
# Category: skills/tests
|
||||
# =============================================
|
||||
|
||||
"""Tests for the skills entry point CLI routing."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
skills_root = Path(__file__).resolve().parent.parent.parent
|
||||
if str(skills_root) not in sys.path:
|
||||
sys.path.insert(0, str(skills_root))
|
||||
|
||||
from aipass.skills.apps.skills import handle_command, _parse_extra_args
|
||||
|
||||
|
||||
class TestParseExtraArgs:
|
||||
def test_key_value_pairs(self):
|
||||
result = _parse_extra_args(["host=localhost", "port=8080"])
|
||||
assert result == {"host": "localhost", "port": "8080"}
|
||||
|
||||
def test_positional_args(self):
|
||||
result = _parse_extra_args(["foo", "bar"])
|
||||
assert result == {"arg0": "foo", "arg1": "bar"}
|
||||
|
||||
def test_mixed_args(self):
|
||||
result = _parse_extra_args(["foo", "key=val", "bar"])
|
||||
assert result == {"arg0": "foo", "key": "val", "arg1": "bar"}
|
||||
|
||||
def test_empty_args(self):
|
||||
result = _parse_extra_args([])
|
||||
assert result == {}
|
||||
|
||||
def test_value_with_equals_sign(self):
|
||||
"""key=value where value itself contains '='."""
|
||||
result = _parse_extra_args(["query=a=b"])
|
||||
assert result == {"query": "a=b"}
|
||||
|
||||
|
||||
class TestHandleCommand:
|
||||
def test_none_command_shows_introspection(self):
|
||||
result = handle_command(None)
|
||||
assert result is True
|
||||
|
||||
def test_help_command(self):
|
||||
result = handle_command("--help")
|
||||
assert result is True
|
||||
|
||||
def test_help_alias(self):
|
||||
result = handle_command("help")
|
||||
assert result is True
|
||||
|
||||
def test_h_flag(self):
|
||||
result = handle_command("-h")
|
||||
assert result is True
|
||||
|
||||
def test_version_command(self):
|
||||
result = handle_command("--version")
|
||||
assert result is True
|
||||
|
||||
def test_version_short_flag(self):
|
||||
result = handle_command("-V")
|
||||
assert result is True
|
||||
|
||||
def test_unknown_command_returns_false(self):
|
||||
result = handle_command("bogus_command_xyz")
|
||||
assert result is False
|
||||
|
||||
def test_list_command(self):
|
||||
result = handle_command("list")
|
||||
assert result is True
|
||||
|
||||
def test_info_missing_args_returns_false(self):
|
||||
result = handle_command("info")
|
||||
assert result is False
|
||||
|
||||
def test_info_with_valid_skill(self):
|
||||
result = handle_command("info", ["github"])
|
||||
assert result is True
|
||||
|
||||
def test_run_missing_args_returns_false(self):
|
||||
result = handle_command("run")
|
||||
assert result is False
|
||||
|
||||
def test_run_with_valid_skill(self):
|
||||
result = handle_command("run", ["system_status", "disk"])
|
||||
assert result is True
|
||||
|
||||
def test_validate_missing_args_returns_false(self):
|
||||
result = handle_command("validate")
|
||||
assert result is False
|
||||
|
||||
def test_validate_with_valid_skill(self):
|
||||
result = handle_command("validate", ["github"])
|
||||
assert result is True
|
||||
|
||||
def test_create_missing_args_returns_false(self):
|
||||
result = handle_command("create")
|
||||
assert result is False
|
||||
|
||||
def test_create_help_flag_returns_true(self):
|
||||
"""create --help shows help instead of treating --help as a skill name."""
|
||||
result = handle_command("create", ["--help"])
|
||||
assert result is True
|
||||
|
||||
def test_create_help_flag_shows_usage(self, capsys):
|
||||
"""create --help prints usage text."""
|
||||
handle_command("create", ["--help"])
|
||||
captured = capsys.readouterr()
|
||||
assert "Usage" in captured.out
|
||||
assert "create" in captured.out.lower()
|
||||
|
||||
def test_create_h_flag_returns_true(self):
|
||||
"""create -h shows help."""
|
||||
result = handle_command("create", ["-h"])
|
||||
assert result is True
|
||||
|
||||
def test_create_help_word_returns_true(self):
|
||||
"""create help shows help."""
|
||||
result = handle_command("create", ["help"])
|
||||
assert result is True
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Missing coverage: no_args, print_help, print_introspection, output_capture
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestNoArgs:
|
||||
"""Test no_args behavior -- None command triggers introspection."""
|
||||
|
||||
def test_no_args_returns_true(self):
|
||||
"""no_args: handle_command(None) returns True."""
|
||||
result = handle_command(None)
|
||||
assert result is True
|
||||
|
||||
def test_no_args_triggers_introspection(self, capsys):
|
||||
"""no_args_triggers: calling with None produces introspection output."""
|
||||
handle_command(None)
|
||||
captured = capsys.readouterr()
|
||||
assert "skills" in captured.out.lower() or "Entry Point" in captured.out
|
||||
|
||||
|
||||
class TestPrintHelp:
|
||||
"""Tests for print_help output."""
|
||||
|
||||
def test_print_help_produces_output(self, capsys):
|
||||
"""print_help: calling --help produces help text."""
|
||||
from aipass.skills.apps.skills import print_help
|
||||
|
||||
print_help()
|
||||
captured = capsys.readouterr()
|
||||
assert "Usage" in captured.out or "Commands" in captured.out
|
||||
|
||||
def test_print_help_via_command(self, capsys):
|
||||
"""print_help: handle_command('--help') produces output."""
|
||||
handle_command("--help")
|
||||
captured = capsys.readouterr()
|
||||
assert len(captured.out) > 0
|
||||
|
||||
|
||||
class TestPrintIntrospection:
|
||||
"""Tests for print_introspection output."""
|
||||
|
||||
def test_print_introspection_produces_output(self, capsys):
|
||||
"""print_introspection: shows module info."""
|
||||
from aipass.skills.apps.skills import print_introspection
|
||||
|
||||
print_introspection()
|
||||
captured = capsys.readouterr()
|
||||
assert "Entry Point" in captured.out or "skills" in captured.out.lower()
|
||||
|
||||
def test_print_introspection_lists_modules(self, capsys):
|
||||
"""print_introspection: lists connected modules."""
|
||||
from aipass.skills.apps.skills import print_introspection
|
||||
|
||||
print_introspection()
|
||||
captured = capsys.readouterr()
|
||||
assert "modules/" in captured.out or "discovery" in captured.out.lower()
|
||||
|
||||
|
||||
class TestOutputCapture:
|
||||
"""Tests using capsys for output_capture verification."""
|
||||
|
||||
def test_output_capture_help_command(self, capsys):
|
||||
"""output_capture: --help produces non-empty stdout."""
|
||||
handle_command("--help")
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out != ""
|
||||
|
||||
def test_output_capture_version_command(self, capsys):
|
||||
"""output_capture: --version produces version string."""
|
||||
handle_command("--version")
|
||||
captured = capsys.readouterr()
|
||||
assert "SKILLS" in captured.out or "1.0.0" in captured.out
|
||||
|
||||
def test_output_capture_unknown_command(self, capsys):
|
||||
"""output_capture: unknown command produces output."""
|
||||
handle_command("bogus_xyz")
|
||||
captured = capsys.readouterr()
|
||||
assert "Unknown command" in captured.out or "unknown" in captured.out.lower() or len(captured.out) > 0
|
||||
@@ -0,0 +1,132 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_contracts.py
|
||||
# Description: Contract Tests (return types, exceptions, data structures)
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-03-28
|
||||
# Modified: 2026-03-28
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Contract Tests for skills branch.
|
||||
|
||||
Covers 3 groups:
|
||||
- Return type contracts (4): command_returns_bool, paths_return_path,
|
||||
ensure_returns_bool, load_correct_type
|
||||
- Exception contracts (3): create_default_raises, save_invalid_raises,
|
||||
invalid_mode_raises
|
||||
- Data structure contracts (3): config_keys, data_keys, log_entry_field
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
BRANCH_MODULE = "skills"
|
||||
_json_mod_path = f"{BRANCH_MODULE}.apps.handlers.json.json_handler"
|
||||
|
||||
|
||||
def _import_handler():
|
||||
"""Import json_handler."""
|
||||
return importlib.import_module(_json_mod_path)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Group 1 -- Return type contracts
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_handle_command_returns_bool() -> None:
|
||||
"""handle_command must return a bool (command_returns_bool)."""
|
||||
from aipass.skills.apps.skills import handle_command
|
||||
|
||||
result = handle_command("--help")
|
||||
assert isinstance(result, bool)
|
||||
|
||||
|
||||
def test_get_json_path_returns_path() -> None:
|
||||
"""get_json_path must return a Path (paths_return_path contract)."""
|
||||
handler = _import_handler()
|
||||
result = handler.get_json_path("contract_mod", "config")
|
||||
assert isinstance(result, Path)
|
||||
|
||||
|
||||
def test_ensure_json_exists_returns_bool() -> None:
|
||||
"""ensure_json_exists must return a bool."""
|
||||
handler = _import_handler()
|
||||
result = handler.ensure_json_exists("contract_mod", "data")
|
||||
assert isinstance(result, bool)
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_load_json_returns_dict_for_config() -> None:
|
||||
"""load_json for config type must return a dict."""
|
||||
handler = _import_handler()
|
||||
result = handler.load_json("contract_mod", "config")
|
||||
assert isinstance(result, dict)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Group 2 -- Exception contracts
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_save_json_invalid_structure_rejects() -> None:
|
||||
"""save_json must reject invalid structure -- save_invalid_raises contract."""
|
||||
handler = _import_handler()
|
||||
result = handler.save_json("bad", "config", {"missing": "keys"})
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_validate_rejects_invalid_mode() -> None:
|
||||
"""validate_json_structure must return False for unknown json_type (invalid_mode_raises)."""
|
||||
handler = _import_handler()
|
||||
try:
|
||||
result = handler.validate_json_structure({}, "invalid_mode_xyz")
|
||||
except ValueError:
|
||||
return
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_save_invalid_raises_no_exception() -> None:
|
||||
"""save_json with invalid data returns False, no exception (save_invalid_raises)."""
|
||||
handler = _import_handler()
|
||||
result = handler.save_json("x", "config", "not_a_dict")
|
||||
assert result is False
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Group 3 -- Data structure contracts
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_config_has_required_keys() -> None:
|
||||
"""Config must contain module_name and version (config_keys)."""
|
||||
handler = _import_handler()
|
||||
handler.ensure_json_exists("struct_mod", "config")
|
||||
result = handler.load_json("struct_mod", "config")
|
||||
assert isinstance(result, dict)
|
||||
assert "module_name" in result
|
||||
assert "version" in result
|
||||
|
||||
|
||||
def test_data_has_date_keys() -> None:
|
||||
"""Data structure must contain created and last_updated (data_keys)."""
|
||||
handler = _import_handler()
|
||||
handler.ensure_json_exists("struct_mod", "data")
|
||||
result = handler.load_json("struct_mod", "data")
|
||||
assert isinstance(result, dict)
|
||||
assert "created" in result
|
||||
assert "last_updated" in result
|
||||
|
||||
|
||||
def test_log_entry_has_operation_field() -> None:
|
||||
"""Log entries must contain an 'operation' field (log_entry_field)."""
|
||||
handler = _import_handler()
|
||||
handler.log_operation("contract_test", module_name="struct_mod")
|
||||
|
||||
log_path = handler.get_json_path("struct_mod", "log")
|
||||
log = json.loads(log_path.read_text(encoding="utf-8"))
|
||||
assert len(log) >= 1
|
||||
assert "operation" in log[-1]
|
||||
assert log[-1]["operation"] == "contract_test"
|
||||
@@ -0,0 +1,164 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_creator.py
|
||||
# Description: Tests for creator module orchestration layer
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-04-03
|
||||
# Modified: 2026-04-03
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Tests for modules/creator.py — thin orchestration layer.
|
||||
|
||||
Covers: handle_command (routing, introspection, --help), create_skill
|
||||
(delegation to handler, Rich output, trigger firing, json logging),
|
||||
print_introspection.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from aipass.skills.apps.modules.creator import create_skill, handle_command, print_introspection
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 1. handle_command — command routing
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestHandleCommand:
|
||||
"""Tests for handle_command — CLI routing logic."""
|
||||
|
||||
def test_no_args_shows_introspection(self, capsys):
|
||||
result = handle_command("create", [])
|
||||
assert result is True
|
||||
output = capsys.readouterr().out
|
||||
assert "creator Module" in output
|
||||
|
||||
def test_help_flag_shows_introspection(self, capsys):
|
||||
result = handle_command("create", ["--help"])
|
||||
assert result is True
|
||||
output = capsys.readouterr().out
|
||||
assert "creator Module" in output
|
||||
|
||||
def test_create_with_valid_name(self, tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
result = handle_command("create", ["test-skill"])
|
||||
assert result is True
|
||||
|
||||
def test_create_with_handler_flag(self, tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
result = handle_command("create", ["test-hnd", "--with-handler"])
|
||||
assert result is True
|
||||
skill_path = tmp_path / ".aipass" / "skills" / "test-hnd"
|
||||
assert (skill_path / "handler.py").exists()
|
||||
|
||||
def test_create_with_full_flag(self, tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
result = handle_command("create", ["test-full", "--full"])
|
||||
assert result is True
|
||||
skill_path = tmp_path / ".aipass" / "skills" / "test-full"
|
||||
assert (skill_path / "apps").is_dir()
|
||||
|
||||
def test_create_invalid_name_returns_false(self, tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
result = handle_command("create", ["Bad Name!"])
|
||||
assert result is False
|
||||
|
||||
def test_unknown_command_returns_false(self):
|
||||
result = handle_command("nonexistent", ["arg"])
|
||||
assert result is False
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 2. create_skill — module-level wrapper
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestCreateSkillModule:
|
||||
"""Tests for create_skill module wrapper — delegates + renders output."""
|
||||
|
||||
def test_success_prints_output(self, tmp_path, capsys):
|
||||
result = create_skill("print-test", template_type="markdown_only", target_dir=tmp_path)
|
||||
assert result["success"] is True
|
||||
output = capsys.readouterr().out
|
||||
assert "print-test" in output
|
||||
assert "markdown_only" in output
|
||||
|
||||
def test_success_returns_handler_result(self, tmp_path):
|
||||
result = create_skill("result-test", template_type="markdown_only", target_dir=tmp_path)
|
||||
assert result["success"] is True
|
||||
assert result["path"] is not None
|
||||
assert isinstance(result["files"], list)
|
||||
assert result["error"] is None
|
||||
|
||||
def test_failure_does_not_print_success_output(self, capsys):
|
||||
result = create_skill("", template_type="markdown_only")
|
||||
assert result["success"] is False
|
||||
output = capsys.readouterr().out
|
||||
assert "Created skill" not in output
|
||||
|
||||
def test_trigger_fired_on_success(self, tmp_path):
|
||||
mock_trigger = MagicMock()
|
||||
with patch("aipass.skills.apps.modules.creator.trigger", mock_trigger):
|
||||
create_skill("trigger-test", template_type="markdown_only", target_dir=tmp_path)
|
||||
mock_trigger.fire.assert_called_once()
|
||||
call_args = mock_trigger.fire.call_args
|
||||
assert call_args[0][0] == "skill_created"
|
||||
assert call_args[1]["name"] == "trigger-test"
|
||||
|
||||
def test_trigger_not_fired_on_failure(self):
|
||||
mock_trigger = MagicMock()
|
||||
with patch("aipass.skills.apps.modules.creator.trigger", mock_trigger):
|
||||
create_skill("", template_type="markdown_only")
|
||||
mock_trigger.fire.assert_not_called()
|
||||
|
||||
def test_trigger_none_does_not_crash(self, tmp_path):
|
||||
"""When trigger is None (import failed), create_skill still works."""
|
||||
with patch("aipass.skills.apps.modules.creator.trigger", None):
|
||||
result = create_skill("no-trigger", template_type="markdown_only", target_dir=tmp_path)
|
||||
assert result["success"] is True
|
||||
|
||||
@patch("aipass.skills.apps.modules.creator.json_handler")
|
||||
def test_json_log_on_success(self, mock_jh, tmp_path):
|
||||
create_skill("jlog-test", template_type="markdown_only", target_dir=tmp_path)
|
||||
mock_jh.log_operation.assert_called_once()
|
||||
call_args = mock_jh.log_operation.call_args
|
||||
assert call_args[0][0] == "skill_created"
|
||||
assert call_args[0][1]["success"] is True
|
||||
|
||||
@patch("aipass.skills.apps.modules.creator.json_handler")
|
||||
def test_json_log_on_failure(self, mock_jh):
|
||||
create_skill("", template_type="markdown_only")
|
||||
mock_jh.log_operation.assert_called_once()
|
||||
call_args = mock_jh.log_operation.call_args
|
||||
assert call_args[0][1]["success"] is False
|
||||
|
||||
def test_files_listed_in_output(self, tmp_path, capsys):
|
||||
create_skill("files-test", template_type="with_handler", target_dir=tmp_path)
|
||||
output = capsys.readouterr().out
|
||||
assert "SKILL.md" in output
|
||||
assert "handler.py" in output
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 3. print_introspection — module info display
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestPrintIntrospection:
|
||||
"""Tests for print_introspection — module self-description."""
|
||||
|
||||
def test_prints_module_name(self, capsys):
|
||||
print_introspection()
|
||||
output = capsys.readouterr().out
|
||||
assert "creator Module" in output
|
||||
|
||||
def test_prints_description(self, capsys):
|
||||
print_introspection()
|
||||
output = capsys.readouterr().out
|
||||
assert "Scaffold" in output
|
||||
|
||||
def test_prints_connected_handlers(self, capsys):
|
||||
print_introspection()
|
||||
output = capsys.readouterr().out
|
||||
assert "creator_handler.py" in output
|
||||
assert "template.py" in output
|
||||
@@ -0,0 +1,169 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_creator_handler.py
|
||||
# Description: Tests for skill creation handler
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-04-03
|
||||
# Modified: 2026-04-03
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Tests for creator_handler.py — skill name validation and create_skill logic.
|
||||
|
||||
Covers: is_valid_name, create_skill (success paths, validation failures,
|
||||
template failures, target_dir default, json logging).
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from aipass.skills.apps.handlers.creator_handler import create_skill, is_valid_name
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 1. is_valid_name — name validation
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestIsValidName:
|
||||
"""Tests for is_valid_name — skill name validation rules."""
|
||||
|
||||
def test_simple_lowercase_name(self):
|
||||
assert is_valid_name("my-skill") is True
|
||||
|
||||
def test_single_letter(self):
|
||||
assert is_valid_name("a") is True
|
||||
|
||||
def test_lowercase_with_numbers(self):
|
||||
assert is_valid_name("skill2") is True
|
||||
|
||||
def test_underscores_allowed(self):
|
||||
assert is_valid_name("my_skill") is True
|
||||
|
||||
def test_hyphens_allowed(self):
|
||||
assert is_valid_name("my-skill") is True
|
||||
|
||||
def test_mixed_separators(self):
|
||||
assert is_valid_name("my-skill_v2") is True
|
||||
|
||||
def test_rejects_empty_string(self):
|
||||
assert is_valid_name("") is False
|
||||
|
||||
def test_rejects_none(self):
|
||||
"""None is falsy — short-circuits to False via 'not name'."""
|
||||
assert is_valid_name(None) is False
|
||||
|
||||
def test_rejects_starts_with_number(self):
|
||||
assert is_valid_name("2skill") is False
|
||||
|
||||
def test_rejects_starts_with_hyphen(self):
|
||||
assert is_valid_name("-skill") is False
|
||||
|
||||
def test_rejects_uppercase(self):
|
||||
assert is_valid_name("MySkill") is False
|
||||
|
||||
def test_rejects_mixed_case(self):
|
||||
assert is_valid_name("mySkill") is False
|
||||
|
||||
def test_rejects_spaces(self):
|
||||
assert is_valid_name("my skill") is False
|
||||
|
||||
def test_rejects_special_chars(self):
|
||||
assert is_valid_name("my.skill") is False
|
||||
|
||||
def test_rejects_slash(self):
|
||||
assert is_valid_name("my/skill") is False
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 2. create_skill — skill creation orchestration
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestCreateSkill:
|
||||
"""Tests for create_skill — full creation pipeline."""
|
||||
|
||||
def test_create_markdown_skill_succeeds(self, tmp_path):
|
||||
result = create_skill("test-md", template_type="markdown_only", target_dir=tmp_path)
|
||||
assert result["success"] is True
|
||||
assert result["path"] is not None
|
||||
assert Path(result["path"]).exists()
|
||||
assert (Path(result["path"]) / "SKILL.md").exists()
|
||||
|
||||
def test_create_handler_skill_succeeds(self, tmp_path):
|
||||
result = create_skill("test-hnd", template_type="with_handler", target_dir=tmp_path)
|
||||
assert result["success"] is True
|
||||
assert (Path(result["path"]) / "handler.py").exists()
|
||||
|
||||
def test_create_full_skill_succeeds(self, tmp_path):
|
||||
result = create_skill("test-full", template_type="full", target_dir=tmp_path)
|
||||
assert result["success"] is True
|
||||
assert (Path(result["path"]) / "apps").is_dir()
|
||||
|
||||
def test_returns_created_files_list(self, tmp_path):
|
||||
result = create_skill("test-files", template_type="markdown_only", target_dir=tmp_path)
|
||||
assert isinstance(result["files"], list)
|
||||
assert len(result["files"]) > 0
|
||||
assert "SKILL.md" in result["files"]
|
||||
|
||||
def test_empty_name_fails(self):
|
||||
result = create_skill("", template_type="markdown_only")
|
||||
assert result["success"] is False
|
||||
assert result["error"] == "Skill name is required."
|
||||
assert result["path"] is None
|
||||
assert result["files"] == []
|
||||
|
||||
def test_invalid_name_fails(self):
|
||||
result = create_skill("Bad Name!", template_type="markdown_only")
|
||||
assert result["success"] is False
|
||||
assert "Invalid skill name" in result["error"]
|
||||
|
||||
def test_invalid_template_type_fails(self, tmp_path):
|
||||
result = create_skill("valid-name", template_type="nonexistent", target_dir=tmp_path)
|
||||
assert result["success"] is False
|
||||
assert "Unknown template type" in result["error"]
|
||||
|
||||
def test_default_target_dir_uses_cwd(self, monkeypatch, tmp_path):
|
||||
"""When target_dir is None, uses CWD/.aipass/skills/."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
result = create_skill("cwd-test", template_type="markdown_only")
|
||||
assert result["success"] is True
|
||||
expected_parent = tmp_path / ".aipass" / "skills"
|
||||
assert str(expected_parent) in result["path"]
|
||||
|
||||
def test_duplicate_name_fails(self, tmp_path):
|
||||
"""Creating a skill that already exists should fail."""
|
||||
create_skill("dupe-test", template_type="markdown_only", target_dir=tmp_path)
|
||||
result = create_skill("dupe-test", template_type="markdown_only", target_dir=tmp_path)
|
||||
assert result["success"] is False
|
||||
assert "already exists" in result["error"]
|
||||
|
||||
def test_placeholder_replacement(self, tmp_path):
|
||||
"""Skill name replaces {{SKILL_NAME}} in created files."""
|
||||
result = create_skill("my-replaced", template_type="markdown_only", target_dir=tmp_path)
|
||||
content = (Path(result["path"]) / "SKILL.md").read_text()
|
||||
assert "my-replaced" in content
|
||||
assert "{{SKILL_NAME}}" not in content
|
||||
|
||||
def test_logs_json_operation_on_success(self, tmp_path):
|
||||
_mod = sys.modules["aipass.skills.apps.handlers.creator_handler"]
|
||||
|
||||
with patch.object(_mod, "json_handler") as mock_jh:
|
||||
create_skill("log-test", template_type="markdown_only", target_dir=tmp_path)
|
||||
mock_jh.log_operation.assert_called_once()
|
||||
call_args = mock_jh.log_operation.call_args
|
||||
assert call_args[0][0] == "skill_scaffold"
|
||||
assert call_args[0][1]["success"] is True
|
||||
|
||||
def test_logs_json_operation_on_failure(self, tmp_path):
|
||||
_mod = sys.modules["aipass.skills.apps.handlers.creator_handler"]
|
||||
|
||||
# Use a duplicate-name scenario so validation passes but copy fails,
|
||||
# which is the only failure path that reaches json_handler.log_operation.
|
||||
create_skill("dup-log", template_type="markdown_only", target_dir=tmp_path)
|
||||
|
||||
with patch.object(_mod, "json_handler") as mock_jh:
|
||||
create_skill("dup-log", template_type="markdown_only", target_dir=tmp_path)
|
||||
mock_jh.log_operation.assert_called_once()
|
||||
call_args = mock_jh.log_operation.call_args
|
||||
assert call_args[0][1]["success"] is False
|
||||
@@ -0,0 +1,204 @@
|
||||
# ===================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 tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.skills.apps.handlers.discovery_handler 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 is not None
|
||||
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 is not None
|
||||
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,98 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_error_resilience.py
|
||||
# Description: Error Resilience Tests for skills branch
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-03-28
|
||||
# Modified: 2026-03-28
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Error Resilience Tests for skills branch.
|
||||
|
||||
Covers 4 tests:
|
||||
- missing_file, corrupt_json, empty_file, nonexistent_dir
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
BRANCH_MODULE = "aipass.skills"
|
||||
_json_mod_path = f"{BRANCH_MODULE}.apps.handlers.json.json_handler"
|
||||
|
||||
|
||||
def _import_handler():
|
||||
"""Import json_handler."""
|
||||
return importlib.import_module(_json_mod_path)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Error Resilience Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_missing_file() -> None:
|
||||
"""Loading a non-existent file returns a graceful default, not a crash."""
|
||||
handler = _import_handler()
|
||||
target = handler.get_json_path("ghost", "config")
|
||||
assert not target.exists()
|
||||
|
||||
try:
|
||||
result = handler.load_json("ghost", "config")
|
||||
except FileNotFoundError:
|
||||
return
|
||||
|
||||
assert result is not None
|
||||
assert isinstance(result, dict)
|
||||
|
||||
|
||||
def test_corrupt_json() -> None:
|
||||
"""Corrupt JSON on disk is handled gracefully -- file is regenerated."""
|
||||
handler = _import_handler()
|
||||
json_dir = handler.SKILLS_JSON_DIR
|
||||
json_dir.mkdir(parents=True, exist_ok=True)
|
||||
target = handler.get_json_path("corrupt", "data")
|
||||
target.write_bytes(b"\x00\x01NOT-JSON{{{broken")
|
||||
|
||||
result = handler.ensure_json_exists("corrupt", "data")
|
||||
assert result is True
|
||||
|
||||
raw = target.read_text(encoding="utf-8")
|
||||
data = json.loads(raw)
|
||||
assert isinstance(data, dict)
|
||||
assert "created" in data
|
||||
assert "last_updated" in data
|
||||
|
||||
|
||||
def test_empty_file() -> None:
|
||||
"""An empty file (0 bytes) is handled gracefully."""
|
||||
handler = _import_handler()
|
||||
json_dir = handler.SKILLS_JSON_DIR
|
||||
json_dir.mkdir(parents=True, exist_ok=True)
|
||||
target = handler.get_json_path("empty", "log")
|
||||
target.write_text("", encoding="utf-8")
|
||||
|
||||
result = handler.ensure_json_exists("empty", "log")
|
||||
assert result is True
|
||||
|
||||
raw = target.read_text(encoding="utf-8")
|
||||
data = json.loads(raw)
|
||||
assert isinstance(data, list)
|
||||
|
||||
|
||||
def test_nonexistent_dir(tmp_path: Path) -> None:
|
||||
"""Missing parent directory is handled gracefully."""
|
||||
handler = _import_handler()
|
||||
from unittest.mock import patch
|
||||
|
||||
nested_dir = tmp_path / "does_not_exist" / "nested"
|
||||
assert not nested_dir.exists()
|
||||
|
||||
with patch.object(handler, "SKILLS_JSON_DIR", nested_dir):
|
||||
try:
|
||||
result = handler.ensure_json_exists("nodir", "config")
|
||||
assert nested_dir.exists()
|
||||
assert result is True
|
||||
except (FileNotFoundError, OSError):
|
||||
pass
|
||||
@@ -0,0 +1,108 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_init_provisioning.py
|
||||
# Description: Init/Provisioning Tests for skills branch
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-03-28
|
||||
# Modified: 2026-03-28
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Init/Provisioning Tests for skills branch.
|
||||
|
||||
Covers 4 tests:
|
||||
- creates_files, auto_creates_dir, no_overwrite, returns_dict
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
BRANCH_MODULE = "skills"
|
||||
_json_mod_path = f"{BRANCH_MODULE}.apps.handlers.json.json_handler"
|
||||
|
||||
|
||||
def _import_handler():
|
||||
"""Import json_handler."""
|
||||
return importlib.import_module(_json_mod_path)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Init/Provisioning Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_creates_expected_files() -> None:
|
||||
"""ensure_json_exists creates expected files on disk."""
|
||||
handler = _import_handler()
|
||||
json_dir = handler.SKILLS_JSON_DIR
|
||||
|
||||
for json_type in ("config", "data", "log"):
|
||||
result = handler.ensure_json_exists("prov_mod", json_type)
|
||||
assert result is True
|
||||
|
||||
expected = json_dir / f"prov_mod_{json_type}.json"
|
||||
assert expected.exists()
|
||||
|
||||
raw = expected.read_text(encoding="utf-8")
|
||||
parsed = json.loads(raw)
|
||||
assert parsed is not None
|
||||
|
||||
|
||||
def test_auto_creates_directory(tmp_path: Path) -> None:
|
||||
"""ensure_json_exists auto-creates parent directory when missing."""
|
||||
handler = _import_handler()
|
||||
nested_dir = tmp_path / "auto_created" / "subdir"
|
||||
assert not nested_dir.exists()
|
||||
|
||||
with patch.object(handler, "SKILLS_JSON_DIR", nested_dir):
|
||||
try:
|
||||
result = handler.ensure_json_exists("autodir", "config")
|
||||
assert nested_dir.exists()
|
||||
assert result is True
|
||||
assert (nested_dir / "autodir_config.json").exists()
|
||||
except (FileNotFoundError, OSError):
|
||||
pytest.skip("Branch does not auto-create missing directories")
|
||||
|
||||
|
||||
def test_no_overwrite_on_second_call() -> None:
|
||||
"""Second call must not overwrite existing data (no_overwrite idempotency)."""
|
||||
handler = _import_handler()
|
||||
json_dir = handler.SKILLS_JSON_DIR
|
||||
json_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
handler.ensure_json_exists("idem_mod", "data")
|
||||
|
||||
target = json_dir / "idem_mod_data.json"
|
||||
original = json.loads(target.read_text(encoding="utf-8"))
|
||||
original["custom_field"] = "do_not_overwrite"
|
||||
target.write_text(json.dumps(original, indent=2), encoding="utf-8")
|
||||
|
||||
handler.ensure_json_exists("idem_mod", "data")
|
||||
|
||||
after = json.loads(target.read_text(encoding="utf-8"))
|
||||
assert after.get("custom_field") == "do_not_overwrite"
|
||||
|
||||
|
||||
def test_returns_dict_with_expected_keys() -> None:
|
||||
"""Provisioned files contain the correct structure keys."""
|
||||
handler = _import_handler()
|
||||
|
||||
handler.ensure_json_exists("key_mod", "config")
|
||||
config = handler.load_json("key_mod", "config")
|
||||
assert isinstance(config, dict)
|
||||
assert "module_name" in config
|
||||
assert "version" in config
|
||||
|
||||
handler.ensure_json_exists("key_mod", "data")
|
||||
data = handler.load_json("key_mod", "data")
|
||||
assert isinstance(data, dict)
|
||||
assert "created" in data
|
||||
assert "last_updated" in data
|
||||
|
||||
handler.ensure_json_exists("key_mod", "log")
|
||||
log = handler.load_json("key_mod", "log")
|
||||
assert isinstance(log, list)
|
||||
@@ -0,0 +1,285 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_json_handler.py
|
||||
# Description: Tests for skills JSON handler
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-03-28
|
||||
# Modified: 2026-03-28
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Tests for skills JSON handler -- auto-creating JSON system.
|
||||
|
||||
Covers json_handler.py functions: validate_json_structure, get_json_path,
|
||||
ensure_json_exists, load_json, save_json, _get_default, ensure_module_jsons,
|
||||
log_operation.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Import helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
BRANCH_MODULE = "skills"
|
||||
_json_mod_path = f"{BRANCH_MODULE}.apps.handlers.json.json_handler"
|
||||
|
||||
|
||||
def _import_handler():
|
||||
"""Import json_handler inside test so autouse mocks are active."""
|
||||
return importlib.import_module(_json_mod_path)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def sample_data():
|
||||
"""Sample test data for JSON operations."""
|
||||
return {
|
||||
"config": {
|
||||
"module_name": "test_module",
|
||||
"version": "1.0.0",
|
||||
"config": {"max_log_entries": 50},
|
||||
"timestamp": "2026-03-28",
|
||||
},
|
||||
"data": {
|
||||
"module_name": "test_module",
|
||||
"created": "2026-03-28",
|
||||
"last_updated": "2026-03-28",
|
||||
"operations_total": 0,
|
||||
"operations_successful": 0,
|
||||
"operations_failed": 0,
|
||||
},
|
||||
"log": [{"timestamp": "2026-03-28T10:00:00", "operation": "test"}],
|
||||
}
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 1. _get_default -- default factory for JSON types
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestDefaultFactory:
|
||||
"""Tests for _get_default template default_factory."""
|
||||
|
||||
def test_config_default_factory_has_module_name(self):
|
||||
handler = _import_handler()
|
||||
result = handler._get_default("config", "test_mod")
|
||||
assert result["module_name"] == "test_mod"
|
||||
|
||||
def test_config_default_factory_has_required_keys(self):
|
||||
handler = _import_handler()
|
||||
result = handler._get_default("config", "test_mod")
|
||||
assert "module_name" in result
|
||||
assert "version" in result
|
||||
assert "config" in result
|
||||
|
||||
def test_data_default_factory_has_dates(self):
|
||||
handler = _import_handler()
|
||||
result = handler._get_default("data", "test_mod")
|
||||
assert "created" in result
|
||||
assert "last_updated" in result
|
||||
|
||||
def test_log_default_factory_is_list(self):
|
||||
handler = _import_handler()
|
||||
result = handler._get_default("log", "test_mod")
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 0
|
||||
|
||||
def test_unknown_type_default_factory_returns_none(self):
|
||||
handler = _import_handler()
|
||||
result = handler._get_default("nonexistent", "test_mod")
|
||||
assert result is None
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 2. validate_json_structure
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestValidate:
|
||||
"""Tests for validate_json_structure -- validate."""
|
||||
|
||||
def test_validate_valid_config(self, sample_data):
|
||||
handler = _import_handler()
|
||||
assert handler.validate_json_structure(sample_data["config"], "config") is True
|
||||
|
||||
def test_validate_valid_data(self, sample_data):
|
||||
handler = _import_handler()
|
||||
assert handler.validate_json_structure(sample_data["data"], "data") is True
|
||||
|
||||
def test_validate_valid_log(self, sample_data):
|
||||
handler = _import_handler()
|
||||
assert handler.validate_json_structure(sample_data["log"], "log") is True
|
||||
|
||||
def test_validate_invalid_config_missing_keys(self):
|
||||
handler = _import_handler()
|
||||
assert handler.validate_json_structure({"only": "partial"}, "config") is False
|
||||
|
||||
def test_validate_config_non_dict_fails(self):
|
||||
handler = _import_handler()
|
||||
assert handler.validate_json_structure("not a dict", "config") is False
|
||||
|
||||
def test_validate_unknown_type_fails(self):
|
||||
handler = _import_handler()
|
||||
assert handler.validate_json_structure({}, "unknown_type") is False
|
||||
|
||||
def test_validate_log_non_list_fails(self):
|
||||
handler = _import_handler()
|
||||
assert handler.validate_json_structure({"not": "a list"}, "log") is False
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 3. get_json_path -- get_path
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestGetPath:
|
||||
"""Tests for get_json_path -- get_path."""
|
||||
|
||||
def test_get_path_returns_path_type(self):
|
||||
handler = _import_handler()
|
||||
result = handler.get_json_path("test_mod", "config")
|
||||
assert isinstance(result, Path)
|
||||
|
||||
def test_get_path_contains_module_and_type(self):
|
||||
handler = _import_handler()
|
||||
result = handler.get_json_path("my_module", "data")
|
||||
assert result.name == "my_module_data.json"
|
||||
|
||||
def test_get_path_in_skills_json_dir(self):
|
||||
handler = _import_handler()
|
||||
result = handler.get_json_path("mod", "log")
|
||||
assert "skills_json" in str(result) or result.parent == handler.SKILLS_JSON_DIR
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 4. ensure_json_exists -- ensure_exists
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestEnsureExists:
|
||||
"""Tests for ensure_json_exists -- ensure_exists."""
|
||||
|
||||
def test_ensure_exists_creates_new_file(self):
|
||||
handler = _import_handler()
|
||||
result = handler.ensure_json_exists("test", "config")
|
||||
assert result is True
|
||||
|
||||
def test_ensure_exists_auto_creates_dir(self, tmp_path):
|
||||
handler = _import_handler()
|
||||
new_dir = tmp_path / "new_subdir"
|
||||
with patch.object(handler, "SKILLS_JSON_DIR", new_dir):
|
||||
result = handler.ensure_json_exists("test", "config")
|
||||
assert result is True
|
||||
assert new_dir.exists()
|
||||
|
||||
def test_ensure_exists_returns_false_for_unknown_type(self):
|
||||
handler = _import_handler()
|
||||
result = handler.ensure_json_exists("test", "nonexistent")
|
||||
assert result is False
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 5. load_json -- load
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestLoad:
|
||||
"""Tests for load_json -- load."""
|
||||
|
||||
def test_load_config_returns_dict(self):
|
||||
handler = _import_handler()
|
||||
result = handler.load_json("t", "config")
|
||||
assert isinstance(result, dict)
|
||||
|
||||
def test_load_log_returns_list(self):
|
||||
handler = _import_handler()
|
||||
result = handler.load_json("t", "log")
|
||||
assert isinstance(result, list)
|
||||
|
||||
def test_load_returns_none_for_bad_type(self):
|
||||
handler = _import_handler()
|
||||
result = handler.load_json("t", "nonexistent")
|
||||
assert result is None
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 6. save_json -- save
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestSave:
|
||||
"""Tests for save_json -- save."""
|
||||
|
||||
def test_save_valid_config(self, sample_data):
|
||||
handler = _import_handler()
|
||||
handler.ensure_json_exists("test", "config")
|
||||
result = handler.save_json("test", "config", sample_data["config"])
|
||||
assert result is True
|
||||
|
||||
def test_save_invalid_structure_returns_false(self):
|
||||
"""save_json rejects invalid data."""
|
||||
handler = _import_handler()
|
||||
result = handler.save_json("test", "config", {"bad": "structure"})
|
||||
assert result is False
|
||||
|
||||
def test_save_updates_last_updated_for_data(self, sample_data):
|
||||
handler = _import_handler()
|
||||
handler.ensure_json_exists("test", "data")
|
||||
handler.save_json("test", "data", sample_data["data"])
|
||||
json_path = handler.get_json_path("test", "data")
|
||||
saved = json.loads(json_path.read_text(encoding="utf-8"))
|
||||
assert "last_updated" in saved
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 7. log_operation
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestLogOperation:
|
||||
"""Tests for log_operation."""
|
||||
|
||||
def test_log_operation_creates_entry(self):
|
||||
handler = _import_handler()
|
||||
result = handler.log_operation("test_op", module_name="test_mod")
|
||||
assert result is True
|
||||
|
||||
def test_log_operation_entry_has_operation_field(self):
|
||||
handler = _import_handler()
|
||||
handler.log_operation("my_op", module_name="log_mod")
|
||||
log = handler.load_json("log_mod", "log")
|
||||
assert len(log) >= 1
|
||||
assert "operation" in log[-1]
|
||||
assert log[-1]["operation"] == "my_op"
|
||||
|
||||
def test_log_operation_with_data(self):
|
||||
handler = _import_handler()
|
||||
handler.log_operation("data_op", data={"key": "value"}, module_name="log_mod2")
|
||||
log = handler.load_json("log_mod2", "log")
|
||||
assert log[-1]["data"]["key"] == "value"
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 8. ensure_module_jsons -- ensure_module
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestEnsureModule:
|
||||
"""Tests for ensure_module_jsons -- ensure_module."""
|
||||
|
||||
def test_ensure_module_returns_true(self):
|
||||
handler = _import_handler()
|
||||
result = handler.ensure_module_jsons("test_mod")
|
||||
assert result is True
|
||||
|
||||
def test_ensure_module_creates_all_three(self):
|
||||
handler = _import_handler()
|
||||
handler.ensure_module_jsons("full_mod")
|
||||
for json_type in ("config", "data", "log"):
|
||||
path = handler.get_json_path("full_mod", json_type)
|
||||
assert path.exists()
|
||||
@@ -0,0 +1,208 @@
|
||||
# ===================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
|
||||
|
||||
skills_root = Path(__file__).resolve().parent.parent.parent
|
||||
if str(skills_root) not in sys.path:
|
||||
sys.path.insert(0, str(skills_root))
|
||||
|
||||
from aipass.skills.apps.handlers.template import copy_template, get_template # noqa: E402
|
||||
from aipass.skills.apps.modules.creator import create_skill # noqa: E402
|
||||
from aipass.skills.apps.modules.discovery import discover_skills_in_path, parse_frontmatter # noqa: E402, F401
|
||||
from aipass.skills.apps.handlers.loader_handler import import_handler, parse_full_skill_md # noqa: E402
|
||||
from aipass.skills.apps.modules.runner import run_skill # noqa: E402
|
||||
|
||||
|
||||
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)
|
||||
result = parse_full_skill_md(skill_path / "SKILL.md")
|
||||
metadata, body = result[0], result[1]
|
||||
assert metadata is not None
|
||||
assert isinstance(metadata, dict)
|
||||
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 = import_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
|
||||
result = parse_full_skill_md(github[0]["path"] / "SKILL.md")
|
||||
metadata, body = result[0], result[1]
|
||||
assert metadata is not None
|
||||
assert isinstance(metadata, dict)
|
||||
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)
|
||||
|
||||
def test_copy_template_excludes_pycache(self):
|
||||
"""copy_template must not include __pycache__ directories in output."""
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
try:
|
||||
template = get_template("full")
|
||||
assert template["success"] is True
|
||||
# Create a __pycache__ dir inside the template to ensure it gets filtered
|
||||
pycache = template["path"] / "__pycache__"
|
||||
pycache_existed = pycache.exists()
|
||||
if not pycache_existed:
|
||||
pycache.mkdir()
|
||||
(pycache / "dummy.pyc").write_bytes(b"\x00")
|
||||
try:
|
||||
target = Path(tmpdir) / "cache-test"
|
||||
result = copy_template(template["path"], target, "cache-test")
|
||||
assert result["success"] is True
|
||||
assert not (target / "__pycache__").exists()
|
||||
for f in result["created_files"]:
|
||||
assert "__pycache__" not in f
|
||||
finally:
|
||||
if not pycache_existed:
|
||||
shutil.rmtree(str(pycache))
|
||||
finally:
|
||||
shutil.rmtree(tmpdir)
|
||||
@@ -0,0 +1,70 @@
|
||||
# ===================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."""
|
||||
|
||||
from aipass.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
|
||||
# Verify actual values, not just key existence
|
||||
assert metadata["name"] == "github"
|
||||
assert isinstance(metadata["description"], str)
|
||||
assert len(metadata["description"]) > 0
|
||||
|
||||
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,165 @@
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: test_registry.py - Unit tests for skills registry
|
||||
# Date: 2026-03-10
|
||||
# Version: 1.0.0
|
||||
# Category: skills/tests
|
||||
# =============================================
|
||||
|
||||
"""Tests for the skills registry handler."""
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
skills_root = Path(__file__).resolve().parent.parent.parent
|
||||
if str(skills_root) not in sys.path:
|
||||
sys.path.insert(0, str(skills_root))
|
||||
|
||||
from aipass.skills.apps.handlers.registry import build_registry, get_skill, get_skill_names # noqa: E402
|
||||
|
||||
|
||||
class TestBuildRegistry:
|
||||
def _make_discover_fn(self, skills_by_path):
|
||||
"""Helper: returns a discover_fn that returns skills based on path."""
|
||||
|
||||
def discover_fn(path, source_label):
|
||||
return skills_by_path.get(str(path), [])
|
||||
|
||||
return discover_fn
|
||||
|
||||
def test_empty_search_paths(self):
|
||||
registry = build_registry([], lambda p, s: [])
|
||||
assert registry == []
|
||||
|
||||
def test_nonexistent_path_skipped(self):
|
||||
def discover_fn(p, s):
|
||||
return [{"name": "should-not-appear"}]
|
||||
|
||||
registry = build_registry(
|
||||
[("/nonexistent/path/xyz_abc_123", "test")],
|
||||
discover_fn,
|
||||
)
|
||||
assert registry == []
|
||||
|
||||
def test_discovers_skills_from_valid_path(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
skill = {"name": "alpha", "description": "A skill", "source": "test"}
|
||||
discover_fn = self._make_discover_fn({tmpdir: [skill]})
|
||||
registry = build_registry([(tmpdir, "test")], discover_fn)
|
||||
assert len(registry) == 1
|
||||
assert registry[0]["name"] == "alpha"
|
||||
assert registry[0]["description"] == "A skill"
|
||||
|
||||
def test_first_match_wins_dedup(self):
|
||||
"""When two paths contain a skill with the same name, first path wins."""
|
||||
with tempfile.TemporaryDirectory() as dir1, tempfile.TemporaryDirectory() as dir2:
|
||||
skill_v1 = {"name": "dupe", "description": "First", "source": "project"}
|
||||
skill_v2 = {"name": "dupe", "description": "Second", "source": "builtin"}
|
||||
discover_fn = self._make_discover_fn(
|
||||
{
|
||||
dir1: [skill_v1],
|
||||
dir2: [skill_v2],
|
||||
}
|
||||
)
|
||||
registry = build_registry(
|
||||
[(dir1, "project"), (dir2, "builtin")],
|
||||
discover_fn,
|
||||
)
|
||||
assert len(registry) == 1
|
||||
assert registry[0]["description"] == "First"
|
||||
assert registry[0]["source"] == "project"
|
||||
|
||||
def test_different_names_both_included(self):
|
||||
with tempfile.TemporaryDirectory() as dir1, tempfile.TemporaryDirectory() as dir2:
|
||||
skill_a = {"name": "alpha", "description": "A"}
|
||||
skill_b = {"name": "beta", "description": "B"}
|
||||
discover_fn = self._make_discover_fn(
|
||||
{
|
||||
dir1: [skill_a],
|
||||
dir2: [skill_b],
|
||||
}
|
||||
)
|
||||
registry = build_registry(
|
||||
[(dir1, "project"), (dir2, "builtin")],
|
||||
discover_fn,
|
||||
)
|
||||
assert len(registry) == 2
|
||||
names = {s["name"] for s in registry}
|
||||
assert names == {"alpha", "beta"}
|
||||
|
||||
def test_multiple_skills_from_single_path(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
skills = [
|
||||
{"name": "one", "description": "First"},
|
||||
{"name": "two", "description": "Second"},
|
||||
{"name": "three", "description": "Third"},
|
||||
]
|
||||
discover_fn = self._make_discover_fn({tmpdir: skills})
|
||||
registry = build_registry([(tmpdir, "test")], discover_fn)
|
||||
assert len(registry) == 3
|
||||
|
||||
def test_discover_fn_is_called_with_path_and_label(self):
|
||||
"""Verify discover_fn receives Path object and source label."""
|
||||
calls = []
|
||||
|
||||
def tracking_fn(path, source_label):
|
||||
calls.append((path, source_label))
|
||||
return []
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
build_registry([(tmpdir, "my_source")], tracking_fn)
|
||||
assert len(calls) == 1
|
||||
assert isinstance(calls[0][0], Path)
|
||||
assert calls[0][1] == "my_source"
|
||||
|
||||
|
||||
class TestGetSkill:
|
||||
def test_found(self):
|
||||
registry = [
|
||||
{"name": "alpha", "description": "A"},
|
||||
{"name": "beta", "description": "B"},
|
||||
]
|
||||
result = get_skill("beta", registry)
|
||||
assert result is not None
|
||||
assert result["name"] == "beta"
|
||||
assert result["description"] == "B"
|
||||
|
||||
def test_not_found(self):
|
||||
registry = [{"name": "alpha", "description": "A"}]
|
||||
result = get_skill("nonexistent", registry)
|
||||
assert result is None
|
||||
|
||||
def test_empty_registry(self):
|
||||
result = get_skill("anything", [])
|
||||
assert result is None
|
||||
|
||||
def test_returns_first_match(self):
|
||||
"""If registry somehow has duplicates, returns the first one."""
|
||||
registry = [
|
||||
{"name": "dup", "description": "First"},
|
||||
{"name": "dup", "description": "Second"},
|
||||
]
|
||||
result = get_skill("dup", registry)
|
||||
assert result is not None
|
||||
assert result["description"] == "First"
|
||||
|
||||
|
||||
class TestGetSkillNames:
|
||||
def test_returns_sorted_names(self):
|
||||
registry = [
|
||||
{"name": "charlie"},
|
||||
{"name": "alpha"},
|
||||
{"name": "bravo"},
|
||||
]
|
||||
names = get_skill_names(registry)
|
||||
assert names == ["alpha", "bravo", "charlie"]
|
||||
|
||||
def test_empty_registry(self):
|
||||
names = get_skill_names([])
|
||||
assert names == []
|
||||
|
||||
def test_single_skill(self):
|
||||
registry = [{"name": "only"}]
|
||||
names = get_skill_names(registry)
|
||||
assert names == ["only"]
|
||||
@@ -0,0 +1,101 @@
|
||||
# ===================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."""
|
||||
|
||||
from aipass.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
|
||||
# Verify values are correct, not just keys
|
||||
assert result["success"] is True
|
||||
assert "Disk Usage" in result["output"]
|
||||
assert result["error"] is None
|
||||
|
||||
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
|
||||
# Content assertions — not just types
|
||||
assert result["success"] is True
|
||||
assert len(result["output"]) > 0
|
||||
assert "Disk Usage" in result["output"]
|
||||
|
||||
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)
|
||||
# Content assertions — not just types
|
||||
assert result["success"] is False
|
||||
assert "not found" in result["error"].lower()
|
||||
assert result["output"] == ""
|
||||
@@ -0,0 +1,118 @@
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: test_runner_handler.py - Unit tests for runner_handler (empty body, etc.)
|
||||
# Date: 2026-03-10
|
||||
# Version: 1.0.0
|
||||
# Category: skills/tests
|
||||
# =============================================
|
||||
|
||||
"""Tests for the skills runner handler, focusing on run_markdown edge cases."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
skills_root = Path(__file__).resolve().parent.parent.parent
|
||||
if str(skills_root) not in sys.path:
|
||||
sys.path.insert(0, str(skills_root))
|
||||
|
||||
from aipass.skills.apps.handlers.runner_handler import run_markdown, run_handler # noqa: E402
|
||||
|
||||
|
||||
class TestRunMarkdownEmptyBody:
|
||||
def test_empty_body_returns_success(self):
|
||||
result = run_markdown("empty-skill", {"description": "test"}, "")
|
||||
assert result["success"] is True
|
||||
|
||||
def test_empty_body_output_mentions_no_instructions(self):
|
||||
result = run_markdown("empty-skill", {}, "")
|
||||
assert "no instructions body" in result["output"].lower()
|
||||
assert "empty-skill" in result["output"]
|
||||
|
||||
def test_none_body_returns_no_instructions(self):
|
||||
result = run_markdown("test-skill", {}, None)
|
||||
assert result["success"] is True
|
||||
assert "no instructions body" in result["output"].lower()
|
||||
|
||||
def test_empty_body_no_error(self):
|
||||
result = run_markdown("test-skill", {}, "")
|
||||
assert result["error"] is None
|
||||
|
||||
|
||||
class TestRunMarkdownWithBody:
|
||||
def test_body_included_in_output(self):
|
||||
result = run_markdown("my-skill", {"description": "A skill"}, "# Instructions\nDo stuff.")
|
||||
assert result["success"] is True
|
||||
assert "# Instructions" in result["output"]
|
||||
assert "Do stuff." in result["output"]
|
||||
|
||||
def test_header_includes_skill_name(self):
|
||||
result = run_markdown("my-skill", {}, "body content")
|
||||
assert "=== Skill: my-skill ===" in result["output"]
|
||||
|
||||
def test_header_includes_description(self):
|
||||
result = run_markdown("my-skill", {"description": "Does things"}, "body")
|
||||
assert "Does things" in result["output"]
|
||||
|
||||
def test_no_description_still_works(self):
|
||||
result = run_markdown("my-skill", {}, "body")
|
||||
assert result["success"] is True
|
||||
assert "=== Skill: my-skill ===" in result["output"]
|
||||
|
||||
|
||||
class TestRunHandler:
|
||||
def test_no_action_with_get_actions(self):
|
||||
"""When action is None and handler has get_actions, list them."""
|
||||
|
||||
class MockHandler:
|
||||
def get_actions(self):
|
||||
return ["disk", "memory"]
|
||||
|
||||
result = run_handler(MockHandler(), "test-skill", None, {}, {})
|
||||
assert result["success"] is True
|
||||
assert "disk" in result["output"]
|
||||
assert "memory" in result["output"]
|
||||
|
||||
def test_no_action_without_get_actions(self):
|
||||
"""When action is None and handler lacks get_actions, return error."""
|
||||
|
||||
class MockHandler:
|
||||
pass
|
||||
|
||||
result = run_handler(MockHandler(), "test-skill", None, {}, {})
|
||||
assert result["success"] is False
|
||||
assert "no action specified" in result["error"].lower()
|
||||
|
||||
def test_handler_no_run_function(self):
|
||||
class MockHandler:
|
||||
pass
|
||||
|
||||
result = run_handler(MockHandler(), "test-skill", "do_stuff", {}, {})
|
||||
assert result["success"] is False
|
||||
assert "no run() function" in result["error"].lower()
|
||||
|
||||
def test_handler_returns_dict(self):
|
||||
class MockHandler:
|
||||
def run(self, action, args=None, config=None):
|
||||
return {"success": True, "output": "done", "error": None}
|
||||
|
||||
result = run_handler(MockHandler(), "test-skill", "go", {}, {})
|
||||
assert result["success"] is True
|
||||
assert result["output"] == "done"
|
||||
|
||||
def test_handler_returns_non_dict(self):
|
||||
class MockHandler:
|
||||
def run(self, action, args=None, config=None):
|
||||
return "just a string"
|
||||
|
||||
result = run_handler(MockHandler(), "test-skill", "go", {}, {})
|
||||
assert result["success"] is True
|
||||
assert result["output"] == "just a string"
|
||||
|
||||
def test_handler_raises_exception(self):
|
||||
class MockHandler:
|
||||
def run(self, action, args=None, config=None):
|
||||
raise ValueError("boom")
|
||||
|
||||
result = run_handler(MockHandler(), "test-skill", "go", {}, {})
|
||||
assert result["success"] is False
|
||||
assert "boom" in result["error"]
|
||||
@@ -0,0 +1,235 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_template.py
|
||||
# Description: Tests for skill template management
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-04-03
|
||||
# Modified: 2026-04-03
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Tests for template.py — template resolution, placeholder replacement, copy logic.
|
||||
|
||||
Covers: get_template, _replace_placeholder_in_file, copy_template
|
||||
(valid/invalid types, placeholder replacement, binary skip, error paths,
|
||||
target exists, cleanup on failure, __pycache__ exclusion).
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from aipass.skills.apps.handlers.template import (
|
||||
TEMPLATES_DIR,
|
||||
VALID_TYPES,
|
||||
_replace_placeholder_in_file,
|
||||
copy_template,
|
||||
get_template,
|
||||
)
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 1. get_template — template path resolution
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestGetTemplate:
|
||||
"""Tests for get_template — resolve template directories."""
|
||||
|
||||
def test_markdown_only_returns_valid_path(self):
|
||||
result = get_template("markdown_only")
|
||||
assert result["success"] is True
|
||||
assert result["path"].exists()
|
||||
assert result["path"].is_dir()
|
||||
assert result["error"] is None
|
||||
|
||||
def test_with_handler_returns_valid_path(self):
|
||||
result = get_template("with_handler")
|
||||
assert result["success"] is True
|
||||
assert result["path"].exists()
|
||||
|
||||
def test_full_returns_valid_path(self):
|
||||
result = get_template("full")
|
||||
assert result["success"] is True
|
||||
assert result["path"].exists()
|
||||
|
||||
def test_invalid_type_fails(self):
|
||||
result = get_template("bogus")
|
||||
assert result["success"] is False
|
||||
assert result["path"] is None
|
||||
assert "Unknown template type" in result["error"]
|
||||
assert "bogus" in result["error"]
|
||||
|
||||
def test_error_lists_valid_types(self):
|
||||
result = get_template("wrong")
|
||||
for vt in VALID_TYPES:
|
||||
assert vt in result["error"]
|
||||
|
||||
def test_missing_directory_fails(self, monkeypatch):
|
||||
"""If template dir doesn't exist on disk, should fail gracefully."""
|
||||
_tpl_mod = sys.modules["aipass.skills.apps.handlers.template"]
|
||||
|
||||
monkeypatch.setattr(
|
||||
_tpl_mod,
|
||||
"TEMPLATES_DIR",
|
||||
Path("/nonexistent/templates"),
|
||||
)
|
||||
result = get_template("markdown_only")
|
||||
assert result["success"] is False
|
||||
assert "not found" in result["error"]
|
||||
|
||||
def test_templates_dir_points_to_real_directory(self):
|
||||
assert TEMPLATES_DIR.exists()
|
||||
assert TEMPLATES_DIR.is_dir()
|
||||
|
||||
def test_all_valid_types_have_directories(self):
|
||||
for vt in VALID_TYPES:
|
||||
assert (TEMPLATES_DIR / vt).exists(), f"Missing template dir: {vt}"
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 2. _replace_placeholder_in_file — in-file substitution
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestReplacePlaceholder:
|
||||
"""Tests for _replace_placeholder_in_file — {{SKILL_NAME}} replacement."""
|
||||
|
||||
def test_replaces_placeholder_in_text(self, tmp_path):
|
||||
f = tmp_path / "test.md"
|
||||
f.write_text("name: {{SKILL_NAME}}\ndesc: {{SKILL_NAME}} is great")
|
||||
_replace_placeholder_in_file(f, "my-tool")
|
||||
content = f.read_text()
|
||||
assert "my-tool" in content
|
||||
assert "{{SKILL_NAME}}" not in content
|
||||
|
||||
def test_no_placeholder_leaves_file_unchanged(self, tmp_path):
|
||||
f = tmp_path / "noop.txt"
|
||||
original = "no placeholders here"
|
||||
f.write_text(original)
|
||||
_replace_placeholder_in_file(f, "anything")
|
||||
assert f.read_text() == original
|
||||
|
||||
def test_skips_binary_file(self, tmp_path):
|
||||
"""Binary files with UnicodeDecodeError should be silently skipped."""
|
||||
f = tmp_path / "binary.bin"
|
||||
f.write_bytes(b"\x80\x81\x82\xff{{SKILL_NAME}}")
|
||||
# Should not raise
|
||||
_replace_placeholder_in_file(f, "test")
|
||||
# File should still be binary (unchanged or at least not crash)
|
||||
assert f.exists()
|
||||
|
||||
def test_empty_file_no_error(self, tmp_path):
|
||||
f = tmp_path / "empty.md"
|
||||
f.write_text("")
|
||||
_replace_placeholder_in_file(f, "test")
|
||||
assert f.read_text() == ""
|
||||
|
||||
def test_multiple_placeholders_all_replaced(self, tmp_path):
|
||||
f = tmp_path / "multi.md"
|
||||
f.write_text("A={{SKILL_NAME}} B={{SKILL_NAME}} C={{SKILL_NAME}}")
|
||||
_replace_placeholder_in_file(f, "x")
|
||||
content = f.read_text()
|
||||
assert content == "A=x B=x C=x"
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 3. copy_template — full template copy pipeline
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestCopyTemplate:
|
||||
"""Tests for copy_template — copy + placeholder replacement."""
|
||||
|
||||
def test_copy_markdown_template(self, tmp_path):
|
||||
src = get_template("markdown_only")
|
||||
target = tmp_path / "new-skill"
|
||||
result = copy_template(src["path"], target, "new-skill")
|
||||
assert result["success"] is True
|
||||
assert target.exists()
|
||||
assert len(result["created_files"]) > 0
|
||||
assert result["error"] is None
|
||||
|
||||
def test_created_files_are_sorted(self, tmp_path):
|
||||
src = get_template("with_handler")
|
||||
target = tmp_path / "sorted-test"
|
||||
result = copy_template(src["path"], target, "sorted-test")
|
||||
assert result["created_files"] == sorted(result["created_files"])
|
||||
|
||||
def test_placeholders_replaced_in_all_files(self, tmp_path):
|
||||
src = get_template("with_handler")
|
||||
target = tmp_path / "placeholder-test"
|
||||
copy_template(src["path"], target, "placeholder-test")
|
||||
for f in target.rglob("*"):
|
||||
if f.is_file():
|
||||
try:
|
||||
content = f.read_text(encoding="utf-8")
|
||||
assert "{{SKILL_NAME}}" not in content, f"Unreplaced in {f.name}"
|
||||
except UnicodeDecodeError:
|
||||
pass # skip binary
|
||||
|
||||
def test_target_already_exists_fails(self, tmp_path):
|
||||
target = tmp_path / "exists"
|
||||
target.mkdir()
|
||||
src = get_template("markdown_only")
|
||||
result = copy_template(src["path"], target, "exists")
|
||||
assert result["success"] is False
|
||||
assert "already exists" in result["error"]
|
||||
assert result["created_files"] == []
|
||||
|
||||
def test_invalid_source_fails(self, tmp_path):
|
||||
target = tmp_path / "bad-src"
|
||||
result = copy_template(Path("/nonexistent/template"), target, "bad")
|
||||
assert result["success"] is False
|
||||
assert "Failed to create skill" in result["error"]
|
||||
|
||||
def test_cleanup_on_failure(self, tmp_path):
|
||||
"""If copy fails mid-way, target dir should be cleaned up."""
|
||||
target = tmp_path / "cleanup-test"
|
||||
result = copy_template(Path("/nonexistent"), target, "test")
|
||||
assert result["success"] is False
|
||||
# Target should not exist after cleanup
|
||||
assert not target.exists()
|
||||
|
||||
def test_pycache_excluded(self, tmp_path):
|
||||
"""__pycache__ directories must not appear in output."""
|
||||
src = get_template("full")
|
||||
assert src["success"]
|
||||
# Inject a __pycache__ into the template temporarily
|
||||
pycache = src["path"] / "__pycache__"
|
||||
created = False
|
||||
if not pycache.exists():
|
||||
pycache.mkdir()
|
||||
(pycache / "cached.pyc").write_bytes(b"\x00")
|
||||
created = True
|
||||
try:
|
||||
target = tmp_path / "no-cache"
|
||||
result = copy_template(src["path"], target, "no-cache")
|
||||
assert result["success"] is True
|
||||
assert not (target / "__pycache__").exists()
|
||||
for f in result["created_files"]:
|
||||
assert "__pycache__" not in f
|
||||
finally:
|
||||
if created:
|
||||
shutil.rmtree(str(pycache))
|
||||
|
||||
def test_full_template_has_apps_structure(self, tmp_path):
|
||||
src = get_template("full")
|
||||
target = tmp_path / "full-test"
|
||||
result = copy_template(src["path"], target, "full-test")
|
||||
assert result["success"] is True
|
||||
assert (target / "apps").is_dir()
|
||||
assert (target / "apps" / "modules").is_dir()
|
||||
assert (target / "apps" / "handlers").is_dir()
|
||||
|
||||
def test_logs_template_copied_operation(self, tmp_path):
|
||||
_tpl_mod = sys.modules["aipass.skills.apps.handlers.template"]
|
||||
|
||||
with patch.object(_tpl_mod, "json_handler") as mock_jh:
|
||||
src = get_template("markdown_only")
|
||||
target = tmp_path / "log-test"
|
||||
copy_template(src["path"], target, "log-test")
|
||||
mock_jh.log_operation.assert_called_once()
|
||||
call_args = mock_jh.log_operation.call_args
|
||||
assert call_args[0][0] == "template_copied"
|
||||
assert call_args[0][1]["files_count"] > 0
|
||||
@@ -0,0 +1,111 @@
|
||||
# ===================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
|
||||
|
||||
skills_root = Path(__file__).resolve().parent.parent.parent
|
||||
if str(skills_root) not in sys.path:
|
||||
sys.path.insert(0, str(skills_root))
|
||||
|
||||
from aipass.skills.apps.handlers.validator import validate_skill # noqa: E402
|
||||
|
||||
|
||||
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
|
||||
# Verify actual values, not just key existence
|
||||
assert result["valid"] is True
|
||||
assert result["missing_pip"] == []
|
||||
assert result["missing_bins"] == []
|
||||
assert result["missing_config"] == []
|
||||
|
||||
def test_return_structure_with_failures(self):
|
||||
"""Verify structure contains actual failure data, not just keys."""
|
||||
result = validate_skill(
|
||||
{
|
||||
"requires": {
|
||||
"pip": ["nonexistent_pkg_xyz_123"],
|
||||
"bins": ["nonexistent_bin_xyz"],
|
||||
"config": ["NONEXISTENT_VAR_XYZ"],
|
||||
}
|
||||
}
|
||||
)
|
||||
assert result["valid"] is False
|
||||
assert result["missing_pip"] == ["nonexistent_pkg_xyz_123"]
|
||||
assert result["missing_bins"] == ["nonexistent_bin_xyz"]
|
||||
assert result["missing_config"] == ["NONEXISTENT_VAR_XYZ"]
|
||||
Reference in New Issue
Block a user