Merge pull request #74 from AIOSAI/feat/drone-activated-commands
feat(drone): restore activated commands — scan, activate, list, remove, execute
This commit is contained in:
@@ -25,6 +25,10 @@ drone systems # List all registered modules and branches
|
||||
drone @seedgo verify # Route "verify" to the seedgo module
|
||||
drone @seedgo audit aipass # Route "audit aipass" to seedgo
|
||||
drone @module --help # Show help for any module
|
||||
drone scan @branch # Discover available commands in a branch
|
||||
drone activate @branch # Scan + register all commands from a branch
|
||||
drone list # List registered custom command shortcuts
|
||||
drone remove <name> # Remove a custom command shortcut
|
||||
drone --version # Show version
|
||||
drone --help # Show usage information
|
||||
```
|
||||
@@ -91,10 +95,15 @@ drone/
|
||||
│ │ ├── resolver.py # Branch resolution (@name -> path)
|
||||
│ │ ├── router.py # Command routing via subprocess
|
||||
│ │ ├── discovery.py # Module and command discovery
|
||||
│ │ └── module_registry.py # Internal module routing
|
||||
│ │ ├── module_registry.py # Internal module routing
|
||||
│ │ ├── commands.py # Custom command shortcut orchestrator
|
||||
│ │ └── scan.py # Branch command scanning
|
||||
│ └── handlers/ # Implementation
|
||||
│ ├── executor.py # Safe subprocess execution
|
||||
│ └── exceptions.py # Exception hierarchy
|
||||
│ ├── exceptions.py # Exception hierarchy
|
||||
│ ├── json/ # Three-JSON Pattern handler
|
||||
│ ├── scanning/ # Scan result formatting + discovery
|
||||
│ └── command_registry/ # Command shortcut CRUD + lookup
|
||||
├── docs/ # Documentation
|
||||
└── tests/
|
||||
```
|
||||
@@ -140,3 +149,4 @@ To add: edit `interactive_commands` or `interactive_branches` in `_handle_target
|
||||
---
|
||||
|
||||
**Last Updated:** 2026-03-17
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: drone.py
|
||||
# Description: Drone - Command Router & Discovery
|
||||
# Version: 1.0.0
|
||||
# Version: 1.1.0
|
||||
# Created: 2026-03-05
|
||||
# Modified: 2026-03-08
|
||||
# Modified: 2026-03-17
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
@@ -31,7 +31,7 @@ from aipass.drone.apps.modules.module_registry import (
|
||||
get_module_help,
|
||||
)
|
||||
|
||||
VERSION = "1.0.0"
|
||||
VERSION = "1.1.0"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -49,6 +49,10 @@ def show_help() -> None:
|
||||
console.print(" drone @target command \\[args] Route command to branch or module")
|
||||
console.print(" drone @target --help Show help for branch or module")
|
||||
console.print(" drone systems List registered branches and modules")
|
||||
console.print(" drone scan @target Discover available commands in a branch")
|
||||
console.print(" drone activate @target Register all commands from a branch")
|
||||
console.print(" drone list List registered custom commands")
|
||||
console.print(" drone remove <name> Remove a custom command")
|
||||
console.print(" drone --help Show this help")
|
||||
console.print(" drone --version Show version")
|
||||
console.print()
|
||||
@@ -57,6 +61,8 @@ def show_help() -> None:
|
||||
console.print(" drone @seedgo list")
|
||||
console.print(" drone @flow status")
|
||||
console.print(" drone systems")
|
||||
console.print(" drone activate @seedgo")
|
||||
console.print(" drone audit (custom command shortcut)")
|
||||
console.print()
|
||||
|
||||
|
||||
@@ -169,6 +175,104 @@ def _handle_module(name: str, args: List[str]) -> int:
|
||||
return result.get("exit_code", 0)
|
||||
|
||||
|
||||
def _handle_activate(target: str) -> int:
|
||||
"""Handle `drone activate @branch` -- scan and register all discovered commands."""
|
||||
from aipass.drone.apps.modules.scan import scan
|
||||
from aipass.drone.apps.modules.commands import add
|
||||
from aipass.drone.apps.modules.commands import format_activation_results
|
||||
|
||||
branch_name = target.lstrip("@").lower()
|
||||
|
||||
results = scan(target)
|
||||
if results is None:
|
||||
err_console.print(f"drone: could not resolve '{target}'")
|
||||
return 1
|
||||
|
||||
if not results:
|
||||
return 0
|
||||
|
||||
added: list[str] = []
|
||||
skipped: list[str] = []
|
||||
|
||||
for cmd in results:
|
||||
name = cmd["name"]
|
||||
description = cmd.get("description", "")
|
||||
success = add(
|
||||
name=name,
|
||||
target=f"@{branch_name}",
|
||||
command=name,
|
||||
description=description,
|
||||
source_branch=branch_name,
|
||||
)
|
||||
if success:
|
||||
added.append(name)
|
||||
else:
|
||||
skipped.append(name)
|
||||
|
||||
format_activation_results(branch_name, added, skipped)
|
||||
return 0
|
||||
|
||||
|
||||
def _handle_list() -> int:
|
||||
"""Handle `drone list` -- show all registered custom commands."""
|
||||
from aipass.drone.apps.modules.commands import list_all
|
||||
from aipass.drone.apps.modules.commands import format_command_list
|
||||
|
||||
commands = list_all()
|
||||
format_command_list(commands)
|
||||
return 0
|
||||
|
||||
|
||||
def _handle_remove(name: str) -> int:
|
||||
"""Handle `drone remove <name>` -- remove a custom command."""
|
||||
from aipass.drone.apps.modules.commands import remove
|
||||
from aipass.drone.apps.modules.commands import format_removal
|
||||
|
||||
success = remove(name)
|
||||
format_removal(name, success)
|
||||
return 0 if success else 1
|
||||
|
||||
|
||||
def _handle_custom_command(args: list[str]) -> int:
|
||||
"""Handle a custom command shortcut by matching and routing.
|
||||
|
||||
Uses greedy multi-word matching to resolve user input to a registered
|
||||
custom command, then routes through the same path as ``@target`` commands.
|
||||
"""
|
||||
from aipass.drone.apps.modules.commands import match
|
||||
|
||||
matched = match(args)
|
||||
if matched is None:
|
||||
return -1 # Signal: not a custom command
|
||||
|
||||
cmd_data, remaining_args = matched
|
||||
target = cmd_data["target"]
|
||||
command = cmd_data["command"]
|
||||
cmd_args = list(cmd_data.get("args", [])) + remaining_args
|
||||
module_name = target.lstrip("@").lower()
|
||||
|
||||
# Interactive detection -- same logic as _handle_target
|
||||
interactive_commands = ("monitor", "snapshot", "versioned")
|
||||
interactive_branches = ("cli",)
|
||||
interactive = command in interactive_commands or module_name in interactive_branches
|
||||
|
||||
try:
|
||||
result = route_command(
|
||||
target, command,
|
||||
args=cmd_args if cmd_args else None,
|
||||
interactive=interactive,
|
||||
)
|
||||
except (BranchNotFoundError, CommandExecutionError, RegistryError) as exc:
|
||||
err_console.print(f"drone: {exc}")
|
||||
return 1
|
||||
|
||||
if result.stdout:
|
||||
console.print(result.stdout, end="", highlight=False)
|
||||
if result.stderr:
|
||||
err_console.print(result.stderr, end="", highlight=False)
|
||||
return result.exit_code
|
||||
|
||||
|
||||
def _handle_target(args: List[str]) -> int:
|
||||
"""Handle `drone @target command [args]` or `drone @target --help`."""
|
||||
target = args[0]
|
||||
@@ -270,10 +374,42 @@ def main() -> int:
|
||||
err_console.print(f"drone: {exc}")
|
||||
return 1
|
||||
|
||||
# scan — discover available commands in a branch
|
||||
if command == "scan":
|
||||
if len(args) < 2:
|
||||
err_console.print("drone: scan requires a target (e.g., drone scan @seedgo)")
|
||||
return 1
|
||||
from aipass.drone.apps.modules.scan import scan
|
||||
results = scan(args[1])
|
||||
return 0 if results is not None else 1
|
||||
|
||||
# activate — scan + register all discovered commands from a branch
|
||||
if command == "activate":
|
||||
if len(args) < 2:
|
||||
err_console.print("drone: activate requires a target (e.g., drone activate @seedgo)")
|
||||
return 1
|
||||
return _handle_activate(args[1])
|
||||
|
||||
# list — show registered custom commands
|
||||
if command == "list":
|
||||
return _handle_list()
|
||||
|
||||
# remove — remove a custom command by name
|
||||
if command == "remove":
|
||||
if len(args) < 2:
|
||||
err_console.print("drone: remove requires a command name (e.g., drone remove audit)")
|
||||
return 1
|
||||
return _handle_remove(args[1])
|
||||
|
||||
# @target — route to branch or module
|
||||
if command.startswith("@"):
|
||||
return _handle_target(args)
|
||||
|
||||
# Custom command matching (greedy multi-word, before unknown error)
|
||||
custom_result = _handle_custom_command(args)
|
||||
if custom_result != -1:
|
||||
return custom_result
|
||||
|
||||
# Unknown command
|
||||
err_console.print(f"drone: unknown command '{command}'")
|
||||
err_console.print("Run 'drone --help' for usage.")
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: command_registry/__init__.py
|
||||
# Description: Custom command registry handler package
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-03-17
|
||||
# Modified: 2026-03-17
|
||||
# =============================================
|
||||
|
||||
"""Custom command registry handler package.
|
||||
|
||||
Provides persistent storage and lookup for user-defined command shortcuts
|
||||
that map short names (e.g. ``audit``) to full drone commands
|
||||
(e.g. ``drone @seedgo audit aipass``).
|
||||
"""
|
||||
@@ -0,0 +1,117 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: formatters.py
|
||||
# Description: Rich output formatting for custom command registry
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-03-17
|
||||
# Modified: 2026-03-17
|
||||
# =============================================
|
||||
|
||||
"""Rich output formatting for custom command registry operations.
|
||||
|
||||
Renders command lists, activation results, and removal confirmations
|
||||
using Rich tables and styled console output.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
try:
|
||||
from aipass.cli.apps.modules import console
|
||||
except ImportError:
|
||||
from rich.console import Console
|
||||
console = Console()
|
||||
|
||||
from rich.table import Table
|
||||
|
||||
from aipass.drone.apps.handlers.json import json_handler
|
||||
|
||||
|
||||
def format_command_list(commands: list[dict]) -> None:
|
||||
"""Display registered custom commands as a Rich table.
|
||||
|
||||
Args:
|
||||
commands: List of command dicts with ``name``, ``target``,
|
||||
``command``, ``args``, ``description`` keys.
|
||||
"""
|
||||
json_handler.log_operation(
|
||||
"format_command_list",
|
||||
{"command_count": len(commands)},
|
||||
)
|
||||
|
||||
if not commands:
|
||||
console.print()
|
||||
console.print("No custom commands registered.")
|
||||
console.print("Use 'drone activate @branch' to register commands from a branch.")
|
||||
console.print()
|
||||
return
|
||||
|
||||
console.print()
|
||||
|
||||
table = Table(show_header=True, header_style="bold")
|
||||
table.add_column("Name", min_width=14)
|
||||
table.add_column("Target", min_width=10)
|
||||
table.add_column("Command", min_width=12)
|
||||
table.add_column("Args", min_width=12)
|
||||
table.add_column("Description", min_width=24)
|
||||
|
||||
for cmd in commands:
|
||||
table.add_row(
|
||||
cmd.get("name", ""),
|
||||
cmd.get("target", ""),
|
||||
cmd.get("command", ""),
|
||||
" ".join(cmd.get("args", [])),
|
||||
cmd.get("description", ""),
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print()
|
||||
console.print(f"{len(commands)} custom command(s) registered.")
|
||||
console.print()
|
||||
|
||||
|
||||
def format_activation_results(
|
||||
branch: str,
|
||||
added: list[str],
|
||||
skipped: list[str],
|
||||
) -> None:
|
||||
"""Display what was registered during activation.
|
||||
|
||||
Args:
|
||||
branch: Branch name (with or without ``@`` prefix).
|
||||
added: List of command names that were successfully registered.
|
||||
skipped: List of command names that were skipped (already exist).
|
||||
"""
|
||||
display_name = branch if branch.startswith("@") else f"@{branch}"
|
||||
|
||||
console.print()
|
||||
|
||||
if added:
|
||||
console.print(f"Activated {len(added)} command(s) from [bold]{display_name}[/bold]:")
|
||||
for name in added:
|
||||
console.print(f" + {name}")
|
||||
|
||||
if skipped:
|
||||
if added:
|
||||
console.print()
|
||||
console.print(f"Skipped {len(skipped)} command(s) (already registered):")
|
||||
for name in skipped:
|
||||
console.print(f" - {name}")
|
||||
|
||||
if not added and not skipped:
|
||||
console.print(f"No commands discovered in {display_name}.")
|
||||
|
||||
console.print()
|
||||
|
||||
|
||||
def format_removal(name: str, success: bool) -> None:
|
||||
"""Display the result of removing a custom command.
|
||||
|
||||
Args:
|
||||
name: The command name that was targeted for removal.
|
||||
success: Whether the removal succeeded.
|
||||
"""
|
||||
console.print()
|
||||
if success:
|
||||
console.print(f"Removed custom command '{name}'.")
|
||||
else:
|
||||
console.print(f"Command '{name}' not found in registry.")
|
||||
console.print()
|
||||
@@ -0,0 +1,136 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: lookup.py
|
||||
# Description: Command lookup and matching for custom command shortcuts
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-03-17
|
||||
# Modified: 2026-03-17
|
||||
# =============================================
|
||||
|
||||
"""Command Lookup and Matching for Custom Commands.
|
||||
|
||||
Provides exact-match lookup and greedy multi-word matching so that
|
||||
user input like ``plan create flow`` resolves to the longest registered
|
||||
shortcut name (trying 3 words, then 2, then 1).
|
||||
|
||||
Usage:
|
||||
from aipass.drone.apps.handlers.command_registry.lookup import (
|
||||
lookup_command, match_command, list_commands,
|
||||
)
|
||||
|
||||
cmd = lookup_command("audit")
|
||||
result = match_command(["plan", "create", "flow", "--verbose"])
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.drone.apps.handlers.json import json_handler
|
||||
from .ops import load_registry
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
MODULE_NAME = "command_lookup"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lookup functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def lookup_command(name: str) -> dict[str, Any] | None:
|
||||
"""Look up a custom command by exact name.
|
||||
|
||||
Args:
|
||||
name: Shortcut name to look up.
|
||||
|
||||
Returns:
|
||||
Command dict if found, None otherwise.
|
||||
"""
|
||||
try:
|
||||
registry = load_registry()
|
||||
return registry.get("commands", {}).get(name)
|
||||
except Exception as exc:
|
||||
logger.error("[%s] Failed to look up command '%s': %s", MODULE_NAME, name, exc)
|
||||
return None
|
||||
|
||||
|
||||
def match_command(args: list[str]) -> tuple[dict[str, Any], list[str]] | None:
|
||||
"""Match user input against registered commands using greedy multi-word matching.
|
||||
|
||||
Tries the longest candidate first (up to 4 words), then progressively
|
||||
shorter candidates until a match is found.
|
||||
|
||||
For example, given args ``["plan", "create", "flow", "--verbose"]``
|
||||
it tries:
|
||||
1. ``"plan create flow --verbose"`` (4 words)
|
||||
2. ``"plan create flow"`` (3 words)
|
||||
3. ``"plan create"`` (2 words)
|
||||
4. ``"plan"`` (1 word)
|
||||
|
||||
Args:
|
||||
args: List of whitespace-split user input tokens.
|
||||
|
||||
Returns:
|
||||
Tuple of (command_dict, remaining_args) on match, None otherwise.
|
||||
"""
|
||||
if not args:
|
||||
return None
|
||||
|
||||
try:
|
||||
registry = load_registry()
|
||||
commands = registry.get("commands", {})
|
||||
except Exception as exc:
|
||||
logger.error("[%s] Failed to load registry for matching: %s", MODULE_NAME, exc)
|
||||
return None
|
||||
|
||||
for i in range(min(len(args), 4), 0, -1):
|
||||
candidate = " ".join(args[:i])
|
||||
if candidate in commands:
|
||||
remaining = args[i:]
|
||||
json_handler.log_operation(
|
||||
"match_command",
|
||||
{"matched": candidate, "remaining_args": remaining},
|
||||
)
|
||||
return (commands[candidate], remaining)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def list_commands() -> list[dict[str, Any]]:
|
||||
"""List all registered custom commands, sorted by name.
|
||||
|
||||
Returns:
|
||||
List of command dicts sorted alphabetically by name.
|
||||
"""
|
||||
try:
|
||||
registry = load_registry()
|
||||
commands = registry.get("commands", {})
|
||||
return sorted(commands.values(), key=lambda c: c.get("name", ""))
|
||||
except Exception as exc:
|
||||
logger.error("[%s] Failed to list commands: %s", MODULE_NAME, exc)
|
||||
return []
|
||||
|
||||
|
||||
def list_commands_by_branch(branch_name: str) -> list[dict[str, Any]]:
|
||||
"""List custom commands filtered by source branch.
|
||||
|
||||
Args:
|
||||
branch_name: Branch name to filter by (e.g. ``"seedgo"``).
|
||||
|
||||
Returns:
|
||||
List of command dicts whose source_branch matches, sorted by name.
|
||||
"""
|
||||
try:
|
||||
registry = load_registry()
|
||||
commands = registry.get("commands", {})
|
||||
filtered = [
|
||||
cmd for cmd in commands.values()
|
||||
if cmd.get("source_branch") == branch_name
|
||||
]
|
||||
return sorted(filtered, key=lambda c: c.get("name", ""))
|
||||
except Exception as exc:
|
||||
logger.error("[%s] Failed to list commands for branch '%s': %s", MODULE_NAME, branch_name, exc)
|
||||
return []
|
||||
@@ -0,0 +1,271 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: ops.py
|
||||
# Description: Registry CRUD operations for custom command shortcuts
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-03-17
|
||||
# Modified: 2026-03-17
|
||||
# =============================================
|
||||
|
||||
"""Registry CRUD Operations for Custom Commands.
|
||||
|
||||
Core operations for loading, saving, and managing the drone command registry.
|
||||
The registry maps shortcut names to full drone commands so users can type
|
||||
``audit`` instead of ``drone @seedgo audit aipass``.
|
||||
|
||||
Usage:
|
||||
from aipass.drone.apps.handlers.command_registry.ops import (
|
||||
load_registry, save_registry, add_command, remove_command,
|
||||
)
|
||||
|
||||
registry = load_registry()
|
||||
add_command("audit", "@seedgo", "audit", ["aipass"], "Run audit", "seedgo")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.drone.apps.handlers.json import json_handler
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
MODULE_NAME = "command_registry"
|
||||
|
||||
# ops.py -> command_registry/ -> handlers/ -> apps/ -> drone/
|
||||
_BRANCH_ROOT: Path = Path(__file__).resolve().parents[3]
|
||||
REGISTRY_FILE: Path = _BRANCH_ROOT / "drone_command_registry.json"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _today() -> str:
|
||||
"""Return today's date as ISO string."""
|
||||
return datetime.now().date().isoformat()
|
||||
|
||||
|
||||
def _empty_registry() -> dict[str, Any]:
|
||||
"""Return a new empty registry structure."""
|
||||
today = _today()
|
||||
return {
|
||||
"commands": {},
|
||||
"metadata": {
|
||||
"version": "1.0.0",
|
||||
"last_updated": today,
|
||||
"command_count": 0,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _registry_path() -> Path:
|
||||
"""Return the current registry file path.
|
||||
|
||||
Wrapped in a function so tests can monkeypatch ``ops.REGISTRY_FILE``.
|
||||
"""
|
||||
return REGISTRY_FILE
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def load_registry() -> dict[str, Any]:
|
||||
"""Load the command registry from disk, auto-creating if missing.
|
||||
|
||||
Returns:
|
||||
Registry dict with ``commands`` and ``metadata`` keys.
|
||||
"""
|
||||
path = _registry_path()
|
||||
|
||||
if not path.exists():
|
||||
logger.info("[%s] Registry not found, creating at %s", MODULE_NAME, path)
|
||||
registry = _empty_registry()
|
||||
save_registry(registry)
|
||||
return registry
|
||||
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
logger.warning("[%s] Corrupt registry, recreating: %s", MODULE_NAME, exc)
|
||||
registry = _empty_registry()
|
||||
save_registry(registry)
|
||||
return registry
|
||||
|
||||
# Auto-heal missing keys
|
||||
if not isinstance(data, dict):
|
||||
logger.warning("[%s] Invalid registry structure, recreating", MODULE_NAME)
|
||||
registry = _empty_registry()
|
||||
save_registry(registry)
|
||||
return registry
|
||||
|
||||
if "commands" not in data:
|
||||
data["commands"] = {}
|
||||
if "metadata" not in data:
|
||||
data["metadata"] = {
|
||||
"version": "1.0.0",
|
||||
"last_updated": _today(),
|
||||
"command_count": len(data["commands"]),
|
||||
}
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def save_registry(data: dict[str, Any]) -> bool:
|
||||
"""Save the command registry to disk with validation.
|
||||
|
||||
Args:
|
||||
data: Registry dict to persist.
|
||||
|
||||
Returns:
|
||||
True if saved successfully, False on error.
|
||||
"""
|
||||
path = _registry_path()
|
||||
|
||||
if not isinstance(data, dict) or "commands" not in data:
|
||||
logger.error("[%s] Cannot save invalid registry structure", MODULE_NAME)
|
||||
return False
|
||||
|
||||
# Refresh metadata
|
||||
data.setdefault("metadata", {})
|
||||
data["metadata"]["last_updated"] = _today()
|
||||
data["metadata"]["command_count"] = len(data["commands"])
|
||||
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
json.dump(data, fh, indent=2, ensure_ascii=False)
|
||||
json_handler.log_operation(
|
||||
"save_registry",
|
||||
{"command_count": data["metadata"]["command_count"]},
|
||||
module_name=MODULE_NAME,
|
||||
)
|
||||
return True
|
||||
except OSError as exc:
|
||||
logger.error("[%s] Failed to save registry: %s", MODULE_NAME, exc)
|
||||
return False
|
||||
|
||||
|
||||
def add_command(
|
||||
name: str,
|
||||
target: str,
|
||||
command: str,
|
||||
args: list[str] | None = None,
|
||||
description: str = "",
|
||||
source_branch: str = "",
|
||||
) -> bool:
|
||||
"""Add a new custom command to the registry.
|
||||
|
||||
Args:
|
||||
name: Shortcut name (e.g. ``"audit"``).
|
||||
target: Target branch (e.g. ``"@seedgo"``).
|
||||
command: Actual command to run on the target.
|
||||
args: Extra arguments for the command.
|
||||
description: Human-readable description.
|
||||
source_branch: Branch the command originated from.
|
||||
|
||||
Returns:
|
||||
True if added, False if name already exists or save fails.
|
||||
"""
|
||||
registry = load_registry()
|
||||
|
||||
if name in registry["commands"]:
|
||||
logger.warning("[%s] Command '%s' already exists", MODULE_NAME, name)
|
||||
return False
|
||||
|
||||
registry["commands"][name] = {
|
||||
"name": name,
|
||||
"target": target,
|
||||
"command": command,
|
||||
"args": args if args is not None else [],
|
||||
"description": description,
|
||||
"created": _today(),
|
||||
"source_branch": source_branch,
|
||||
}
|
||||
|
||||
result = save_registry(registry)
|
||||
if result:
|
||||
json_handler.log_operation(
|
||||
"add_command",
|
||||
{"name": name, "target": target, "command": command},
|
||||
module_name=MODULE_NAME,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def remove_command(name: str) -> bool:
|
||||
"""Remove a custom command from the registry.
|
||||
|
||||
Args:
|
||||
name: Shortcut name to remove.
|
||||
|
||||
Returns:
|
||||
True if removed, False if not found or save fails.
|
||||
"""
|
||||
registry = load_registry()
|
||||
|
||||
if name not in registry["commands"]:
|
||||
logger.warning("[%s] Command '%s' not found", MODULE_NAME, name)
|
||||
return False
|
||||
|
||||
del registry["commands"][name]
|
||||
|
||||
result = save_registry(registry)
|
||||
if result:
|
||||
json_handler.log_operation(
|
||||
"remove_command",
|
||||
{"name": name},
|
||||
module_name=MODULE_NAME,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def update_command(name: str, **kwargs: Any) -> bool:
|
||||
"""Update fields of an existing custom command.
|
||||
|
||||
Args:
|
||||
name: Shortcut name to update.
|
||||
**kwargs: Fields to update (e.g. ``target="@prax"``, ``description="..."``).
|
||||
|
||||
Returns:
|
||||
True if updated, False if not found or save fails.
|
||||
"""
|
||||
registry = load_registry()
|
||||
|
||||
if name not in registry["commands"]:
|
||||
logger.warning("[%s] Command '%s' not found for update", MODULE_NAME, name)
|
||||
return False
|
||||
|
||||
allowed_keys = {"target", "command", "args", "description", "source_branch"}
|
||||
for key, value in kwargs.items():
|
||||
if key in allowed_keys:
|
||||
registry["commands"][name][key] = value
|
||||
|
||||
result = save_registry(registry)
|
||||
if result:
|
||||
json_handler.log_operation(
|
||||
"update_command",
|
||||
{"name": name, "updated_fields": list(kwargs.keys())},
|
||||
module_name=MODULE_NAME,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def command_exists(name: str) -> bool:
|
||||
"""Check whether a custom command exists in the registry.
|
||||
|
||||
Args:
|
||||
name: Shortcut name to check.
|
||||
|
||||
Returns:
|
||||
True if the command exists, False otherwise.
|
||||
"""
|
||||
registry = load_registry()
|
||||
return name in registry["commands"]
|
||||
@@ -0,0 +1,27 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: __init__.py
|
||||
# Description: Scanning handler package for module command discovery
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-03-17
|
||||
# Modified: 2026-03-17
|
||||
# =============================================
|
||||
|
||||
"""Scanning handler package -- discovers available commands in branches."""
|
||||
|
||||
from aipass.drone.apps.handlers.scanning.scanner import (
|
||||
scan_branch,
|
||||
scan_help_output,
|
||||
scan_module_files,
|
||||
)
|
||||
from aipass.drone.apps.handlers.scanning.formatters import (
|
||||
format_no_commands,
|
||||
format_scan_results,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"format_no_commands",
|
||||
"format_scan_results",
|
||||
"scan_branch",
|
||||
"scan_help_output",
|
||||
"scan_module_files",
|
||||
]
|
||||
@@ -0,0 +1,77 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: formatters.py
|
||||
# Description: Rich output formatting for scan results
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-03-17
|
||||
# Modified: 2026-03-17
|
||||
# =============================================
|
||||
|
||||
"""Rich output formatting for scan results.
|
||||
|
||||
Renders discovered-command lists as clean Rich tables or informational
|
||||
messages when no commands are found.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
try:
|
||||
from aipass.cli.apps.modules import console
|
||||
except ImportError:
|
||||
from rich.console import Console
|
||||
console = Console()
|
||||
|
||||
from rich.table import Table
|
||||
|
||||
from aipass.drone.apps.handlers.json import json_handler
|
||||
|
||||
|
||||
def format_scan_results(branch_name: str, commands: list[dict]) -> None:
|
||||
"""Display scan results as a Rich table.
|
||||
|
||||
Args:
|
||||
branch_name: Branch name (with or without ``@`` prefix) for the title.
|
||||
commands: List of command dicts with ``name``, ``description``, ``source``.
|
||||
"""
|
||||
display_name = branch_name if branch_name.startswith("@") else f"@{branch_name}"
|
||||
|
||||
json_handler.log_operation(
|
||||
"format_scan_results",
|
||||
{"branch": display_name, "command_count": len(commands)},
|
||||
)
|
||||
|
||||
console.print()
|
||||
console.print(f"Scan results for [bold]{display_name}[/bold]")
|
||||
console.print()
|
||||
|
||||
table = Table(show_header=True, header_style="bold")
|
||||
table.add_column("#", justify="right", style="dim", width=4)
|
||||
table.add_column("Command", min_width=18)
|
||||
table.add_column("Description", min_width=30)
|
||||
table.add_column("Source", justify="center", width=8)
|
||||
|
||||
for idx, cmd in enumerate(commands, start=1):
|
||||
table.add_row(
|
||||
str(idx),
|
||||
cmd.get("name", ""),
|
||||
cmd.get("description", ""),
|
||||
cmd.get("source", ""),
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print()
|
||||
console.print(f"{len(commands)} command(s) discovered for {display_name}")
|
||||
console.print()
|
||||
|
||||
|
||||
def format_no_commands(branch_name: str) -> None:
|
||||
"""Display a message when no commands are found for a branch.
|
||||
|
||||
Args:
|
||||
branch_name: Branch name (with or without ``@`` prefix).
|
||||
"""
|
||||
display_name = branch_name if branch_name.startswith("@") else f"@{branch_name}"
|
||||
|
||||
console.print()
|
||||
console.print(f"No commands discovered for [bold]{display_name}[/bold].")
|
||||
console.print("Ensure the branch has an entry point with --help or modules with handle_command().")
|
||||
console.print()
|
||||
@@ -0,0 +1,207 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: scanner.py
|
||||
# Description: Module scanning for command discovery
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-03-17
|
||||
# Modified: 2026-03-17
|
||||
# =============================================
|
||||
|
||||
"""Core scanning logic for discovering available commands in a branch.
|
||||
|
||||
Combines two discovery strategies:
|
||||
1. Run the branch entry point with ``--help`` and parse the output.
|
||||
2. Scan ``apps/modules/*.py`` for files containing ``handle_command()``.
|
||||
|
||||
Results are merged and deduplicated, then returned as a flat list of dicts
|
||||
suitable for display or downstream activation.
|
||||
|
||||
Reuses existing discovery-handler functions where possible.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.drone.apps.handlers.json import json_handler
|
||||
from aipass.drone.apps.handlers.discovery_handler import (
|
||||
get_entry_point,
|
||||
parse_help_for_commands,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Help-output scanning
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def scan_help_output(branch_path: str, branch_name: str) -> list[dict]:
|
||||
"""Run the branch entry point with ``--help`` and parse discovered commands.
|
||||
|
||||
Args:
|
||||
branch_path: Absolute path to the branch directory.
|
||||
branch_name: Branch name (without ``@`` prefix).
|
||||
|
||||
Returns:
|
||||
List of command dicts ``{"name": ..., "description": ..., "source": "help"}``.
|
||||
"""
|
||||
entry_point = get_entry_point(branch_path, branch_name)
|
||||
if entry_point is None:
|
||||
return []
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(entry_point.relative_to(branch_path)), "--help"],
|
||||
cwd=branch_path,
|
||||
capture_output=True,
|
||||
timeout=10,
|
||||
shell=False,
|
||||
)
|
||||
help_text = result.stdout.decode("utf-8", errors="replace")
|
||||
if not help_text:
|
||||
help_text = result.stderr.decode("utf-8", errors="replace")
|
||||
except (subprocess.TimeoutExpired, OSError) as exc:
|
||||
logger.info("scan_help_output: entry point execution failed for %s: %s", branch_name, exc)
|
||||
return []
|
||||
|
||||
command_names = parse_help_for_commands(help_text)
|
||||
|
||||
# Build richer dicts by re-scanning the help text for descriptions
|
||||
description_map = _extract_descriptions(help_text, command_names)
|
||||
|
||||
return [
|
||||
{
|
||||
"name": name,
|
||||
"description": description_map.get(name, ""),
|
||||
"source": "help",
|
||||
}
|
||||
for name in command_names
|
||||
]
|
||||
|
||||
|
||||
def _extract_descriptions(help_text: str, command_names: list[str]) -> dict[str, str]:
|
||||
"""Best-effort extraction of one-line descriptions from help text.
|
||||
|
||||
Looks for lines like `` command_name Description text`` and pairs
|
||||
the first token with the rest of the line.
|
||||
"""
|
||||
descriptions: dict[str, str] = {}
|
||||
name_set = set(command_names)
|
||||
|
||||
for line in help_text.splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
parts = stripped.split(None, 1)
|
||||
if len(parts) == 2 and parts[0] in name_set:
|
||||
descriptions[parts[0]] = parts[1].strip()
|
||||
|
||||
return descriptions
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-file scanning
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def scan_module_files(branch_path: str) -> list[dict]:
|
||||
"""Scan ``apps/modules/*.py`` for files that define ``handle_command()``.
|
||||
|
||||
Args:
|
||||
branch_path: Absolute path to the branch directory.
|
||||
|
||||
Returns:
|
||||
List of command dicts ``{"name": ..., "description": ..., "source": "module"}``.
|
||||
"""
|
||||
modules_dir = Path(branch_path) / "apps" / "modules"
|
||||
if not modules_dir.is_dir():
|
||||
return []
|
||||
|
||||
excluded = {"__init__", "__main__"}
|
||||
results: list[dict] = []
|
||||
|
||||
for py_file in sorted(modules_dir.glob("*.py")):
|
||||
if py_file.stem in excluded:
|
||||
continue
|
||||
|
||||
try:
|
||||
source = py_file.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
if "def handle_command" not in source:
|
||||
continue
|
||||
|
||||
# Extract a description from the module docstring (first line).
|
||||
description = _extract_module_description(source)
|
||||
|
||||
results.append(
|
||||
{
|
||||
"name": py_file.stem,
|
||||
"description": description,
|
||||
"source": "module",
|
||||
}
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _extract_module_description(source: str) -> str:
|
||||
"""Extract the first line of a module-level docstring, if present."""
|
||||
for delimiter in ('"""', "'''"):
|
||||
idx = source.find(delimiter)
|
||||
if idx == -1:
|
||||
continue
|
||||
start = idx + len(delimiter)
|
||||
end = source.find(delimiter, start)
|
||||
if end == -1:
|
||||
continue
|
||||
docstring = source[start:end].strip()
|
||||
first_line = docstring.split("\n", 1)[0].strip()
|
||||
if first_line:
|
||||
return first_line
|
||||
return ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Full branch scan (merge + deduplicate)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def scan_branch(branch_path: str, branch_name: str) -> list[dict]:
|
||||
"""Perform a full scan of a branch to discover available commands.
|
||||
|
||||
Combines both ``--help`` parsing and ``apps/modules/*.py`` file scanning,
|
||||
deduplicates by command name (``--help`` wins on conflict), and returns
|
||||
the merged result.
|
||||
|
||||
Args:
|
||||
branch_path: Absolute path to the branch directory.
|
||||
branch_name: Branch name (without ``@`` prefix).
|
||||
|
||||
Returns:
|
||||
List of command dicts with keys ``name``, ``description``, ``source``.
|
||||
"""
|
||||
help_commands = scan_help_output(branch_path, branch_name)
|
||||
module_commands = scan_module_files(branch_path)
|
||||
|
||||
# Merge: help results take priority for duplicates.
|
||||
seen: dict[str, dict] = {}
|
||||
for cmd in help_commands:
|
||||
seen[cmd["name"]] = cmd
|
||||
for cmd in module_commands:
|
||||
if cmd["name"] not in seen:
|
||||
seen[cmd["name"]] = cmd
|
||||
|
||||
merged = sorted(seen.values(), key=lambda c: c["name"])
|
||||
|
||||
json_handler.log_operation(
|
||||
"scan_branch",
|
||||
{
|
||||
"branch": branch_name,
|
||||
"help_count": len(help_commands),
|
||||
"module_count": len(module_commands),
|
||||
"total": len(merged),
|
||||
},
|
||||
)
|
||||
|
||||
return merged
|
||||
@@ -0,0 +1,245 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: commands.py
|
||||
# Description: Module orchestrator for custom command shortcuts
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-03-17
|
||||
# Modified: 2026-03-17
|
||||
# =============================================
|
||||
|
||||
"""Module orchestrator for custom command shortcuts.
|
||||
|
||||
Thin orchestrator that delegates to the command_registry handler package
|
||||
for all CRUD and lookup operations on user-defined command aliases.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.drone.apps.handlers.json import json_handler
|
||||
from aipass.drone.apps.handlers.command_registry.ops import (
|
||||
add_command as _add_command,
|
||||
command_exists as _command_exists,
|
||||
remove_command as _remove_command,
|
||||
update_command as _update_command,
|
||||
)
|
||||
from aipass.drone.apps.handlers.command_registry.lookup import (
|
||||
list_commands as _list_commands,
|
||||
list_commands_by_branch as _list_commands_by_branch,
|
||||
lookup_command as _lookup_command,
|
||||
match_command as _match_command,
|
||||
)
|
||||
from aipass.drone.apps.handlers.command_registry.formatters import (
|
||||
format_activation_results,
|
||||
format_command_list,
|
||||
format_removal,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"add",
|
||||
"format_activation_results",
|
||||
"format_command_list",
|
||||
"format_removal",
|
||||
"handle_command",
|
||||
"list_all",
|
||||
"lookup",
|
||||
"match",
|
||||
"print_help",
|
||||
"print_introspection",
|
||||
"remove",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Standard module interface
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def handle_command(command: str | None = None, args: list[str] | None = None) -> bool:
|
||||
"""Route commands subcommands to handler functions.
|
||||
|
||||
Args:
|
||||
command: The subcommand string (e.g. ``"add"``, ``"remove"``, ``"list"``).
|
||||
args: List of arguments for the subcommand.
|
||||
|
||||
Returns:
|
||||
True if the command succeeded, False otherwise.
|
||||
"""
|
||||
if not args:
|
||||
if command is None:
|
||||
print_introspection()
|
||||
return True
|
||||
args = []
|
||||
|
||||
json_handler.log_operation("handle_command", {"module": "commands", "command": command})
|
||||
|
||||
if command == "add":
|
||||
if len(args) < 3:
|
||||
logger.warning("commands add requires: <name> <target> <command> [args...] [--desc=...] [--branch=...]")
|
||||
return False
|
||||
name = args[0]
|
||||
target = args[1]
|
||||
cmd = args[2]
|
||||
extra = args[3:]
|
||||
|
||||
# Extract optional --desc= and --branch= flags
|
||||
description = ""
|
||||
source_branch = ""
|
||||
cmd_args: list[str] = []
|
||||
for arg in extra:
|
||||
if arg.startswith("--desc="):
|
||||
description = arg[len("--desc="):]
|
||||
elif arg.startswith("--branch="):
|
||||
source_branch = arg[len("--branch="):]
|
||||
else:
|
||||
cmd_args.append(arg)
|
||||
|
||||
return add(name, target, cmd, cmd_args, description, source_branch)
|
||||
|
||||
if command == "remove":
|
||||
if not args:
|
||||
logger.warning("commands remove requires a command name")
|
||||
return False
|
||||
return remove(args[0])
|
||||
|
||||
if command == "list":
|
||||
branch_filter = args[0] if args else None
|
||||
if branch_filter:
|
||||
cmds = _list_commands_by_branch(branch_filter)
|
||||
else:
|
||||
cmds = list_all()
|
||||
for cmd_entry in cmds:
|
||||
logger.info(
|
||||
" %s -> %s %s %s",
|
||||
cmd_entry.get("name", "?"),
|
||||
cmd_entry.get("target", "?"),
|
||||
cmd_entry.get("command", "?"),
|
||||
" ".join(cmd_entry.get("args", [])),
|
||||
)
|
||||
if not cmds:
|
||||
logger.info(" (no commands registered)")
|
||||
return True
|
||||
|
||||
if command == "lookup":
|
||||
if not args:
|
||||
logger.warning("commands lookup requires a command name")
|
||||
return False
|
||||
result = lookup(args[0])
|
||||
if result:
|
||||
logger.info(" %s -> %s %s %s", result["name"], result["target"], result["command"], " ".join(result.get("args", [])))
|
||||
else:
|
||||
logger.warning(" Command '%s' not found", args[0])
|
||||
return False
|
||||
return True
|
||||
|
||||
logger.warning("commands: unknown subcommand '%s'", command)
|
||||
return False
|
||||
|
||||
|
||||
def print_introspection() -> None:
|
||||
"""Display module introspection info."""
|
||||
try:
|
||||
from aipass.cli.apps.modules.display import console
|
||||
except ImportError:
|
||||
from rich.console import Console
|
||||
console = Console()
|
||||
|
||||
console.print()
|
||||
console.print("commands Module")
|
||||
console.print("Custom command shortcuts — map short names to full drone commands.")
|
||||
console.print()
|
||||
console.print("Connected Handlers:")
|
||||
console.print(" handlers/command_registry/")
|
||||
console.print(" - ops.py (add_command, remove_command, update_command, command_exists)")
|
||||
console.print(" - lookup.py (lookup_command, match_command, list_commands, list_commands_by_branch)")
|
||||
console.print()
|
||||
|
||||
|
||||
def print_help() -> None:
|
||||
"""Print help for the commands module."""
|
||||
try:
|
||||
from aipass.cli.apps.modules.display import console
|
||||
except ImportError:
|
||||
from rich.console import Console
|
||||
console = Console()
|
||||
|
||||
console.print("commands -- Custom command shortcuts")
|
||||
console.print()
|
||||
console.print("Commands:")
|
||||
console.print(" add <name> <target> <cmd> [args...] [--desc=...] [--branch=...]")
|
||||
console.print(" remove <name> Remove a custom command")
|
||||
console.print(" list [branch] List commands (optionally by branch)")
|
||||
console.print(" lookup <name> Look up a command by name")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Delegated operations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def add(
|
||||
name: str,
|
||||
target: str,
|
||||
command: str,
|
||||
args: list[str] | None = None,
|
||||
description: str = "",
|
||||
source_branch: str = "",
|
||||
) -> bool:
|
||||
"""Add a custom command shortcut.
|
||||
|
||||
Args:
|
||||
name: Shortcut name.
|
||||
target: Target branch (e.g. ``"@seedgo"``).
|
||||
command: Command to run on the target.
|
||||
args: Extra arguments.
|
||||
description: Human-readable description.
|
||||
source_branch: Originating branch name.
|
||||
|
||||
Returns:
|
||||
True if added successfully.
|
||||
"""
|
||||
return _add_command(name, target, command, args, description, source_branch)
|
||||
|
||||
|
||||
def remove(name: str) -> bool:
|
||||
"""Remove a custom command shortcut.
|
||||
|
||||
Args:
|
||||
name: Shortcut name to remove.
|
||||
|
||||
Returns:
|
||||
True if removed successfully.
|
||||
"""
|
||||
return _remove_command(name)
|
||||
|
||||
|
||||
def list_all() -> list[dict[str, Any]]:
|
||||
"""List all registered custom commands sorted by name.
|
||||
|
||||
Returns:
|
||||
List of command dicts.
|
||||
"""
|
||||
return _list_commands()
|
||||
|
||||
|
||||
def lookup(name: str) -> dict[str, Any] | None:
|
||||
"""Look up a custom command by exact name.
|
||||
|
||||
Args:
|
||||
name: Shortcut name.
|
||||
|
||||
Returns:
|
||||
Command dict if found, None otherwise.
|
||||
"""
|
||||
return _lookup_command(name)
|
||||
|
||||
|
||||
def match(args: list[str]) -> tuple[dict[str, Any], list[str]] | None:
|
||||
"""Match user input to a registered command using greedy multi-word matching.
|
||||
|
||||
Args:
|
||||
args: Whitespace-split user input tokens.
|
||||
|
||||
Returns:
|
||||
Tuple of (command_dict, remaining_args) on match, None otherwise.
|
||||
"""
|
||||
return _match_command(args)
|
||||
@@ -0,0 +1,138 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: scan.py
|
||||
# Description: Module orchestrator for branch command scanning
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-03-17
|
||||
# Modified: 2026-03-17
|
||||
# =============================================
|
||||
|
||||
"""Module orchestrator for branch command scanning.
|
||||
|
||||
Thin orchestrator that resolves ``@target`` to a branch path and delegates
|
||||
scanning to the handler layer. Displays results via Rich formatters.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.drone.apps.handlers.json import json_handler
|
||||
from aipass.drone.apps.handlers.scanning.scanner import scan_branch
|
||||
from aipass.drone.apps.handlers.scanning.formatters import (
|
||||
format_no_commands,
|
||||
format_scan_results,
|
||||
)
|
||||
from aipass.drone.apps.modules.resolver import resolve_branch
|
||||
|
||||
__all__ = [
|
||||
"handle_command",
|
||||
"print_help",
|
||||
"print_introspection",
|
||||
"scan",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Standard module interface
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def handle_command(command: str | None = None, args: list[str] | None = None) -> bool:
|
||||
"""Route scan subcommands to handler functions.
|
||||
|
||||
Args:
|
||||
command: The subcommand string (currently unused -- scans are positional).
|
||||
args: List of arguments; first element is the ``@target``.
|
||||
|
||||
Returns:
|
||||
True if the command succeeded, False otherwise.
|
||||
"""
|
||||
if not args:
|
||||
if command is None:
|
||||
print_introspection()
|
||||
return True
|
||||
args = []
|
||||
|
||||
json_handler.log_operation("handle_command", {"module": "scan", "command": command})
|
||||
|
||||
if not args:
|
||||
logger.warning("scan requires a target argument (e.g. scan @seedgo)")
|
||||
return False
|
||||
|
||||
results = scan(args[0])
|
||||
return results is not None
|
||||
|
||||
|
||||
def print_introspection() -> None:
|
||||
"""Display module introspection info."""
|
||||
try:
|
||||
from aipass.cli.apps.modules.display import console
|
||||
except ImportError:
|
||||
from rich.console import Console
|
||||
console = Console()
|
||||
|
||||
console.print()
|
||||
console.print("scan Module")
|
||||
console.print("Branch command scanning -- discover available commands in a branch.")
|
||||
console.print()
|
||||
console.print("Connected Handlers:")
|
||||
console.print(" handlers/scanning/")
|
||||
console.print(" - scanner.py (scan_branch, scan_help_output, scan_module_files)")
|
||||
console.print(" - formatters.py (format_scan_results, format_no_commands)")
|
||||
console.print()
|
||||
console.print("Connected Modules:")
|
||||
console.print(" modules/")
|
||||
console.print(" - resolver.py (resolve_branch -- branch name resolution)")
|
||||
console.print()
|
||||
|
||||
|
||||
def print_help() -> None:
|
||||
"""Print help for the scan module."""
|
||||
try:
|
||||
from aipass.cli.apps.modules.display import console
|
||||
except ImportError:
|
||||
from rich.console import Console
|
||||
console = Console()
|
||||
|
||||
console.print("scan -- Branch command scanning")
|
||||
console.print()
|
||||
console.print("Usage:")
|
||||
console.print(" drone scan @target Scan a branch for available commands")
|
||||
console.print()
|
||||
console.print("Examples:")
|
||||
console.print(" drone scan @seedgo Scan seedgo for commands")
|
||||
console.print(" drone scan @flow Scan flow for commands")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core operation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def scan(target: str) -> list[dict] | None:
|
||||
"""Resolve ``@target``, scan for commands, display and return results.
|
||||
|
||||
Args:
|
||||
target: Symbolic branch name (e.g. ``@seedgo``).
|
||||
|
||||
Returns:
|
||||
List of discovered command dicts, or None on resolution failure.
|
||||
"""
|
||||
branch_name = target.lstrip("@").lower()
|
||||
|
||||
try:
|
||||
branch_path = resolve_branch(target)
|
||||
except Exception as exc:
|
||||
logger.warning("scan: could not resolve '%s': %s", target, exc)
|
||||
try:
|
||||
from aipass.cli.apps.modules import err_console
|
||||
err_console.print(f"scan: could not resolve '{target}': {exc}")
|
||||
except ImportError:
|
||||
pass
|
||||
return None
|
||||
|
||||
commands = scan_branch(branch_path, branch_name)
|
||||
|
||||
if commands:
|
||||
format_scan_results(branch_name, commands)
|
||||
else:
|
||||
format_no_commands(branch_name)
|
||||
|
||||
return commands
|
||||
@@ -0,0 +1,654 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_activation.py
|
||||
# Description: Tests for command activation, listing, removal, and custom execution
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-03-17
|
||||
# Modified: 2026-03-17
|
||||
# =============================================
|
||||
|
||||
"""Tests for command activation, listing, removal, and custom execution.
|
||||
|
||||
Covers:
|
||||
- ``drone activate @branch`` registers discovered commands
|
||||
- ``drone list`` displays custom commands
|
||||
- ``drone remove <name>`` removes a custom command
|
||||
- Custom command execution via ``main()`` flow
|
||||
- ``match_command`` integration with ``route_command``
|
||||
- Formatter output for activation, listing, removal
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from aipass.drone.apps.handlers.command_registry import ops, lookup
|
||||
from aipass.drone.apps.handlers.command_registry.formatters import (
|
||||
format_activation_results,
|
||||
format_command_list,
|
||||
format_removal,
|
||||
)
|
||||
from aipass.drone.apps.handlers.executor import CommandResult
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated_registry(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
"""Point the registry at a temp file so tests never touch the real one."""
|
||||
registry_file = tmp_path / "drone_command_registry.json"
|
||||
monkeypatch.setattr(ops, "REGISTRY_FILE", registry_file)
|
||||
return registry_file
|
||||
|
||||
|
||||
def _seed_commands(**commands: dict[str, Any]) -> None:
|
||||
"""Register commands via ops.add_command for test setup."""
|
||||
for name, data in commands.items():
|
||||
ops.add_command(
|
||||
name=name,
|
||||
target=data.get("target", "@test"),
|
||||
command=data.get("command", name),
|
||||
args=data.get("args"),
|
||||
description=data.get("description", ""),
|
||||
source_branch=data.get("source_branch", "test"),
|
||||
)
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 1. Formatters
|
||||
# ===================================================================
|
||||
|
||||
class TestFormatCommandList:
|
||||
"""Tests for format_command_list()."""
|
||||
|
||||
def test_displays_commands(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Should display a table with command details."""
|
||||
commands = [
|
||||
{
|
||||
"name": "audit",
|
||||
"target": "@seedgo",
|
||||
"command": "audit",
|
||||
"args": ["aipass"],
|
||||
"description": "Run audit",
|
||||
},
|
||||
{
|
||||
"name": "check",
|
||||
"target": "@seedgo",
|
||||
"command": "check",
|
||||
"args": [],
|
||||
"description": "Run checks",
|
||||
},
|
||||
]
|
||||
format_command_list(commands)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "audit" in captured.out
|
||||
assert "check" in captured.out
|
||||
assert "@seedgo" in captured.out
|
||||
assert "registered" in captured.out
|
||||
assert "custom" in captured.out
|
||||
|
||||
def test_empty_list(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Should show informational message when no commands exist."""
|
||||
format_command_list([])
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "No custom commands registered" in captured.out
|
||||
assert "drone activate" in captured.out
|
||||
|
||||
|
||||
class TestFormatActivationResults:
|
||||
"""Tests for format_activation_results()."""
|
||||
|
||||
def test_shows_added(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Should display added commands."""
|
||||
format_activation_results("seedgo", ["audit", "list"], [])
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Activated" in captured.out
|
||||
assert "@seedgo" in captured.out
|
||||
assert "+ audit" in captured.out
|
||||
assert "+ list" in captured.out
|
||||
|
||||
def test_shows_skipped(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Should display skipped commands."""
|
||||
format_activation_results("seedgo", [], ["audit"])
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Skipped" in captured.out
|
||||
assert "already registered" in captured.out
|
||||
assert "- audit" in captured.out
|
||||
|
||||
def test_shows_both_added_and_skipped(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Should display both added and skipped when both exist."""
|
||||
format_activation_results("seedgo", ["list"], ["audit"])
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Activated" in captured.out
|
||||
assert "Skipped" in captured.out
|
||||
|
||||
def test_shows_no_commands(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Should show a message when nothing was added or skipped."""
|
||||
format_activation_results("seedgo", [], [])
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "No commands discovered" in captured.out
|
||||
|
||||
def test_adds_at_prefix(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Should add @ prefix to branch name if missing."""
|
||||
format_activation_results("seedgo", ["cmd"], [])
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "@seedgo" in captured.out
|
||||
|
||||
|
||||
class TestFormatRemoval:
|
||||
"""Tests for format_removal()."""
|
||||
|
||||
def test_success(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Should show removal confirmation on success."""
|
||||
format_removal("audit", True)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Removed custom command" in captured.out
|
||||
assert "audit" in captured.out
|
||||
|
||||
def test_failure(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Should show not-found message on failure."""
|
||||
format_removal("ghost", False)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "not found" in captured.out
|
||||
assert "ghost" in captured.out
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 2. _handle_activate
|
||||
# ===================================================================
|
||||
|
||||
class TestHandleActivate:
|
||||
"""Tests for _handle_activate() in drone.py."""
|
||||
|
||||
@patch("aipass.drone.apps.drone._handle_activate.__wrapped__", create=True)
|
||||
def _get_handler(self):
|
||||
"""Import the handler to test."""
|
||||
from aipass.drone.apps.drone import _handle_activate
|
||||
return _handle_activate
|
||||
|
||||
@patch("aipass.drone.apps.modules.commands.format_activation_results")
|
||||
@patch("aipass.drone.apps.modules.commands.add")
|
||||
@patch("aipass.drone.apps.modules.scan.scan")
|
||||
def test_registers_discovered_commands(
|
||||
self,
|
||||
mock_scan: MagicMock,
|
||||
mock_add: MagicMock,
|
||||
mock_format: MagicMock,
|
||||
) -> None:
|
||||
"""Should register all commands discovered by scan."""
|
||||
from aipass.drone.apps.drone import _handle_activate
|
||||
|
||||
mock_scan.return_value = [
|
||||
{"name": "audit", "description": "Run audit", "source": "help"},
|
||||
{"name": "list", "description": "List items", "source": "module"},
|
||||
]
|
||||
mock_add.return_value = True
|
||||
|
||||
result = _handle_activate("@seedgo")
|
||||
|
||||
assert result == 0
|
||||
assert mock_add.call_count == 2
|
||||
mock_format.assert_called_once()
|
||||
# Check the added list in format call
|
||||
call_args = mock_format.call_args
|
||||
assert "audit" in call_args[0][1] # added list
|
||||
assert "list" in call_args[0][1]
|
||||
|
||||
@patch("aipass.drone.apps.modules.commands.format_activation_results")
|
||||
@patch("aipass.drone.apps.modules.commands.add")
|
||||
@patch("aipass.drone.apps.modules.scan.scan")
|
||||
def test_skips_existing_commands(
|
||||
self,
|
||||
mock_scan: MagicMock,
|
||||
mock_add: MagicMock,
|
||||
mock_format: MagicMock,
|
||||
) -> None:
|
||||
"""Should skip commands that already exist in registry."""
|
||||
from aipass.drone.apps.drone import _handle_activate
|
||||
|
||||
mock_scan.return_value = [
|
||||
{"name": "audit", "description": "Run audit", "source": "help"},
|
||||
]
|
||||
mock_add.return_value = False # Already exists
|
||||
|
||||
result = _handle_activate("@seedgo")
|
||||
|
||||
assert result == 0
|
||||
call_args = mock_format.call_args
|
||||
assert call_args[0][1] == [] # added = empty
|
||||
assert "audit" in call_args[0][2] # skipped list
|
||||
|
||||
@patch("aipass.drone.apps.modules.scan.scan")
|
||||
def test_returns_1_on_resolution_failure(self, mock_scan: MagicMock) -> None:
|
||||
"""Should return 1 when scan cannot resolve the target."""
|
||||
from aipass.drone.apps.drone import _handle_activate
|
||||
|
||||
mock_scan.return_value = None
|
||||
|
||||
result = _handle_activate("@nonexistent")
|
||||
|
||||
assert result == 1
|
||||
|
||||
@patch("aipass.drone.apps.modules.scan.scan")
|
||||
def test_returns_0_on_empty_scan(self, mock_scan: MagicMock) -> None:
|
||||
"""Should return 0 when scan finds no commands."""
|
||||
from aipass.drone.apps.drone import _handle_activate
|
||||
|
||||
mock_scan.return_value = []
|
||||
|
||||
result = _handle_activate("@emptybranch")
|
||||
|
||||
assert result == 0
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 3. _handle_list
|
||||
# ===================================================================
|
||||
|
||||
class TestHandleList:
|
||||
"""Tests for _handle_list() in drone.py."""
|
||||
|
||||
@patch("aipass.drone.apps.modules.commands.format_command_list")
|
||||
def test_calls_formatter(self, mock_format: MagicMock) -> None:
|
||||
"""Should load commands and pass to formatter."""
|
||||
from aipass.drone.apps.drone import _handle_list
|
||||
|
||||
ops.add_command("audit", "@seedgo", "audit")
|
||||
|
||||
result = _handle_list()
|
||||
|
||||
assert result == 0
|
||||
mock_format.assert_called_once()
|
||||
commands_arg = mock_format.call_args[0][0]
|
||||
assert len(commands_arg) == 1
|
||||
assert commands_arg[0]["name"] == "audit"
|
||||
|
||||
@patch("aipass.drone.apps.modules.commands.format_command_list")
|
||||
def test_empty_registry(self, mock_format: MagicMock) -> None:
|
||||
"""Should pass empty list to formatter when no commands exist."""
|
||||
from aipass.drone.apps.drone import _handle_list
|
||||
|
||||
result = _handle_list()
|
||||
|
||||
assert result == 0
|
||||
mock_format.assert_called_once_with([])
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 4. _handle_remove
|
||||
# ===================================================================
|
||||
|
||||
class TestHandleRemove:
|
||||
"""Tests for _handle_remove() in drone.py."""
|
||||
|
||||
@patch("aipass.drone.apps.modules.commands.format_removal")
|
||||
def test_removes_existing(self, mock_format: MagicMock) -> None:
|
||||
"""Should remove an existing command and return 0."""
|
||||
from aipass.drone.apps.drone import _handle_remove
|
||||
|
||||
ops.add_command("audit", "@seedgo", "audit")
|
||||
|
||||
result = _handle_remove("audit")
|
||||
|
||||
assert result == 0
|
||||
mock_format.assert_called_once_with("audit", True)
|
||||
assert not ops.command_exists("audit")
|
||||
|
||||
@patch("aipass.drone.apps.modules.commands.format_removal")
|
||||
def test_nonexistent_returns_1(self, mock_format: MagicMock) -> None:
|
||||
"""Should return 1 when trying to remove a nonexistent command."""
|
||||
from aipass.drone.apps.drone import _handle_remove
|
||||
|
||||
result = _handle_remove("ghost")
|
||||
|
||||
assert result == 1
|
||||
mock_format.assert_called_once_with("ghost", False)
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 5. _handle_custom_command
|
||||
# ===================================================================
|
||||
|
||||
class TestHandleCustomCommand:
|
||||
"""Tests for _handle_custom_command() in drone.py."""
|
||||
|
||||
@patch("aipass.drone.apps.drone.route_command")
|
||||
def test_routes_matched_command(self, mock_route: MagicMock) -> None:
|
||||
"""Should route a matched custom command through route_command."""
|
||||
from aipass.drone.apps.drone import _handle_custom_command
|
||||
|
||||
ops.add_command("audit", "@seedgo", "audit", args=["aipass"])
|
||||
|
||||
mock_route.return_value = CommandResult(
|
||||
stdout="ok\n", stderr="", exit_code=0, branch="seedgo", command="audit",
|
||||
)
|
||||
|
||||
result = _handle_custom_command(["audit"])
|
||||
|
||||
assert result == 0
|
||||
mock_route.assert_called_once_with(
|
||||
"@seedgo", "audit",
|
||||
args=["aipass"],
|
||||
interactive=False,
|
||||
)
|
||||
|
||||
@patch("aipass.drone.apps.drone.route_command")
|
||||
def test_appends_remaining_args(self, mock_route: MagicMock) -> None:
|
||||
"""Should append remaining args to configured args."""
|
||||
from aipass.drone.apps.drone import _handle_custom_command
|
||||
|
||||
ops.add_command("audit", "@seedgo", "audit", args=["aipass"])
|
||||
|
||||
mock_route.return_value = CommandResult(
|
||||
stdout="", stderr="", exit_code=0, branch="seedgo", command="audit",
|
||||
)
|
||||
|
||||
result = _handle_custom_command(["audit", "@drone"])
|
||||
|
||||
assert result == 0
|
||||
mock_route.assert_called_once_with(
|
||||
"@seedgo", "audit",
|
||||
args=["aipass", "@drone"],
|
||||
interactive=False,
|
||||
)
|
||||
|
||||
def test_returns_negative_1_on_no_match(self) -> None:
|
||||
"""Should return -1 when no custom command matches."""
|
||||
from aipass.drone.apps.drone import _handle_custom_command
|
||||
|
||||
result = _handle_custom_command(["nonexistent"])
|
||||
|
||||
assert result == -1
|
||||
|
||||
@patch("aipass.drone.apps.drone.route_command")
|
||||
def test_interactive_detection_for_command(self, mock_route: MagicMock) -> None:
|
||||
"""Should set interactive=True for interactive commands."""
|
||||
from aipass.drone.apps.drone import _handle_custom_command
|
||||
|
||||
ops.add_command("mon", "@prax", "monitor")
|
||||
|
||||
mock_route.return_value = CommandResult(
|
||||
stdout="", stderr="", exit_code=0, branch="prax", command="monitor",
|
||||
)
|
||||
|
||||
_handle_custom_command(["mon"])
|
||||
|
||||
call_kwargs = mock_route.call_args.kwargs
|
||||
assert call_kwargs["interactive"] is True
|
||||
|
||||
@patch("aipass.drone.apps.drone.route_command")
|
||||
def test_interactive_detection_for_branch(self, mock_route: MagicMock) -> None:
|
||||
"""Should set interactive=True for CLI branch commands."""
|
||||
from aipass.drone.apps.drone import _handle_custom_command
|
||||
|
||||
ops.add_command("status", "@cli", "status")
|
||||
|
||||
mock_route.return_value = CommandResult(
|
||||
stdout="", stderr="", exit_code=0, branch="cli", command="status",
|
||||
)
|
||||
|
||||
_handle_custom_command(["status"])
|
||||
|
||||
call_kwargs = mock_route.call_args.kwargs
|
||||
assert call_kwargs["interactive"] is True
|
||||
|
||||
@patch("aipass.drone.apps.drone.route_command")
|
||||
def test_propagates_exit_code(self, mock_route: MagicMock) -> None:
|
||||
"""Should return the route_command exit code."""
|
||||
from aipass.drone.apps.drone import _handle_custom_command
|
||||
|
||||
ops.add_command("failing", "@test", "fail")
|
||||
|
||||
mock_route.return_value = CommandResult(
|
||||
stdout="", stderr="error\n", exit_code=2, branch="test", command="fail",
|
||||
)
|
||||
|
||||
result = _handle_custom_command(["failing"])
|
||||
|
||||
assert result == 2
|
||||
|
||||
@patch("aipass.drone.apps.drone.route_command")
|
||||
def test_handles_route_exception(self, mock_route: MagicMock) -> None:
|
||||
"""Should return 1 when route_command raises."""
|
||||
from aipass.drone.apps.drone import _handle_custom_command
|
||||
from aipass.drone.apps.modules import BranchNotFoundError
|
||||
|
||||
ops.add_command("bad", "@ghost", "cmd")
|
||||
|
||||
mock_route.side_effect = BranchNotFoundError("not found")
|
||||
|
||||
result = _handle_custom_command(["bad"])
|
||||
|
||||
assert result == 1
|
||||
|
||||
@patch("aipass.drone.apps.drone.route_command")
|
||||
def test_no_args_passes_none(self, mock_route: MagicMock) -> None:
|
||||
"""Should pass args=None when configured args and remaining args are both empty."""
|
||||
from aipass.drone.apps.drone import _handle_custom_command
|
||||
|
||||
ops.add_command("simple", "@test", "simple")
|
||||
|
||||
mock_route.return_value = CommandResult(
|
||||
stdout="", stderr="", exit_code=0, branch="test", command="simple",
|
||||
)
|
||||
|
||||
_handle_custom_command(["simple"])
|
||||
|
||||
call_kwargs = mock_route.call_args.kwargs
|
||||
assert call_kwargs["args"] is None
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 6. main() integration
|
||||
# ===================================================================
|
||||
|
||||
class TestMainIntegration:
|
||||
"""Tests for main() routing of new commands."""
|
||||
|
||||
@patch("aipass.drone.apps.drone._handle_activate")
|
||||
def test_activate_route(self, mock_activate: MagicMock) -> None:
|
||||
"""main() routes 'activate @branch' to _handle_activate."""
|
||||
from aipass.drone.apps.drone import main
|
||||
|
||||
mock_activate.return_value = 0
|
||||
|
||||
with patch("sys.argv", ["drone", "activate", "@seedgo"]):
|
||||
result = main()
|
||||
|
||||
assert result == 0
|
||||
mock_activate.assert_called_once_with("@seedgo")
|
||||
|
||||
def test_activate_no_target(self) -> None:
|
||||
"""main() returns 1 when activate is called without a target."""
|
||||
from aipass.drone.apps.drone import main
|
||||
|
||||
with patch("sys.argv", ["drone", "activate"]):
|
||||
result = main()
|
||||
|
||||
assert result == 1
|
||||
|
||||
@patch("aipass.drone.apps.drone._handle_list")
|
||||
def test_list_route(self, mock_list: MagicMock) -> None:
|
||||
"""main() routes 'list' to _handle_list."""
|
||||
from aipass.drone.apps.drone import main
|
||||
|
||||
mock_list.return_value = 0
|
||||
|
||||
with patch("sys.argv", ["drone", "list"]):
|
||||
result = main()
|
||||
|
||||
assert result == 0
|
||||
mock_list.assert_called_once()
|
||||
|
||||
@patch("aipass.drone.apps.drone._handle_remove")
|
||||
def test_remove_route(self, mock_remove: MagicMock) -> None:
|
||||
"""main() routes 'remove name' to _handle_remove."""
|
||||
from aipass.drone.apps.drone import main
|
||||
|
||||
mock_remove.return_value = 0
|
||||
|
||||
with patch("sys.argv", ["drone", "remove", "audit"]):
|
||||
result = main()
|
||||
|
||||
assert result == 0
|
||||
mock_remove.assert_called_once_with("audit")
|
||||
|
||||
def test_remove_no_name(self) -> None:
|
||||
"""main() returns 1 when remove is called without a name."""
|
||||
from aipass.drone.apps.drone import main
|
||||
|
||||
with patch("sys.argv", ["drone", "remove"]):
|
||||
result = main()
|
||||
|
||||
assert result == 1
|
||||
|
||||
@patch("aipass.drone.apps.drone._handle_custom_command")
|
||||
def test_custom_command_route(self, mock_custom: MagicMock) -> None:
|
||||
"""main() routes unrecognized commands to custom command matching."""
|
||||
from aipass.drone.apps.drone import main
|
||||
|
||||
mock_custom.return_value = 0
|
||||
|
||||
with patch("sys.argv", ["drone", "audit"]):
|
||||
result = main()
|
||||
|
||||
assert result == 0
|
||||
mock_custom.assert_called_once_with(["audit"])
|
||||
|
||||
@patch("aipass.drone.apps.drone._handle_custom_command")
|
||||
def test_unknown_command_when_no_custom_match(self, mock_custom: MagicMock) -> None:
|
||||
"""main() shows unknown command when custom matching returns -1."""
|
||||
from aipass.drone.apps.drone import main
|
||||
|
||||
mock_custom.return_value = -1
|
||||
|
||||
with patch("sys.argv", ["drone", "nonexistent"]):
|
||||
result = main()
|
||||
|
||||
assert result == 1
|
||||
|
||||
@patch("aipass.drone.apps.drone.route_command")
|
||||
def test_custom_command_end_to_end(self, mock_route: MagicMock) -> None:
|
||||
"""Full integration: registered command routes through route_command."""
|
||||
from aipass.drone.apps.drone import main
|
||||
|
||||
ops.add_command("audit", "@seedgo", "audit", args=["aipass"])
|
||||
|
||||
mock_route.return_value = CommandResult(
|
||||
stdout="audit output\n", stderr="", exit_code=0,
|
||||
branch="seedgo", command="audit",
|
||||
)
|
||||
|
||||
with patch("sys.argv", ["drone", "audit"]):
|
||||
result = main()
|
||||
|
||||
assert result == 0
|
||||
mock_route.assert_called_once_with(
|
||||
"@seedgo", "audit",
|
||||
args=["aipass"],
|
||||
interactive=False,
|
||||
)
|
||||
|
||||
@patch("aipass.drone.apps.drone.route_command")
|
||||
def test_custom_command_with_extra_args_end_to_end(self, mock_route: MagicMock) -> None:
|
||||
"""Full integration: remaining args appended to configured args."""
|
||||
from aipass.drone.apps.drone import main
|
||||
|
||||
ops.add_command("audit", "@seedgo", "audit", args=["aipass"])
|
||||
|
||||
mock_route.return_value = CommandResult(
|
||||
stdout="", stderr="", exit_code=0,
|
||||
branch="seedgo", command="audit",
|
||||
)
|
||||
|
||||
with patch("sys.argv", ["drone", "audit", "@drone"]):
|
||||
result = main()
|
||||
|
||||
assert result == 0
|
||||
mock_route.assert_called_once_with(
|
||||
"@seedgo", "audit",
|
||||
args=["aipass", "@drone"],
|
||||
interactive=False,
|
||||
)
|
||||
|
||||
def test_builtin_commands_take_priority(self) -> None:
|
||||
"""Built-in commands like 'systems' should NOT be overridden by custom commands."""
|
||||
from aipass.drone.apps.drone import main
|
||||
|
||||
# Register a custom command named 'systems' (should be shadowed)
|
||||
ops.add_command("systems", "@test", "systems")
|
||||
|
||||
with patch("aipass.drone.apps.drone._handle_systems", return_value=0) as mock_sys:
|
||||
with patch("sys.argv", ["drone", "systems"]):
|
||||
result = main()
|
||||
|
||||
assert result == 0
|
||||
mock_sys.assert_called_once()
|
||||
|
||||
def test_at_target_takes_priority_over_custom(self) -> None:
|
||||
"""@target routing should take priority over custom command matching."""
|
||||
from aipass.drone.apps.drone import main
|
||||
|
||||
with patch("aipass.drone.apps.drone._handle_target", return_value=0) as mock_target:
|
||||
with patch("sys.argv", ["drone", "@seedgo", "audit"]):
|
||||
result = main()
|
||||
|
||||
assert result == 0
|
||||
mock_target.assert_called_once()
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 7. match_command integration
|
||||
# ===================================================================
|
||||
|
||||
class TestMatchCommandIntegration:
|
||||
"""Tests verifying match_command works correctly with registered commands."""
|
||||
|
||||
def test_multi_word_custom_command(self) -> None:
|
||||
"""Multi-word commands should match and leave remaining args."""
|
||||
ops.add_command("plan create", "@flow", "create", args=["--type=plan"])
|
||||
|
||||
result = lookup.match_command(["plan", "create", "my-plan"])
|
||||
|
||||
assert result is not None
|
||||
cmd, remaining = result
|
||||
assert cmd["name"] == "plan create"
|
||||
assert cmd["target"] == "@flow"
|
||||
assert remaining == ["my-plan"]
|
||||
|
||||
@patch("aipass.drone.apps.drone.route_command")
|
||||
def test_multi_word_end_to_end(self, mock_route: MagicMock) -> None:
|
||||
"""Multi-word custom command routes correctly through main()."""
|
||||
from aipass.drone.apps.drone import main
|
||||
|
||||
ops.add_command("plan create", "@flow", "create", args=["--type=plan"])
|
||||
|
||||
mock_route.return_value = CommandResult(
|
||||
stdout="created\n", stderr="", exit_code=0,
|
||||
branch="flow", command="create",
|
||||
)
|
||||
|
||||
with patch("sys.argv", ["drone", "plan", "create", "my-plan"]):
|
||||
result = main()
|
||||
|
||||
assert result == 0
|
||||
mock_route.assert_called_once_with(
|
||||
"@flow", "create",
|
||||
args=["--type=plan", "my-plan"],
|
||||
interactive=False,
|
||||
)
|
||||
@@ -0,0 +1,431 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_commands.py
|
||||
# Description: Tests for the custom command registry
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-03-17
|
||||
# Modified: 2026-03-17
|
||||
# =============================================
|
||||
|
||||
"""Tests for the custom command registry.
|
||||
|
||||
Covers:
|
||||
- CRUD operations (add, remove, update, exists)
|
||||
- Exact-match lookup
|
||||
- Multi-word greedy matching
|
||||
- Registry auto-creation
|
||||
- List filtering by branch
|
||||
- Module orchestrator (handle_command)
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from aipass.drone.apps.handlers.command_registry import ops, lookup
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated_registry(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
"""Point the registry at a temp file so tests never touch the real one."""
|
||||
registry_file = tmp_path / "drone_command_registry.json"
|
||||
monkeypatch.setattr(ops, "REGISTRY_FILE", registry_file)
|
||||
return registry_file
|
||||
|
||||
|
||||
def _seed_registry(registry_file: Path, commands: dict[str, Any] | None = None) -> None:
|
||||
"""Write a registry file with pre-populated commands."""
|
||||
if commands is None:
|
||||
commands = {}
|
||||
data = {
|
||||
"commands": commands,
|
||||
"metadata": {
|
||||
"version": "1.0.0",
|
||||
"last_updated": "2026-03-17",
|
||||
"command_count": len(commands),
|
||||
},
|
||||
}
|
||||
registry_file.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 1. Registry auto-creation
|
||||
# ===================================================================
|
||||
|
||||
class TestRegistryAutoCreation:
|
||||
|
||||
def test_load_creates_file_when_missing(self, isolated_registry: Path) -> None:
|
||||
"""load_registry() creates a new file when none exists."""
|
||||
assert not isolated_registry.exists()
|
||||
|
||||
registry = ops.load_registry()
|
||||
|
||||
assert isolated_registry.exists()
|
||||
assert isinstance(registry, dict)
|
||||
assert "commands" in registry
|
||||
assert "metadata" in registry
|
||||
assert registry["metadata"]["command_count"] == 0
|
||||
|
||||
def test_load_recreates_on_corrupt_json(self, isolated_registry: Path) -> None:
|
||||
"""Corrupt JSON triggers auto-recreation."""
|
||||
isolated_registry.write_text("{{{bad json", encoding="utf-8")
|
||||
|
||||
registry = ops.load_registry()
|
||||
|
||||
assert registry["commands"] == {}
|
||||
assert registry["metadata"]["command_count"] == 0
|
||||
|
||||
def test_load_recreates_on_non_dict(self, isolated_registry: Path) -> None:
|
||||
"""Non-dict top-level JSON triggers auto-recreation."""
|
||||
isolated_registry.write_text(json.dumps([1, 2, 3]), encoding="utf-8")
|
||||
|
||||
registry = ops.load_registry()
|
||||
|
||||
assert isinstance(registry, dict)
|
||||
assert "commands" in registry
|
||||
|
||||
def test_auto_heals_missing_keys(self, isolated_registry: Path) -> None:
|
||||
"""Missing 'commands' or 'metadata' keys are auto-healed."""
|
||||
isolated_registry.write_text(json.dumps({"other": "data"}), encoding="utf-8")
|
||||
|
||||
registry = ops.load_registry()
|
||||
|
||||
assert "commands" in registry
|
||||
assert "metadata" in registry
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 2. CRUD operations
|
||||
# ===================================================================
|
||||
|
||||
class TestCRUDOperations:
|
||||
|
||||
def test_add_command(self, isolated_registry: Path) -> None:
|
||||
"""add_command creates a new entry in the registry."""
|
||||
result = ops.add_command(
|
||||
name="audit",
|
||||
target="@seedgo",
|
||||
command="audit",
|
||||
args=["aipass"],
|
||||
description="Run audit",
|
||||
source_branch="seedgo",
|
||||
)
|
||||
|
||||
assert result is True
|
||||
|
||||
registry = ops.load_registry()
|
||||
assert "audit" in registry["commands"]
|
||||
cmd = registry["commands"]["audit"]
|
||||
assert cmd["name"] == "audit"
|
||||
assert cmd["target"] == "@seedgo"
|
||||
assert cmd["command"] == "audit"
|
||||
assert cmd["args"] == ["aipass"]
|
||||
assert cmd["description"] == "Run audit"
|
||||
assert cmd["source_branch"] == "seedgo"
|
||||
|
||||
def test_add_duplicate_returns_false(self, isolated_registry: Path) -> None:
|
||||
"""Adding a command with an existing name returns False."""
|
||||
ops.add_command("audit", "@seedgo", "audit")
|
||||
|
||||
result = ops.add_command("audit", "@prax", "something_else")
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_add_with_default_args(self, isolated_registry: Path) -> None:
|
||||
"""add_command with no args defaults to empty list."""
|
||||
ops.add_command("monitor", "@prax", "monitor")
|
||||
|
||||
registry = ops.load_registry()
|
||||
assert registry["commands"]["monitor"]["args"] == []
|
||||
|
||||
def test_remove_command(self, isolated_registry: Path) -> None:
|
||||
"""remove_command deletes an existing entry."""
|
||||
ops.add_command("audit", "@seedgo", "audit")
|
||||
|
||||
result = ops.remove_command("audit")
|
||||
|
||||
assert result is True
|
||||
registry = ops.load_registry()
|
||||
assert "audit" not in registry["commands"]
|
||||
|
||||
def test_remove_nonexistent_returns_false(self, isolated_registry: Path) -> None:
|
||||
"""Removing a nonexistent command returns False."""
|
||||
result = ops.remove_command("nonexistent")
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_update_command(self, isolated_registry: Path) -> None:
|
||||
"""update_command modifies fields of an existing entry."""
|
||||
ops.add_command("audit", "@seedgo", "audit", description="Old desc")
|
||||
|
||||
result = ops.update_command("audit", description="New desc", target="@prax")
|
||||
|
||||
assert result is True
|
||||
registry = ops.load_registry()
|
||||
cmd = registry["commands"]["audit"]
|
||||
assert cmd["description"] == "New desc"
|
||||
assert cmd["target"] == "@prax"
|
||||
|
||||
def test_update_nonexistent_returns_false(self, isolated_registry: Path) -> None:
|
||||
"""Updating a nonexistent command returns False."""
|
||||
result = ops.update_command("ghost", description="nope")
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_update_ignores_disallowed_keys(self, isolated_registry: Path) -> None:
|
||||
"""update_command ignores keys outside the allowed set."""
|
||||
ops.add_command("audit", "@seedgo", "audit")
|
||||
|
||||
ops.update_command("audit", created="1970-01-01")
|
||||
|
||||
registry = ops.load_registry()
|
||||
cmd = registry["commands"]["audit"]
|
||||
# 'created' is not in the allowed set, so it should NOT be overwritten
|
||||
assert cmd["created"] != "1970-01-01"
|
||||
|
||||
def test_command_exists_true(self, isolated_registry: Path) -> None:
|
||||
"""command_exists returns True for registered commands."""
|
||||
ops.add_command("audit", "@seedgo", "audit")
|
||||
|
||||
assert ops.command_exists("audit") is True
|
||||
|
||||
def test_command_exists_false(self, isolated_registry: Path) -> None:
|
||||
"""command_exists returns False for unregistered commands."""
|
||||
assert ops.command_exists("nonexistent") is False
|
||||
|
||||
def test_metadata_command_count_updates(self, isolated_registry: Path) -> None:
|
||||
"""metadata.command_count tracks the number of commands after each save."""
|
||||
ops.add_command("a", "@t", "c1")
|
||||
ops.add_command("b", "@t", "c2")
|
||||
|
||||
registry = ops.load_registry()
|
||||
assert registry["metadata"]["command_count"] == 2
|
||||
|
||||
ops.remove_command("a")
|
||||
|
||||
registry = ops.load_registry()
|
||||
assert registry["metadata"]["command_count"] == 1
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 3. Lookup (exact match)
|
||||
# ===================================================================
|
||||
|
||||
class TestLookup:
|
||||
|
||||
def test_lookup_found(self, isolated_registry: Path) -> None:
|
||||
"""lookup_command returns the command dict for an exact match."""
|
||||
ops.add_command("audit", "@seedgo", "audit", ["aipass"], "Run audit", "seedgo")
|
||||
|
||||
result = lookup.lookup_command("audit")
|
||||
|
||||
assert result is not None
|
||||
assert result["name"] == "audit"
|
||||
assert result["target"] == "@seedgo"
|
||||
|
||||
def test_lookup_not_found(self, isolated_registry: Path) -> None:
|
||||
"""lookup_command returns None when no match exists."""
|
||||
result = lookup.lookup_command("nonexistent")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 4. Multi-word greedy matching
|
||||
# ===================================================================
|
||||
|
||||
class TestMultiWordMatching:
|
||||
|
||||
def test_single_word_match(self, isolated_registry: Path) -> None:
|
||||
"""match_command matches a single-word command."""
|
||||
ops.add_command("audit", "@seedgo", "audit")
|
||||
|
||||
result = lookup.match_command(["audit", "--verbose"])
|
||||
|
||||
assert result is not None
|
||||
cmd, remaining = result
|
||||
assert cmd["name"] == "audit"
|
||||
assert remaining == ["--verbose"]
|
||||
|
||||
def test_multi_word_match(self, isolated_registry: Path) -> None:
|
||||
"""match_command matches a multi-word command name."""
|
||||
ops.add_command("plan create", "@flow", "create")
|
||||
|
||||
result = lookup.match_command(["plan", "create", "my-plan"])
|
||||
|
||||
assert result is not None
|
||||
cmd, remaining = result
|
||||
assert cmd["name"] == "plan create"
|
||||
assert remaining == ["my-plan"]
|
||||
|
||||
def test_greedy_longest_match(self, isolated_registry: Path) -> None:
|
||||
"""match_command prefers the longest matching candidate."""
|
||||
ops.add_command("plan", "@flow", "plan_list")
|
||||
ops.add_command("plan create", "@flow", "plan_create")
|
||||
|
||||
result = lookup.match_command(["plan", "create", "my-plan"])
|
||||
|
||||
assert result is not None
|
||||
cmd, remaining = result
|
||||
assert cmd["name"] == "plan create"
|
||||
assert remaining == ["my-plan"]
|
||||
|
||||
def test_four_word_match(self, isolated_registry: Path) -> None:
|
||||
"""match_command handles up to 4-word candidates."""
|
||||
ops.add_command("a b c d", "@test", "test_cmd")
|
||||
|
||||
result = lookup.match_command(["a", "b", "c", "d", "extra"])
|
||||
|
||||
assert result is not None
|
||||
cmd, remaining = result
|
||||
assert cmd["name"] == "a b c d"
|
||||
assert remaining == ["extra"]
|
||||
|
||||
def test_no_match_returns_none(self, isolated_registry: Path) -> None:
|
||||
"""match_command returns None when no candidate matches."""
|
||||
ops.add_command("audit", "@seedgo", "audit")
|
||||
|
||||
result = lookup.match_command(["deploy", "staging"])
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_empty_args_returns_none(self, isolated_registry: Path) -> None:
|
||||
"""match_command returns None for empty args."""
|
||||
result = lookup.match_command([])
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_exact_match_no_remaining(self, isolated_registry: Path) -> None:
|
||||
"""match_command with no extra args returns empty remaining list."""
|
||||
ops.add_command("audit", "@seedgo", "audit")
|
||||
|
||||
result = lookup.match_command(["audit"])
|
||||
|
||||
assert result is not None
|
||||
cmd, remaining = result
|
||||
assert cmd["name"] == "audit"
|
||||
assert remaining == []
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 5. List and filter by branch
|
||||
# ===================================================================
|
||||
|
||||
class TestListAndFilter:
|
||||
|
||||
def test_list_commands_sorted(self, isolated_registry: Path) -> None:
|
||||
"""list_commands returns all commands sorted by name."""
|
||||
ops.add_command("zebra", "@t", "c1")
|
||||
ops.add_command("alpha", "@t", "c2")
|
||||
ops.add_command("middle", "@t", "c3")
|
||||
|
||||
result = lookup.list_commands()
|
||||
|
||||
assert len(result) == 3
|
||||
names = [c["name"] for c in result]
|
||||
assert names == ["alpha", "middle", "zebra"]
|
||||
|
||||
def test_list_commands_empty(self, isolated_registry: Path) -> None:
|
||||
"""list_commands returns empty list when no commands exist."""
|
||||
result = lookup.list_commands()
|
||||
|
||||
assert result == []
|
||||
|
||||
def test_list_by_branch(self, isolated_registry: Path) -> None:
|
||||
"""list_commands_by_branch filters by source_branch."""
|
||||
ops.add_command("audit", "@seedgo", "audit", source_branch="seedgo")
|
||||
ops.add_command("monitor", "@prax", "monitor", source_branch="prax")
|
||||
ops.add_command("check", "@seedgo", "check", source_branch="seedgo")
|
||||
|
||||
seedgo_cmds = lookup.list_commands_by_branch("seedgo")
|
||||
|
||||
assert len(seedgo_cmds) == 2
|
||||
names = [c["name"] for c in seedgo_cmds]
|
||||
assert "audit" in names
|
||||
assert "check" in names
|
||||
|
||||
def test_list_by_branch_no_match(self, isolated_registry: Path) -> None:
|
||||
"""list_commands_by_branch returns empty for nonexistent branch."""
|
||||
ops.add_command("audit", "@seedgo", "audit", source_branch="seedgo")
|
||||
|
||||
result = lookup.list_commands_by_branch("nonexistent")
|
||||
|
||||
assert result == []
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 6. Save validation
|
||||
# ===================================================================
|
||||
|
||||
class TestSaveValidation:
|
||||
|
||||
def test_save_rejects_non_dict(self, isolated_registry: Path) -> None:
|
||||
"""save_registry rejects data that is not a dict."""
|
||||
result = ops.save_registry([1, 2, 3]) # type: ignore[arg-type]
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_save_rejects_missing_commands(self, isolated_registry: Path) -> None:
|
||||
"""save_registry rejects a dict without 'commands' key."""
|
||||
result = ops.save_registry({"metadata": {}})
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 7. Module orchestrator
|
||||
# ===================================================================
|
||||
|
||||
class TestModuleOrchestrator:
|
||||
|
||||
def test_handle_command_introspection(self, isolated_registry: Path) -> None:
|
||||
"""handle_command with no args triggers introspection."""
|
||||
from aipass.drone.apps.modules.commands import handle_command
|
||||
|
||||
result = handle_command()
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_handle_command_add(self, isolated_registry: Path) -> None:
|
||||
"""handle_command 'add' creates a command."""
|
||||
from aipass.drone.apps.modules.commands import handle_command
|
||||
|
||||
result = handle_command("add", ["test_cmd", "@branch", "do_thing", "--desc=A test"])
|
||||
|
||||
assert result is True
|
||||
assert ops.command_exists("test_cmd") is True
|
||||
|
||||
registry = ops.load_registry()
|
||||
assert registry["commands"]["test_cmd"]["description"] == "A test"
|
||||
|
||||
def test_handle_command_remove(self, isolated_registry: Path) -> None:
|
||||
"""handle_command 'remove' deletes a command."""
|
||||
from aipass.drone.apps.modules.commands import handle_command
|
||||
|
||||
ops.add_command("doomed", "@t", "c")
|
||||
result = handle_command("remove", ["doomed"])
|
||||
|
||||
assert result is True
|
||||
assert ops.command_exists("doomed") is False
|
||||
|
||||
def test_handle_command_list(self, isolated_registry: Path) -> None:
|
||||
"""handle_command 'list' succeeds."""
|
||||
from aipass.drone.apps.modules.commands import handle_command
|
||||
|
||||
ops.add_command("a", "@t", "c1")
|
||||
result = handle_command("list", [])
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_handle_command_unknown(self, isolated_registry: Path) -> None:
|
||||
"""handle_command with unknown subcommand returns False."""
|
||||
from aipass.drone.apps.modules.commands import handle_command
|
||||
|
||||
result = handle_command("unknown_sub", ["arg"])
|
||||
|
||||
assert result is False
|
||||
@@ -0,0 +1,493 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_scan.py
|
||||
# Description: Tests for branch command scanning
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-03-17
|
||||
# Modified: 2026-03-17
|
||||
# =============================================
|
||||
|
||||
"""Tests for branch command scanning.
|
||||
|
||||
Covers handler-layer functions (scan_help_output, scan_module_files,
|
||||
scan_branch) and the orchestration module (scan.handle_command, scan.scan).
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from aipass.drone.apps.handlers.scanning.scanner import (
|
||||
scan_branch,
|
||||
scan_help_output,
|
||||
scan_module_files,
|
||||
)
|
||||
from aipass.drone.apps.handlers.scanning.formatters import (
|
||||
format_no_commands,
|
||||
format_scan_results,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# scan_help_output tests
|
||||
# =============================================================================
|
||||
|
||||
class TestScanHelpOutput:
|
||||
"""Tests for scan_help_output()."""
|
||||
|
||||
def test_returns_commands_from_help(self, temp_test_dir: Path) -> None:
|
||||
"""Should parse commands from --help output."""
|
||||
apps_dir = temp_test_dir / "apps"
|
||||
apps_dir.mkdir(parents=True)
|
||||
(apps_dir / "mybranch.py").write_text("# entry", encoding="utf-8")
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.stdout = (
|
||||
b"Usage: mybranch\n\n"
|
||||
b"Commands:\n"
|
||||
b" audit Run an audit\n"
|
||||
b" list List items\n\n"
|
||||
)
|
||||
mock_result.stderr = b""
|
||||
|
||||
with patch(
|
||||
"aipass.drone.apps.handlers.scanning.scanner.subprocess.run",
|
||||
return_value=mock_result,
|
||||
):
|
||||
result = scan_help_output(str(temp_test_dir), "mybranch")
|
||||
|
||||
assert len(result) == 2
|
||||
names = [c["name"] for c in result]
|
||||
assert "audit" in names
|
||||
assert "list" in names
|
||||
assert all(c["source"] == "help" for c in result)
|
||||
|
||||
def test_extracts_descriptions(self, temp_test_dir: Path) -> None:
|
||||
"""Should extract descriptions alongside command names."""
|
||||
apps_dir = temp_test_dir / "apps"
|
||||
apps_dir.mkdir(parents=True)
|
||||
(apps_dir / "mybranch.py").write_text("# entry", encoding="utf-8")
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.stdout = (
|
||||
b"Commands:\n"
|
||||
b" deploy Deploy to production\n\n"
|
||||
)
|
||||
mock_result.stderr = b""
|
||||
|
||||
with patch(
|
||||
"aipass.drone.apps.handlers.scanning.scanner.subprocess.run",
|
||||
return_value=mock_result,
|
||||
):
|
||||
result = scan_help_output(str(temp_test_dir), "mybranch")
|
||||
|
||||
assert result[0]["description"] == "Deploy to production"
|
||||
|
||||
def test_returns_empty_when_no_entry_point(self, temp_test_dir: Path) -> None:
|
||||
"""Should return empty list when no entry point exists."""
|
||||
result = scan_help_output(str(temp_test_dir), "nonexistent")
|
||||
assert result == []
|
||||
|
||||
def test_returns_empty_on_timeout(self, temp_test_dir: Path) -> None:
|
||||
"""Should return empty list when subprocess times out."""
|
||||
apps_dir = temp_test_dir / "apps"
|
||||
apps_dir.mkdir(parents=True)
|
||||
(apps_dir / "slow.py").write_text("# entry", encoding="utf-8")
|
||||
|
||||
with patch(
|
||||
"aipass.drone.apps.handlers.scanning.scanner.subprocess.run",
|
||||
side_effect=subprocess.TimeoutExpired(cmd="test", timeout=10),
|
||||
):
|
||||
result = scan_help_output(str(temp_test_dir), "slow")
|
||||
|
||||
assert result == []
|
||||
|
||||
def test_returns_empty_on_oserror(self, temp_test_dir: Path) -> None:
|
||||
"""Should return empty list on OSError."""
|
||||
apps_dir = temp_test_dir / "apps"
|
||||
apps_dir.mkdir(parents=True)
|
||||
(apps_dir / "broken.py").write_text("# entry", encoding="utf-8")
|
||||
|
||||
with patch(
|
||||
"aipass.drone.apps.handlers.scanning.scanner.subprocess.run",
|
||||
side_effect=OSError("No such file"),
|
||||
):
|
||||
result = scan_help_output(str(temp_test_dir), "broken")
|
||||
|
||||
assert result == []
|
||||
|
||||
def test_falls_back_to_stderr(self, temp_test_dir: Path) -> None:
|
||||
"""Should parse stderr when stdout is empty."""
|
||||
apps_dir = temp_test_dir / "apps"
|
||||
apps_dir.mkdir(parents=True)
|
||||
(apps_dir / "mybranch.py").write_text("# entry", encoding="utf-8")
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.stdout = b""
|
||||
mock_result.stderr = b"Commands:\n check Run checks\n\n"
|
||||
|
||||
with patch(
|
||||
"aipass.drone.apps.handlers.scanning.scanner.subprocess.run",
|
||||
return_value=mock_result,
|
||||
):
|
||||
result = scan_help_output(str(temp_test_dir), "mybranch")
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["name"] == "check"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# scan_module_files tests
|
||||
# =============================================================================
|
||||
|
||||
class TestScanModuleFiles:
|
||||
"""Tests for scan_module_files()."""
|
||||
|
||||
def test_finds_modules_with_handle_command(self, temp_test_dir: Path) -> None:
|
||||
"""Should find .py files that define handle_command()."""
|
||||
modules_dir = temp_test_dir / "apps" / "modules"
|
||||
modules_dir.mkdir(parents=True)
|
||||
(modules_dir / "alpha.py").write_text(
|
||||
'"""Alpha module."""\ndef handle_command(command=None, args=None): pass\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
(modules_dir / "beta.py").write_text(
|
||||
'"""Beta module."""\ndef handle_command(command=None, args=None): pass\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = scan_module_files(str(temp_test_dir))
|
||||
|
||||
assert len(result) == 2
|
||||
names = [c["name"] for c in result]
|
||||
assert "alpha" in names
|
||||
assert "beta" in names
|
||||
assert all(c["source"] == "module" for c in result)
|
||||
|
||||
def test_skips_files_without_handle_command(self, temp_test_dir: Path) -> None:
|
||||
"""Should skip modules that do not define handle_command()."""
|
||||
modules_dir = temp_test_dir / "apps" / "modules"
|
||||
modules_dir.mkdir(parents=True)
|
||||
(modules_dir / "utility.py").write_text(
|
||||
"def helper(): pass\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = scan_module_files(str(temp_test_dir))
|
||||
|
||||
assert result == []
|
||||
|
||||
def test_skips_init_and_main(self, temp_test_dir: Path) -> None:
|
||||
"""Should exclude __init__.py and __main__.py."""
|
||||
modules_dir = temp_test_dir / "apps" / "modules"
|
||||
modules_dir.mkdir(parents=True)
|
||||
(modules_dir / "__init__.py").write_text(
|
||||
"def handle_command(): pass\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(modules_dir / "__main__.py").write_text(
|
||||
"def handle_command(): pass\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(modules_dir / "real.py").write_text(
|
||||
'"""Real module."""\ndef handle_command(command=None, args=None): pass\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = scan_module_files(str(temp_test_dir))
|
||||
|
||||
names = [c["name"] for c in result]
|
||||
assert "__init__" not in names
|
||||
assert "__main__" not in names
|
||||
assert "real" in names
|
||||
|
||||
def test_returns_empty_when_no_modules_dir(self, temp_test_dir: Path) -> None:
|
||||
"""Should return empty list when apps/modules/ does not exist."""
|
||||
result = scan_module_files(str(temp_test_dir))
|
||||
assert result == []
|
||||
|
||||
def test_extracts_module_description(self, temp_test_dir: Path) -> None:
|
||||
"""Should extract the first line of the module docstring."""
|
||||
modules_dir = temp_test_dir / "apps" / "modules"
|
||||
modules_dir.mkdir(parents=True)
|
||||
(modules_dir / "config.py").write_text(
|
||||
'"""Configuration management for the branch."""\n'
|
||||
"def handle_command(command=None, args=None): pass\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = scan_module_files(str(temp_test_dir))
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["description"] == "Configuration management for the branch."
|
||||
|
||||
def test_results_are_sorted(self, temp_test_dir: Path) -> None:
|
||||
"""Results should be sorted alphabetically by module name."""
|
||||
modules_dir = temp_test_dir / "apps" / "modules"
|
||||
modules_dir.mkdir(parents=True)
|
||||
for name in ["zebra", "apple", "mango"]:
|
||||
(modules_dir / f"{name}.py").write_text(
|
||||
f'"""{name} module."""\ndef handle_command(): pass\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = scan_module_files(str(temp_test_dir))
|
||||
|
||||
names = [c["name"] for c in result]
|
||||
assert names == sorted(names)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# scan_branch tests
|
||||
# =============================================================================
|
||||
|
||||
class TestScanBranch:
|
||||
"""Tests for scan_branch()."""
|
||||
|
||||
def test_merges_help_and_module_results(self, temp_test_dir: Path) -> None:
|
||||
"""Should combine help and module results, deduplicating by name."""
|
||||
apps_dir = temp_test_dir / "apps"
|
||||
apps_dir.mkdir(parents=True)
|
||||
(apps_dir / "mybranch.py").write_text("# entry", encoding="utf-8")
|
||||
|
||||
modules_dir = apps_dir / "modules"
|
||||
modules_dir.mkdir()
|
||||
(modules_dir / "extra.py").write_text(
|
||||
'"""Extra module."""\ndef handle_command(): pass\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.stdout = b"Commands:\n audit Run audit\n\n"
|
||||
mock_result.stderr = b""
|
||||
|
||||
with patch(
|
||||
"aipass.drone.apps.handlers.scanning.scanner.subprocess.run",
|
||||
return_value=mock_result,
|
||||
):
|
||||
result = scan_branch(str(temp_test_dir), "mybranch")
|
||||
|
||||
names = [c["name"] for c in result]
|
||||
assert "audit" in names
|
||||
assert "extra" in names
|
||||
|
||||
def test_help_wins_on_duplicate(self, temp_test_dir: Path) -> None:
|
||||
"""When a command appears in both help and module, help source should win."""
|
||||
apps_dir = temp_test_dir / "apps"
|
||||
apps_dir.mkdir(parents=True)
|
||||
(apps_dir / "mybranch.py").write_text("# entry", encoding="utf-8")
|
||||
|
||||
modules_dir = apps_dir / "modules"
|
||||
modules_dir.mkdir()
|
||||
# Module named "audit" -- same as a help command
|
||||
(modules_dir / "audit.py").write_text(
|
||||
'"""Audit module."""\ndef handle_command(): pass\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.stdout = b"Commands:\n audit Run audit from help\n\n"
|
||||
mock_result.stderr = b""
|
||||
|
||||
with patch(
|
||||
"aipass.drone.apps.handlers.scanning.scanner.subprocess.run",
|
||||
return_value=mock_result,
|
||||
):
|
||||
result = scan_branch(str(temp_test_dir), "mybranch")
|
||||
|
||||
audit_cmds = [c for c in result if c["name"] == "audit"]
|
||||
assert len(audit_cmds) == 1
|
||||
assert audit_cmds[0]["source"] == "help"
|
||||
|
||||
def test_returns_empty_when_nothing_found(self, temp_test_dir: Path) -> None:
|
||||
"""Should return empty list when no commands found anywhere."""
|
||||
result = scan_branch(str(temp_test_dir), "empty")
|
||||
assert result == []
|
||||
|
||||
def test_results_sorted_by_name(self, temp_test_dir: Path) -> None:
|
||||
"""Results should be sorted alphabetically."""
|
||||
modules_dir = temp_test_dir / "apps" / "modules"
|
||||
modules_dir.mkdir(parents=True)
|
||||
for name in ["zebra", "apple"]:
|
||||
(modules_dir / f"{name}.py").write_text(
|
||||
f'"""{name}."""\ndef handle_command(): pass\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = scan_branch(str(temp_test_dir), "test")
|
||||
|
||||
names = [c["name"] for c in result]
|
||||
assert names == sorted(names)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# format_scan_results tests
|
||||
# =============================================================================
|
||||
|
||||
class TestFormatScanResults:
|
||||
"""Tests for format_scan_results()."""
|
||||
|
||||
def test_produces_output(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Should produce console output with command names."""
|
||||
commands = [
|
||||
{"name": "audit", "description": "Run audit", "source": "help"},
|
||||
{"name": "list", "description": "List items", "source": "module"},
|
||||
]
|
||||
format_scan_results("testbranch", commands)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "audit" in captured.out
|
||||
assert "list" in captured.out
|
||||
assert "@testbranch" in captured.out
|
||||
assert "command" in captured.out and "discovered" in captured.out
|
||||
|
||||
def test_adds_at_prefix_if_missing(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Should add @ prefix for display when missing."""
|
||||
format_scan_results("mybranch", [{"name": "x", "description": "", "source": "help"}])
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "@mybranch" in captured.out
|
||||
|
||||
def test_preserves_at_prefix(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Should not double the @ prefix."""
|
||||
format_scan_results("@mybranch", [{"name": "x", "description": "", "source": "help"}])
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "@mybranch" in captured.out
|
||||
assert "@@mybranch" not in captured.out
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# format_no_commands tests
|
||||
# =============================================================================
|
||||
|
||||
class TestFormatNoCommands:
|
||||
"""Tests for format_no_commands()."""
|
||||
|
||||
def test_produces_output(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Should produce informational console output."""
|
||||
format_no_commands("emptybranch")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "No commands discovered" in captured.out
|
||||
assert "@emptybranch" in captured.out
|
||||
|
||||
def test_adds_at_prefix_if_missing(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Should add @ prefix for display."""
|
||||
format_no_commands("mybranch")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "@mybranch" in captured.out
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# scan module handle_command tests
|
||||
# =============================================================================
|
||||
|
||||
class TestScanHandleCommand:
|
||||
"""Tests for scan.handle_command() routing."""
|
||||
|
||||
@patch("aipass.drone.apps.modules.scan.scan")
|
||||
def test_routes_to_scan(self, mock_scan: MagicMock) -> None:
|
||||
"""Should call scan() with the target argument."""
|
||||
from aipass.drone.apps.modules.scan import handle_command
|
||||
|
||||
mock_scan.return_value = [{"name": "x", "description": "", "source": "help"}]
|
||||
|
||||
result = handle_command(command=None, args=["@testbranch"])
|
||||
|
||||
assert result is True
|
||||
mock_scan.assert_called_once_with("@testbranch")
|
||||
|
||||
@patch("aipass.drone.apps.modules.scan.scan")
|
||||
def test_returns_false_when_scan_fails(self, mock_scan: MagicMock) -> None:
|
||||
"""Should return False when scan returns None (resolution failure)."""
|
||||
from aipass.drone.apps.modules.scan import handle_command
|
||||
|
||||
mock_scan.return_value = None
|
||||
|
||||
result = handle_command(command=None, args=["@nonexistent"])
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_no_args_shows_introspection(self) -> None:
|
||||
"""Should call print_introspection when command is None and no args."""
|
||||
from aipass.drone.apps.modules.scan import handle_command
|
||||
|
||||
with patch("aipass.drone.apps.modules.scan.print_introspection") as mock_intro:
|
||||
result = handle_command(command=None, args=None)
|
||||
|
||||
assert result is True
|
||||
mock_intro.assert_called_once()
|
||||
|
||||
def test_empty_args_returns_false(self) -> None:
|
||||
"""Should return False when command is given but no args."""
|
||||
from aipass.drone.apps.modules.scan import handle_command
|
||||
|
||||
result = handle_command(command="scan", args=[])
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# scan module scan() tests
|
||||
# =============================================================================
|
||||
|
||||
class TestScanFunction:
|
||||
"""Tests for scan.scan() orchestration."""
|
||||
|
||||
@patch("aipass.drone.apps.modules.scan.resolve_branch")
|
||||
@patch("aipass.drone.apps.modules.scan.scan_branch")
|
||||
@patch("aipass.drone.apps.modules.scan.format_scan_results")
|
||||
def test_resolves_and_scans(
|
||||
self,
|
||||
mock_format: MagicMock,
|
||||
mock_scan_branch: MagicMock,
|
||||
mock_resolve: MagicMock,
|
||||
) -> None:
|
||||
"""Should resolve target, scan, format, and return results."""
|
||||
from aipass.drone.apps.modules.scan import scan
|
||||
|
||||
mock_resolve.return_value = "/fake/path"
|
||||
mock_scan_branch.return_value = [
|
||||
{"name": "audit", "description": "Run audit", "source": "help"},
|
||||
]
|
||||
|
||||
result = scan("@testbranch")
|
||||
|
||||
mock_resolve.assert_called_once_with("@testbranch")
|
||||
mock_scan_branch.assert_called_once_with("/fake/path", "testbranch")
|
||||
mock_format.assert_called_once()
|
||||
assert result is not None
|
||||
assert len(result) == 1
|
||||
|
||||
@patch("aipass.drone.apps.modules.scan.resolve_branch")
|
||||
@patch("aipass.drone.apps.modules.scan.scan_branch")
|
||||
@patch("aipass.drone.apps.modules.scan.format_no_commands")
|
||||
def test_shows_no_commands_message(
|
||||
self,
|
||||
mock_format_none: MagicMock,
|
||||
mock_scan_branch: MagicMock,
|
||||
mock_resolve: MagicMock,
|
||||
) -> None:
|
||||
"""Should display no-commands message when scan finds nothing."""
|
||||
from aipass.drone.apps.modules.scan import scan
|
||||
|
||||
mock_resolve.return_value = "/fake/path"
|
||||
mock_scan_branch.return_value = []
|
||||
|
||||
result = scan("@emptybranch")
|
||||
|
||||
mock_format_none.assert_called_once()
|
||||
assert result is not None # Empty list, not None
|
||||
assert len(result) == 0
|
||||
|
||||
@patch("aipass.drone.apps.modules.scan.resolve_branch", side_effect=Exception("not found"))
|
||||
def test_returns_none_on_resolution_failure(self, mock_resolve: MagicMock) -> None:
|
||||
"""Should return None when branch resolution fails."""
|
||||
from aipass.drone.apps.modules.scan import scan
|
||||
|
||||
result = scan("@nonexistent")
|
||||
|
||||
assert result is None
|
||||
Reference in New Issue
Block a user