feat: add development framework support with skill system and UI

Add a framework selection system that lets users choose structured
development workflows. Frameworks provide curated skills that guide
the agent through established methodologies.

Features:
- Framework registry with 11 frameworks: GSD, Superpowers, BMAD,
  BMAD Builder, BMAD Creative Intelligence Suite, BMAD Game Dev Studio,
  Spec Kit, PRP, AgentOS, AMPLIHACK, and Agent Zero Dev
- 62 workflow skills organized by framework
- Global framework selection in Settings > Agent > Framework
- Per-project framework override (Settings > Projects > Edit)
- Framework-aware skill discovery prioritizes active framework's skills
- System prompt injection provides workflow context to the agent
- Skills Import moved to dedicated Settings tab

Backend:
- python/helpers/frameworks.py: Framework registry and utilities
- python/api/frameworks.py: Framework list/get API endpoint
- python/helpers/settings.py: Added dev_framework setting
- python/helpers/projects.py: Added dev_framework to project config
- python/helpers/skills.py: Framework-aware get_skill_roots()
- python/tools/skills_tool.py: Pass framework_id to skill helpers
- python/extensions/message_loop_prompts_after/_55_recall_skills.py:
  Framework context in skill recall
- python/extensions/system_prompt/_10_system_prompt.py: Framework prompt

Frontend:
- webui/components/settings/agent/framework.html: Framework selector
- webui/components/settings/frameworks/: Framework details modal + store
- webui/components/settings/skills/skills-settings.html: Skills tab

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
TerminallyLazy 2026-01-29 18:25:25 -05:00
parent 52404a776a
commit 99d5bf6f05
139 changed files with 19790 additions and 110 deletions

View file

