#!/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//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 OPENCODE_TOPICS = { "security-offensive", "security-defensive", "security-cloud", "security-specialized", "security-iam", "security-network", "security-general", "ai-llm-dev", "coding-backend", "coding-frontend", "coding-tools", "cloud-infra", "database", "browser-scrape", "ops-sysadmin", "osint-intel", "marketing-content", "business-pm", "uncategorized", } # Default set: dev + security + AI + ops. Drops marketing/biz fluff. OPENCODE_DEFAULT_TOPICS = { "security-offensive", "security-defensive", "security-cloud", "security-specialized", "security-iam", "security-network", "security-general", "ai-llm-dev", "coding-backend", "coding-frontend", "coding-tools", "cloud-infra", "database", "browser-scrape", "ops-sysadmin", "osint-intel", } def _classify_skill_topic(name: str, fm: dict) -> str: """Map a skill to one of OPENCODE_TOPICS based on frontmatter + name.""" CYBER_MAP = { "red-teaming": "security-offensive", "penetration-testing": "security-offensive", "web-application-security": "security-offensive", "api-security": "security-offensive", "mobile-security": "security-offensive", "cryptography": "security-offensive", "threat-hunting": "security-defensive", "threat-intelligence": "security-defensive", "threat-detection": "security-defensive", "digital-forensics": "security-defensive", "incident-response": "security-defensive", "soc-operations": "security-defensive", "security-operations": "security-defensive", "malware-analysis": "security-defensive", "ransomware-defense": "security-defensive", "phishing-defense": "security-defensive", "endpoint-security": "security-defensive", "deception-technology": "security-defensive", "network-security": "security-network", "cloud-security": "security-cloud", "container-security": "security-cloud", "identity-access-management": "security-iam", "zero-trust-architecture": "security-iam", "ot-ics-security": "security-specialized", "vulnerability-management": "security-specialized", "devsecops": "security-specialized", "compliance-governance": "security-specialized", "application-security": "security-specialized", "supply-chain-security": "security-specialized", } sd = fm.get("subdomain") if sd in CYBER_MAP: return CYBER_MAP[sd] if fm.get("domain") == "cybersecurity": return "security-general" NAME_PATTERNS = [ ("coding-frontend", r"^(react|nextjs|next-|angular|vue-|svelte|tailwind|shadcn|vercel|expo|remotion|frontend|ui-ux|accessibility|canvas-|stitch|framer)"), ("coding-backend", r"^(python|java-|csharp|dotnet|aspnet|kotlin|swift|rust-|golang|go-|ruby-|php-|nodejs|node-|bash-|cli-|bazel|async-|architecting-|aspire-)"), ("coding-tools", r"^(commit|changelog|debug-|refactor|test-driven|tdd|bdd|git-|github-|gitlab-|bats|copilot|codeql|code-review|linting|formatting|add-|adr-|agent-browser|mcp-)"), ("ai-llm-dev", r"^(ai-|agentic|claude-|mcp|openai|anthropic|llm|rag-|embedding|fine-tun|prompt|anythingllm|olla|huggingface|elevenlabs|crawl-for-ai|agent-tools|agent-ui|agent-governance|para-memory|knowledge-hub)"), ("cloud-infra", r"^(aws|azure|gcp|kubernetes|docker|terraform|cloudflare|vercel|netlify|supabase|firebase|k8s|iac|devops|cicd|ansible|helm|bigquery|airflow|az-)"), ("database", r"^(sql-|postgres|mysql|mongodb|redis)"), ("browser-scrape", r"^(browser|playwright|puppeteer|firecrawl|stealth|scrape|crawl|use-my-browser)"), ("osint-intel", r"^(osint|recon|intel-|foia|seithar|deep-scraper|stealth-browser|social-trust|news-crawler|proudguard|gov-cyber|tavily|session-logs|youtube-transcript)"), ("marketing-content", r"^(copywriting|content-|seo-|blog-|article-|marketing-|ad-(creative|campaign)|brand-|banner|churn|billing|gtm-|competitive|backlink|boost|twitter|ai-social|ai-marketing|ai-content|ai-podcast|ai-music|ai-avatar|ai-automation|ai-image|ai-video|impeccable)"), ("ops-sysadmin", r"^(healthcheck|sysadmin|dns-networking|network-|nmap-|pcap-|tmux|freshrss|obsidian-|librarian|pdf-|image-ocr|mistral-ocr|analyze|weather|node-connect|clawflow|skill-creator|devops-engineer)"), ("business-pm", r"^(ceo-|cfo-|product-manager|marketing-strategist|marketing-psychology|qa-testing|design-md|persona-customer|product-|gtm-|arize|dataverse|power-|microsoft-)"), ("security-offensive", r"^(exploiting|pentest-|performing-(web|api|initial|privilege|credential|graphql|soap|lateral|clickjacking|subdomain|open-source|wireless|physical|iot|external|directory|oauth|csrf|web-application|web-cache|http|thick|content-security|active-directory|kerberoasting|second-order|blind-ssrf|jwt-none|initial-access)|testing-(for|api|oauth2|jwt|websocket|websocket-api|cors)|sql-injection|pwnclaw-security)"), ("security-defensive", r"^(security-(review|audit|scanner|headers|skill-scanner)|senior-secops|threat-|ctf-|sys-guard|clawsec|agent-intelligence|war-intel|sentinel)"), ] for topic, pattern in NAME_PATTERNS: if re.match(pattern, name.lower()): return topic return "uncategorized" def _parse_skill_frontmatter_simple(skill_md: Path) -> dict: """Minimal YAML frontmatter parser — just key: value pairs.""" try: text = skill_md.read_text(encoding="utf-8", errors="ignore") except Exception: return {} if not text.startswith("---"): return {} end = text.find("\n---", 4) if end < 0: return {} fm = {} for line in text[4:end].splitlines(): m = re.match(r"^([a-z_]+):\s*(.+?)\s*$", line) if m: fm[m.group(1)] = m.group(2).strip().strip('"\'') return fm def install_opencode( output_dir: Path, shared_dir: Path | None = None, topics: set[str] | None = None, ): """Install personas to OpenCode as agents + skills. OpenCode agent format (per https://opencode.ai/docs/agents/): - Location: ~/.config/opencode/agents/.md - YAML frontmatter: description, mode (primary|subagent), model, temperature, color, permission (edit/bash/webfetch/task). OpenCode skill format (per https://opencode.ai/docs/skills/): - Location: ~/.config/opencode/skills//SKILL.md - YAML frontmatter: name, description (required). - OpenCode ALSO reads ~/.claude/skills/ natively. Args: topics: set of topics to install (see OPENCODE_TOPICS). Defaults to OPENCODE_DEFAULT_TOPICS which drops marketing/biz skills. """ if topics is None: topics = OPENCODE_DEFAULT_TOPICS agents_dir = Path.home() / ".config" / "opencode" / "agents" skills_dir = Path.home() / ".config" / "opencode" / "skills" agents_dir.mkdir(parents=True, exist_ok=True) skills_dir.mkdir(parents=True, exist_ok=True) # Offensive/engineering personas get full permissions (primary mode). # Analytical personas are subagents with readonly bias. OFFENSIVE_DOMAINS = { "cybersecurity", "engineering", "devops", "software-development", "ai-ml", } DOMAIN_COLOR = { "cybersecurity": "error", # red-like "intelligence": "info", # cyan-like "military": "warning", # orange "law": "warning", "economics": "success", "politics": "accent", "history": "primary", "linguistics": "secondary", "media": "secondary", "engineering": "success", "academia": "primary", "humanities": "accent", "science": "info", "strategy": "accent", } agent_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" 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", "") quote = data.get("quote", "") skills = data.get("skills", []) 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" is_offensive = domain in OFFENSIVE_DOMAINS mode = "primary" if is_offensive else "subagent" color = DOMAIN_COLOR.get(domain, "primary") # Permission block differs for offensive vs analytical personas. if is_offensive: permission_block = ( "permission:\n" " edit: allow\n" " bash:\n" ' "*": allow\n' " webfetch: allow\n" ) else: permission_block = ( "permission:\n" " edit: ask\n" " bash:\n" ' "*": ask\n' " webfetch: allow\n" ) desc = f"{name} ({address_to}) — {role}. Domain: {domain}.".replace( "\n", " " ) frontmatter = ( "---\n" f"description: {desc}\n" f"mode: {mode}\n" "temperature: 0.3\n" f"color: {color}\n" f"{permission_block}" "---\n\n" ) agent_file = agents_dir / f"{codename}.md" agent_file.write_text(frontmatter + body, encoding="utf-8") agent_count += 1 # Install shared skills with topic filter. OpenCode reads SKILL.md with # name+description frontmatter (same as Claude). skill_count = 0 per_topic: dict[str, int] = {} skipped_topic = 0 # Purge existing skills dir so stale filtered-out skills are removed. if skills_dir.exists(): import shutil as _shutil for existing in skills_dir.iterdir(): if existing.is_dir(): _shutil.rmtree(existing) if shared_dir: for skills_subdir in ["skills", "paperclip-skills", "community-skills"]: src_root = shared_dir / skills_subdir if not src_root.exists(): continue for skill_dir in src_root.iterdir(): if not skill_dir.is_dir(): continue src_skill = skill_dir / "SKILL.md" if not src_skill.exists(): continue # Honor opencode name regex: ^[a-z0-9]+(-[a-z0-9]+)*$. sanitized = skill_dir.name.lower() if not re.match(r"^[a-z0-9]+(-[a-z0-9]+)*$", sanitized): continue # Topic filter — drop skills not in requested topics. fm = _parse_skill_frontmatter_simple(src_skill) topic = _classify_skill_topic(skill_dir.name, fm) if topic not in topics: skipped_topic += 1 continue per_topic[topic] = per_topic.get(topic, 0) + 1 dest_dir = skills_dir / sanitized dest_dir.mkdir(parents=True, exist_ok=True) dest_skill = dest_dir / "SKILL.md" dest_skill.write_text( src_skill.read_text(encoding="utf-8"), encoding="utf-8" ) # Copy references/ if present. refs = skill_dir / "references" if refs.exists() and refs.is_dir(): dest_refs = dest_dir / "references" dest_refs.mkdir(exist_ok=True) for ref in refs.iterdir(): if ref.is_file(): (dest_refs / ref.name).write_text( ref.read_text(encoding="utf-8"), encoding="utf-8", ) skill_count += 1 print( f" OpenCode: {agent_count} agents installed to {agents_dir}" ) print( f" OpenCode skills: {skill_count} installed " f"({skipped_topic} skipped by topic filter)" ) if per_topic: print(" Per topic: " + ", ".join( f"{k}={v}" for k, v in sorted(per_topic.items(), key=lambda x: -x[1]) )) return agent_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", "opencode", "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/ 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//.", ) 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( "--opencode-topics", default=None, help="Comma-separated topic filter for --install opencode. " "Topics: security-offensive, security-defensive, security-cloud, " "security-specialized, security-iam, security-network, security-general, " "ai-llm-dev, coding-backend, coding-frontend, coding-tools, cloud-infra, " "database, browser-scrape, ops-sysadmin, osint-intel, marketing-content, " "business-pm, uncategorized. " "Default drops marketing/biz. Use 'all' for no filter.", ) 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", "opencode", "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 == "opencode": if args.opencode_topics: if args.opencode_topics.strip().lower() == "all": oc_topics = OPENCODE_TOPICS else: oc_topics = { t.strip() for t in args.opencode_topics.split(",") if t.strip() } else: oc_topics = None # use default install_opencode(output_dir, shared_dir, topics=oc_topics) 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()