reorganize
This commit is contained in:
580
build.py
580
build.py
@@ -8,6 +8,7 @@ New users: copy config.example.yaml → config.yaml and customize.
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import unicodedata
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
@@ -27,10 +28,48 @@ def load_config(root: Path) -> dict:
|
||||
|
||||
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.")
|
||||
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.
|
||||
|
||||
@@ -44,7 +83,9 @@ def flatten_config(config: dict, prefix: str = "") -> dict:
|
||||
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))
|
||||
flat[f"{full_key}.csv"] = ", ".join(
|
||||
str(v) for v in value if not isinstance(v, dict)
|
||||
)
|
||||
else:
|
||||
flat[full_key] = value
|
||||
return flat
|
||||
@@ -52,6 +93,7 @@ def flatten_config(config: dict, prefix: str = "") -> dict:
|
||||
|
||||
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
|
||||
@@ -66,6 +108,7 @@ def inject_config(content: str, flat_config: dict) -> str:
|
||||
|
||||
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()
|
||||
@@ -75,7 +118,9 @@ def check_conditionals(content: str, flat_config: dict) -> str:
|
||||
return body
|
||||
return ""
|
||||
|
||||
content = re.sub(r"\{\{#if (.+?)\}\}(.*?)\{\{/if\}\}", if_replacer, content, flags=re.DOTALL)
|
||||
content = re.sub(
|
||||
r"\{\{#if (.+?)\}\}(.*?)\{\{/if\}\}", if_replacer, content, flags=re.DOTALL
|
||||
)
|
||||
|
||||
# Handle {{#unless key}}content{{/unless}}
|
||||
def unless_replacer(match):
|
||||
@@ -86,7 +131,12 @@ def check_conditionals(content: str, flat_config: dict) -> str:
|
||||
return body
|
||||
return ""
|
||||
|
||||
content = re.sub(r"\{\{#unless (.+?)\}\}(.*?)\{\{/unless\}\}", unless_replacer, content, flags=re.DOTALL)
|
||||
content = re.sub(
|
||||
r"\{\{#unless (.+?)\}\}(.*?)\{\{/unless\}\}",
|
||||
unless_replacer,
|
||||
content,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
|
||||
return content
|
||||
|
||||
@@ -118,7 +168,9 @@ def parse_persona_md(filepath: Path, flat_config: dict) -> dict:
|
||||
if line.startswith("## "):
|
||||
if current_section:
|
||||
sections[current_section] = "\n".join(current_content).strip()
|
||||
current_section = line[3:].strip().lower().replace(" ", "_").replace("&", "and")
|
||||
current_section = (
|
||||
line[3:].strip().lower().replace(" ", "_").replace("&", "and")
|
||||
)
|
||||
current_content = []
|
||||
else:
|
||||
current_content.append(line)
|
||||
@@ -133,7 +185,14 @@ def parse_persona_md(filepath: Path, flat_config: dict) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def build_persona(persona_dir: Path, output_dir: Path, flat_config: dict, config: dict, escalation_graph: dict = None, skills_index: dict = None):
|
||||
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:
|
||||
@@ -168,14 +227,27 @@ def build_persona(persona_dir: Path, output_dir: Path, flat_config: dict, config
|
||||
continue
|
||||
|
||||
# Build output object
|
||||
output = {**meta, **parsed["metadata"], "variant": variant, "sections": parsed["sections"]}
|
||||
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},
|
||||
"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", {}),
|
||||
}
|
||||
|
||||
@@ -207,13 +279,17 @@ def build_persona(persona_dir: Path, output_dir: Path, flat_config: dict, config
|
||||
# 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),
|
||||
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")
|
||||
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"
|
||||
@@ -227,37 +303,192 @@ def build_persona(persona_dir: Path, output_dir: Path, flat_config: dict, config
|
||||
|
||||
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"],
|
||||
"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"],
|
||||
"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"],
|
||||
"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"],
|
||||
"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"],
|
||||
"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 = dict(DEFAULT_SKILL_PERSONA_MAP)
|
||||
merged.update(custom)
|
||||
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
|
||||
|
||||
|
||||
@@ -289,7 +520,9 @@ def search_skills(shared_dir: Path, query: str):
|
||||
desc = ""
|
||||
for line in content.split("\n"):
|
||||
line = line.strip()
|
||||
if line and not line.startswith(("---", "#", "name:", "description:")):
|
||||
if line and not line.startswith(
|
||||
("---", "#", "name:", "description:")
|
||||
):
|
||||
desc = line[:100]
|
||||
break
|
||||
results.append((score, name, skills_subdir, desc))
|
||||
@@ -297,7 +530,7 @@ def search_skills(shared_dir: Path, query: str):
|
||||
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})")
|
||||
print(f" {i + 1:2}. [{score:3}] {name} ({source})")
|
||||
if desc:
|
||||
print(f" {desc}")
|
||||
if len(results) > 20:
|
||||
@@ -351,20 +584,26 @@ def run_tests(personas_dir: Path, target: str = None):
|
||||
# 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")
|
||||
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")
|
||||
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")
|
||||
warnings.append(
|
||||
f" {persona_name}/{test_name}: confidence levels not defined in persona"
|
||||
)
|
||||
test_passed = False
|
||||
|
||||
if test_passed:
|
||||
@@ -384,9 +623,16 @@ def run_tests(personas_dir: Path, target: str = None):
|
||||
|
||||
|
||||
def build_skills_index(shared_dir: Path, config: dict = None) -> dict:
|
||||
"""Index all shared skills from _shared/skills/ and _shared/paperclip-skills/."""
|
||||
"""Index all shared skills from _shared/{skills,paperclip-skills,community-skills}/."""
|
||||
skill_map = load_skill_persona_map(config or {})
|
||||
index = {"skills": {}, "paperclip_skills": {}, "design_brands": [], "ui_ux_styles": 0, "_skill_persona_map": skill_map}
|
||||
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"
|
||||
@@ -396,16 +642,33 @@ def build_skills_index(shared_dir: Path, config: dict = None) -> dict:
|
||||
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:")):
|
||||
if line and not line.startswith(
|
||||
("---", "#", "name:", "description:")
|
||||
):
|
||||
first_line = line[:120]
|
||||
break
|
||||
index["skills"][skill_dir.name] = {
|
||||
"personas": skill_map.get(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(),
|
||||
}
|
||||
|
||||
@@ -419,10 +682,22 @@ def build_skills_index(shared_dir: Path, config: dict = None) -> dict:
|
||||
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()])
|
||||
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"
|
||||
@@ -477,7 +752,9 @@ def validate_persona(persona_name: str, parsed: dict) -> list:
|
||||
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}")
|
||||
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"]:
|
||||
@@ -487,7 +764,13 @@ def validate_persona(persona_name: str, parsed: dict) -> list:
|
||||
return warnings
|
||||
|
||||
|
||||
def build_catalog(personas_dir: Path, output_dir: Path, config: dict, flat_config: dict):
|
||||
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", {})
|
||||
|
||||
@@ -515,7 +798,11 @@ def build_catalog(personas_dir: Path, output_dir: Path, config: dict, flat_confi
|
||||
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("_")]
|
||||
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"
|
||||
@@ -540,7 +827,9 @@ def build_catalog(personas_dir: Path, output_dir: Path, config: dict, flat_confi
|
||||
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")
|
||||
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("")
|
||||
@@ -559,7 +848,9 @@ def build_catalog(personas_dir: Path, output_dir: Path, config: dict, flat_confi
|
||||
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"- Escalation connections: {sum(len(v) for v in escalation_graph.values())}"
|
||||
)
|
||||
catalog_lines.append(f"- Unique triggers: {len(trigger_index)}")
|
||||
catalog_lines.append("")
|
||||
|
||||
@@ -580,13 +871,18 @@ def build_catalog(personas_dir: Path, output_dir: Path, config: dict, flat_confi
|
||||
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)
|
||||
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 + {len(si.get('paperclip_skills', {}))} paperclip + {len(si.get('design_brands', []))} design brands + {si.get('ui_ux_styles', 0)} UI/UX data files")
|
||||
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:
|
||||
@@ -597,7 +893,9 @@ def build_catalog(personas_dir: Path, output_dir: Path, config: dict, flat_confi
|
||||
return total_words
|
||||
|
||||
|
||||
def print_summary(config: dict, total_personas: int, total_variants: int, total_words: int = 0):
|
||||
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")
|
||||
@@ -609,8 +907,14 @@ def print_summary(config: dict, total_personas: int, total_variants: int, total_
|
||||
|
||||
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)
|
||||
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")
|
||||
@@ -641,7 +945,11 @@ def install_claude(output_dir: Path):
|
||||
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}"
|
||||
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"
|
||||
@@ -683,10 +991,24 @@ def install_claude(output_dir: Path):
|
||||
"name": codename,
|
||||
"description": f"{name} ({address_to}) — {role}. {domain}.",
|
||||
"instructions": instructions,
|
||||
"allowedTools": ["Read(*)", "Edit(*)", "Write(*)", "Bash(*)", "Glob(*)", "Grep(*)", "WebFetch(*)", "WebSearch(*)"],
|
||||
"allowedTools": [
|
||||
"Read(*)",
|
||||
"Edit(*)",
|
||||
"Write(*)",
|
||||
"Bash(*)",
|
||||
"Glob(*)",
|
||||
"Grep(*)",
|
||||
"WebFetch(*)",
|
||||
"WebSearch(*)",
|
||||
],
|
||||
}
|
||||
agent_file = agents_dir / f"{codename}.yml"
|
||||
agent_file.write_text(yaml.dump(agent, allow_unicode=True, default_flow_style=False, sort_keys=False), encoding="utf-8")
|
||||
agent_file.write_text(
|
||||
yaml.dump(
|
||||
agent, allow_unicode=True, default_flow_style=False, sort_keys=False
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
agent_count += 1
|
||||
|
||||
print(f" Claude: {cmd_count} commands + {agent_count} agents installed")
|
||||
@@ -730,10 +1052,13 @@ def install_gemini(output_dir: Path):
|
||||
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", ""),
|
||||
"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,
|
||||
@@ -744,17 +1069,26 @@ def install_gemini(output_dir: Path):
|
||||
},
|
||||
}
|
||||
dest = gems_dir / f"{codename}-{variant}.json"
|
||||
dest.write_text(json.dumps(gem, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
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):
|
||||
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)
|
||||
|
||||
@@ -860,11 +1194,13 @@ def install_paperclip(output_dir: Path, personas_dir: Path):
|
||||
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_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 = personas_dir / "_shared" / "skills"
|
||||
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():
|
||||
@@ -873,15 +1209,18 @@ def install_paperclip(output_dir: Path, personas_dir: Path):
|
||||
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")
|
||||
(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 = personas_dir / "_shared" / "paperclip-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():
|
||||
@@ -890,25 +1229,54 @@ def install_paperclip(output_dir: Path, personas_dir: Path):
|
||||
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")
|
||||
(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 = personas_dir / "_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 = agent_src.name
|
||||
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
|
||||
@@ -916,11 +1284,19 @@ def install_paperclip(output_dir: Path, personas_dir: Path):
|
||||
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")
|
||||
(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}")
|
||||
print(
|
||||
f" Paperclip: {agent_count} persona agents + {pc_agent_count} company agents + {skill_count} skills to {pc_dir}"
|
||||
)
|
||||
return total_agents
|
||||
|
||||
|
||||
@@ -955,13 +1331,28 @@ def install_openclaw(output_dir: Path):
|
||||
|
||||
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", "paperclip", "all"],
|
||||
help="Install generated personas to a target platform")
|
||||
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)")
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Build persona library and optionally install to platforms."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--install",
|
||||
choices=["claude", "antigravity", "gemini", "openclaw", "paperclip", "all"],
|
||||
help="Install generated personas to a target platform",
|
||||
)
|
||||
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
|
||||
@@ -979,17 +1370,28 @@ def main():
|
||||
|
||||
# Find all persona directories
|
||||
persona_dirs = [
|
||||
d for d in sorted(personas_dir.iterdir()) if d.is_dir() and not d.name.startswith((".", "_"))
|
||||
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 = personas_dir / "_shared"
|
||||
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
|
||||
|
||||
@@ -1004,18 +1406,26 @@ def main():
|
||||
|
||||
# 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.exists() else {}
|
||||
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_variants += build_persona(
|
||||
pdir, output_dir, flat_config, config, escalation_graph, skills_index
|
||||
)
|
||||
|
||||
total_words = build_catalog(personas_dir, output_dir, config, flat_config)
|
||||
total_words = build_catalog(
|
||||
personas_dir, output_dir, config, flat_config, shared_dir
|
||||
)
|
||||
|
||||
# Platform installation
|
||||
if args.install:
|
||||
print(f"\n--- Installing to: {args.install} ---\n")
|
||||
targets = ["claude", "antigravity", "gemini", "openclaw", "paperclip"] if args.install == "all" else [args.install]
|
||||
targets = (
|
||||
["claude", "antigravity", "gemini", "openclaw", "paperclip"]
|
||||
if args.install == "all"
|
||||
else [args.install]
|
||||
)
|
||||
for target in targets:
|
||||
if target == "claude":
|
||||
install_claude(output_dir)
|
||||
@@ -1026,7 +1436,7 @@ def main():
|
||||
elif target == "openclaw":
|
||||
install_openclaw(output_dir)
|
||||
elif target == "paperclip":
|
||||
install_paperclip(output_dir, personas_dir)
|
||||
install_paperclip(output_dir, personas_dir, shared_dir)
|
||||
|
||||
print_summary(config, len(persona_dirs), total_variants, total_words)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user