@ -64,9 +64,9 @@ class BackupService:
{agent_root}/knowledge/**
!{agent_root}/knowledge/default/**
# Agent Zero Instruments (excluding defaults)
{agent_root}/instruments/**
!{agent_root}/instruments/default/**
# Agent Zero Skills (excluding builtins)
{agent_root}/skills/**
!{agent_root}/skills/builtin/**
# Memory (excluding embeddings cache)
{agent_root}/memory/**

View file

@ -0,0 +1,623 @@
"""
Development Framework Registry for Agent Zero.
This module defines the available development frameworks that can be used
to guide agent workflows. Each framework provides structured methodologies
for software development tasks.
Frameworks are selected globally in settings or overridden per-project.
When a framework is active, its skills are prioritized in skill discovery
and framework context is injected into the system prompt.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, List, Literal, Optional
if TYPE_CHECKING:
from agent import AgentContext
# Supported framework identifiers
FrameworkId = Literal[
"none",
"gsd",
"superpowers",
"bmad",
"bmad-builder",
"bmad-cis",
"bmad-gds",
"speckit",
"prp",
"agentos",
"amplihack",
"agent-zero-dev",
]
ALL_FRAMEWORK_IDS: List[str] = [
"none",
"gsd",
"superpowers",
"bmad",
"bmad-builder",
"bmad-cis",
"bmad-gds",
"speckit",
"prp",
"agentos",
"amplihack",
"agent-zero-dev",
]
@dataclass(slots=True)
class FrameworkWorkflow:
"""A single workflow step within a framework."""
name: str # e.g., "plan-phase"
skill_name: str # Maps to SKILL.md name, e.g., "gsd-plan-phase"
description: str
sequence: int # Order in workflow (1-based)
@dataclass(slots=True)
class Framework:
"""A development framework definition."""
id: FrameworkId
name: str # Display name
description: str
skill_prefix: str # Namespace for skills, e.g., "gsd", "bmad"
workflows: List[FrameworkWorkflow] = field(default_factory=list)
# ─────────────────────────────────────────────────────────────────────────────
# Framework Registry
# ─────────────────────────────────────────────────────────────────────────────
FRAMEWORK_REGISTRY: dict[str, Framework] = {
"none": Framework(
id="none",
name="None",
description="No development framework. Agent operates with standard skills only.",
skill_prefix="",
workflows=[],
),
"gsd": Framework(
id="gsd",
name="GSD (Get Stuff Done)",
description="A structured methodology emphasizing planning before implementation. Features clear phases: project setup, discussion, planning, execution, verification, and milestone completion.",
skill_prefix="gsd",
workflows=[
FrameworkWorkflow(
name="New Project",
skill_name="gsd-new-project",
description="Initialize project structure, requirements, and roadmap",
sequence=1,
),
FrameworkWorkflow(
name="Discuss Phase",
skill_name="gsd-discuss-phase",
description="Capture implementation decisions and preferences before planning",
sequence=2,
),
FrameworkWorkflow(
name="Plan Phase",
skill_name="gsd-plan-phase",
description="Research, create atomic task plans, and verify against requirements",
sequence=3,
),
FrameworkWorkflow(
name="Execute Phase",
skill_name="gsd-execute-phase",
description="Run plans in parallel waves with fresh context per task",
sequence=4,
),
FrameworkWorkflow(
name="Verify Work",
skill_name="gsd-verify-work",
description="Manual user acceptance testing with automatic fix generation",
sequence=5,
),
FrameworkWorkflow(
name="Complete Milestone",
skill_name="gsd-complete-milestone",
description="Archive milestone, tag release, prepare for next iteration",
sequence=6,
),
],
),
"superpowers": Framework(
id="superpowers",
name="Superpowers",
description="A comprehensive development workflow framework for Claude Code. Emphasizes TDD, brainstorming, planning, subagent execution, code review, and proper branch management.",
skill_prefix="sp",
workflows=[
FrameworkWorkflow(
name="Brainstorming",
skill_name="sp-brainstorming",
description="Socratic design refinement before writing code",
sequence=1,
),
FrameworkWorkflow(
name="Git Worktrees",
skill_name="sp-git-worktrees",
description="Create isolated workspace on new branch for development",
sequence=2,
),
FrameworkWorkflow(
name="Writing Plans",
skill_name="sp-writing-plans",
description="Break work into bite-sized tasks (2-5 min each) with verification",
sequence=3,
),
FrameworkWorkflow(
name="Test-Driven Development",
skill_name="sp-test-driven-development",
description="RED-GREEN-REFACTOR: test first, minimal code, commit",
sequence=4,
),
FrameworkWorkflow(
name="Executing Plans",
skill_name="sp-executing-plans",
description="Dispatch subagents per task with two-stage review",
sequence=5,
),
FrameworkWorkflow(
name="Code Review",
skill_name="sp-code-review",
description="Review against plan, report issues by severity",
sequence=6,
),
FrameworkWorkflow(
name="Finishing Branch",
skill_name="sp-finishing-branch",
description="Verify tests, merge/PR options, cleanup worktree",
sequence=7,
),
],
),
"bmad": Framework(
id="bmad",
name="BMAD (Business-Minded Agile Development)",
description="A business-focused agile methodology with 21 specialized agents. Features two paths: Quick (spec→dev→review) for small tasks, Full (brief→PRD→arch→epics→sprint→stories) for complex projects.",
skill_prefix="bmad",
workflows=[
FrameworkWorkflow(
name="Quick Spec",
skill_name="bmad-quick-spec",
description="(Quick Path) Analyze codebase and produce tech-spec with stories",
sequence=1,
),
FrameworkWorkflow(
name="Product Brief",
skill_name="bmad-product-brief",
description="(Full Path) Define problem, users, and MVP scope",
sequence=2,
),
FrameworkWorkflow(
name="Create PRD",
skill_name="bmad-create-prd",
description="Full requirements with personas, metrics, and risks",
sequence=3,
),
FrameworkWorkflow(
name="Architecture",
skill_name="bmad-create-architecture",
description="Technical decisions and system design",
sequence=4,
),
FrameworkWorkflow(
name="Create Epics",
skill_name="bmad-create-epics",
description="Break work into prioritized epics and stories",
sequence=5,
),
FrameworkWorkflow(
name="Sprint Planning",
skill_name="bmad-sprint-planning",
description="Initialize sprint tracking and story selection",
sequence=6,
),
FrameworkWorkflow(
name="Developer Story",
skill_name="bmad-dev-story",
description="Implement individual stories with guidance",
sequence=7,
),
FrameworkWorkflow(
name="Code Review",
skill_name="bmad-code-review",
description="Validate quality and completeness",
sequence=8,
),
],
),
"bmad-builder": Framework(
id="bmad-builder",
name="BMad Builder",
description="Meta-module for creating custom BMad agents, workflows, and domain-specific modules. Build specialized AI agents with custom expertise, structured workflows, and shareable module packages.",
skill_prefix="bmb",
workflows=[
FrameworkWorkflow(
name="Build Agent",
skill_name="bmb-agent",
description="Create specialized AI agents with custom expertise and tools",
sequence=1,
),
FrameworkWorkflow(
name="Build Workflow",
skill_name="bmb-workflow",
description="Design structured workflows with steps and cross-workflow communication",
sequence=2,
),
FrameworkWorkflow(
name="Build Module",
skill_name="bmb-module",
description="Package agents and workflows into shareable BMad modules",
sequence=3,
),
],
),
"bmad-cis": Framework(
id="bmad-cis",
name="BMad Creative Intelligence Suite",
description="Tools for the fuzzy front-end of development—where ideas are born, problems are reframed, and solutions emerge through structured creativity. Features innovation, design thinking, and brainstorming workflows.",
skill_prefix="cis",
workflows=[
FrameworkWorkflow(
name="Brainstorm",
skill_name="cis-brainstorm",
description="Generate ideas with structured techniques (SCAMPER, Reverse Brainstorming)",
sequence=1,
),
FrameworkWorkflow(
name="Design Thinking",
skill_name="cis-design-thinking",
description="Human-centered design through empathy, ideation, and prototyping",
sequence=2,
),
FrameworkWorkflow(
name="Problem Solve",
skill_name="cis-problem-solve",
description="Systematic problem diagnosis and root cause analysis",
sequence=3,
),
FrameworkWorkflow(
name="Innovation",
skill_name="cis-innovation",
description="Business model innovation and disruption opportunity analysis",
sequence=4,
),
FrameworkWorkflow(
name="Storytelling",
skill_name="cis-storytelling",
description="Craft compelling narratives for products and features",
sequence=5,
),
FrameworkWorkflow(
name="Presentation",
skill_name="cis-presentation",
description="Structure and deliver persuasive presentations",
sequence=6,
),
],
),
"bmad-gds": Framework(
id="bmad-gds",
name="BMad Game Dev Studio",
description="Six specialized game development agents: Game Designer (Samus Shepard), Game Architect (Cloud Dragonborn), Game Developer (Link Freeman), Game Scrum Master (Max), Game QA (GLaDOS), and Game Solo Dev (Indie). Two paths: Full (brief→GDD→arch→sprint→stories) for team projects, Quick Flow for solo/indie dev.",
skill_prefix="gds",
workflows=[
FrameworkWorkflow(
name="Brainstorm Game",
skill_name="gds-brainstorm-game",
description="Guided game ideation with Game Designer (Samus Shepard)",
sequence=1,
),
FrameworkWorkflow(
name="Create Game Brief",
skill_name="gds-create-brief",
description="Define game vision, core loop, and target experience",
sequence=2,
),
FrameworkWorkflow(
name="Create GDD",
skill_name="gds-create-gdd",
description="Full Game Design Document with mechanics and systems",
sequence=3,
),
FrameworkWorkflow(
name="Game Architecture",
skill_name="gds-create-architecture",
description="Technical architecture with Game Architect (Cloud Dragonborn)",
sequence=4,
),
FrameworkWorkflow(
name="Sprint Planning",
skill_name="gds-sprint-planning",
description="Plan sprints with Game Scrum Master (Max)",
sequence=5,
),
FrameworkWorkflow(
name="Dev Story",
skill_name="gds-dev-story",
description="Implement stories with Game Developer (Link Freeman)",
sequence=6,
),
FrameworkWorkflow(
name="QA Framework",
skill_name="gds-qa-framework",
description="Set up testing with Game QA (GLaDOS)",
sequence=7,
),
FrameworkWorkflow(
name="Quick Flow",
skill_name="gds-quick-flow",
description="Solo dev fast path with Game Solo Dev (Indie)",
sequence=8,
),
],
),
"speckit": Framework(
id="speckit",
name="Spec Kit",
description="A specification-driven approach emphasizing upfront clarity. Starts with constitution definition, progresses through specification, planning, task generation, and implementation.",
skill_prefix="speckit",
workflows=[
FrameworkWorkflow(
name="Constitution",
skill_name="speckit-constitution",
description="Define project principles and constraints",
sequence=1,
),
FrameworkWorkflow(
name="Specify",
skill_name="speckit-specify",
description="Create detailed specifications",
sequence=2,
),
FrameworkWorkflow(
name="Plan",
skill_name="speckit-plan",
description="Generate implementation roadmap from specs",
sequence=3,
),
FrameworkWorkflow(
name="Tasks",
skill_name="speckit-tasks",
description="Break plan into actionable tasks",
sequence=4,
),
FrameworkWorkflow(
name="Implement",
skill_name="speckit-implement",
description="Execute tasks following specifications",
sequence=5,
),
],
),
"prp": Framework(
id="prp",
name="PRP (Prompt-Response Protocol)",
description="A lightweight two-phase methodology: generate comprehensive PRPs (prompts) for tasks, then execute them. Ideal for well-defined, repeatable tasks.",
skill_prefix="prp",
workflows=[
FrameworkWorkflow(
name="Generate PRP",
skill_name="prp-generate",
description="Create detailed prompt specification for task",
sequence=1,
),
FrameworkWorkflow(
name="Execute PRP",
skill_name="prp-execute",
description="Execute the generated prompt systematically",
sequence=2,
),
],
),
"agentos": Framework(
id="agentos",
name="AgentOS",
description="A standards-based framework focusing on project initialization and adherence to coding standards. Emphasizes consistent project structure and quality gates.",
skill_prefix="agentos",
workflows=[
FrameworkWorkflow(
name="Project Install",
skill_name="agentos-project-install",
description="Initialize project with standard structure",
sequence=1,
),
FrameworkWorkflow(
name="Standards",
skill_name="agentos-standards",
description="Apply and verify coding standards",
sequence=2,
),
],
),
"amplihack": Framework(
id="amplihack",
name="AMPLIHACK",
description="A multi-agent orchestration framework with specialized agents. Features auto workflow selection, analysis, cascade patterns, debate workflows, fix workflows, and modular building.",
skill_prefix="amplihack",
workflows=[
FrameworkWorkflow(
name="Auto",
skill_name="amplihack-auto",
description="Automatic workflow selection based on task complexity",
sequence=1,
),
FrameworkWorkflow(
name="Analyze",
skill_name="amplihack-analyze",
description="Deep code/requirements analysis with multiple perspectives",
sequence=2,
),
FrameworkWorkflow(
name="Cascade",
skill_name="amplihack-cascade",
description="Sequential multi-agent processing for complex tasks",
sequence=3,
),
FrameworkWorkflow(
name="Debate",
skill_name="amplihack-debate",
description="Multi-perspective debate for technical decisions",
sequence=4,
),
FrameworkWorkflow(
name="Fix",
skill_name="amplihack-fix",
description="Systematic error resolution with pattern-specific context",
sequence=5,
),
FrameworkWorkflow(
name="Modular Build",
skill_name="amplihack-modular-build",
description="Build code following brick philosophy with modules",
sequence=6,
),
],
),
"agent-zero-dev": Framework(
id="agent-zero-dev",
name="Agent Zero Dev",
description="Development framework for extending and building features for Agent Zero. Provides patterns, templates, and code generators for creating tools, extensions, skills, API endpoints, subordinate profiles, and project configurations.",
skill_prefix="a0dev",
workflows=[
FrameworkWorkflow(
name="Quickstart",
skill_name="a0dev-quickstart",
description="5-minute guide to extending Agent Zero",
sequence=1,
),
FrameworkWorkflow(
name="Create Tool",
skill_name="a0dev-create-tool",
description="Create new agent capabilities (tools)",
sequence=2,
),
FrameworkWorkflow(
name="Create Extension",
skill_name="a0dev-create-extension",
description="Hook into agent lifecycle events",
sequence=3,
),
FrameworkWorkflow(
name="Create Skill",
skill_name="a0dev-create-skill",
description="Build reusable instruction bundles (SKILL.md)",
sequence=4,
),
FrameworkWorkflow(
name="Create API",
skill_name="a0dev-create-api",
description="Add Web UI / REST API endpoints",
sequence=5,
),
FrameworkWorkflow(
name="Create Subordinate",
skill_name="a0dev-create-subordinate",
description="Create specialized agent profiles",
sequence=6,
),
FrameworkWorkflow(
name="Create Project",
skill_name="a0dev-create-project",
description="Set up project-specific configuration",
sequence=7,
),
FrameworkWorkflow(
name="Dev Workflow",
skill_name="a0dev-workflow",
description="Full Agent Zero development workflow",
sequence=8,
),
],
),
}
# ─────────────────────────────────────────────────────────────────────────────
# Public API
# ─────────────────────────────────────────────────────────────────────────────
def get_framework(framework_id: str) -> Optional[Framework]:
"""
Get a framework by its ID.
Args:
framework_id: The framework identifier (e.g., "gsd", "bmad")
Returns:
Framework object if found, None otherwise
"""
return FRAMEWORK_REGISTRY.get(framework_id)
def list_frameworks() -> List[Framework]:
"""
List all available frameworks.
Returns:
List of all Framework objects in registry order
"""
return [FRAMEWORK_REGISTRY[fid] for fid in ALL_FRAMEWORK_IDS if fid in FRAMEWORK_REGISTRY]
def get_active_framework(context: "AgentContext") -> Optional[Framework]:
"""
Get the active framework for an agent context.
Priority:
1. Project-level override (if project has dev_framework set)
2. Global setting (settings.dev_framework)
3. None (no framework active)
Args:
context: The agent context
Returns:
Active Framework object, or None if "none" is selected
"""
from python.helpers import projects
from python.helpers.settings import get_settings
framework_id: str = "none"
# Check project-level override first
project_name = projects.get_context_project_name(context)
if project_name:
try:
project_data = projects.load_basic_project_data(project_name)
project_fw = project_data.get("dev_framework", "")
if project_fw and project_fw != "":
framework_id = project_fw
except Exception:
pass
# Fall back to global setting
if framework_id == "none" or not framework_id:
settings = get_settings()
framework_id = settings.get("dev_framework", "none")
if framework_id == "none" or not framework_id:
return None
return get_framework(framework_id)
def get_framework_options() -> List[dict]:
"""
Get framework options formatted for settings UI select field.
Returns:
List of dicts with 'value' and 'label' keys
"""
return [
{"value": fw.id, "label": fw.name}
for fw in list_frameworks()
]

View file

@ -484,4 +484,4 @@ async def mcp_middleware(request: Request, call_next):
status_code=403, detail="MCP server is disabled in settings."
)
return await call_next(request)
return await call_next(request)

View file

@ -57,7 +57,7 @@ class Memory:
MAIN = "main"
FRAGMENTS = "fragments"
SOLUTIONS = "solutions"
INSTRUMENTS = "instruments"
SKILLS = "skills" # Open SKILL.md standard (replaces legacy instruments)
index: dict[str, "MyFaiss"] = {}
@ -323,15 +323,17 @@ class Memory:
recursive=True,
)
# load instruments descriptions
index = knowledge_import.load_knowledge(
log_item,
files.get_abs_path("instruments"),
index,
{"area": Memory.Area.INSTRUMENTS.value},
filename_pattern="**/*.md",
recursive=True,
)
# load skills from custom, builtin, and shared directories (SKILL.md standard)
skills_dirs = ["custom", "builtin", "shared"]
for skills_subdir in skills_dirs:
skills_path = files.get_abs_path("skills", skills_subdir)
index = knowledge_import.load_knowledge(
log_item,
skills_path,
index,
{"area": Memory.Area.SKILLS.value},
filename_pattern="**/SKILL.md",
)
return index

