From 1871e55b513ff2db161caf79399e930835255a4a Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Fri, 12 Jun 2026 00:25:11 -0700 Subject: [PATCH] =?UTF-8?q?feat(skills):=20revive=20dormant=20citizen=20?= =?UTF-8?q?=E2=80=94=20namespace=20skills.*=E2=86=92aipass.skills.*=20(48?= =?UTF-8?q?=20imports),=20path-depth=20parents[3]=E2=86=92[4],=20happy-pat?= =?UTF-8?q?h=20logger.info()=20central=20logging=20(DPLAN-0203=20night=20s?= =?UTF-8?q?hift).=20252=20tests,=20seedgo=20100%?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/aipass/skills/.aipass/README.md | 3 + .../skills/.aipass/aipass_local_prompt.md | 58 +++ .../.aipass/skills/another_test/SKILL.md | 27 ++ .../.aipass/skills/another_test/handler.py | 30 ++ .../skills/.aipass/skills/full_test/SKILL.md | 27 ++ .../.aipass/skills/full_test/apps/__init__.py | 7 + .../full_test/apps/handlers/__init__.py | 13 + .../skills/full_test/apps/modules/__init__.py | 13 + .../.aipass/skills/full_test/handler.py | 24 ++ .../skills/.aipass/skills/test_skill/SKILL.md | 27 ++ src/aipass/skills/.claude/README.md | 5 + src/aipass/skills/.gitignore | 14 + src/aipass/skills/.seedgo/README.md | 3 + src/aipass/skills/.seedgo/bypass.json | 57 +++ src/aipass/skills/README.md | 169 +++++++++ src/aipass/skills/__init__.py | 13 + src/aipass/skills/apps/README.md | 3 + src/aipass/skills/apps/__init__.py | 15 + src/aipass/skills/apps/handlers/README.md | 3 + src/aipass/skills/apps/handlers/__init__.py | 124 +++++++ .../skills/apps/handlers/creator_handler.py | 110 ++++++ .../skills/apps/handlers/discovery_handler.py | 268 ++++++++++++++ .../skills/apps/handlers/json/__init__.py | 1 + .../skills/apps/handlers/json/json_handler.py | 221 ++++++++++++ .../skills/apps/handlers/loader_handler.py | 185 ++++++++++ src/aipass/skills/apps/handlers/registry.py | 77 ++++ .../skills/apps/handlers/runner_handler.py | 127 +++++++ src/aipass/skills/apps/handlers/template.py | 121 +++++++ src/aipass/skills/apps/handlers/validator.py | 114 ++++++ src/aipass/skills/apps/integrations/README.md | 3 + .../apps/json_templates/default/config.json | 9 + .../apps/json_templates/default/data.json | 8 + .../apps/json_templates/default/log.json | 1 + src/aipass/skills/apps/modules/README.md | 3 + src/aipass/skills/apps/modules/__init__.py | 13 + src/aipass/skills/apps/modules/creator.py | 114 ++++++ src/aipass/skills/apps/modules/discovery.py | 105 ++++++ src/aipass/skills/apps/modules/loader.py | 98 ++++++ src/aipass/skills/apps/modules/runner.py | 137 ++++++++ src/aipass/skills/apps/modules/validator.py | 107 ++++++ src/aipass/skills/apps/plugins/README.md | 3 + src/aipass/skills/apps/plugins/__init__.py | 7 + src/aipass/skills/apps/skills.py | 330 ++++++++++++++++++ src/aipass/skills/catalog/.gitkeep | 0 .../skills/catalog/branch_health/SKILL.md | 46 +++ .../skills/catalog/branch_health/handler.py | 226 ++++++++++++ .../skills/catalog/drone_commands/SKILL.md | 76 ++++ .../catalog/drone_commands/apps/__init__.py | 13 + .../drone_commands/apps/handlers/__init__.py | 13 + .../drone_commands/apps/handlers/executor.py | 104 ++++++ .../drone_commands/apps/handlers/parser.py | 122 +++++++ .../drone_commands/apps/modules/__init__.py | 13 + .../apps/modules/command_runner.py | 180 ++++++++++ .../skills/catalog/drone_commands/handler.py | 101 ++++++ src/aipass/skills/catalog/github/SKILL.md | 145 ++++++++ .../skills/catalog/inbox_check/SKILL.md | 46 +++ .../skills/catalog/inbox_check/handler.py | 185 ++++++++++ .../skills/catalog/system_status/SKILL.md | 58 +++ .../skills/catalog/system_status/handler.py | 243 +++++++++++++ src/aipass/skills/docs/.gitkeep | 0 src/aipass/skills/docs/README.md | 3 + src/aipass/skills/pytest.ini | 22 ++ src/aipass/skills/requirements.project.txt | 0 src/aipass/skills/templates/README.md | 3 + src/aipass/skills/templates/full/SKILL.md | 27 ++ .../skills/templates/full/apps/__init__.py | 7 + .../templates/full/apps/handlers/__init__.py | 13 + .../templates/full/apps/modules/__init__.py | 13 + src/aipass/skills/templates/full/handler.py | 24 ++ .../skills/templates/markdown_only/SKILL.md | 27 ++ .../skills/templates/with_handler/SKILL.md | 27 ++ .../skills/templates/with_handler/handler.py | 30 ++ src/aipass/skills/tests/README.md | 3 + src/aipass/skills/tests/__init__.py | 13 + src/aipass/skills/tests/conftest.py | 178 ++++++++++ src/aipass/skills/tests/test_cli_routing.py | 205 +++++++++++ src/aipass/skills/tests/test_contracts.py | 132 +++++++ src/aipass/skills/tests/test_creator.py | 164 +++++++++ .../skills/tests/test_creator_handler.py | 169 +++++++++ src/aipass/skills/tests/test_discovery.py | 204 +++++++++++ .../skills/tests/test_error_resilience.py | 98 ++++++ .../skills/tests/test_init_provisioning.py | 108 ++++++ src/aipass/skills/tests/test_json_handler.py | 285 +++++++++++++++ src/aipass/skills/tests/test_lifecycle.py | 208 +++++++++++ src/aipass/skills/tests/test_loader.py | 70 ++++ src/aipass/skills/tests/test_registry.py | 165 +++++++++ src/aipass/skills/tests/test_runner.py | 101 ++++++ .../skills/tests/test_runner_handler.py | 118 +++++++ src/aipass/skills/tests/test_template.py | 235 +++++++++++++ src/aipass/skills/tests/test_validator.py | 111 ++++++ 90 files changed, 7131 insertions(+) create mode 100644 src/aipass/skills/.aipass/README.md create mode 100644 src/aipass/skills/.aipass/aipass_local_prompt.md create mode 100644 src/aipass/skills/.aipass/skills/another_test/SKILL.md create mode 100644 src/aipass/skills/.aipass/skills/another_test/handler.py create mode 100644 src/aipass/skills/.aipass/skills/full_test/SKILL.md create mode 100644 src/aipass/skills/.aipass/skills/full_test/apps/__init__.py create mode 100644 src/aipass/skills/.aipass/skills/full_test/apps/handlers/__init__.py create mode 100644 src/aipass/skills/.aipass/skills/full_test/apps/modules/__init__.py create mode 100644 src/aipass/skills/.aipass/skills/full_test/handler.py create mode 100644 src/aipass/skills/.aipass/skills/test_skill/SKILL.md create mode 100644 src/aipass/skills/.claude/README.md create mode 100644 src/aipass/skills/.gitignore create mode 100644 src/aipass/skills/.seedgo/README.md create mode 100644 src/aipass/skills/.seedgo/bypass.json create mode 100644 src/aipass/skills/README.md create mode 100644 src/aipass/skills/__init__.py create mode 100644 src/aipass/skills/apps/README.md create mode 100644 src/aipass/skills/apps/__init__.py create mode 100644 src/aipass/skills/apps/handlers/README.md create mode 100644 src/aipass/skills/apps/handlers/__init__.py create mode 100644 src/aipass/skills/apps/handlers/creator_handler.py create mode 100644 src/aipass/skills/apps/handlers/discovery_handler.py create mode 100644 src/aipass/skills/apps/handlers/json/__init__.py create mode 100644 src/aipass/skills/apps/handlers/json/json_handler.py create mode 100644 src/aipass/skills/apps/handlers/loader_handler.py create mode 100644 src/aipass/skills/apps/handlers/registry.py create mode 100644 src/aipass/skills/apps/handlers/runner_handler.py create mode 100644 src/aipass/skills/apps/handlers/template.py create mode 100644 src/aipass/skills/apps/handlers/validator.py create mode 100644 src/aipass/skills/apps/integrations/README.md create mode 100644 src/aipass/skills/apps/json_templates/default/config.json create mode 100644 src/aipass/skills/apps/json_templates/default/data.json create mode 100644 src/aipass/skills/apps/json_templates/default/log.json create mode 100644 src/aipass/skills/apps/modules/README.md create mode 100644 src/aipass/skills/apps/modules/__init__.py create mode 100644 src/aipass/skills/apps/modules/creator.py create mode 100644 src/aipass/skills/apps/modules/discovery.py create mode 100644 src/aipass/skills/apps/modules/loader.py create mode 100644 src/aipass/skills/apps/modules/runner.py create mode 100644 src/aipass/skills/apps/modules/validator.py create mode 100644 src/aipass/skills/apps/plugins/README.md create mode 100644 src/aipass/skills/apps/plugins/__init__.py create mode 100644 src/aipass/skills/apps/skills.py create mode 100644 src/aipass/skills/catalog/.gitkeep create mode 100644 src/aipass/skills/catalog/branch_health/SKILL.md create mode 100644 src/aipass/skills/catalog/branch_health/handler.py create mode 100644 src/aipass/skills/catalog/drone_commands/SKILL.md create mode 100644 src/aipass/skills/catalog/drone_commands/apps/__init__.py create mode 100644 src/aipass/skills/catalog/drone_commands/apps/handlers/__init__.py create mode 100644 src/aipass/skills/catalog/drone_commands/apps/handlers/executor.py create mode 100644 src/aipass/skills/catalog/drone_commands/apps/handlers/parser.py create mode 100644 src/aipass/skills/catalog/drone_commands/apps/modules/__init__.py create mode 100644 src/aipass/skills/catalog/drone_commands/apps/modules/command_runner.py create mode 100644 src/aipass/skills/catalog/drone_commands/handler.py create mode 100644 src/aipass/skills/catalog/github/SKILL.md create mode 100644 src/aipass/skills/catalog/inbox_check/SKILL.md create mode 100644 src/aipass/skills/catalog/inbox_check/handler.py create mode 100644 src/aipass/skills/catalog/system_status/SKILL.md create mode 100644 src/aipass/skills/catalog/system_status/handler.py create mode 100644 src/aipass/skills/docs/.gitkeep create mode 100644 src/aipass/skills/docs/README.md create mode 100644 src/aipass/skills/pytest.ini create mode 100644 src/aipass/skills/requirements.project.txt create mode 100644 src/aipass/skills/templates/README.md create mode 100644 src/aipass/skills/templates/full/SKILL.md create mode 100644 src/aipass/skills/templates/full/apps/__init__.py create mode 100644 src/aipass/skills/templates/full/apps/handlers/__init__.py create mode 100644 src/aipass/skills/templates/full/apps/modules/__init__.py create mode 100644 src/aipass/skills/templates/full/handler.py create mode 100644 src/aipass/skills/templates/markdown_only/SKILL.md create mode 100644 src/aipass/skills/templates/with_handler/SKILL.md create mode 100644 src/aipass/skills/templates/with_handler/handler.py create mode 100644 src/aipass/skills/tests/README.md create mode 100644 src/aipass/skills/tests/__init__.py create mode 100644 src/aipass/skills/tests/conftest.py create mode 100644 src/aipass/skills/tests/test_cli_routing.py create mode 100644 src/aipass/skills/tests/test_contracts.py create mode 100644 src/aipass/skills/tests/test_creator.py create mode 100644 src/aipass/skills/tests/test_creator_handler.py create mode 100644 src/aipass/skills/tests/test_discovery.py create mode 100644 src/aipass/skills/tests/test_error_resilience.py create mode 100644 src/aipass/skills/tests/test_init_provisioning.py create mode 100644 src/aipass/skills/tests/test_json_handler.py create mode 100644 src/aipass/skills/tests/test_lifecycle.py create mode 100644 src/aipass/skills/tests/test_loader.py create mode 100644 src/aipass/skills/tests/test_registry.py create mode 100644 src/aipass/skills/tests/test_runner.py create mode 100644 src/aipass/skills/tests/test_runner_handler.py create mode 100644 src/aipass/skills/tests/test_template.py create mode 100644 src/aipass/skills/tests/test_validator.py diff --git a/src/aipass/skills/.aipass/README.md b/src/aipass/skills/.aipass/README.md new file mode 100644 index 00000000..3a18f3c0 --- /dev/null +++ b/src/aipass/skills/.aipass/README.md @@ -0,0 +1,3 @@ +# .aipass + +AIPass local configuration and prompts for the skills branch. diff --git a/src/aipass/skills/.aipass/aipass_local_prompt.md b/src/aipass/skills/.aipass/aipass_local_prompt.md new file mode 100644 index 00000000..a00be480 --- /dev/null +++ b/src/aipass/skills/.aipass/aipass_local_prompt.md @@ -0,0 +1,58 @@ +# SKILLS — Branch Context + + +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 # Display SKILL.md contents +drone @skills run [action] [args] # Execute a skill's handler +drone @skills create # Scaffold new skill (markdown only) +drone @skills create --with-handler # Scaffold with handler.py +drone @skills create --full # Scaffold with full 3-layer structure +drone @skills validate # 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 diff --git a/src/aipass/skills/.aipass/skills/another_test/SKILL.md b/src/aipass/skills/.aipass/skills/another_test/SKILL.md new file mode 100644 index 00000000..bc7f5601 --- /dev/null +++ b/src/aipass/skills/.aipass/skills/another_test/SKILL.md @@ -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 +``` diff --git a/src/aipass/skills/.aipass/skills/another_test/handler.py b/src/aipass/skills/.aipass/skills/another_test/handler.py new file mode 100644 index 00000000..aa687a64 --- /dev/null +++ b/src/aipass/skills/.aipass/skills/another_test/handler.py @@ -0,0 +1,30 @@ +""" +another_test skill handler + +Called by: drone @skills run another_test [args] +""" + + +def run(action, args=None, config=None): + """Execute a skill action. + + Args: + action: What to do + args: Dict of action arguments + config: Dict of resolved config values + + Returns: + {"success": bool, "output": str, "error": str|None} + """ + args = args or {} + config = config or {} + + if action == "example": + return {"success": True, "output": "It works!", "error": None} + + return {"success": False, "output": "", "error": f"Unknown action: {action}"} + + +def get_actions(): + """List available actions for this skill.""" + return ["example"] diff --git a/src/aipass/skills/.aipass/skills/full_test/SKILL.md b/src/aipass/skills/.aipass/skills/full_test/SKILL.md new file mode 100644 index 00000000..14ccde01 --- /dev/null +++ b/src/aipass/skills/.aipass/skills/full_test/SKILL.md @@ -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 +``` diff --git a/src/aipass/skills/.aipass/skills/full_test/apps/__init__.py b/src/aipass/skills/.aipass/skills/full_test/apps/__init__.py new file mode 100644 index 00000000..254aa8dd --- /dev/null +++ b/src/aipass/skills/.aipass/skills/full_test/apps/__init__.py @@ -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 +# ============================================= diff --git a/src/aipass/skills/.aipass/skills/full_test/apps/handlers/__init__.py b/src/aipass/skills/.aipass/skills/full_test/apps/handlers/__init__.py new file mode 100644 index 00000000..d8469cca --- /dev/null +++ b/src/aipass/skills/.aipass/skills/full_test/apps/handlers/__init__.py @@ -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 +# ============================================= diff --git a/src/aipass/skills/.aipass/skills/full_test/apps/modules/__init__.py b/src/aipass/skills/.aipass/skills/full_test/apps/modules/__init__.py new file mode 100644 index 00000000..f1d4d7f5 --- /dev/null +++ b/src/aipass/skills/.aipass/skills/full_test/apps/modules/__init__.py @@ -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) +# ============================================= diff --git a/src/aipass/skills/.aipass/skills/full_test/handler.py b/src/aipass/skills/.aipass/skills/full_test/handler.py new file mode 100644 index 00000000..d213f2ba --- /dev/null +++ b/src/aipass/skills/.aipass/skills/full_test/handler.py @@ -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, + } diff --git a/src/aipass/skills/.aipass/skills/test_skill/SKILL.md b/src/aipass/skills/.aipass/skills/test_skill/SKILL.md new file mode 100644 index 00000000..a9e3aa22 --- /dev/null +++ b/src/aipass/skills/.aipass/skills/test_skill/SKILL.md @@ -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 +``` diff --git a/src/aipass/skills/.claude/README.md b/src/aipass/skills/.claude/README.md new file mode 100644 index 00000000..43662d6d --- /dev/null +++ b/src/aipass/skills/.claude/README.md @@ -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. diff --git a/src/aipass/skills/.gitignore b/src/aipass/skills/.gitignore new file mode 100644 index 00000000..9cf1dfc4 --- /dev/null +++ b/src/aipass/skills/.gitignore @@ -0,0 +1,14 @@ +__pycache__/ +*.pyc +*.pyo +.env +*.egg-info/ +.coverage +htmlcov/ +.pytest_cache/ +.mypy_cache/ +dist/ +build/ +*.log +*.tmp +*.swp diff --git a/src/aipass/skills/.seedgo/README.md b/src/aipass/skills/.seedgo/README.md new file mode 100644 index 00000000..28284aee --- /dev/null +++ b/src/aipass/skills/.seedgo/README.md @@ -0,0 +1,3 @@ +# .seedgo + +Seedgo audit configuration and bypass rules for the skills branch. diff --git a/src/aipass/skills/.seedgo/bypass.json b/src/aipass/skills/.seedgo/bypass.json new file mode 100644 index 00000000..0f99da83 --- /dev/null +++ b/src/aipass/skills/.seedgo/bypass.json @@ -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" + } + } +} \ No newline at end of file diff --git a/src/aipass/skills/README.md b/src/aipass/skills/README.md new file mode 100644 index 00000000..94f0afe6 --- /dev/null +++ b/src/aipass/skills/README.md @@ -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 # Display SKILL.md contents +drone @skills run [action] [args] # Execute a skill's handler +drone @skills create # Scaffold new skill (markdown only) +drone @skills create --with-handler # Scaffold with handler.py +drone @skills create --full # Scaffold with full 3-layer structure +drone @skills validate # 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) \ No newline at end of file diff --git a/src/aipass/skills/__init__.py b/src/aipass/skills/__init__.py new file mode 100644 index 00000000..42c29217 --- /dev/null +++ b/src/aipass/skills/__init__.py @@ -0,0 +1,13 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: __init__.py - Skills package root +# Date: 2026-03-07 +# Version: 1.0.0 +# Category: skills +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-03-07): Initial implementation +# +# CODE STANDARDS: +# - Package root for the Skills system +# ============================================= diff --git a/src/aipass/skills/apps/README.md b/src/aipass/skills/apps/README.md new file mode 100644 index 00000000..bfe80c28 --- /dev/null +++ b/src/aipass/skills/apps/README.md @@ -0,0 +1,3 @@ +# apps + +Core application code for the skills module. diff --git a/src/aipass/skills/apps/__init__.py b/src/aipass/skills/apps/__init__.py new file mode 100644 index 00000000..ae85af88 --- /dev/null +++ b/src/aipass/skills/apps/__init__.py @@ -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 diff --git a/src/aipass/skills/apps/handlers/README.md b/src/aipass/skills/apps/handlers/README.md new file mode 100644 index 00000000..084fbf46 --- /dev/null +++ b/src/aipass/skills/apps/handlers/README.md @@ -0,0 +1,3 @@ +# handlers + +Low-level handler functions for skill operations. diff --git a/src/aipass/skills/apps/handlers/__init__.py b/src/aipass/skills/apps/handlers/__init__.py new file mode 100644 index 00000000..b5d1e28e --- /dev/null +++ b/src/aipass/skills/apps/handlers/__init__.py @@ -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 ("", ""): + 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. import \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. import \n" + f"\n" + f" For full standards guide:\n" + f" drone @seedgo handlers\n" + f"{'=' * 60}" + ) + + +# Run guard at import time +_guard_branch_access() diff --git a/src/aipass/skills/apps/handlers/creator_handler.py b/src/aipass/skills/apps/handlers/creator_handler.py new file mode 100644 index 00000000..e4498104 --- /dev/null +++ b/src/aipass/skills/apps/handlers/creator_handler.py @@ -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"], + } diff --git a/src/aipass/skills/apps/handlers/discovery_handler.py b/src/aipass/skills/apps/handlers/discovery_handler.py new file mode 100644 index 00000000..e9c09274 --- /dev/null +++ b/src/aipass/skills/apps/handlers/discovery_handler.py @@ -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("'\"") diff --git a/src/aipass/skills/apps/handlers/json/__init__.py b/src/aipass/skills/apps/handlers/json/__init__.py new file mode 100644 index 00000000..df96d94e --- /dev/null +++ b/src/aipass/skills/apps/handlers/json/__init__.py @@ -0,0 +1 @@ +"""Skills JSON handler package.""" diff --git a/src/aipass/skills/apps/handlers/json/json_handler.py b/src/aipass/skills/apps/handlers/json/json_handler.py new file mode 100644 index 00000000..e056bb85 --- /dev/null +++ b/src/aipass/skills/apps/handlers/json/json_handler.py @@ -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) diff --git a/src/aipass/skills/apps/handlers/loader_handler.py b/src/aipass/skills/apps/handlers/loader_handler.py new file mode 100644 index 00000000..e83c6de9 --- /dev/null +++ b/src/aipass/skills/apps/handlers/loader_handler.py @@ -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, + } diff --git a/src/aipass/skills/apps/handlers/registry.py b/src/aipass/skills/apps/handlers/registry.py new file mode 100644 index 00000000..349928c9 --- /dev/null +++ b/src/aipass/skills/apps/handlers/registry.py @@ -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) diff --git a/src/aipass/skills/apps/handlers/runner_handler.py b/src/aipass/skills/apps/handlers/runner_handler.py new file mode 100644 index 00000000..3a3240e2 --- /dev/null +++ b/src/aipass/skills/apps/handlers/runner_handler.py @@ -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, + } diff --git a/src/aipass/skills/apps/handlers/template.py b/src/aipass/skills/apps/handlers/template.py new file mode 100644 index 00000000..9ded5643 --- /dev/null +++ b/src/aipass/skills/apps/handlers/template.py @@ -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}", + } diff --git a/src/aipass/skills/apps/handlers/validator.py b/src/aipass/skills/apps/handlers/validator.py new file mode 100644 index 00000000..27fe9848 --- /dev/null +++ b/src/aipass/skills/apps/handlers/validator.py @@ -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 diff --git a/src/aipass/skills/apps/integrations/README.md b/src/aipass/skills/apps/integrations/README.md new file mode 100644 index 00000000..f5a21921 --- /dev/null +++ b/src/aipass/skills/apps/integrations/README.md @@ -0,0 +1,3 @@ +# Integrations + +Extension point for external integrations. diff --git a/src/aipass/skills/apps/json_templates/default/config.json b/src/aipass/skills/apps/json_templates/default/config.json new file mode 100644 index 00000000..9f7e5454 --- /dev/null +++ b/src/aipass/skills/apps/json_templates/default/config.json @@ -0,0 +1,9 @@ +{ + "module_name": "{{MODULE_NAME}}", + "version": "1.0.0", + "timestamp": "{{TIMESTAMP}}", + "config": { + "auto_save": true, + "enabled": true + } +} diff --git a/src/aipass/skills/apps/json_templates/default/data.json b/src/aipass/skills/apps/json_templates/default/data.json new file mode 100644 index 00000000..c88b23de --- /dev/null +++ b/src/aipass/skills/apps/json_templates/default/data.json @@ -0,0 +1,8 @@ +{ + "module_name": "{{MODULE_NAME}}", + "created": "{{TIMESTAMP}}", + "last_updated": "{{TIMESTAMP}}", + "operations_total": 0, + "operations_successful": 0, + "operations_failed": 0 +} diff --git a/src/aipass/skills/apps/json_templates/default/log.json b/src/aipass/skills/apps/json_templates/default/log.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/src/aipass/skills/apps/json_templates/default/log.json @@ -0,0 +1 @@ +[] diff --git a/src/aipass/skills/apps/modules/README.md b/src/aipass/skills/apps/modules/README.md new file mode 100644 index 00000000..860f270f --- /dev/null +++ b/src/aipass/skills/apps/modules/README.md @@ -0,0 +1,3 @@ +# modules + +High-level module interfaces that orchestrate handler functions. diff --git a/src/aipass/skills/apps/modules/__init__.py b/src/aipass/skills/apps/modules/__init__.py new file mode 100644 index 00000000..fcda6ee4 --- /dev/null +++ b/src/aipass/skills/apps/modules/__init__.py @@ -0,0 +1,13 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: __init__.py - Skills modules package +# Date: 2026-03-07 +# Version: 1.0.0 +# Category: skills/apps/modules +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-03-07): Initial implementation +# +# CODE STANDARDS: +# - Modules layer: business logic orchestration (can print) +# ============================================= diff --git a/src/aipass/skills/apps/modules/creator.py b/src/aipass/skills/apps/modules/creator.py new file mode 100644 index 00000000..9e3017f4 --- /dev/null +++ b/src/aipass/skills/apps/modules/creator.py @@ -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 [--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() diff --git a/src/aipass/skills/apps/modules/discovery.py b/src/aipass/skills/apps/modules/discovery.py new file mode 100644 index 00000000..861801d0 --- /dev/null +++ b/src/aipass/skills/apps/modules/discovery.py @@ -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 ") + 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() diff --git a/src/aipass/skills/apps/modules/loader.py b/src/aipass/skills/apps/modules/loader.py new file mode 100644 index 00000000..ba0e5607 --- /dev/null +++ b/src/aipass/skills/apps/modules/loader.py @@ -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() diff --git a/src/aipass/skills/apps/modules/runner.py b/src/aipass/skills/apps/modules/runner.py new file mode 100644 index 00000000..be2adaa3 --- /dev/null +++ b/src/aipass/skills/apps/modules/runner.py @@ -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 [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() diff --git a/src/aipass/skills/apps/modules/validator.py b/src/aipass/skills/apps/modules/validator.py new file mode 100644 index 00000000..a33cb266 --- /dev/null +++ b/src/aipass/skills/apps/modules/validator.py @@ -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 ") + 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() diff --git a/src/aipass/skills/apps/plugins/README.md b/src/aipass/skills/apps/plugins/README.md new file mode 100644 index 00000000..6f8f8981 --- /dev/null +++ b/src/aipass/skills/apps/plugins/README.md @@ -0,0 +1,3 @@ +# plugins + +Plugin extensions for the skills system. diff --git a/src/aipass/skills/apps/plugins/__init__.py b/src/aipass/skills/apps/plugins/__init__.py new file mode 100644 index 00000000..6e5f5b47 --- /dev/null +++ b/src/aipass/skills/apps/plugins/__init__.py @@ -0,0 +1,7 @@ +# =================== AIPass ==================== +# Name: __init__.py +# Description: Skills plugins package +# Version: 1.0.0 +# Created: 2026-03-08 +# Modified: 2026-03-08 +# ============================================= diff --git a/src/aipass/skills/apps/skills.py b/src/aipass/skills/apps/skills.py new file mode 100644 index 00000000..a774988f --- /dev/null +++ b/src/aipass/skills/apps/skills.py @@ -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 ") + return False + return _cmd_info(args[0]) + + if command == "run": + if not args: + error("Error: skill name required. Usage: skills run [action] [args...]") + return False + name = args[0] + action = args[1] if len(args) > 1 else None + extra_args = _parse_extra_args(args[2:]) if len(args) > 2 else {} + return _cmd_run(name, action, extra_args) + + if command == "create": + if not args: + error("Error: skill name required. Usage: skills create [--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 ") + 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 [args]") + console.print() + console.print("Commands:") + console.print(" list Show all discovered skills") + console.print(" info Display SKILL.md contents") + console.print(" run [action] [args] Execute a skill's handler") + console.print(" create Scaffold new skill (markdown only)") + console.print(" create --with-handler Scaffold with handler.py") + console.print(" create --full Scaffold with full 3-layer structure") + console.print(" validate 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 ") + 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 Create a markdown-only skill") + console.print(" drone @skills create --with-handler Create with handler.py") + console.print(" drone @skills create --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) diff --git a/src/aipass/skills/catalog/.gitkeep b/src/aipass/skills/catalog/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/skills/catalog/branch_health/SKILL.md b/src/aipass/skills/catalog/branch_health/SKILL.md new file mode 100644 index 00000000..5f82f162 --- /dev/null +++ b/src/aipass/skills/catalog/branch_health/SKILL.md @@ -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 diff --git a/src/aipass/skills/catalog/branch_health/handler.py b/src/aipass/skills/catalog/branch_health/handler.py new file mode 100644 index 00000000..c9d74b8d --- /dev/null +++ b/src/aipass/skills/catalog/branch_health/handler.py @@ -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 +""" + +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", ""] + + +# --------------------------------------------------------------------------- +# 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// first, then src// + 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} diff --git a/src/aipass/skills/catalog/drone_commands/SKILL.md b/src/aipass/skills/catalog/drone_commands/SKILL.md new file mode 100644 index 00000000..08cb8a0f --- /dev/null +++ b/src/aipass/skills/catalog/drone_commands/SKILL.md @@ -0,0 +1,76 @@ +--- +name: drone_commands +description: Execute drone commands -- the AIPass CLI interface for all module operations +version: 1.0.0 +tags: [system, cli, drone, aipass] +requires: + pip: [] + bins: [] + config: [] +has_handler: true +--- + +# Drone Commands Skill + +Execute drone commands programmatically. Drone is the AIPass CLI router that dispatches commands to system modules. + +## Available Actions + +| Action | Description | +|----------|-----------------------------------------------------| +| `run` | Execute an arbitrary drone command string | +| `list` | List all available drone modules (`drone systems`) | +| `help` | Get help for a specific module (`drone @module --help`) | + +## Usage + +```bash +drone @skills run drone_commands run --args '{"command": "drone @ai_mail inbox"}' +drone @skills run drone_commands list +drone @skills run drone_commands help --args '{"module": "ai_mail"}' +``` + +## How Drone Routing Works + +Drone uses `@module` syntax to route commands to the correct system module: + +``` +drone @ai_mail inbox -> routes to ai_mail module +drone @skills list -> routes to skills module +drone @devpulse dashboard -> routes to devpulse module +drone commons feed -> special case (no @ prefix) +drone systems -> lists all registered modules +``` + +## Architecture + +This skill follows the AIPass 3-layer pattern: + +``` +drone_commands/ + SKILL.md # This file + handler.py # Top-level handler (delegates to apps/) + apps/ + modules/ + command_runner.py # Orchestrates drone command execution + handlers/ + executor.py # Runs commands via subprocess + parser.py # Parses drone output +``` + +## Output Format + +All actions return structured dicts: + +```python +{"success": True, "output": "...", "error": None} +``` + +The `run` action returns the full stdout/stderr from the drone command. + +## Notes + +- Commands execute in the AIPASS_ROOT directory by default +- Timeout defaults to 30 seconds (configurable) +- Never runs commands that modify system state without explicit action +- All output is captured, never printed directly diff --git a/src/aipass/skills/catalog/drone_commands/apps/__init__.py b/src/aipass/skills/catalog/drone_commands/apps/__init__.py new file mode 100644 index 00000000..1db46e55 --- /dev/null +++ b/src/aipass/skills/catalog/drone_commands/apps/__init__.py @@ -0,0 +1,13 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: __init__.py - drone_commands apps package +# Date: 2026-03-07 +# Version: 1.0.0 +# Category: skills/catalog/drone_commands/apps +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-03-07): Initial implementation +# +# CODE STANDARDS: +# - Apps layer entry point +# ============================================= diff --git a/src/aipass/skills/catalog/drone_commands/apps/handlers/__init__.py b/src/aipass/skills/catalog/drone_commands/apps/handlers/__init__.py new file mode 100644 index 00000000..a38b5733 --- /dev/null +++ b/src/aipass/skills/catalog/drone_commands/apps/handlers/__init__.py @@ -0,0 +1,13 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: __init__.py - drone_commands handlers package +# Date: 2026-03-07 +# Version: 1.0.0 +# Category: skills/catalog/drone_commands/apps/handlers +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-03-07): Initial implementation +# +# CODE STANDARDS: +# - Handlers layer: returns dicts, NEVER prints +# ============================================= diff --git a/src/aipass/skills/catalog/drone_commands/apps/handlers/executor.py b/src/aipass/skills/catalog/drone_commands/apps/handlers/executor.py new file mode 100644 index 00000000..a25910ff --- /dev/null +++ b/src/aipass/skills/catalog/drone_commands/apps/handlers/executor.py @@ -0,0 +1,104 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: executor.py - Runs drone commands via subprocess +# Date: 2026-03-07 +# Version: 1.0.0 +# Category: skills/catalog/drone_commands/apps/handlers +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-03-07): Initial implementation +# +# CODE STANDARDS: +# - Handlers layer: returns dicts, NEVER prints +# - stdlib only (no external deps) +# - Graceful error handling +# ============================================= + +""" +Executor handler for drone commands. + +Runs shell commands via subprocess and captures output. +Never prints -- always returns structured dicts. +""" + +import os +import subprocess +from pathlib import Path + +AIPASS_ROOT = Path(os.environ.get("AIPASS_ROOT", str(Path.home()))) +DEFAULT_TIMEOUT = 30 + + +def execute(command, cwd=None, timeout=None): + """Run a command via subprocess and capture output. + + Args: + command: The command string to execute. + cwd: Working directory for the command. Defaults to AIPASS_ROOT. + timeout: Timeout in seconds. Defaults to DEFAULT_TIMEOUT. + + Returns: + { + "success": bool, + "stdout": str, + "stderr": str, + "returncode": int + } + """ + if cwd is None: + cwd = str(AIPASS_ROOT) + if timeout is None: + timeout = DEFAULT_TIMEOUT + + # Validate command is not empty + if not command or not command.strip(): + return { + "success": False, + "stdout": "", + "stderr": "Empty command", + "returncode": -1, + } + + try: + result = subprocess.run( + command, + shell=True, + capture_output=True, + text=True, + cwd=cwd, + timeout=timeout, + ) + return { + "success": result.returncode == 0, + "stdout": result.stdout, + "stderr": result.stderr, + "returncode": result.returncode, + } + except subprocess.TimeoutExpired: + return { + "success": False, + "stdout": "", + "stderr": f"Command timed out after {timeout}s: {command}", + "returncode": -1, + } + except FileNotFoundError as exc: + return { + "success": False, + "stdout": "", + "stderr": f"File not found (bad cwd or shell?): {exc}", + "returncode": -1, + } + except OSError as exc: + return { + "success": False, + "stdout": "", + "stderr": f"OS error running command: {exc}", + "returncode": -1, + } + except Exception as exc: + return { + "success": False, + "stdout": "", + "stderr": f"Unexpected error: {exc}", + "returncode": -1, + } diff --git a/src/aipass/skills/catalog/drone_commands/apps/handlers/parser.py b/src/aipass/skills/catalog/drone_commands/apps/handlers/parser.py new file mode 100644 index 00000000..74737a98 --- /dev/null +++ b/src/aipass/skills/catalog/drone_commands/apps/handlers/parser.py @@ -0,0 +1,122 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: parser.py - Parses drone command output +# Date: 2026-03-07 +# Version: 1.0.0 +# Category: skills/catalog/drone_commands/apps/handlers +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-03-07): Initial implementation +# +# CODE STANDARDS: +# - Handlers layer: returns dicts, NEVER prints +# - stdlib only (no external deps) +# - Pure functions, no side effects +# ============================================= + +""" +Parser handler for drone command output. + +Cleans up and structures raw drone output into usable data. +Never prints -- always returns structured results. +""" + +import re + + +def parse_output(raw_output): + """Clean up raw drone command output. + + Strips ANSI escape codes, trims whitespace, and normalizes line endings. + + Args: + raw_output: Raw string output from a drone command. + + Returns: + str: Cleaned output string. + """ + if not raw_output: + return "" + + # Strip ANSI escape sequences (color codes, cursor movements, etc.) + ansi_pattern = re.compile(r"\x1b\[[0-9;]*[a-zA-Z]") + cleaned = ansi_pattern.sub("", raw_output) + + # Normalize line endings + cleaned = cleaned.replace("\r\n", "\n").replace("\r", "\n") + + # Strip trailing whitespace from each line, remove excess blank lines + lines = cleaned.split("\n") + lines = [line.rstrip() for line in lines] + + # Collapse multiple consecutive blank lines into one + result_lines = [] + prev_blank = False + for line in lines: + is_blank = len(line.strip()) == 0 + if is_blank and prev_blank: + continue + result_lines.append(line) + prev_blank = is_blank + + # Strip leading/trailing blank lines from result + result = "\n".join(result_lines).strip() + + return result + + +def extract_modules(systems_output): + """Parse `drone systems` output into a list of module names. + + Expects output where each line contains a module name, possibly with + status indicators or descriptions. Extracts the module name from each + non-empty, non-header line. + + Args: + systems_output: Raw output from `drone systems` command. + + Returns: + list[str]: List of module name strings. + """ + if not systems_output: + return [] + + cleaned = parse_output(systems_output) + lines = cleaned.split("\n") + + modules = [] + for line in lines: + line = line.strip() + + # Skip empty lines + if not line: + continue + + # Skip header/separator lines (dashes, equals, common headers) + if line.startswith("---") or line.startswith("==="): + continue + if line.lower().startswith("registered") or line.lower().startswith("available"): + continue + + # Extract module name -- could be first word, or prefixed with indicators + # Common formats: + # module_name - plain name + # [OK] module_name - with status + # * module_name - with bullet + # @module_name - with @ prefix + + # Remove common prefixes + cleaned_line = line + cleaned_line = re.sub(r"^\[.*?\]\s*", "", cleaned_line) # [OK], [ERR], etc. + cleaned_line = re.sub(r"^[*\-+]\s*", "", cleaned_line) # bullet points + cleaned_line = cleaned_line.lstrip("@") # @ prefix + + # Take first word as module name + parts = cleaned_line.split() + if parts: + module_name = parts[0].strip() + # Validate it looks like a module name (alphanumeric + underscores) + if re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", module_name): + modules.append(module_name) + + return modules diff --git a/src/aipass/skills/catalog/drone_commands/apps/modules/__init__.py b/src/aipass/skills/catalog/drone_commands/apps/modules/__init__.py new file mode 100644 index 00000000..310625a8 --- /dev/null +++ b/src/aipass/skills/catalog/drone_commands/apps/modules/__init__.py @@ -0,0 +1,13 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: __init__.py - drone_commands modules package +# Date: 2026-03-07 +# Version: 1.0.0 +# Category: skills/catalog/drone_commands/apps/modules +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-03-07): Initial implementation +# +# CODE STANDARDS: +# - Modules layer: orchestration (can print) +# ============================================= diff --git a/src/aipass/skills/catalog/drone_commands/apps/modules/command_runner.py b/src/aipass/skills/catalog/drone_commands/apps/modules/command_runner.py new file mode 100644 index 00000000..ba34f1dd --- /dev/null +++ b/src/aipass/skills/catalog/drone_commands/apps/modules/command_runner.py @@ -0,0 +1,180 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: command_runner.py - Orchestrates drone command execution +# Date: 2026-03-07 +# Version: 1.0.0 +# Category: skills/catalog/drone_commands/apps/modules +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-03-07): Initial implementation +# +# CODE STANDARDS: +# - Modules layer: orchestration +# - Delegates to handlers for execution and parsing +# - Returns dicts for skill handler contract +# - stdlib only (no external deps) +# ============================================= + +""" +Command runner module for drone_commands skill. + +Orchestrates drone command execution by coordinating between +the executor (subprocess) and parser (output cleanup) handlers. +""" + +import os +import sys + +# Resolve imports relative to this skill's package +_THIS_DIR = os.path.dirname(os.path.abspath(__file__)) +_APPS_DIR = os.path.dirname(_THIS_DIR) +_HANDLERS_DIR = os.path.join(_APPS_DIR, "handlers") + +# Add handlers to path if not already there +if _HANDLERS_DIR not in sys.path: + sys.path.insert(0, _HANDLERS_DIR) +if _APPS_DIR not in sys.path: + sys.path.insert(0, _APPS_DIR) + +from handlers import executor, parser # noqa: E402 + + +AIPASS_ROOT = os.environ.get("AIPASS_ROOT", os.path.expanduser("~")) +DRONE_BIN = os.path.join(AIPASS_ROOT, "drone") + + +def run_command(command_string, timeout=None): + """Run an arbitrary drone command. + + Args: + command_string: The full drone command to execute + (e.g., "drone @ai_mail inbox"). + timeout: Optional timeout in seconds. + + Returns: + {"success": bool, "output": str, "error": str|None} + """ + if not command_string or not command_string.strip(): + return { + "success": False, + "output": "", + "error": "No command provided", + } + + # Ensure command starts with "drone" if not already + cmd = command_string.strip() + if not cmd.startswith("drone"): + cmd = f"drone {cmd}" + + # Execute via handler + result = executor.execute(cmd, cwd=AIPASS_ROOT, timeout=timeout) + + # Parse and clean output + stdout_clean = parser.parse_output(result.get("stdout", "")) + stderr_clean = parser.parse_output(result.get("stderr", "")) + + if result["success"]: + return { + "success": True, + "output": stdout_clean, + "error": None, + } + + # Command failed -- include both stdout and stderr + error_parts = [] + if stderr_clean: + error_parts.append(stderr_clean) + error_msg = "\n".join(error_parts) if error_parts else f"Command failed with exit code {result['returncode']}" + + output = stdout_clean if stdout_clean else "" + + return { + "success": False, + "output": output, + "error": error_msg, + } + + +def list_modules(timeout=None): + """List all available drone modules via `drone systems`. + + Args: + timeout: Optional timeout in seconds. + + Returns: + {"success": bool, "output": str, "error": str|None} + """ + result = executor.execute("drone systems", cwd=AIPASS_ROOT, timeout=timeout) + + if not result["success"]: + stderr_clean = parser.parse_output(result.get("stderr", "")) + return { + "success": False, + "output": "", + "error": stderr_clean or f"'drone systems' failed with exit code {result['returncode']}", + } + + stdout_clean = parser.parse_output(result.get("stdout", "")) + modules = parser.extract_modules(result.get("stdout", "")) + + if modules: + module_list = "\n".join(f" - {m}" for m in modules) + output = f"Registered modules ({len(modules)}):\n{module_list}" + else: + # Fallback: show raw cleaned output if parsing found nothing + output = stdout_clean if stdout_clean else "No modules found" + + return { + "success": True, + "output": output, + "error": None, + } + + +def module_help(module_name, timeout=None): + """Get help for a specific drone module. + + Args: + module_name: The module to get help for (e.g., "ai_mail"). + timeout: Optional timeout in seconds. + + Returns: + {"success": bool, "output": str, "error": str|None} + """ + if not module_name or not module_name.strip(): + return { + "success": False, + "output": "", + "error": "No module name provided", + } + + module_name = module_name.strip().lstrip("@") + cmd = f"drone @{module_name} --help" + + result = executor.execute(cmd, cwd=AIPASS_ROOT, timeout=timeout) + + stdout_clean = parser.parse_output(result.get("stdout", "")) + stderr_clean = parser.parse_output(result.get("stderr", "")) + + if result["success"]: + output = stdout_clean if stdout_clean else f"No help output for module '{module_name}'" + return { + "success": True, + "output": output, + "error": None, + } + + # Some modules output help to stderr + if stderr_clean and ("usage" in stderr_clean.lower() or "help" in stderr_clean.lower()): + return { + "success": True, + "output": stderr_clean, + "error": None, + } + + error_msg = stderr_clean or f"Failed to get help for module '{module_name}'" + return { + "success": False, + "output": stdout_clean, + "error": error_msg, + } diff --git a/src/aipass/skills/catalog/drone_commands/handler.py b/src/aipass/skills/catalog/drone_commands/handler.py new file mode 100644 index 00000000..88f679e4 --- /dev/null +++ b/src/aipass/skills/catalog/drone_commands/handler.py @@ -0,0 +1,101 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: handler.py - Drone Commands skill handler +# Date: 2026-03-07 +# Version: 1.0.0 +# Category: skills/catalog/drone_commands +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-03-07): Initial implementation +# +# CODE STANDARDS: +# - Top-level handler: delegates to apps/modules/ +# - Returns dicts, NEVER prints +# - stdlib only (no external deps) +# - Graceful error handling +# ============================================= + +""" +Drone Commands skill handler. + +Top-level entry point that delegates to the command_runner module +in the 3-layer apps/ structure. + +Called by: drone @skills run drone_commands [args] +""" + +import os +import sys + +# Set up import path for this skill's apps package +_THIS_DIR = os.path.dirname(os.path.abspath(__file__)) +_APPS_DIR = os.path.join(_THIS_DIR, "apps") +_MODULES_DIR = os.path.join(_APPS_DIR, "modules") + +if _MODULES_DIR not in sys.path: + sys.path.insert(0, _MODULES_DIR) +if _APPS_DIR not in sys.path: + sys.path.insert(0, _APPS_DIR) + +from modules import command_runner # noqa: E402 + + +def run(action, args=None, config=None): + """Execute a drone commands action. + + Args: + action: One of: run, list, help + args: Dict of action arguments: + - run: {"command": "drone @module action"} + - list: {} (no args needed) + - help: {"module": "module_name"} + config: Dict of resolved config values (unused) + + Returns: + {"success": bool, "output": str, "error": str|None} + """ + args = args or {} + config = config or {} + + timeout = args.get("timeout") + if timeout is not None: + try: + timeout = int(timeout) + except (ValueError, TypeError): + timeout = None + + if action == "run": + command = args.get("command", "") + if not command: + return { + "success": False, + "output": "", + "error": "Missing 'command' argument. Usage: --args '{\"command\": \"drone @module action\"}'", + } + return command_runner.run_command(command, timeout=timeout) + + elif action == "list": + return command_runner.list_modules(timeout=timeout) + + elif action == "help": + module_name = args.get("module", "") + if not module_name: + return { + "success": False, + "output": "", + "error": "Missing 'module' argument. Usage: --args '{\"module\": \"module_name\"}'", + } + return command_runner.module_help(module_name, timeout=timeout) + + else: + available = ", ".join(get_actions()) + return { + "success": False, + "output": "", + "error": f"Unknown action: {action}. Available: {available}", + } + + +def get_actions(): + """List available actions for this skill.""" + return ["run", "list", "help"] diff --git a/src/aipass/skills/catalog/github/SKILL.md b/src/aipass/skills/catalog/github/SKILL.md new file mode 100644 index 00000000..780616cc --- /dev/null +++ b/src/aipass/skills/catalog/github/SKILL.md @@ -0,0 +1,145 @@ +--- +name: github +description: "GitHub operations via gh CLI: issues, PRs, CI runs, code review, API queries." +version: 1.0.0 +tags: [dev, git, ci, github] +requires: + bins: [gh] + pip: [] + config: [] +has_handler: false +--- + +# GitHub Skill + +Use the `gh` CLI to interact with GitHub repositories, issues, PRs, and CI. + +## When to Use + +**USE this skill when:** + +- Checking PR status, reviews, or merge readiness +- Viewing CI/workflow run status and logs +- Creating, closing, or commenting on issues +- Creating or merging pull requests +- Querying GitHub API for repository data +- Listing repos, releases, or collaborators + +## When NOT to Use + +**DON'T use this skill when:** + +- Local git operations (commit, push, pull, branch) -> use `git` directly +- Non-GitHub repos (GitLab, Bitbucket, self-hosted) -> different CLIs +- Cloning repositories -> use `git clone` +- Reviewing actual code changes -> use `coding-agent` skill +- Complex multi-file diffs -> use `coding-agent` or read files directly + +## Setup + +```bash +# Authenticate (one-time) +gh auth login + +# Verify +gh auth status +``` + +## Common Commands + +### Pull Requests + +```bash +# List PRs +gh pr list --repo owner/repo + +# Check CI status +gh pr checks 55 --repo owner/repo + +# View PR details +gh pr view 55 --repo owner/repo + +# Create PR +gh pr create --title "feat: add feature" --body "Description" + +# Merge PR +gh pr merge 55 --squash --repo owner/repo +``` + +### Issues + +```bash +# List issues +gh issue list --repo owner/repo --state open + +# Create issue +gh issue create --title "Bug: something broken" --body "Details..." + +# Close issue +gh issue close 42 --repo owner/repo +``` + +### CI/Workflow Runs + +```bash +# List recent runs +gh run list --repo owner/repo --limit 10 + +# View specific run +gh run view --repo owner/repo + +# View failed step logs only +gh run view --repo owner/repo --log-failed + +# Re-run failed jobs +gh run rerun --failed --repo owner/repo +``` + +### API Queries + +```bash +# Get PR with specific fields +gh api repos/owner/repo/pulls/55 --jq '.title, .state, .user.login' + +# List all labels +gh api repos/owner/repo/labels --jq '.[].name' + +# Get repo stats +gh api repos/owner/repo --jq '{stars: .stargazers_count, forks: .forks_count}' +``` + +## JSON Output + +Most commands support `--json` for structured output with `--jq` filtering: + +```bash +gh issue list --repo owner/repo --json number,title --jq '.[] | "\(.number): \(.title)"' +gh pr list --json number,title,state,mergeable --jq '.[] | select(.mergeable == "MERGEABLE")' +``` + +## Templates + +### PR Review Summary + +```bash +# Get PR overview for review +PR=55 REPO=owner/repo +echo "## PR #$PR Summary" +gh pr view $PR --repo $REPO --json title,body,author,additions,deletions,changedFiles \ + --jq '"**\(.title)** by @\(.author.login)\n\n\(.body)\n\n+\(.additions) -\(.deletions) across \(.changedFiles) files"' +gh pr checks $PR --repo $REPO +``` + +### Issue Triage + +```bash +# Quick issue triage view +gh issue list --repo owner/repo --state open --json number,title,labels,createdAt \ + --jq '.[] | "[\(.number)] \(.title) - \([.labels[].name] | join(", ")) (\(.createdAt[:10]))"' +``` + +## Notes + +- Always specify `--repo owner/repo` when not in a git directory +- Use URLs directly: `gh pr view https://github.com/owner/repo/pull/55` +- Rate limits apply; use `gh api --cache 1h` for repeated queries diff --git a/src/aipass/skills/catalog/inbox_check/SKILL.md b/src/aipass/skills/catalog/inbox_check/SKILL.md new file mode 100644 index 00000000..78db3927 --- /dev/null +++ b/src/aipass/skills/catalog/inbox_check/SKILL.md @@ -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 diff --git a/src/aipass/skills/catalog/inbox_check/handler.py b/src/aipass/skills/catalog/inbox_check/handler.py new file mode 100644 index 00000000..c5d23ff3 --- /dev/null +++ b/src/aipass/skills/catalog/inbox_check/handler.py @@ -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 +""" + +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", ""] + + +# --------------------------------------------------------------------------- +# 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// first, then src// + 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} diff --git a/src/aipass/skills/catalog/system_status/SKILL.md b/src/aipass/skills/catalog/system_status/SKILL.md new file mode 100644 index 00000000..dfd6fb49 --- /dev/null +++ b/src/aipass/skills/catalog/system_status/SKILL.md @@ -0,0 +1,58 @@ +--- +name: system_status +description: Check system health -- disk usage, memory, running processes, uptime +version: 1.0.0 +tags: [system, monitoring, health] +requires: + pip: [] + bins: [] + config: [] +has_handler: true +--- + +# System Status Skill + +Check system health metrics without leaving your workflow. Returns structured data about disk usage, memory, running processes, and system uptime. + +## Available Actions + +| Action | Description | +|-------------|------------------------------------------------| +| `disk` | Disk usage for the root filesystem | +| `memory` | Memory usage from /proc/meminfo (Linux) | +| `uptime` | System uptime from /proc/uptime | +| `processes` | Count of currently running processes | +| `summary` | All of the above combined into one report | + +## Usage + +```bash +drone @skills run system_status disk +drone @skills run system_status memory +drone @skills run system_status uptime +drone @skills run system_status processes +drone @skills run system_status summary +``` + +## Output Format + +All actions return structured dicts: + +```python +{"success": True, "output": "...", "error": None} +``` + +## When to Use + +- Quick health check before resource-intensive operations +- Diagnosing slow performance (memory pressure, disk full) +- Monitoring system state during long-running tasks +- Getting a snapshot of system health for reports + +## Notes + +- All data comes from stdlib / procfs -- no external dependencies +- Memory info reads from `/proc/meminfo` (Linux only) +- Uptime reads from `/proc/uptime` (Linux only) +- Disk usage uses `shutil.disk_usage()` (cross-platform) +- Process count uses `/proc` directory listing (Linux only) diff --git a/src/aipass/skills/catalog/system_status/handler.py b/src/aipass/skills/catalog/system_status/handler.py new file mode 100644 index 00000000..072ab15e --- /dev/null +++ b/src/aipass/skills/catalog/system_status/handler.py @@ -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 +""" + +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} diff --git a/src/aipass/skills/docs/.gitkeep b/src/aipass/skills/docs/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/skills/docs/README.md b/src/aipass/skills/docs/README.md new file mode 100644 index 00000000..ad75bf3f --- /dev/null +++ b/src/aipass/skills/docs/README.md @@ -0,0 +1,3 @@ +# docs + +Public documentation for the skills module. diff --git a/src/aipass/skills/pytest.ini b/src/aipass/skills/pytest.ini new file mode 100644 index 00000000..036a1e82 --- /dev/null +++ b/src/aipass/skills/pytest.ini @@ -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 diff --git a/src/aipass/skills/requirements.project.txt b/src/aipass/skills/requirements.project.txt new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/skills/templates/README.md b/src/aipass/skills/templates/README.md new file mode 100644 index 00000000..1eef4f94 --- /dev/null +++ b/src/aipass/skills/templates/README.md @@ -0,0 +1,3 @@ +# templates + +Skill scaffolding templates (markdown_only, with_handler, full). diff --git a/src/aipass/skills/templates/full/SKILL.md b/src/aipass/skills/templates/full/SKILL.md new file mode 100644 index 00000000..6b2a1026 --- /dev/null +++ b/src/aipass/skills/templates/full/SKILL.md @@ -0,0 +1,27 @@ +--- +name: {{SKILL_NAME}} +description: TODO — describe what this skill does +version: 1.0.0 +tags: [] +requires: + pip: [] + bins: [] + config: [] +has_handler: true +--- + +# {{SKILL_NAME}} + +## What This Does +TODO + +## When to Use +TODO + +## Steps +1. TODO + +## Example +``` +TODO +``` diff --git a/src/aipass/skills/templates/full/apps/__init__.py b/src/aipass/skills/templates/full/apps/__init__.py new file mode 100644 index 00000000..39ea0f03 --- /dev/null +++ b/src/aipass/skills/templates/full/apps/__init__.py @@ -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 +# ============================================= diff --git a/src/aipass/skills/templates/full/apps/handlers/__init__.py b/src/aipass/skills/templates/full/apps/handlers/__init__.py new file mode 100644 index 00000000..00bdc3c8 --- /dev/null +++ b/src/aipass/skills/templates/full/apps/handlers/__init__.py @@ -0,0 +1,13 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: __init__.py - {{SKILL_NAME}} handlers package +# Date: 2026-03-07 +# Version: 1.0.0 +# Category: skills/catalog/{{SKILL_NAME}}/apps/handlers +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-03-07): Initial scaffold +# +# CODE STANDARDS: +# - Handlers layer: returns dicts, NEVER prints +# ============================================= diff --git a/src/aipass/skills/templates/full/apps/modules/__init__.py b/src/aipass/skills/templates/full/apps/modules/__init__.py new file mode 100644 index 00000000..37ce0633 --- /dev/null +++ b/src/aipass/skills/templates/full/apps/modules/__init__.py @@ -0,0 +1,13 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: __init__.py - {{SKILL_NAME}} modules package +# Date: 2026-03-07 +# Version: 1.0.0 +# Category: skills/catalog/{{SKILL_NAME}}/apps/modules +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-03-07): Initial scaffold +# +# CODE STANDARDS: +# - Modules layer: orchestration (can print) +# ============================================= diff --git a/src/aipass/skills/templates/full/handler.py b/src/aipass/skills/templates/full/handler.py new file mode 100644 index 00000000..f6ca247f --- /dev/null +++ b/src/aipass/skills/templates/full/handler.py @@ -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, + } diff --git a/src/aipass/skills/templates/markdown_only/SKILL.md b/src/aipass/skills/templates/markdown_only/SKILL.md new file mode 100644 index 00000000..d51bd04e --- /dev/null +++ b/src/aipass/skills/templates/markdown_only/SKILL.md @@ -0,0 +1,27 @@ +--- +name: {{SKILL_NAME}} +description: TODO — describe what this skill does +version: 1.0.0 +tags: [] +requires: + pip: [] + bins: [] + config: [] +has_handler: false +--- + +# {{SKILL_NAME}} + +## What This Does +TODO + +## When to Use +TODO + +## Steps +1. TODO + +## Example +``` +TODO +``` diff --git a/src/aipass/skills/templates/with_handler/SKILL.md b/src/aipass/skills/templates/with_handler/SKILL.md new file mode 100644 index 00000000..6b2a1026 --- /dev/null +++ b/src/aipass/skills/templates/with_handler/SKILL.md @@ -0,0 +1,27 @@ +--- +name: {{SKILL_NAME}} +description: TODO — describe what this skill does +version: 1.0.0 +tags: [] +requires: + pip: [] + bins: [] + config: [] +has_handler: true +--- + +# {{SKILL_NAME}} + +## What This Does +TODO + +## When to Use +TODO + +## Steps +1. TODO + +## Example +``` +TODO +``` diff --git a/src/aipass/skills/templates/with_handler/handler.py b/src/aipass/skills/templates/with_handler/handler.py new file mode 100644 index 00000000..d130dd51 --- /dev/null +++ b/src/aipass/skills/templates/with_handler/handler.py @@ -0,0 +1,30 @@ +""" +{{SKILL_NAME}} skill handler + +Called by: drone @skills run {{SKILL_NAME}} [args] +""" + + +def run(action, args=None, config=None): + """Execute a skill action. + + Args: + action: What to do + args: Dict of action arguments + config: Dict of resolved config values + + Returns: + {"success": bool, "output": str, "error": str|None} + """ + args = args or {} + config = config or {} + + if action == "example": + return {"success": True, "output": "It works!", "error": None} + + return {"success": False, "output": "", "error": f"Unknown action: {action}"} + + +def get_actions(): + """List available actions for this skill.""" + return ["example"] diff --git a/src/aipass/skills/tests/README.md b/src/aipass/skills/tests/README.md new file mode 100644 index 00000000..554393f9 --- /dev/null +++ b/src/aipass/skills/tests/README.md @@ -0,0 +1,3 @@ +# tests + +Test suite for the skills module. diff --git a/src/aipass/skills/tests/__init__.py b/src/aipass/skills/tests/__init__.py new file mode 100644 index 00000000..fe9c92ef --- /dev/null +++ b/src/aipass/skills/tests/__init__.py @@ -0,0 +1,13 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: __init__.py - Skills tests package +# Date: 2026-03-07 +# Version: 1.0.0 +# Category: skills/tests +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-03-07): Initial implementation +# +# CODE STANDARDS: +# - Test package for the Skills system +# ============================================= diff --git a/src/aipass/skills/tests/conftest.py b/src/aipass/skills/tests/conftest.py new file mode 100644 index 00000000..b19add62 --- /dev/null +++ b/src/aipass/skills/tests/conftest.py @@ -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 diff --git a/src/aipass/skills/tests/test_cli_routing.py b/src/aipass/skills/tests/test_cli_routing.py new file mode 100644 index 00000000..7d3bf3da --- /dev/null +++ b/src/aipass/skills/tests/test_cli_routing.py @@ -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 diff --git a/src/aipass/skills/tests/test_contracts.py b/src/aipass/skills/tests/test_contracts.py new file mode 100644 index 00000000..945ee279 --- /dev/null +++ b/src/aipass/skills/tests/test_contracts.py @@ -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" diff --git a/src/aipass/skills/tests/test_creator.py b/src/aipass/skills/tests/test_creator.py new file mode 100644 index 00000000..d23e3b67 --- /dev/null +++ b/src/aipass/skills/tests/test_creator.py @@ -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 diff --git a/src/aipass/skills/tests/test_creator_handler.py b/src/aipass/skills/tests/test_creator_handler.py new file mode 100644 index 00000000..9b6d59bf --- /dev/null +++ b/src/aipass/skills/tests/test_creator_handler.py @@ -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 diff --git a/src/aipass/skills/tests/test_discovery.py b/src/aipass/skills/tests/test_discovery.py new file mode 100644 index 00000000..91acf4af --- /dev/null +++ b/src/aipass/skills/tests/test_discovery.py @@ -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 diff --git a/src/aipass/skills/tests/test_error_resilience.py b/src/aipass/skills/tests/test_error_resilience.py new file mode 100644 index 00000000..b6805920 --- /dev/null +++ b/src/aipass/skills/tests/test_error_resilience.py @@ -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 diff --git a/src/aipass/skills/tests/test_init_provisioning.py b/src/aipass/skills/tests/test_init_provisioning.py new file mode 100644 index 00000000..bddab684 --- /dev/null +++ b/src/aipass/skills/tests/test_init_provisioning.py @@ -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) diff --git a/src/aipass/skills/tests/test_json_handler.py b/src/aipass/skills/tests/test_json_handler.py new file mode 100644 index 00000000..8db6a272 --- /dev/null +++ b/src/aipass/skills/tests/test_json_handler.py @@ -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() diff --git a/src/aipass/skills/tests/test_lifecycle.py b/src/aipass/skills/tests/test_lifecycle.py new file mode 100644 index 00000000..7c85cfe9 --- /dev/null +++ b/src/aipass/skills/tests/test_lifecycle.py @@ -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) diff --git a/src/aipass/skills/tests/test_loader.py b/src/aipass/skills/tests/test_loader.py new file mode 100644 index 00000000..48b79d3f --- /dev/null +++ b/src/aipass/skills/tests/test_loader.py @@ -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 diff --git a/src/aipass/skills/tests/test_registry.py b/src/aipass/skills/tests/test_registry.py new file mode 100644 index 00000000..91099ebe --- /dev/null +++ b/src/aipass/skills/tests/test_registry.py @@ -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"] diff --git a/src/aipass/skills/tests/test_runner.py b/src/aipass/skills/tests/test_runner.py new file mode 100644 index 00000000..09db2cf6 --- /dev/null +++ b/src/aipass/skills/tests/test_runner.py @@ -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"] == "" diff --git a/src/aipass/skills/tests/test_runner_handler.py b/src/aipass/skills/tests/test_runner_handler.py new file mode 100644 index 00000000..28219634 --- /dev/null +++ b/src/aipass/skills/tests/test_runner_handler.py @@ -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"] diff --git a/src/aipass/skills/tests/test_template.py b/src/aipass/skills/tests/test_template.py new file mode 100644 index 00000000..f33c63ae --- /dev/null +++ b/src/aipass/skills/tests/test_template.py @@ -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 diff --git a/src/aipass/skills/tests/test_validator.py b/src/aipass/skills/tests/test_validator.py new file mode 100644 index 00000000..2f6b5eea --- /dev/null +++ b/src/aipass/skills/tests/test_validator.py @@ -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"]