Files
personas/build.py
salvacybersec 309e389c65 feat(install): add --install claude-skills with category filters
New install target copies _shared/skills/**/SKILL.md directories to
~/.claude/skills/<name>/ as native Claude Code skills, with filters to
keep the list manageable (Claude evaluates each skill description on
every message, so 800+ bulk installs slow routing).

Filters (apply to cybersecurity-domain skills only; non-cyber sources
like paperclip/community pass through):
  --skill-sources     comma-list of _shared/<dir> (default: skills,paperclip-skills)
  --skill-subdomains  SKILL.md frontmatter subdomain filter
  --skill-prefix      verb-prefix filter (performing,detecting,hunting,...)
  --skill-exclude     regex blocklist
  --skill-dry-run     preview
  --skill-force       overwrite existing

Presets (set subdomains+prefix together):
  offensive   red-team/pentest/web/api/IAM × performing,exploiting,testing,hunting,analyzing,scanning
  defensive   DFIR/IR/SOC/endpoint/malware × detecting,analyzing,hunting,implementing,building
  ctiops      threat-intel+hunting+malware × analyzing,hunting,detecting
  minimal     top 5 subdomains × top 5 verbs
  all         no filters

Also purges broken ~/.claude/skills/Anthropic-Cybersecurity-Skills/
(whole-repo dir from an older flow — not a valid skill).

Examples:
  python3 build.py --install claude-skills --skill-preset offensive
  python3 build.py --install claude-skills --skill-preset ctiops --skill-sources skills
  python3 build.py --install claude-skills --skill-preset all  # 754 cyber skills
  python3 build.py --install claude-skills --skill-preset minimal \
      --skill-sources skills,paperclip-skills,community-skills  # 859 total

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 21:37:29 +03:00

1751 lines
63 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Build script: Generate .yaml, .json, .prompt.md from persona .md files.
Supports config.yaml for dynamic variable injection and user-specific customization.
New users: copy config.example.yaml → config.yaml and customize.
"""
import json
import re
import sys
import unicodedata
from pathlib import Path
try:
import yaml
except ImportError:
print("PyYAML required: pip install pyyaml")
sys.exit(1)
def load_config(root: Path) -> dict:
"""Load config.yaml if it exists, otherwise return empty config."""
config_path = root / "config.yaml"
if config_path.exists():
config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
print(f"Config loaded: {config_path}")
return config
example_path = root / "config.example.yaml"
if example_path.exists():
print(
"WARN: No config.yaml found. Using defaults. Copy config.example.yaml → config.yaml to customize."
)
return {}
def resolve_shared_dir(root: Path, personas_dir: Path) -> Path | None:
"""Resolve canonical shared library path.
Primary location is personas/_shared. If that is missing, fall back to
known source mirrors under sources/.
"""
primary = personas_dir / "_shared"
if primary.exists():
return primary
sources_dir = root / "sources"
fallbacks = [
sources_dir / "temp-cyber-skills" / "personas" / "_shared",
sources_dir / "paperclip-docs-main" / "_shared",
]
for candidate in fallbacks:
if candidate.exists():
return candidate
return None
def discover_sources(root: Path) -> list[str]:
"""List known source mirrors under root/sources."""
sources_dir = root / "sources"
if not sources_dir.exists():
return []
known = [
"Anthropic-Cybersecurity-Skills",
"paperclip-docs-main",
"temp-cyber-skills",
]
return [name for name in known if (sources_dir / name).exists()]
def flatten_config(config: dict, prefix: str = "") -> dict:
"""Flatten nested config dict for template substitution.
Example: {"user": {"name": "Salva"}} → {"user.name": "Salva"}
"""
flat = {}
for key, value in config.items():
full_key = f"{prefix}{key}" if not prefix else f"{prefix}.{key}"
if isinstance(value, dict):
flat.update(flatten_config(value, full_key))
elif isinstance(value, list):
flat[full_key] = value
flat[f"{full_key}.count"] = len(value)
flat[f"{full_key}.csv"] = ", ".join(
str(v) for v in value if not isinstance(v, dict)
)
else:
flat[full_key] = value
return flat
def inject_config(content: str, flat_config: dict) -> str:
"""Replace {{config.key}} placeholders with config values."""
def replacer(match):
key = match.group(1).strip()
value = flat_config.get(key, match.group(0)) # keep original if not found
if isinstance(value, list):
return ", ".join(str(v) for v in value if not isinstance(v, dict))
if isinstance(value, bool):
return "enabled" if value else "disabled"
return str(value)
return re.sub(r"\{\{(.+?)\}\}", replacer, content)
def check_conditionals(content: str, flat_config: dict) -> str:
"""Process {{#if key}}...{{/if}} and {{#unless key}}...{{/unless}} blocks."""
# Handle {{#if key}}content{{/if}}
def if_replacer(match):
key = match.group(1).strip()
body = match.group(2)
value = flat_config.get(key)
if value and value not in (False, 0, "", "false", "none", "disabled", None, []):
return body
return ""
content = re.sub(
r"\{\{#if (.+?)\}\}(.*?)\{\{/if\}\}", if_replacer, content, flags=re.DOTALL
)
# Handle {{#unless key}}content{{/unless}}
def unless_replacer(match):
key = match.group(1).strip()
body = match.group(2)
value = flat_config.get(key)
if not value or value in (False, 0, "", "false", "none", "disabled", None, []):
return body
return ""
content = re.sub(
r"\{\{#unless (.+?)\}\}(.*?)\{\{/unless\}\}",
unless_replacer,
content,
flags=re.DOTALL,
)
return content
def parse_persona_md(filepath: Path, flat_config: dict) -> dict:
"""Parse a persona markdown file into structured data."""
content = filepath.read_text(encoding="utf-8")
# Apply config injection
if flat_config:
content = check_conditionals(content, flat_config)
content = inject_config(content, flat_config)
# Extract YAML frontmatter
fm_match = re.match(r"^---\n(.*?)\n---\n(.*)$", content, re.DOTALL)
if not fm_match:
print(f" WARN: No frontmatter in {filepath}")
return {}
frontmatter = yaml.safe_load(fm_match.group(1))
body = fm_match.group(2).strip()
# Extract sections from body
sections = {}
current_section = None
current_content = []
for line in body.split("\n"):
if line.startswith("## "):
if current_section:
sections[current_section] = "\n".join(current_content).strip()
current_section = (
line[3:].strip().lower().replace(" ", "_").replace("&", "and")
)
current_content = []
else:
current_content.append(line)
if current_section:
sections[current_section] = "\n".join(current_content).strip()
return {
"metadata": frontmatter,
"sections": sections,
"raw_body": body,
}
def build_persona(
persona_dir: Path,
output_dir: Path,
flat_config: dict,
config: dict,
escalation_graph: dict = None,
skills_index: dict = None,
):
"""Build all variants for a persona directory."""
md_files = sorted(persona_dir.glob("*.md"))
if not md_files:
return 0
persona_name = persona_dir.name
out_path = output_dir / persona_name
out_path.mkdir(parents=True, exist_ok=True)
# Load _meta.yaml if exists
meta_file = persona_dir / "_meta.yaml"
meta = {}
if meta_file.exists():
meta_content = meta_file.read_text(encoding="utf-8")
if flat_config:
meta_content = inject_config(meta_content, flat_config)
meta = yaml.safe_load(meta_content) or {}
# Apply config overrides for address
addresses = config.get("persona_defaults", {}).get("custom_addresses", {})
if persona_name in addresses:
meta["address_to"] = addresses[persona_name]
count = 0
for md_file in md_files:
if md_file.name.startswith("_"):
continue
variant = md_file.stem
parsed = parse_persona_md(md_file, flat_config)
if not parsed:
continue
# Build output object
output = {
**meta,
**parsed["metadata"],
"variant": variant,
"sections": parsed["sections"],
}
# Inject config metadata
if config:
output["_config"] = {
"user": config.get("user", {}).get("name", "unknown"),
"tools": {
k: v
for k, v in config.get("infrastructure", {})
.get("tools", {})
.items()
if v is True
},
"frameworks": {
k: v for k, v in config.get("frameworks", {}).items() if v is True
},
"regional_focus": config.get("regional_focus", {}),
}
# Inject escalation graph for this persona
if escalation_graph and persona_name in escalation_graph:
output["escalates_to"] = escalation_graph[persona_name]
# Inject mapped skills for this persona
if skills_index:
mapped_skills = []
for skill_name, skill_info in skills_index.get("skills", {}).items():
if persona_name in skill_info.get("personas", []):
mapped_skills.append(skill_name)
# Also check config-based custom mapping
skill_map = skills_index.get("_skill_persona_map", {})
for skill_name, persona_list in skill_map.items():
if persona_name in persona_list and skill_name not in mapped_skills:
mapped_skills.append(skill_name)
if mapped_skills:
output["skills"] = sorted(mapped_skills)
# Inject section word counts for quality tracking
output["_stats"] = {
"total_words": sum(len(s.split()) for s in parsed["sections"].values()),
"sections": list(parsed["sections"].keys()),
"section_count": len(parsed["sections"]),
}
# Write YAML
yaml_out = out_path / f"{variant}.yaml"
yaml_out.write_text(
yaml.dump(
output, allow_unicode=True, default_flow_style=False, sort_keys=False
),
encoding="utf-8",
)
# Write JSON
json_out = out_path / f"{variant}.json"
json_out.write_text(
json.dumps(output, ensure_ascii=False, indent=2), encoding="utf-8"
)
# Write plain system prompt (just the body, no config metadata)
prompt_out = out_path / f"{variant}.prompt.md"
prompt_out.write_text(parsed["raw_body"], encoding="utf-8")
count += 1
print(f" Built: {persona_name}/{variant} -> .yaml .json .prompt.md")
return count
DEFAULT_SKILL_PERSONA_MAP = {
# Cybersecurity skills → personas
"pentest": ["neo"],
"nmap-recon": ["neo", "vortex"],
"security-scanner": ["neo", "phantom"],
"sql-injection-testing": ["neo", "phantom"],
"stealth-browser": ["neo", "oracle"],
"security-audit-toolkit": ["neo", "forge"],
"pwnclaw-security-scan": ["neo"],
"senior-secops": ["bastion"],
"clawsec": ["neo", "vortex"],
"pcap-analyzer": ["vortex", "bastion"],
"sys-guard-linux-remediator": ["bastion"],
"ctf-writeup-generator": ["neo"],
"dns-networking": ["vortex", "architect"],
"network-scanner": ["neo", "vortex"],
"security-skill-scanner": ["neo"],
"pentest-active-directory": ["neo"],
"pentest-api-attacker": ["neo", "phantom"],
"pentest-auth-bypass": ["neo", "phantom"],
"pentest-c2-operator": ["neo", "sentinel"],
"gov-cybersecurity": ["sentinel", "bastion"],
# Intelligence skills → personas
"osint-investigator": ["oracle"],
"seithar-intel": ["sentinel", "frodo"],
"freshrss": ["frodo", "oracle"],
"freshrss-reader": ["frodo", "oracle"],
"war-intel-monitor": ["frodo", "marshal"],
"news-crawler": ["frodo", "herald"],
"dellight-intelligence-ops": ["frodo", "echo"],
"dellight-strategic-intelligence": ["frodo"],
"agent-intelligence-network-scan": ["oracle"],
"social-trust-manipulation-detector": ["ghost"],
# Infrastructure skills → personas
"docker-essentials": ["architect"],
"session-logs": ["architect"],
# Document processing → personas
"image-ocr": ["oracle", "scribe"],
"mistral-ocr": ["oracle", "scribe"],
"pdf-text-extractor": ["scribe", "scholar"],
"youtube-transcript": ["herald", "scholar"],
# Web scraping → personas
"deep-scraper": ["oracle"],
"crawl-for-ai": ["oracle", "herald"],
}
VALID_PERSONAS = {
"arbiter",
"architect",
"bastion",
"centurion",
"chronos",
"cipher",
"corsair",
"echo",
"forge",
"frodo",
"gambit",
"ghost",
"herald",
"ledger",
"marshal",
"medic",
"neo",
"oracle",
"phantom",
"polyglot",
"sage",
"scholar",
"scribe",
"sentinel",
"specter",
"tribune",
"vortex",
"warden",
"wraith",
}
def parse_skill_frontmatter(skill_md: Path) -> dict:
"""Parse YAML frontmatter from SKILL.md; return empty dict if absent/invalid."""
content = skill_md.read_text(encoding="utf-8")
fm_match = re.match(r"^---\n(.*?)\n---\n", content, re.DOTALL)
if not fm_match:
return {}
parsed = yaml.safe_load(fm_match.group(1))
return parsed if isinstance(parsed, dict) else {}
def infer_personas_from_skill_metadata(skill_name: str, metadata: dict) -> list:
"""Infer likely persona mappings using skill frontmatter metadata and naming."""
name = (skill_name or "").lower()
domain = str(metadata.get("domain", "")).lower()
subdomain = str(metadata.get("subdomain", "")).lower()
description = str(metadata.get("description", "")).lower()
tags = [str(t).lower() for t in metadata.get("tags", []) if t is not None]
blob = " ".join([name, domain, subdomain, description] + tags)
personas = set()
# Subdomain affinity
subdomain_map = {
"penetration-testing": ["neo", "phantom", "vortex"],
"application-security": ["phantom", "neo"],
"api-security": ["phantom", "neo"],
"web-security": ["phantom", "neo"],
"malware-analysis": ["specter", "bastion", "sentinel"],
"memory-forensics": ["specter", "bastion"],
"forensics": ["specter", "bastion"],
"threat-intelligence": ["sentinel", "frodo", "oracle"],
"incident-response": ["bastion", "sentinel", "medic"],
"soc-operations": ["bastion", "sentinel"],
"threat-hunting": ["sentinel", "bastion", "vortex"],
"network-security": ["vortex", "bastion"],
"network-forensics": ["vortex", "specter", "bastion"],
"cloud-security": ["architect", "bastion", "sentinel"],
"identity-security": ["cipher", "neo", "bastion"],
"active-directory": ["cipher", "neo", "bastion"],
"vulnerability-management": ["bastion", "forge"],
"compliance": ["ledger", "arbiter", "bastion"],
"ot-security": ["centurion", "bastion", "sentinel"],
}
personas.update(subdomain_map.get(subdomain, []))
# Keyword affinity fallback
keyword_map = {
"apt": ["sentinel", "frodo"],
"threat intel": ["sentinel", "oracle", "frodo"],
"ioc": ["sentinel", "bastion"],
"misp": ["sentinel", "oracle"],
"siem": ["bastion", "sentinel"],
"splunk": ["bastion", "sentinel"],
"soc": ["bastion", "sentinel"],
"incident response": ["bastion", "medic", "sentinel"],
"phishing": ["bastion", "oracle", "sentinel"],
"malware": ["specter", "bastion", "sentinel"],
"ransomware": ["specter", "bastion", "sentinel"],
"forensic": ["specter", "bastion"],
"volatility": ["specter", "bastion"],
"yara": ["specter", "bastion"],
"memory": ["specter", "bastion"],
"network": ["vortex", "bastion"],
"zeek": ["vortex", "bastion", "sentinel"],
"wireshark": ["vortex", "bastion"],
"nmap": ["neo", "vortex"],
"pentest": ["neo", "phantom", "vortex"],
"red team": ["neo", "phantom", "specter"],
"web": ["phantom", "neo"],
"xss": ["phantom", "neo"],
"sql injection": ["phantom", "neo"],
"api": ["phantom", "neo"],
"kubernetes": ["architect", "bastion", "sentinel"],
"docker": ["architect", "bastion"],
"aws": ["architect", "bastion", "sentinel"],
"azure": ["architect", "bastion", "sentinel"],
"gcp": ["architect", "bastion", "sentinel"],
"iam": ["cipher", "architect", "bastion"],
"active directory": ["cipher", "neo", "bastion"],
"kerberos": ["cipher", "neo", "bastion"],
"compliance": ["ledger", "arbiter", "bastion"],
"nist": ["ledger", "bastion", "sentinel"],
"ot": ["centurion", "bastion", "sentinel"],
"scada": ["centurion", "bastion", "sentinel"],
"ics": ["centurion", "bastion", "sentinel"],
}
for keyword, mapped_personas in keyword_map.items():
if keyword in blob:
personas.update(mapped_personas)
# Conservative fallback for unmapped cybersecurity skills
if not personas and "cyber" in domain:
personas.update(["bastion"])
# Keep only valid personas and deterministic order
return sorted([p for p in personas if p in VALID_PERSONAS])
def load_skill_persona_map(config: dict) -> dict:
"""Load skill→persona mapping from config.yaml or use defaults."""
custom = config.get("skill_persona_map", {})
merged = {
k: [p for p in v if p in VALID_PERSONAS]
for k, v in DEFAULT_SKILL_PERSONA_MAP.items()
}
for skill, personas in custom.items():
if isinstance(personas, list):
merged[skill] = [p for p in personas if p in VALID_PERSONAS]
return merged
def search_skills(shared_dir: Path, query: str):
"""Search across all shared skills using simple BM25-like scoring."""
query_terms = query.lower().split()
results = []
for skills_subdir in ["skills", "paperclip-skills", "community-skills"]:
skills_path = shared_dir / skills_subdir
if not skills_path.exists():
continue
for skill_dir in sorted(skills_path.iterdir()):
if not skill_dir.is_dir():
continue
skill_md = skill_dir / "SKILL.md"
if not skill_md.exists():
continue
content = skill_md.read_text(encoding="utf-8").lower()
# Simple scoring: count query term occurrences weighted by position
score = 0
first_50 = content[:500] # boost matches in header/description
for term in query_terms:
score += first_50.count(term) * 3 # header boost
score += content.count(term)
if score > 0:
# Extract name and first description line
name = skill_dir.name
desc = ""
for line in content.split("\n"):
line = line.strip()
if line and not line.startswith(
("---", "#", "name:", "description:")
):
desc = line[:100]
break
results.append((score, name, skills_subdir, desc))
results.sort(key=lambda x: -x[0])
print(f"\n Search: '{query}'{len(results)} results\n")
for i, (score, name, source, desc) in enumerate(results[:20]):
print(f" {i + 1:2}. [{score:3}] {name} ({source})")
if desc:
print(f" {desc}")
if len(results) > 20:
print(f"\n ... and {len(results) - 20} more. Refine your query.")
elif len(results) == 0:
print(" No matches found. Try different keywords.")
def run_tests(personas_dir: Path, target: str = None):
"""Run persona test suite from _tests/*.yaml files."""
tests_dir = personas_dir / "_tests"
if not tests_dir.exists():
print(" No _tests/ directory found.")
return
test_files = sorted(tests_dir.glob("*.yaml"))
if not test_files:
print(" No test files found in _tests/")
return
total = 0
passed = 0
failed = 0
warnings = []
for test_file in test_files:
if test_file.name == "README.md":
continue
suite = yaml.safe_load(test_file.read_text(encoding="utf-8"))
if not suite:
continue
persona_name = suite.get("persona", test_file.stem)
if target and persona_name != target:
continue
print(f"\n Testing: {persona_name} ({len(suite.get('tests', []))} cases)")
# Load persona prompt for validation
persona_prompt_path = personas_dir / persona_name / "general.md"
if not persona_prompt_path.exists():
print(f" SKIP: {persona_name}/general.md not found")
continue
prompt_content = persona_prompt_path.read_text(encoding="utf-8").lower()
for test in suite.get("tests", []):
total += 1
test_name = test.get("name", f"test_{total}")
expect = test.get("expect", {})
test_passed = True
# Check must_include keywords exist in persona definition
for keyword in expect.get("must_include", []):
if keyword.lower() not in prompt_content:
warnings.append(
f" {persona_name}/{test_name}: '{keyword}' not in persona prompt"
)
test_passed = False
# Check escalation targets are defined
if expect.get("escalation"):
target_persona = expect["escalation"].lower()
if target_persona not in prompt_content:
warnings.append(
f" {persona_name}/{test_name}: escalation to '{target_persona}' not defined in boundaries"
)
test_passed = False
# Check confidence language for intel personas
if expect.get("confidence"):
if "confidence" not in prompt_content and "high" not in prompt_content:
warnings.append(
f" {persona_name}/{test_name}: confidence levels not defined in persona"
)
test_passed = False
if test_passed:
passed += 1
print(f" PASS: {test_name}")
else:
failed += 1
print(f" WARN: {test_name}")
print(f"\n {'=' * 40}")
print(f" Tests: {total} total, {passed} passed, {failed} warnings")
if warnings:
print(f"\n Warnings:")
for w in warnings:
print(w)
print(f" {'=' * 40}")
def build_skills_index(shared_dir: Path, config: dict = None) -> dict:
"""Index all shared skills from _shared/{skills,paperclip-skills,community-skills}/."""
skill_map = load_skill_persona_map(config or {})
index = {
"skills": {},
"paperclip_skills": {},
"community_skills": {},
"design_brands": [],
"ui_ux_styles": 0,
"_skill_persona_map": skill_map,
}
# Index shared-skills
skills_dir = shared_dir / "skills"
if skills_dir.exists():
for skill_dir in sorted(skills_dir.iterdir()):
if not skill_dir.is_dir():
continue
skill_md = skill_dir / "SKILL.md"
if skill_md.exists():
skill_meta = parse_skill_frontmatter(skill_md)
inferred_personas = infer_personas_from_skill_metadata(
skill_dir.name, skill_meta
)
configured_personas = skill_map.get(skill_dir.name, [])
merged_personas = sorted(
set(configured_personas).union(inferred_personas)
)
content = skill_md.read_text(encoding="utf-8")
first_line = ""
for line in content.split("\n"):
line = line.strip()
if line and not line.startswith(
("---", "#", "name:", "description:")
):
first_line = line[:120]
break
index["skills"][skill_dir.name] = {
"personas": merged_personas,
"summary": first_line,
"domain": str(skill_meta.get("domain", "")),
"subdomain": str(skill_meta.get("subdomain", "")),
"tags": skill_meta.get("tags", []),
"mapped_by": {
"explicit": configured_personas,
"inferred": inferred_personas,
},
"has_references": (skill_dir / "references").is_dir(),
}
# Index paperclip-skills
pskills_dir = shared_dir / "paperclip-skills"
if pskills_dir.exists():
for skill_dir in sorted(pskills_dir.iterdir()):
if not skill_dir.is_dir():
continue
skill_md = skill_dir / "SKILL.md"
if skill_md.exists():
index["paperclip_skills"][skill_dir.name] = True
# Index community-skills
cskills_dir = shared_dir / "community-skills"
if cskills_dir.exists():
for skill_dir in sorted(cskills_dir.iterdir()):
if not skill_dir.is_dir():
continue
skill_md = skill_dir / "SKILL.md"
if skill_md.exists():
index["community_skills"][skill_dir.name] = True
# Index design brands
design_dir = shared_dir / "design-md"
if design_dir.exists():
index["design_brands"] = sorted(
[d.name for d in design_dir.iterdir() if d.is_dir()]
)
# Count UI/UX data
uiux_dir = shared_dir / "ui-ux-pro-max" / "data"
if uiux_dir.exists():
index["ui_ux_styles"] = sum(1 for f in uiux_dir.glob("*.csv"))
return index
def build_escalation_graph(personas_dir: Path, flat_config: dict) -> dict:
"""Extract cross-persona escalation paths from Boundaries sections."""
graph = {} # {persona: [escalation_targets]}
for persona_dir in sorted(personas_dir.iterdir()):
if not persona_dir.is_dir() or persona_dir.name.startswith((".", "_")):
continue
general = persona_dir / "general.md"
if not general.exists():
continue
parsed = parse_persona_md(general, flat_config)
if not parsed:
continue
boundaries = parsed["sections"].get("boundaries", "")
targets = re.findall(r"Escalate to \*\*(\w+)\*\*", boundaries)
graph[persona_dir.name] = [t.lower() for t in targets]
return graph
def build_trigger_index(personas_dir: Path) -> dict:
"""Build reverse index: trigger keyword → persona codenames for multi-agent routing."""
index = {} # {trigger: [persona_names]}
for persona_dir in sorted(personas_dir.iterdir()):
if not persona_dir.is_dir() or persona_dir.name.startswith((".", "_")):
continue
meta_file = persona_dir / "_meta.yaml"
if not meta_file.exists():
continue
meta = yaml.safe_load(meta_file.read_text(encoding="utf-8")) or {}
triggers = meta.get("activation_triggers", [])
for trigger in triggers:
t = trigger.lower()
if t not in index:
index[t] = []
index[t].append(persona_dir.name)
return index
def validate_persona(persona_name: str, parsed: dict) -> list:
"""Validate persona structure and return warnings."""
warnings = []
required_sections = ["soul", "expertise", "methodology", "boundaries"]
for section in required_sections:
if section not in parsed.get("sections", {}):
warnings.append(f"Missing section: {section}")
elif len(parsed["sections"][section].split()) < 30:
warnings.append(
f"Thin section ({len(parsed['sections'][section].split())} words): {section}"
)
fm = parsed.get("metadata", {})
for field in ["codename", "name", "domain", "address_to", "tone"]:
if field not in fm:
warnings.append(f"Missing frontmatter: {field}")
return warnings
def build_catalog(
personas_dir: Path,
output_dir: Path,
config: dict,
flat_config: dict,
shared_dir: Path | None,
):
"""Generate CATALOG.md with stats, escalation paths, and trigger index."""
addresses = config.get("persona_defaults", {}).get("custom_addresses", {})
# Build escalation graph and trigger index
escalation_graph = build_escalation_graph(personas_dir, flat_config)
trigger_index = build_trigger_index(personas_dir)
catalog_lines = [
"# Persona Catalog\n",
f"_Auto-generated by build.py | User: {config.get('user', {}).get('name', 'default')}_\n",
]
total_words = 0
total_sections = 0
all_warnings = []
for persona_dir in sorted(personas_dir.iterdir()):
if not persona_dir.is_dir() or persona_dir.name.startswith((".", "_")):
continue
meta_file = persona_dir / "_meta.yaml"
if not meta_file.exists():
continue
meta = yaml.safe_load(meta_file.read_text(encoding="utf-8")) or {}
codename = meta.get("codename", persona_dir.name)
address = addresses.get(persona_dir.name, meta.get("address_to", "N/A"))
variants = [
f.stem
for f in sorted(persona_dir.glob("*.md"))
if not f.name.startswith("_")
]
# Parse general.md for stats
general = persona_dir / "general.md"
word_count = 0
section_count = 0
if general.exists():
parsed = parse_persona_md(general, flat_config)
if parsed:
for s in parsed["sections"].values():
word_count += len(s.split())
section_count = len(parsed["sections"])
# Validate
warns = validate_persona(codename, parsed)
for w in warns:
all_warnings.append(f" {codename}: {w}")
total_words += word_count
total_sections += section_count
escalates_to = escalation_graph.get(persona_dir.name, [])
catalog_lines.append(f"## {codename}{meta.get('role', 'Unknown')}")
catalog_lines.append(f"- **Domain:** {meta.get('domain', 'N/A')}")
catalog_lines.append(f"- **Hitap:** {address}")
catalog_lines.append(f"- **Variants:** {', '.join(variants)}")
catalog_lines.append(
f"- **Depth:** {word_count:,} words, {section_count} sections"
)
if escalates_to:
catalog_lines.append(f"- **Escalates to:** {', '.join(escalates_to)}")
catalog_lines.append("")
# Add trigger index section
catalog_lines.append("---\n")
catalog_lines.append("## Activation Trigger Index\n")
catalog_lines.append("_Keyword → persona routing for multi-agent systems_\n")
for trigger in sorted(trigger_index.keys()):
personas = ", ".join(trigger_index[trigger])
catalog_lines.append(f"- **{trigger}** → {personas}")
catalog_lines.append("")
# Add stats
catalog_lines.append("---\n")
catalog_lines.append("## Build Statistics\n")
catalog_lines.append(f"- Total prompt content: {total_words:,} words")
catalog_lines.append(f"- Total sections: {total_sections}")
catalog_lines.append(
f"- Escalation connections: {sum(len(v) for v in escalation_graph.values())}"
)
catalog_lines.append(f"- Unique triggers: {len(trigger_index)}")
catalog_lines.append("")
catalog_path = personas_dir / "CATALOG.md"
catalog_path.write_text("\n".join(catalog_lines), encoding="utf-8")
print(f" Catalog: {catalog_path}")
# Write escalation graph and trigger index as JSON for API consumers
index_path = output_dir / "_index"
index_path.mkdir(parents=True, exist_ok=True)
(index_path / "escalation_graph.json").write_text(
json.dumps(escalation_graph, indent=2, ensure_ascii=False), encoding="utf-8"
)
(index_path / "trigger_index.json").write_text(
json.dumps(trigger_index, indent=2, ensure_ascii=False), encoding="utf-8"
)
print(f" Index: {index_path}/escalation_graph.json, trigger_index.json")
# Write skills index if shared dir exists
if shared_dir and shared_dir.exists():
si = build_skills_index(shared_dir, config)
(index_path / "skills_index.json").write_text(
json.dumps(si, indent=2, ensure_ascii=False), encoding="utf-8"
)
print(
f" Skills: {len(si.get('skills', {}))} shared + "
f"{len(si.get('paperclip_skills', {}))} paperclip + "
f"{len(si.get('community_skills', {}))} community + "
f"{len(si.get('design_brands', []))} design brands + "
f"{si.get('ui_ux_styles', 0)} UI/UX data files"
)
# Print validation warnings
if all_warnings:
print(f"\n WARNINGS ({len(all_warnings)}):")
for w in all_warnings:
print(f" {w}")
return total_words
def print_summary(
config: dict, total_personas: int, total_variants: int, total_words: int = 0
):
"""Print build summary with config status."""
print("\n" + "=" * 50)
print(f"BUILD COMPLETE")
print(f" Personas: {total_personas}")
print(f" Variants: {total_variants}")
print(f" Words: {total_words:,}")
print(f" Output: generated/")
print(f" Index: generated/_index/")
if config:
user = config.get("user", {}).get("name", "?")
tools_on = sum(
1
for v in config.get("infrastructure", {}).get("tools", {}).values()
if v is True
)
frameworks_on = sum(
1 for v in config.get("frameworks", {}).values() if v is True
)
regions = config.get("regional_focus", {}).get("primary", [])
print(f"\n Config: {user}")
print(f" Tools: {tools_on} enabled")
print(f" Frameworks: {frameworks_on} enabled")
if regions:
print(f" Regions: {', '.join(regions)}")
else:
print("\n Config: none (using defaults)")
print(" Tip: Copy config.example.yaml → config.yaml to customize")
print("=" * 50)
def install_claude(output_dir: Path):
"""Install personas to Claude Code as slash commands + native subagents.
Native Claude Code agents are .md files with YAML frontmatter under
~/.claude/agents/. Tool permissions are scoped per domain:
- offensive/dev/engineering: full toolset incl. Bash/Write
- intel/analysis/history/law: read + search + web only
"""
commands_dir = Path.home() / ".claude" / "commands"
agents_dir = Path.home() / ".claude" / "agents"
commands_dir.mkdir(parents=True, exist_ok=True)
agents_dir.mkdir(parents=True, exist_ok=True)
# Domain → tool scope. "full" gets Bash + Write; "readonly" does not.
OFFENSIVE_DOMAINS = {
"cybersecurity",
"engineering",
"devops",
"software-development",
"ai-ml",
}
FULL_TOOLS = (
"Read, Edit, Write, Bash, Glob, Grep, WebFetch, WebSearch"
)
READONLY_TOOLS = "Read, Glob, Grep, WebFetch, WebSearch"
# Color palette per domain family for visual grouping in /agents picker.
DOMAIN_COLOR = {
"cybersecurity": "red",
"intelligence": "cyan",
"military": "orange",
"law": "yellow",
"economics": "green",
"politics": "purple",
"history": "blue",
"linguistics": "pink",
"media": "pink",
"engineering": "green",
"academia": "blue",
}
cmd_count = 0
agent_count = 0
orphans_removed = 0
# Purge legacy .yml agents from previous build (wrong format, unused by CC).
for stale in agents_dir.glob("*.yml"):
stale.unlink()
orphans_removed += 1
for persona_dir in sorted(output_dir.iterdir()):
if not persona_dir.is_dir() or persona_dir.name.startswith("_"):
continue
# Install slash commands for all variants (general + specializations).
for prompt_file in persona_dir.glob("*.prompt.md"):
variant = prompt_file.stem.replace(".prompt", "")
codename = persona_dir.name
cmd_name = (
f"persona-{codename}"
if variant == "general"
else f"persona-{codename}-{variant}"
)
dest = commands_dir / f"{cmd_name}.md"
content = prompt_file.read_text(encoding="utf-8")
command_content = f"{content}\n\n---\nUser query: $ARGUMENTS\n"
dest.write_text(command_content, encoding="utf-8")
cmd_count += 1
# Install native subagent for the general variant only.
general_json = persona_dir / "general.json"
if not general_json.exists():
continue
data = json.loads(general_json.read_text(encoding="utf-8"))
codename = data.get("codename", persona_dir.name)
name = data.get("name", codename.title())
role = data.get("role", "Specialist")
domain = data.get("domain", "")
tone = data.get("tone", "")
address_to = data.get("address_to", "")
skills = data.get("skills", [])
quote = data.get("quote", "")
soul = data.get("sections", {}).get("soul", "")
methodology = data.get("sections", {}).get("methodology", "")
behavior = data.get("sections", {}).get("behavior_rules", "")
body = f"You are **{name}** ({address_to}) — {role}.\n\n"
body += f"Domain: {domain} | Tone: {tone}\n\n"
if quote:
body += f'> "{quote}"\n\n'
if soul:
body += "## Soul\n" + soul.strip() + "\n\n"
if methodology:
body += "## Methodology\n" + methodology.strip() + "\n\n"
if behavior:
body += "## Behavior\n" + behavior.strip() + "\n\n"
if skills:
body += "## Mapped Skills\n" + ", ".join(skills) + "\n"
tools = (
FULL_TOOLS if domain in OFFENSIVE_DOMAINS else READONLY_TOOLS
)
color = DOMAIN_COLOR.get(domain, "gray")
# Single-line description keeps YAML frontmatter safe.
desc = f"{name} ({address_to}) — {role}. Domain: {domain}.".replace(
"\n", " "
)
frontmatter = (
"---\n"
f"name: {codename}\n"
f"description: {desc}\n"
f"tools: {tools}\n"
f"color: {color}\n"
"---\n\n"
)
agent_file = agents_dir / f"{codename}.md"
agent_file.write_text(frontmatter + body, encoding="utf-8")
agent_count += 1
print(
f" Claude: {cmd_count} commands + {agent_count} agents installed "
f"({orphans_removed} legacy .yml purged)"
)
return cmd_count
def _parse_skill_frontmatter(skill_md: Path) -> dict:
"""Extract YAML frontmatter from a SKILL.md. Returns empty dict if absent."""
try:
text = skill_md.read_text(encoding="utf-8", errors="replace")
except OSError:
return {}
if not text.startswith("---"):
return {}
end = text.find("\n---", 3)
if end == -1:
return {}
try:
return yaml.safe_load(text[3:end]) or {}
except yaml.YAMLError:
return {}
def install_claude_skills(
shared_dir: Path | None,
sources: list[str],
subdomains: set[str] | None,
prefixes: list[str] | None,
exclude_regex: str | None,
dry_run: bool,
force: bool,
):
"""Install shared skills as native Claude Code skills.
Claude expects each skill at ~/.claude/skills/<name>/SKILL.md. This copies
each valid skill directory verbatim (preserving scripts/, references/,
assets/ etc.) and applies filters so the skill list stays manageable.
Args:
shared_dir: personas/_shared/ path.
sources: list of subdirs under _shared/ to pull from (e.g. ["skills",
"paperclip-skills", "community-skills"]).
subdomains: set of subdomain values to include. None = no filter.
Only applies to skills that declare a `subdomain:` in frontmatter.
prefixes: skill-name prefixes to include (e.g. ["performing",
"detecting"]). None = no filter.
exclude_regex: skills whose name matches this regex are skipped.
dry_run: print what would be installed, make no changes.
force: overwrite existing skill dirs without prompting.
"""
if shared_dir is None or not shared_dir.exists():
print(" No shared library found — cannot install skills.")
return 0
skills_dir = Path.home() / ".claude" / "skills"
skills_dir.mkdir(parents=True, exist_ok=True)
# Remove the broken whole-repo dir if present (installed by an older flow).
broken_repo = skills_dir / "Anthropic-Cybersecurity-Skills"
if broken_repo.exists() and (broken_repo / "skills").is_dir():
if dry_run:
print(f" [dry-run] would purge broken {broken_repo}")
else:
import shutil
shutil.rmtree(broken_repo)
print(f" Purged broken repo-dir: {broken_repo}")
import re as _re
import shutil as _shutil
exclude_pat = _re.compile(exclude_regex) if exclude_regex else None
totals = {"installed": 0, "skipped_filter": 0, "skipped_invalid": 0,
"skipped_existing": 0, "overwritten": 0}
per_source: dict[str, int] = {}
for source in sources:
source_dir = shared_dir / source
if not source_dir.exists():
print(f" [warn] source not found: {source_dir}")
continue
count = 0
for skill_dir in sorted(source_dir.iterdir()):
if not skill_dir.is_dir() or skill_dir.name.startswith((".", "_")):
continue
skill_md = skill_dir / "SKILL.md"
if not skill_md.exists():
totals["skipped_invalid"] += 1
continue
name = skill_dir.name
# Exclusion regex takes priority across all sources.
if exclude_pat and exclude_pat.search(name):
totals["skipped_filter"] += 1
continue
# Subdomain + prefix filters only apply to cybersecurity skills.
# Non-cyber skills (paperclip ceo-advisor, community shadcn, etc.)
# don't follow the verb-prefix naming convention, so forcing them
# through the filter would drop everything.
fm = _parse_skill_frontmatter(skill_md)
is_cyber = fm.get("domain") == "cybersecurity"
if is_cyber and prefixes:
verb = name.split("-", 1)[0]
if verb not in prefixes:
totals["skipped_filter"] += 1
continue
if is_cyber and subdomains is not None:
sd = fm.get("subdomain")
if sd is not None and sd not in subdomains:
totals["skipped_filter"] += 1
continue
dest = skills_dir / name
if dest.exists():
if not force:
totals["skipped_existing"] += 1
continue
if dry_run:
totals["overwritten"] += 1
else:
_shutil.rmtree(dest)
_shutil.copytree(skill_dir, dest)
totals["overwritten"] += 1
count += 1
continue
if dry_run:
totals["installed"] += 1
else:
_shutil.copytree(skill_dir, dest)
totals["installed"] += 1
count += 1
per_source[source] = count
mode = "[dry-run] " if dry_run else ""
print(f" {mode}Claude skills — per source: "
+ ", ".join(f"{k}={v}" for k, v in per_source.items()))
print(
f" {mode}Totals: {totals['installed']} installed, "
f"{totals['overwritten']} overwritten, "
f"{totals['skipped_existing']} skipped (exists, use --skill-force), "
f"{totals['skipped_filter']} skipped by filter, "
f"{totals['skipped_invalid']} invalid (no SKILL.md)"
)
return totals["installed"] + totals["overwritten"]
def install_antigravity(output_dir: Path):
"""Install personas to Antigravity IDE system prompts."""
# Antigravity stores system prompts in ~/.config/antigravity/prompts/ or project .antigravity/
ag_dir = Path.home() / ".config" / "antigravity" / "personas"
ag_dir.mkdir(parents=True, exist_ok=True)
count = 0
for persona_dir in sorted(output_dir.iterdir()):
if not persona_dir.is_dir() or persona_dir.name.startswith("_"):
continue
for prompt_file in persona_dir.glob("*.prompt.md"):
variant = prompt_file.stem
codename = persona_dir.name
dest = ag_dir / codename / f"{variant}.md"
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(prompt_file.read_text(encoding="utf-8"), encoding="utf-8")
count += 1
print(f" Antigravity: {count} personas installed to {ag_dir}")
return count
def install_gemini(output_dir: Path):
"""Install personas as Gemini Gems (JSON format for Google AI Studio)."""
gems_dir = output_dir / "_gems"
gems_dir.mkdir(parents=True, exist_ok=True)
count = 0
for persona_dir in sorted(output_dir.iterdir()):
if not persona_dir.is_dir() or persona_dir.name.startswith("_"):
continue
for json_file in persona_dir.glob("*.json"):
data = json.loads(json_file.read_text(encoding="utf-8"))
variant = data.get("variant", json_file.stem)
codename = data.get("codename", persona_dir.name)
name = data.get("name", codename.title())
# Build Gemini Gem format
gem = {
"name": f"{name}{variant}" if variant != "general" else name,
"description": f"{data.get('role', '')} | {data.get('domain', '')}",
"system_instruction": data.get("sections", {}).get("soul", "")
+ "\n\n"
+ data.get("sections", {}).get("expertise", "")
+ "\n\n"
+ data.get("sections", {}).get("methodology", "")
+ "\n\n"
+ data.get("sections", {}).get("behavior_rules", ""),
"metadata": {
"codename": codename,
"variant": variant,
"domain": data.get("domain", ""),
"address_to": data.get("address_to", ""),
"tone": data.get("tone", ""),
"activation_triggers": data.get("activation_triggers", []),
},
}
dest = gems_dir / f"{codename}-{variant}.json"
dest.write_text(
json.dumps(gem, ensure_ascii=False, indent=2), encoding="utf-8"
)
count += 1
print(f" Gemini: {count} gems generated to {gems_dir}")
return count
def install_paperclip(output_dir: Path, personas_dir: Path, shared_dir: Path | None):
"""Install personas as Paperclip agents (SOUL.md + hermes-config.yaml + AGENTS.md per agent)."""
pc_dir = output_dir / "_paperclip"
agents_dir = pc_dir / "agents"
skills_dir = pc_dir / "skills"
# Recreate output for deterministic full migration.
if pc_dir.exists():
import shutil
shutil.rmtree(pc_dir)
agents_dir.mkdir(parents=True, exist_ok=True)
skills_dir.mkdir(parents=True, exist_ok=True)
# Build escalation graph for AGENTS.md org chart
flat_config = {}
escalation_graph = build_escalation_graph(personas_dir, flat_config)
# Domain → toolset mapping for hermes-config
domain_toolsets = {
"cybersecurity": ["terminal", "file", "web", "code_execution"],
"intelligence": ["terminal", "file", "web"],
"military": ["terminal", "file", "web"],
"engineering": ["terminal", "file", "web", "code_execution"],
"law-economics": ["file", "web"],
"history": ["file", "web"],
"linguistics": ["file", "web"],
"academia": ["file", "web"],
}
agent_count = 0
skill_count = 0
for persona_dir in sorted(output_dir.iterdir()):
if not persona_dir.is_dir() or persona_dir.name.startswith("_"):
continue
general_json = persona_dir / "general.json"
general_prompt = persona_dir / "general.prompt.md"
if not general_json.exists():
continue
data = json.loads(general_json.read_text(encoding="utf-8"))
codename = data.get("codename", persona_dir.name)
name = data.get("name", codename.title())
role = data.get("role", "Specialist")
domain = data.get("domain", "general")
address_to = data.get("address_to", "")
tone = data.get("tone", "")
escalates_to = escalation_graph.get(persona_dir.name, [])
skills = data.get("skills", [])
agent_dir = agents_dir / codename
agent_dir.mkdir(parents=True, exist_ok=True)
# 1. SOUL.md — persona prompt adapted to Paperclip format
soul_lines = [
f"# {name}{role}\n",
f"## Kimlik",
f"- **Ad:** {name}",
f"- **Kod Adı:** {codename}",
f"- **Hitap:** {address_to}",
f"- **Domain:** {domain}",
f"- **Ton:** {tone}",
"",
]
if escalates_to:
soul_lines.append(f"## İlişkiler")
soul_lines.append(f"- **Escalation:** {', '.join(escalates_to)}")
soul_lines.append("")
if skills:
soul_lines.append(f"## Skills")
for s in skills:
soul_lines.append(f"- {s}")
soul_lines.append("")
# Append the full prompt body
if general_prompt.exists():
soul_lines.append("## Detaylı Tanım\n")
soul_lines.append(general_prompt.read_text(encoding="utf-8"))
(agent_dir / "SOUL.md").write_text("\n".join(soul_lines), encoding="utf-8")
# 2. hermes-config.yaml
toolsets = domain_toolsets.get(domain, ["terminal", "file", "web"])
hermes_config = {
"model": "qwen/qwen3.6-plus:free",
"provider": "openrouter",
"defaults": {"quiet": True, "reasoning_effort": "medium"},
"mcp_servers": {
"web-search": {
"command": "npx",
"args": ["-y", "ddg-mcp-search"],
},
},
"skills": {"external_dirs": ["~/.hermes/skills"]},
"toolsets": toolsets,
}
(agent_dir / "hermes-config.yaml").write_text(
yaml.dump(hermes_config, allow_unicode=True, default_flow_style=False),
encoding="utf-8",
)
# 3. AGENTS.md — workspace overview with org connections
agents_md_lines = [
f"# {name} — Workspace\n",
f"- **Agent:** {name} ({role})",
f"- **Domain:** {domain}",
"",
]
if escalates_to:
agents_md_lines.append("## Bağlantılar\n")
for target in escalates_to:
agents_md_lines.append(f"- → {target}")
agents_md_lines.append("")
(agent_dir / "AGENTS.md").write_text(
"\n".join(agents_md_lines), encoding="utf-8"
)
agent_count += 1
# Copy shared skills as Paperclip skills (SKILL.md format already compatible)
shared_skills = shared_dir / "skills" if shared_dir else Path("__missing__")
if shared_skills.exists():
for skill_dir in sorted(shared_skills.iterdir()):
if not skill_dir.is_dir():
continue
skill_md = skill_dir / "SKILL.md"
if skill_md.exists():
dest = skills_dir / skill_dir.name
dest.mkdir(parents=True, exist_ok=True)
(dest / "SKILL.md").write_text(
skill_md.read_text(encoding="utf-8"), encoding="utf-8"
)
refs = skill_dir / "references"
if refs.is_dir():
import shutil
shutil.copytree(refs, dest / "references", dirs_exist_ok=True)
skill_count += 1
# Copy paperclip-specific skills
pc_skills = shared_dir / "paperclip-skills" if shared_dir else Path("__missing__")
if pc_skills.exists():
for skill_dir in sorted(pc_skills.iterdir()):
if not skill_dir.is_dir():
continue
skill_md = skill_dir / "SKILL.md"
if skill_md.exists() and not (skills_dir / skill_dir.name).exists():
dest = skills_dir / skill_dir.name
dest.mkdir(parents=True, exist_ok=True)
(dest / "SKILL.md").write_text(
skill_md.read_text(encoding="utf-8"), encoding="utf-8"
)
refs = skill_dir / "references"
if refs.is_dir():
import shutil
shutil.copytree(refs, dest / "references", dirs_exist_ok=True)
scripts = skill_dir / "scripts"
if scripts.is_dir():
import shutil
shutil.copytree(scripts, dest / "scripts", dirs_exist_ok=True)
skill_count += 1
# Deploy original Paperclip company agents from _shared/paperclip-agents/
pc_agents_src = (
shared_dir / "paperclip-agents" if shared_dir else Path("__missing__")
)
pc_agent_count = 0
def normalize_agent_name(name: str) -> str:
"""Normalize escaped/unicode-heavy names to stable ASCII directory names."""
decoded = re.sub(
r"#U([0-9A-Fa-f]{4})",
lambda m: chr(int(m.group(1), 16)),
name,
)
ascii_name = (
unicodedata.normalize("NFKD", decoded)
.encode("ascii", "ignore")
.decode("ascii")
)
# Keep names filesystem-safe and deterministic.
slug = re.sub(r"[^a-zA-Z0-9]+", "-", ascii_name).strip("-").lower()
return slug or decoded
if pc_agents_src.exists():
seen_company_agents = set()
collision_count = 0
for agent_src in sorted(pc_agents_src.iterdir()):
if not agent_src.is_dir():
continue
agent_name = normalize_agent_name(agent_src.name)
if agent_name in seen_company_agents:
collision_count += 1
continue
seen_company_agents.add(agent_name)
# Skip if persona-based agent already exists with same name
if (agents_dir / agent_name).exists():
continue
dest = agents_dir / agent_name
dest.mkdir(parents=True, exist_ok=True)
for f in agent_src.iterdir():
if f.is_file():
(dest / f.name).write_text(
f.read_text(encoding="utf-8"), encoding="utf-8"
)
pc_agent_count += 1
if collision_count:
print(
f" Note: skipped {collision_count} duplicate company agent source dirs after name normalization"
)
total_agents = agent_count + pc_agent_count
print(
f" Paperclip: {agent_count} persona agents + {pc_agent_count} company agents + {skill_count} skills to {pc_dir}"
)
return total_agents
def install_openclaw(output_dir: Path):
"""Install personas to OpenClaw format (IDENTITY.md + individual persona files)."""
oc_dir = output_dir / "_openclaw"
oc_dir.mkdir(parents=True, exist_ok=True)
personas_dir = oc_dir / "personas"
personas_dir.mkdir(parents=True, exist_ok=True)
count = 0
identity_sections = []
for persona_dir in sorted(output_dir.iterdir()):
if not persona_dir.is_dir() or persona_dir.name.startswith("_"):
continue
general_prompt = persona_dir / "general.prompt.md"
if not general_prompt.exists():
continue
content = general_prompt.read_text(encoding="utf-8")
codename = persona_dir.name
# Write individual persona file
(personas_dir / f"{codename}.md").write_text(content, encoding="utf-8")
# Extract first line as title for IDENTITY.md
first_line = content.split("\n")[0].strip("# ").strip()
identity_sections.append(f"### {first_line}\nSee: personas/{codename}.md\n")
count += 1
# Write IDENTITY.md
identity = "# IDENTITY — Persona Definitions\n\n" + "\n".join(identity_sections)
(oc_dir / "IDENTITY.md").write_text(identity, encoding="utf-8")
print(f" OpenClaw: {count} personas + IDENTITY.md to {oc_dir}")
return count
def main():
import argparse
parser = argparse.ArgumentParser(
description="Build persona library and optionally install to platforms."
)
parser.add_argument(
"--install",
choices=[
"claude",
"claude-skills",
"antigravity",
"gemini",
"openclaw",
"paperclip",
"all",
],
help="Install generated personas to a target platform. "
"'claude' installs persona agents+commands; 'claude-skills' installs "
"shared skills to ~/.claude/skills/ with category filters.",
)
# --- claude-skills filters --------------------------------------------
parser.add_argument(
"--skill-sources",
default="skills,paperclip-skills",
help="Comma-separated list of _shared/<dir> sources for claude-skills "
"(available: skills,paperclip-skills,community-skills). "
"Default: skills,paperclip-skills",
)
parser.add_argument(
"--skill-subdomains",
default=None,
help="Comma-separated subdomain filter (e.g. "
"'red-teaming,penetration-testing,threat-hunting,malware-analysis'). "
"Skills without a subdomain in frontmatter pass through. Default: no filter.",
)
parser.add_argument(
"--skill-prefix",
default=None,
help="Comma-separated name-prefix filter "
"(e.g. 'performing,detecting,hunting,exploiting,analyzing,testing'). "
"Default: no filter.",
)
parser.add_argument(
"--skill-exclude",
default=None,
metavar="REGEX",
help="Skip skills whose name matches this regex.",
)
parser.add_argument(
"--skill-dry-run",
action="store_true",
help="Preview which skills would be installed without copying.",
)
parser.add_argument(
"--skill-force",
action="store_true",
help="Overwrite existing skill dirs at ~/.claude/skills/<name>/.",
)
parser.add_argument(
"--skill-preset",
choices=["offensive", "defensive", "ctiops", "minimal", "all"],
default=None,
help="Quick preset that sets --skill-subdomains and --skill-prefix together. "
"offensive=red-team+pentest+exploit verbs; defensive=DFIR+threat-hunting; "
"ctiops=threat-intel+APT; minimal=top categories only; all=no filters.",
)
parser.add_argument(
"--search",
type=str,
metavar="QUERY",
help="Search across all shared skills (e.g. --search 'pentest AD')",
)
parser.add_argument(
"--test",
nargs="?",
const="__all__",
metavar="PERSONA",
help="Run persona test suite (optionally specify persona name)",
)
args = parser.parse_args()
root = Path(__file__).parent
personas_dir = root / "personas"
if not personas_dir.exists():
print("No personas/ directory found.")
sys.exit(1)
output_dir = root / "generated"
# Load config
config = load_config(root)
flat_config = flatten_config(config) if config else {}
# Find all persona directories
persona_dirs = [
d
for d in sorted(personas_dir.iterdir())
if d.is_dir() and not d.name.startswith((".", "_"))
]
if not persona_dirs:
print("No persona directories found.")
sys.exit(1)
shared_dir = resolve_shared_dir(root, personas_dir)
source_mirrors = discover_sources(root)
if source_mirrors:
print(f"Detected source mirrors: {', '.join(source_mirrors)}")
else:
print("Detected source mirrors: none")
# Handle search-only mode
if args.search:
if not shared_dir:
print("No shared skill library found.")
return
search_skills(shared_dir, args.search)
return
# Handle test-only mode
if args.test:
target = None if args.test == "__all__" else args.test
run_tests(personas_dir, target)
return
output_dir.mkdir(parents=True, exist_ok=True)
print(f"Building {len(persona_dirs)} personas -> {output_dir}\n")
# Pre-build escalation graph and skills index
escalation_graph = build_escalation_graph(personas_dir, flat_config)
skills_index = build_skills_index(shared_dir, config) if shared_dir else {}
total_variants = 0
for pdir in persona_dirs:
total_variants += build_persona(
pdir, output_dir, flat_config, config, escalation_graph, skills_index
)
total_words = build_catalog(
personas_dir, output_dir, config, flat_config, shared_dir
)
# Resolve claude-skills filter args (presets + explicit flags).
PRESETS = {
"offensive": {
"subdomains": "red-teaming,penetration-testing,web-application-security,"
"api-security,identity-access-management",
"prefix": "performing,exploiting,testing,hunting,analyzing,scanning",
},
"defensive": {
"subdomains": "digital-forensics,incident-response,threat-hunting,"
"soc-operations,security-operations,endpoint-security,malware-analysis",
"prefix": "detecting,analyzing,hunting,implementing,building",
},
"ctiops": {
"subdomains": "threat-intelligence,threat-hunting,malware-analysis",
"prefix": "analyzing,hunting,detecting",
},
"minimal": {
"subdomains": "red-teaming,penetration-testing,threat-hunting,"
"digital-forensics,incident-response",
"prefix": "performing,detecting,hunting,exploiting,analyzing",
},
"all": {"subdomains": None, "prefix": None},
}
if args.skill_preset:
preset = PRESETS[args.skill_preset]
if args.skill_subdomains is None and preset.get("subdomains"):
args.skill_subdomains = preset["subdomains"]
if args.skill_prefix is None and preset.get("prefix"):
args.skill_prefix = preset["prefix"]
skill_sources = [s.strip() for s in args.skill_sources.split(",") if s.strip()]
skill_subdomains = (
set(s.strip() for s in args.skill_subdomains.split(",") if s.strip())
if args.skill_subdomains
else None
)
skill_prefixes = (
[p.strip() for p in args.skill_prefix.split(",") if p.strip()]
if args.skill_prefix
else None
)
# Platform installation
if args.install:
print(f"\n--- Installing to: {args.install} ---\n")
if args.install == "all":
targets = [
"claude",
"claude-skills",
"antigravity",
"gemini",
"openclaw",
"paperclip",
]
else:
targets = [args.install]
for target in targets:
if target == "claude":
install_claude(output_dir)
elif target == "claude-skills":
install_claude_skills(
shared_dir,
sources=skill_sources,
subdomains=skill_subdomains,
prefixes=skill_prefixes,
exclude_regex=args.skill_exclude,
dry_run=args.skill_dry_run,
force=args.skill_force,
)
elif target == "antigravity":
install_antigravity(output_dir)
elif target == "gemini":
install_gemini(output_dir)
elif target == "openclaw":
install_openclaw(output_dir)
elif target == "paperclip":
install_paperclip(output_dir, personas_dir, shared_dir)
print_summary(config, len(persona_dirs), total_variants, total_words)
if __name__ == "__main__":
main()