View file

@ -82,7 +82,7 @@ class MemoryConsolidator:
Args:
new_memory: The new memory content to process
area: Memory area (MAIN, FRAGMENTS, SOLUTIONS, INSTRUMENTS)
area: Memory area (MAIN, FRAGMENTS, SOLUTIONS, SKILLS)
metadata: Initial metadata for the memory
log_item: Optional log item for progress tracking

View file

@ -37,6 +37,7 @@ class BasicProjectData(TypedDict):
"own", "global"
] # in the future we can add cutom and point to another existing folder
file_structure: FileStructureInjectionSettings
dev_framework: str # "" = use global setting, or specific framework ID
class EditProjectData(BasicProjectData):
name: str
@ -112,6 +113,7 @@ def _normalizeBasicData(data: BasicProjectData):
"file_structure",
_default_file_structure_settings(),
),
dev_framework=data.get("dev_framework", ""),
)
@ -132,6 +134,7 @@ def _normalizeEditData(data: EditProjectData):
_default_file_structure_settings(),
),
subagents=data.get("subagents", {}),
dev_framework=data.get("dev_framework", ""),
)

View file

@ -149,6 +149,9 @@ class Settings(TypedDict):
update_check_enabled: bool
# Development framework selection
dev_framework: str
class PartialSettings(Settings, total=False):
pass
@ -198,6 +201,7 @@ class SettingsOutputAdditional(TypedDict):
agent_subdirs: list[FieldOption]
knowledge_subdirs: list[FieldOption]
stt_models: list[FieldOption]
framework_options: list[FieldOption]
is_dockerized: bool
class SettingsOutput(TypedDict):
@ -228,6 +232,8 @@ def _ensure_option_present(options: list[OptionT] | None, current_value: str | N
return opts
def convert_out(settings: Settings) -> SettingsOutput:
from python.helpers import frameworks
out = SettingsOutput(
settings = settings.copy(),
additional = SettingsOutputAdditional(
@ -247,8 +253,8 @@ def convert_out(settings: Settings) -> SettingsOutput:
{"value": "medium", "label": "Medium (769M, English)"},
{"value": "large", "label": "Large (1.5B, Multilingual)"},
{"value": "turbo", "label": "Turbo (Multilingual)"},
]
],
framework_options=cast(list[FieldOption], frameworks.get_framework_options()),
)
)
@ -264,6 +270,7 @@ def convert_out(settings: Settings) -> SettingsOutput:
additional["agent_subdirs"] = _ensure_option_present(additional.get("agent_subdirs"), current.get("agent_profile"))
additional["knowledge_subdirs"] = _ensure_option_present(additional.get("knowledge_subdirs"), current.get("agent_knowledge_subdir"))
additional["stt_models"] = _ensure_option_present(additional.get("stt_models"), current.get("stt_model_size"))
additional["framework_options"] = _ensure_option_present(additional.get("framework_options"), current.get("dev_framework"))
# masked api keys
providers = get_providers("chat") + get_providers("embedding")
@ -304,7 +311,6 @@ def convert_out(settings: Settings) -> SettingsOutput:
out["settings"][key] = _dict_to_env(value)
return out
def _get_api_key_field(settings: Settings, provider: str, title: str) -> SettingsField:
key = settings["api_keys"].get(provider, models.get_api_key(provider))
# For API keys, use simple asterisk placeholder for existing keys
@ -530,6 +536,7 @@ def get_default_settings() -> Settings:
secrets="",
litellm_global_kwargs=get_default_value("litellm_global_kwargs", {}),
update_check_enabled=get_default_value("update_check_enabled", True),
dev_framework=get_default_value("dev_framework", "none"),
)

