mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-05-01 21:20:33 +00:00
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:
parent
52404a776a
commit
99d5bf6f05
139 changed files with 19790 additions and 110 deletions
|
|
@ -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/**
|
||||
|
|
|
|||
623
python/helpers/frameworks.py
Normal file
623
python/helpers/frameworks.py
Normal 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()
|
||||
]
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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", ""),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
368
python/helpers/skills.py
Normal 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
|
||||
|
||||
|
||||
364
python/helpers/skills_cli.py
Normal file
364
python/helpers/skills_cli.py
Normal 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()
|
||||
251
python/helpers/skills_import.py
Normal file
251
python/helpers/skills_import.py
Normal 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,
|
||||
)
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue