Shared library now lives at personas/_shared/ with full source data:
- skills/ — 42 skills from shared-skills + kali-claw (SKILL.md + references)
- paperclip-skills/ — 52 skills from paperclip-docs (ceo-advisor, coding-agent, etc.)
- design-md/ — 58 brand DESIGN.md files (Stripe, Claude, Linear, Apple, Vercel...)
- ui-ux-pro-max/ — BM25 search engine + 14 CSV data files (67 styles, 161 products)
- openclaw-personas/ — 6 original personas + SOUL.md + IDENTITY.md + TOOLS.md
- kali-tools/ — 16 Kali Linux tool reference docs
- osint-sources/ + ad-attack-tools/ — investigation references
Build system enhancements:
- Skills auto-mapped to personas via SKILL_PERSONA_MAP (domain-based)
- Each persona JSON/YAML output now includes "skills" array
- generated/_index/skills_index.json indexes all 42+52 skills + 58 brands + 14 data files
- Skills, escalation graph, and trigger index all generated per build
Sources: shared-skills (Gitea), kali-claw (Gitea), paperclip-docs (Born2beRoot),
awesome-design-md (VoltAgent), ui-ux-pro-max-skill (nextlevelbuilder)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
660 lines
26 KiB
Python
Executable File
660 lines
26 KiB
Python
Executable File
#!/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
|
|
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 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)
|
|
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
|
|
|
|
|
|
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"],
|
|
}
|
|
|
|
|
|
def build_skills_index(shared_dir: Path) -> dict:
|
|
"""Index all shared skills from _shared/skills/ and _shared/paperclip-skills/."""
|
|
index = {"skills": {}, "paperclip_skills": {}, "design_brands": [], "ui_ux_styles": 0}
|
|
|
|
# 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():
|
|
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": SKILL_PERSONA_MAP.get(skill_dir.name, []),
|
|
"summary": first_line,
|
|
"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 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):
|
|
"""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
|
|
shared_dir = personas_dir / "_shared"
|
|
if shared_dir.exists():
|
|
si = build_skills_index(shared_dir)
|
|
(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 + {len(si.get('paperclip_skills', {}))} paperclip + {len(si.get('design_brands', []))} design brands + {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 (~/.claude/commands/)."""
|
|
commands_dir = Path.home() / ".claude" / "commands"
|
|
commands_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
|
|
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")
|
|
# Wrap as Claude command: $ARGUMENTS placeholder for user query
|
|
command_content = f"{content}\n\n---\nUser query: $ARGUMENTS\n"
|
|
dest.write_text(command_content, encoding="utf-8")
|
|
count += 1
|
|
print(f" Claude: {count} commands installed to {commands_dir}")
|
|
return count
|
|
|
|
|
|
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_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", "antigravity", "gemini", "openclaw", "all"],
|
|
help="Install generated personas to a target platform")
|
|
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)
|
|
|
|
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)
|
|
shared_dir = personas_dir / "_shared"
|
|
skills_index = build_skills_index(shared_dir) if shared_dir.exists() 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)
|
|
|
|
# Platform installation
|
|
if args.install:
|
|
print(f"\n--- Installing to: {args.install} ---\n")
|
|
targets = ["claude", "antigravity", "gemini", "openclaw"] if args.install == "all" else [args.install]
|
|
for target in targets:
|
|
if target == "claude":
|
|
install_claude(output_dir)
|
|
elif target == "antigravity":
|
|
install_antigravity(output_dir)
|
|
elif target == "gemini":
|
|
install_gemini(output_dir)
|
|
elif target == "openclaw":
|
|
install_openclaw(output_dir)
|
|
|
|
print_summary(config, len(persona_dirs), total_variants, total_words)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|