368
python/helpers/skills.py Normal file
View file

@ -0,0 +1,368 @@
from __future__ import annotations
import os
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, Iterable, List, Literal, Optional, Tuple
from python.helpers import files
try:
import yaml # type: ignore
except Exception: # pragma: no cover
yaml = None # type: ignore
SkillSource = Literal["custom", "builtin", "shared", "framework"]
@dataclass(slots=True)
class Skill:
name: str
description: str
path: Path
skill_md_path: Path
source: SkillSource
version: str = ""
author: str = ""
tags: List[str] = field(default_factory=list)
triggers: List[str] = field(default_factory=list)
allowed_tools: List[str] = field(default_factory=list)
license: str = ""
metadata: Dict[str, Any] = field(default_factory=dict)
# Optional heavy fields (only set when requested)
content: str = "" # body content (markdown without frontmatter)
raw_frontmatter: Dict[str, Any] = field(default_factory=dict)
def get_skills_base_dir() -> Path:
return Path(files.get_abs_path("skills"))
def get_skill_roots(
order: Optional[List[SkillSource]] = None,
framework_id: Optional[str] = None,
) -> List[Tuple[SkillSource, Path]]:
"""
Get skill root directories in priority order.
Args:
order: List of skill sources to search (default: custom, builtin, shared)
framework_id: If provided and not "none", framework skills are added at highest priority
Returns:
List of (source, path) tuples in priority order
"""
base = get_skills_base_dir()
order = order or ["custom", "builtin", "shared"]
roots: List[Tuple[SkillSource, Path]] = [(src, base / src) for src in order]
# Framework skills take priority when active
if framework_id and framework_id != "none":
fw_path = base / "frameworks" / framework_id
if fw_path.exists():
roots.insert(0, ("framework", fw_path))
return roots
def _is_hidden_path(path: Path) -> bool:
return any(part.startswith(".") for part in path.parts)
def discover_skill_md_files(root: Path) -> List[Path]:
"""
Recursively discover SKILL.md files under a root directory.
Hidden folders/files are ignored.
"""
if not root.exists():
return []
results: List[Path] = []
for p in root.rglob("SKILL.md"):
try:
if not p.is_file():
continue
if _is_hidden_path(p.relative_to(root)):
continue
results.append(p)
except Exception:
# If relative_to fails (weird symlink), fall back to conservative checks
if p.is_file() and ".git" not in str(p):
results.append(p)
results.sort(key=lambda x: str(x))
return results
def _coerce_list(value: Any) -> List[str]:
if value is None:
return []
if isinstance(value, list):
return [str(v).strip() for v in value if str(v).strip()]
if isinstance(value, tuple):
return [str(v).strip() for v in list(value) if str(v).strip()]
if isinstance(value, str):
# Support comma-separated strings
parts = [p.strip() for p in value.split(",")]
return [p for p in parts if p]
return [str(value).strip()] if str(value).strip() else []
def _normalize_name(name: str) -> str:
return re.sub(r"\s+", "-", (name or "").strip().lower())
def _read_text(path: Path) -> str:
return path.read_text(encoding="utf-8", errors="replace")
def split_frontmatter(markdown: str) -> Tuple[Dict[str, Any], str]:
"""
Splits a SKILL.md into (frontmatter_dict, body_text).
If no YAML frontmatter is present, returns ({}, full_text).
"""
text = markdown or ""
if not text.lstrip().startswith("---"):
return {}, text.strip()
# We require frontmatter fence at the start (allow leading whitespace/newlines).
lines = text.splitlines()
# find first '---' line
start_idx = None
for i, line in enumerate(lines):
if line.strip() == "---":
start_idx = i
break
if line.strip(): # non-empty before fence => not frontmatter
return {}, text.strip()
if start_idx is None:
return {}, text.strip()
end_idx = None
for j in range(start_idx + 1, len(lines)):
if lines[j].strip() == "---":
end_idx = j
break
if end_idx is None:
return {}, text.strip()
fm_text = "\n".join(lines[start_idx + 1 : end_idx]).strip()
body = "\n".join(lines[end_idx + 1 :]).strip()
fm = parse_frontmatter(fm_text)
return fm, body
def parse_frontmatter(frontmatter_text: str) -> Dict[str, Any]:
"""
Parse YAML frontmatter. Uses PyYAML if available, otherwise a minimal fallback parser.
"""
if not frontmatter_text.strip():
return {}
if yaml is not None:
try:
parsed = yaml.safe_load(frontmatter_text) # type: ignore[attr-defined]
return parsed if isinstance(parsed, dict) else {}
except Exception:
return {}
# Fallback: very small YAML subset (key: value, lists with '- item')
data: Dict[str, Any] = {}
current_key: Optional[str] = None
for raw in frontmatter_text.splitlines():
line = raw.rstrip()
if not line.strip() or line.strip().startswith("#"):
continue
m = re.match(r"^([A-Za-z0-9_.-]+)\s*:\s*(.*)$", line)
if m:
key = m.group(1)
val = m.group(2).strip()
current_key = key
if val == "":
data[key] = []
else:
# strip surrounding quotes
if (val.startswith('"') and val.endswith('"')) or (
val.startswith("'") and val.endswith("'")
):
val = val[1:-1]
data[key] = val
continue
m_list = re.match(r"^\s*-\s*(.*)$", line)
if m_list and current_key:
item = m_list.group(1).strip()
if (item.startswith('"') and item.endswith('"')) or (
item.startswith("'") and item.endswith("'")
):
item = item[1:-1]
if not isinstance(data.get(current_key), list):
data[current_key] = []
data[current_key].append(item)
continue
return data
def skill_from_markdown(
skill_md_path: Path,
source: SkillSource,
*,
include_content: bool = False,
) -> Optional[Skill]:
try:
text = _read_text(skill_md_path)
except Exception:
return None
fm, body = split_frontmatter(text)
skill_dir = skill_md_path.parent
name = str(fm.get("name") or fm.get("skill") or skill_dir.name).strip()
description = str(
fm.get("description") or fm.get("when_to_use") or fm.get("summary") or ""
).strip()
# Cross-platform aliases:
# - Claude Code leans on description (triggers may be embedded there)
# - Some repos use triggers/trigger_patterns
triggers = _coerce_list(
fm.get("triggers")
or fm.get("trigger_patterns")
or fm.get("trigger")
or fm.get("activation")
)
tags = _coerce_list(fm.get("tags") or fm.get("tag"))
allowed_tools = _coerce_list(fm.get("allowed_tools") or fm.get("tools"))
version = str(fm.get("version") or "").strip()
author = str(fm.get("author") or "").strip()
license_ = str(fm.get("license") or "").strip()
meta = fm.get("metadata")
if not isinstance(meta, dict):
meta = {}
skill = Skill(
name=name,
description=description,
path=skill_dir,
skill_md_path=skill_md_path,
source=source,
version=version,
author=author,
tags=tags,
triggers=triggers,
allowed_tools=allowed_tools,
license=license_,
metadata=dict(meta),
raw_frontmatter=fm if include_content else {},
content=body if include_content else "",
)
return skill
def list_skills(
*,
include_content: bool = False,
dedupe: bool = True,
root_order: Optional[List[SkillSource]] = None,
framework_id: Optional[str] = None,
) -> List[Skill]:
skills: List[Skill] = []
roots = get_skill_roots(order=root_order, framework_id=framework_id)
for source, root in roots:
for skill_md in discover_skill_md_files(root):
s = skill_from_markdown(skill_md, source, include_content=include_content)
if s:
skills.append(s)
if not dedupe:
return skills
# Dedupe by normalized name, preserving root_order priority (earlier wins)
by_name: Dict[str, Skill] = {}
for s in skills:
key = _normalize_name(s.name) or _normalize_name(s.path.name)
if key and key not in by_name:
by_name[key] = s
return list(by_name.values())
def find_skill(
skill_name: str,
*,
include_content: bool = False,
root_order: Optional[List[SkillSource]] = None,
framework_id: Optional[str] = None,
) -> Optional[Skill]:
target = _normalize_name(skill_name)
if not target:
return None
roots = get_skill_roots(order=root_order, framework_id=framework_id)
for source, root in roots:
for skill_md in discover_skill_md_files(root):
s = skill_from_markdown(skill_md, source, include_content=include_content)
if not s:
continue
if _normalize_name(s.name) == target or _normalize_name(s.path.name) == target:
return s
return None
def search_skills(
query: str,
*,
limit: int = 25,
framework_id: Optional[str] = None,
) -> List[Skill]:
q = (query or "").strip().lower()
if not q:
return []
terms = [t for t in re.split(r"\s+", q) if t]
candidates = list_skills(include_content=False, dedupe=True, framework_id=framework_id)
scored: List[Tuple[int, Skill]] = []
for s in candidates:
name = s.name.lower()
desc = (s.description or "").lower()
tags = [t.lower() for t in s.tags]
score = 0
for term in terms:
if term in name:
score += 3
if term in desc:
score += 2
if any(term in tag for tag in tags):
score += 1
if score > 0:
scored.append((score, s))
scored.sort(key=lambda pair: (-pair[0], pair[1].name))
return [s for _score, s in scored[:limit]]
def safe_path_within_dir(base_dir: Path, rel_path: str) -> Path:
"""
Resolve rel_path inside base_dir, preventing directory traversal.
"""
base = base_dir.resolve()
candidate = (base / rel_path).resolve()
if os.path.commonpath([str(candidate), str(base)]) != str(base):
raise ValueError("Path escapes skill directory")
return candidate

View file

@ -0,0 +1,364 @@
#!/usr/bin/env python3
"""
Skills CLI - Easy skill management for Agent Zero
Usage:
python -m python.helpers.skills_cli list List all skills
python -m python.helpers.skills_cli create <name> Create a new skill
python -m python.helpers.skills_cli show <name> Show skill details
python -m python.helpers.skills_cli validate <name> Validate a skill
python -m python.helpers.skills_cli search <query> Search skills
"""
import argparse
import os
import sys
import yaml
import re
from pathlib import Path
from typing import Optional, List, Dict, Any
from dataclasses import dataclass
from datetime import datetime
# Add parent directory to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from python.helpers import files
@dataclass
class Skill:
"""Represents a skill loaded from SKILL.md"""
name: str
description: str
path: Path
version: str = "1.0.0"
author: str = ""
tags: List[str] = None
trigger_patterns: List[str] = None
content: str = ""
def __post_init__(self):
if self.tags is None:
self.tags = []
if self.trigger_patterns is None:
self.trigger_patterns = []
def get_skills_dirs() -> List[Path]:
"""Get all skill directories"""
base = Path(files.get_abs_path("skills"))
return [
base / "builtin",
base / "custom",
base / "shared",
]
def parse_skill_file(skill_path: Path) -> Optional[Skill]:
"""Parse a SKILL.md file and return a Skill object"""
try:
content = skill_path.read_text(encoding="utf-8")
# Parse YAML frontmatter
if content.startswith("---"):
parts = content.split("---", 2)
if len(parts) >= 3:
frontmatter = yaml.safe_load(parts[1])
body = parts[2].strip()
return Skill(
name=frontmatter.get("name", skill_path.parent.name),
description=frontmatter.get("description", ""),
path=skill_path.parent,
version=frontmatter.get("version", "1.0.0"),
author=frontmatter.get("author", ""),
tags=frontmatter.get("tags", []),
trigger_patterns=frontmatter.get("trigger_patterns", []),
content=body,
)
return None
except Exception as e:
print(f"Error parsing {skill_path}: {e}")
return None
def list_skills() -> List[Skill]:
"""List all available skills"""
skills = []
for skills_dir in get_skills_dirs():
if not skills_dir.exists():
continue
for skill_dir in skills_dir.iterdir():
if skill_dir.is_dir():
skill_file = skill_dir / "SKILL.md"
if skill_file.exists():
skill = parse_skill_file(skill_file)
if skill:
skills.append(skill)
return skills
def find_skill(name: str) -> Optional[Skill]:
"""Find a skill by name"""
for skill in list_skills():
if skill.name == name or skill.path.name == name:
return skill
return None
def search_skills(query: str) -> List[Skill]:
"""Search skills by name, description, or tags"""
query = query.lower()
results = []
for skill in list_skills():
if (
query in skill.name.lower()
or query in skill.description.lower()
or any(query in tag.lower() for tag in skill.tags)
or any(query in trigger.lower() for trigger in skill.trigger_patterns)
):
results.append(skill)
return results
def validate_skill(skill: Skill) -> List[str]:
"""Validate a skill and return list of issues"""
issues = []
# Required fields
if not skill.name:
issues.append("Missing required field: name")
if not skill.description:
issues.append("Missing required field: description")
# Name format
if skill.name and not re.match(r"^[a-z0-9_-]+$", skill.name):
issues.append(f"Invalid name format: '{skill.name}' (use lowercase, hyphens, underscores)")
# Description length
if skill.description and len(skill.description) < 20:
issues.append("Description is too short (minimum 20 characters)")
# Content
if len(skill.content) < 100:
issues.append("Skill content is too short (minimum 100 characters)")
# Check for associated files
skill_dir = skill.path
has_scripts = (skill_dir / "scripts").exists()
has_docs = (skill_dir / "docs").exists()
return issues
def create_skill(name: str, description: str = "", author: str = "") -> Path:
"""Create a new skill from template"""
# Use custom directory for user-created skills
custom_dir = Path(files.get_abs_path("skills/custom"))
custom_dir.mkdir(parents=True, exist_ok=True)
skill_dir = custom_dir / name
if skill_dir.exists():
raise ValueError(f"Skill '{name}' already exists at {skill_dir}")
# Create directory structure
skill_dir.mkdir(parents=True)
(skill_dir / "scripts").mkdir()
(skill_dir / "docs").mkdir()
# Create SKILL.md from template
skill_content = f'''---
name: "{name}"
description: "{description or 'Description of what this skill does and when to use it'}"
version: "1.0.0"
author: "{author or 'Your Name'}"
tags: ["custom"]
trigger_patterns:
- "{name}"
---
# {name.replace("-", " ").replace("_", " ").title()}
## When to Use
Describe when this skill should be activated.
## Instructions
Provide detailed instructions for the agent to follow.
### Step 1: First Step
Description of what to do first.
### Step 2: Second Step
Description of what to do next.
## Examples
**User**: "Example prompt that triggers this skill"
**Agent Response**:
> Example of how the agent should respond
## Tips
- Tip 1: Helpful guidance
- Tip 2: More helpful guidance
## Anti-Patterns
- Don't do this
- Avoid that
'''
skill_file = skill_dir / "SKILL.md"
skill_file.write_text(skill_content, encoding="utf-8")
# Create placeholder README in docs
readme = skill_dir / "docs" / "README.md"
readme.write_text(f"# {name}\n\nAdditional documentation for the {name} skill.\n")
return skill_dir
def print_skill_table(skills: List[Skill]):
"""Print skills in a formatted table"""
if not skills:
print("No skills found.")
return
# Calculate column widths
name_width = max(len(s.name) for s in skills) + 2
desc_width = 50
# Print header
print(f"\n{'Name':<{name_width}} {'Version':<10} {'Tags':<20} Description")
print("-" * (name_width + 80))
# Print skills
for skill in skills:
tags = ", ".join(skill.tags[:3])
if len(skill.tags) > 3:
tags += "..."
desc = skill.description[:desc_width]
if len(skill.description) > desc_width:
desc += "..."
print(f"{skill.name:<{name_width}} {skill.version:<10} {tags:<20} {desc}")
print(f"\nTotal: {len(skills)} skills")
def main():
parser = argparse.ArgumentParser(
description="Agent Zero Skills CLI",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s list List all skills
%(prog)s create my-skill Create a new skill
%(prog)s show brainstorming Show skill details
%(prog)s validate my-skill Validate a skill
%(prog)s search python Search for skills
"""
)
subparsers = parser.add_subparsers(dest="command", help="Available commands")
# List command
list_parser = subparsers.add_parser("list", help="List all skills")
list_parser.add_argument("--tags", help="Filter by tags (comma-separated)")
# Create command
create_parser = subparsers.add_parser("create", help="Create a new skill")
create_parser.add_argument("name", help="Skill name (lowercase, use hyphens)")
create_parser.add_argument("-d", "--description", help="Skill description")
create_parser.add_argument("-a", "--author", help="Author name")
# Show command
show_parser = subparsers.add_parser("show", help="Show skill details")
show_parser.add_argument("name", help="Skill name")
# Validate command
validate_parser = subparsers.add_parser("validate", help="Validate a skill")
validate_parser.add_argument("name", help="Skill name")
# Search command
search_parser = subparsers.add_parser("search", help="Search skills")
search_parser.add_argument("query", help="Search query")
args = parser.parse_args()
if args.command == "list":
skills = list_skills()
if args.tags:
filter_tags = [t.strip().lower() for t in args.tags.split(",")]
skills = [s for s in skills if any(t in [tag.lower() for tag in s.tags] for t in filter_tags)]
print_skill_table(skills)
elif args.command == "create":
try:
skill_dir = create_skill(args.name, args.description, args.author)
print(f"\n✅ Created skill at: {skill_dir}")
print(f"\nNext steps:")
print(f" 1. Edit {skill_dir / 'SKILL.md'} to add your instructions")
print(f" 2. Add any helper scripts to {skill_dir / 'scripts'}/")
print(f" 3. Run: python -m python.helpers.skills_cli validate {args.name}")
except ValueError as e:
print(f"\n❌ Error: {e}")
sys.exit(1)
elif args.command == "show":
skill = find_skill(args.name)
if skill:
print(f"\n{'=' * 60}")
print(f"Skill: {skill.name}")
print(f"{'=' * 60}")
print(f"Version: {skill.version}")
print(f"Author: {skill.author or 'Unknown'}")
print(f"Path: {skill.path}")
print(f"Tags: {', '.join(skill.tags) if skill.tags else 'None'}")
print(f"Triggers: {', '.join(skill.trigger_patterns) if skill.trigger_patterns else 'None'}")
print(f"\nDescription:")
print(f" {skill.description}")
print(f"\nContent Preview (first 500 chars):")
print("-" * 60)
print(skill.content[:500])
if len(skill.content) > 500:
print("...")
print("-" * 60)
else:
print(f"\n❌ Skill '{args.name}' not found")
sys.exit(1)
elif args.command == "validate":
skill = find_skill(args.name)
if skill:
issues = validate_skill(skill)
if issues:
print(f"\n⚠️ Validation issues for '{args.name}':")
for issue in issues:
print(f" - {issue}")
else:
print(f"\n✅ Skill '{args.name}' is valid!")
else:
print(f"\n❌ Skill '{args.name}' not found")
sys.exit(1)
elif args.command == "search":
results = search_skills(args.query)
if results:
print(f"\nSearch results for '{args.query}':")
print_skill_table(results)
else:
print(f"\nNo skills found matching '{args.query}'")
else:
parser.print_help()
if __name__ == "__main__":
main()

View file

@ -0,0 +1,251 @@
from __future__ import annotations
import os
import shutil
import tempfile
import time
import zipfile
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable, List, Literal, Optional, Tuple
from python.helpers import files
from python.helpers.skills import discover_skill_md_files
ConflictPolicy = Literal["skip", "overwrite", "rename"]
DestSubdir = Literal["shared", "custom", "project"]
# Project skills folder name (inside .a0proj)
PROJECT_SKILLS_DIR = "skills"
@dataclass(slots=True)
class ImportPlanItem:
src_root: Path
src_skill_dir: Path
dest_skill_dir: Path
@dataclass(slots=True)
class ImportResult:
imported: List[Path]
skipped: List[Path]
source_root: Path
destination_root: Path
namespace: str
def _is_within(child: Path, parent: Path) -> bool:
try:
child.resolve().relative_to(parent.resolve())
return True
except Exception:
return False
def _derive_namespace(source: Path) -> str:
# Use stem for zip, name for directory
return (source.stem or source.name or "import").strip()
def _candidate_skill_roots(source_dir: Path) -> List[Path]:
"""
Heuristics to find likely skill roots inside a repo/pack:
- <source>/skills
- <source>/plugins/*/skills (Claude Code style)
- fallback: <source>
"""
candidates: List[Path] = []
direct = source_dir / "skills"
if direct.is_dir() and discover_skill_md_files(direct):
candidates.append(direct)
plugins = source_dir / "plugins"
if plugins.is_dir():
for child in plugins.iterdir():
if not child.is_dir():
continue
skills_dir = child / "skills"
if skills_dir.is_dir() and discover_skill_md_files(skills_dir):
candidates.append(skills_dir)
# Deduplicate while preserving order
unique: List[Path] = []
seen = set()
for c in candidates:
key = str(c.resolve())
if key not in seen:
seen.add(key)
unique.append(c)
return unique or [source_dir]
def _unzip_to_temp_dir(zip_path: Path) -> Path:
"""
Extract a zip into a temp folder under tmp/skill_imports (inside Agent Zero base dir).
Returns the extraction root folder.
"""
base_tmp = Path(files.get_abs_path("tmp", "skill_imports"))
base_tmp.mkdir(parents=True, exist_ok=True)
stamp = time.strftime("%Y%m%d_%H%M%S")
target = base_tmp / f"import_{zip_path.stem}_{stamp}"
target.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(zip_path, "r") as z:
z.extractall(target)
# If zip contains a single top-level folder, treat that as the root
children = [p for p in target.iterdir()]
if len(children) == 1 and children[0].is_dir():
return children[0]
return target
def build_import_plan(
source: Path,
dest_root: Path,
*,
namespace: Optional[str] = None,
) -> Tuple[List[ImportPlanItem], Path]:
"""
Build a copy plan for importing skills from a source folder.
Returns: (plan_items, source_root_dir_used_for_scan)
"""
source_dir = source
roots = _candidate_skill_roots(source_dir)
plan: List[ImportPlanItem] = []
ns = (namespace or _derive_namespace(source)).strip()
dest_ns_root = dest_root / ns
for root in roots:
for skill_md in discover_skill_md_files(root):
skill_dir = skill_md.parent
# Skip if the skill dir is already inside destination (prevents recursive import)
if _is_within(skill_dir, dest_root):
continue
try:
rel = skill_dir.resolve().relative_to(root.resolve())
except Exception:
# If relative fails due to symlink oddities, just use leaf folder name
rel = Path(skill_dir.name)
dest_dir = dest_ns_root / rel
plan.append(ImportPlanItem(src_root=root, src_skill_dir=skill_dir, dest_skill_dir=dest_dir))
# Deduplicate by destination path (keep first occurrence)
seen_dest = set()
deduped: List[ImportPlanItem] = []
for item in plan:
key = str(item.dest_skill_dir.resolve())
if key in seen_dest:
continue
seen_dest.add(key)
deduped.append(item)
return deduped, roots[0]
def _resolve_conflict(dest: Path, policy: ConflictPolicy) -> Tuple[Path, bool]:
"""
Returns (final_dest_path, should_copy).
"""
if not dest.exists():
return dest, True
if policy == "skip":
return dest, False
if policy == "overwrite":
shutil.rmtree(dest)
return dest, True
# rename
i = 2
while True:
candidate = dest.with_name(f"{dest.name}_{i}")
if not candidate.exists():
return candidate, True
i += 1
def get_project_skills_folder(project_name: str) -> Path:
"""Get the skills folder path for a project."""
from python.helpers.projects import get_project_meta_folder
return Path(get_project_meta_folder(project_name, PROJECT_SKILLS_DIR))
def import_skills(
source_path: str,
*,
dest_subdir: DestSubdir = "shared",
namespace: Optional[str] = None,
conflict: ConflictPolicy = "skip",
dry_run: bool = False,
project_name: Optional[str] = None,
) -> ImportResult:
"""
Import external Skills into skills/<dest_subdir>/<namespace>/...
If dest_subdir is "project", imports into the project's .a0proj/skills/ folder.
- source_path can be a directory or a .zip file
- Uses heuristics to detect the Skills root(s)
- Copies each skill folder (parent of SKILL.md) as-is
"""
src = Path(source_path).expanduser()
if not src.is_absolute():
src = (Path.cwd() / src).resolve()
if not src.exists():
raise FileNotFoundError(f"Source not found: {src}")
# Determine destination root based on dest_subdir
if dest_subdir == "project":
if not project_name:
raise ValueError("project_name is required when dest_subdir is 'project'")
dest_root = get_project_skills_folder(project_name)
else:
dest_root = Path(files.get_abs_path("skills", dest_subdir))
dest_root.mkdir(parents=True, exist_ok=True)
extracted_root: Optional[Path] = None
source_dir: Path
if src.is_file() and src.suffix.lower() == ".zip":
extracted_root = _unzip_to_temp_dir(src)
source_dir = extracted_root
elif src.is_dir():
source_dir = src
else:
raise ValueError("Source must be a directory or a .zip file")
ns = (namespace or _derive_namespace(src)).strip()
if not ns:
ns = "import"
plan, root_used = build_import_plan(source_dir, dest_root, namespace=ns)
imported: List[Path] = []
skipped: List[Path] = []
for item in plan:
final_dest, should_copy = _resolve_conflict(item.dest_skill_dir, conflict)
if not should_copy:
skipped.append(item.dest_skill_dir)
continue
if dry_run:
imported.append(final_dest)
continue
final_dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(item.src_skill_dir, final_dest)
imported.append(final_dest)
return ImportResult(
imported=imported,
skipped=skipped,
source_root=root_used,
destination_root=dest_root,
namespace=ns,
)