feat(install): add --install claude-skills with category filters

New install target copies _shared/skills/**/SKILL.md directories to
~/.claude/skills/<name>/ as native Claude Code skills, with filters to
keep the list manageable (Claude evaluates each skill description on
every message, so 800+ bulk installs slow routing).

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
salvacybersec
2026-04-13 21:37:29 +03:00
parent 1306f422d3
commit 309e389c65

276
build.py
View File

@@ -1058,6 +1058,154 @@ def install_claude(output_dir: Path):
return cmd_count
def _parse_skill_frontmatter(skill_md: Path) -> dict:
"""Extract YAML frontmatter from a SKILL.md. Returns empty dict if absent."""
try:
text = skill_md.read_text(encoding="utf-8", errors="replace")
except OSError:
return {}
if not text.startswith("---"):
return {}
end = text.find("\n---", 3)
if end == -1:
return {}
try:
return yaml.safe_load(text[3:end]) or {}
except yaml.YAMLError:
return {}
def install_claude_skills(
shared_dir: Path | None,
sources: list[str],
subdomains: set[str] | None,
prefixes: list[str] | None,
exclude_regex: str | None,
dry_run: bool,
force: bool,
):
"""Install shared skills as native Claude Code skills.
Claude expects each skill at ~/.claude/skills/<name>/SKILL.md. This copies
each valid skill directory verbatim (preserving scripts/, references/,
assets/ etc.) and applies filters so the skill list stays manageable.
Args:
shared_dir: personas/_shared/ path.
sources: list of subdirs under _shared/ to pull from (e.g. ["skills",
"paperclip-skills", "community-skills"]).
subdomains: set of subdomain values to include. None = no filter.
Only applies to skills that declare a `subdomain:` in frontmatter.
prefixes: skill-name prefixes to include (e.g. ["performing",
"detecting"]). None = no filter.
exclude_regex: skills whose name matches this regex are skipped.
dry_run: print what would be installed, make no changes.
force: overwrite existing skill dirs without prompting.
"""
if shared_dir is None or not shared_dir.exists():
print(" No shared library found — cannot install skills.")
return 0
skills_dir = Path.home() / ".claude" / "skills"
skills_dir.mkdir(parents=True, exist_ok=True)
# Remove the broken whole-repo dir if present (installed by an older flow).
broken_repo = skills_dir / "Anthropic-Cybersecurity-Skills"
if broken_repo.exists() and (broken_repo / "skills").is_dir():
if dry_run:
print(f" [dry-run] would purge broken {broken_repo}")
else:
import shutil
shutil.rmtree(broken_repo)
print(f" Purged broken repo-dir: {broken_repo}")
import re as _re
import shutil as _shutil
exclude_pat = _re.compile(exclude_regex) if exclude_regex else None
totals = {"installed": 0, "skipped_filter": 0, "skipped_invalid": 0,
"skipped_existing": 0, "overwritten": 0}
per_source: dict[str, int] = {}
for source in sources:
source_dir = shared_dir / source
if not source_dir.exists():
print(f" [warn] source not found: {source_dir}")
continue
count = 0
for skill_dir in sorted(source_dir.iterdir()):
if not skill_dir.is_dir() or skill_dir.name.startswith((".", "_")):
continue
skill_md = skill_dir / "SKILL.md"
if not skill_md.exists():
totals["skipped_invalid"] += 1
continue
name = skill_dir.name
# Exclusion regex takes priority across all sources.
if exclude_pat and exclude_pat.search(name):
totals["skipped_filter"] += 1
continue
# Subdomain + prefix filters only apply to cybersecurity skills.
# Non-cyber skills (paperclip ceo-advisor, community shadcn, etc.)
# don't follow the verb-prefix naming convention, so forcing them
# through the filter would drop everything.
fm = _parse_skill_frontmatter(skill_md)
is_cyber = fm.get("domain") == "cybersecurity"
if is_cyber and prefixes:
verb = name.split("-", 1)[0]
if verb not in prefixes:
totals["skipped_filter"] += 1
continue
if is_cyber and subdomains is not None:
sd = fm.get("subdomain")
if sd is not None and sd not in subdomains:
totals["skipped_filter"] += 1
continue
dest = skills_dir / name
if dest.exists():
if not force:
totals["skipped_existing"] += 1
continue
if dry_run:
totals["overwritten"] += 1
else:
_shutil.rmtree(dest)
_shutil.copytree(skill_dir, dest)
totals["overwritten"] += 1
count += 1
continue
if dry_run:
totals["installed"] += 1
else:
_shutil.copytree(skill_dir, dest)
totals["installed"] += 1
count += 1
per_source[source] = count
mode = "[dry-run] " if dry_run else ""
print(f" {mode}Claude skills — per source: "
+ ", ".join(f"{k}={v}" for k, v in per_source.items()))
print(
f" {mode}Totals: {totals['installed']} installed, "
f"{totals['overwritten']} overwritten, "
f"{totals['skipped_existing']} skipped (exists, use --skill-force), "
f"{totals['skipped_filter']} skipped by filter, "
f"{totals['skipped_invalid']} invalid (no SKILL.md)"
)
return totals["installed"] + totals["overwritten"]
def install_antigravity(output_dir: Path):
"""Install personas to Antigravity IDE system prompts."""
# Antigravity stores system prompts in ~/.config/antigravity/prompts/ or project .antigravity/
@@ -1380,8 +1528,64 @@ def main():
)
parser.add_argument(
"--install",
choices=["claude", "antigravity", "gemini", "openclaw", "paperclip", "all"],
help="Install generated personas to a target platform",
choices=[
"claude",
"claude-skills",
"antigravity",
"gemini",
"openclaw",
"paperclip",
"all",
],
help="Install generated personas to a target platform. "
"'claude' installs persona agents+commands; 'claude-skills' installs "
"shared skills to ~/.claude/skills/ with category filters.",
)
# --- claude-skills filters --------------------------------------------
parser.add_argument(
"--skill-sources",
default="skills,paperclip-skills",
help="Comma-separated list of _shared/<dir> sources for claude-skills "
"(available: skills,paperclip-skills,community-skills). "
"Default: skills,paperclip-skills",
)
parser.add_argument(
"--skill-subdomains",
default=None,
help="Comma-separated subdomain filter (e.g. "
"'red-teaming,penetration-testing,threat-hunting,malware-analysis'). "
"Skills without a subdomain in frontmatter pass through. Default: no filter.",
)
parser.add_argument(
"--skill-prefix",
default=None,
help="Comma-separated name-prefix filter "
"(e.g. 'performing,detecting,hunting,exploiting,analyzing,testing'). "
"Default: no filter.",
)
parser.add_argument(
"--skill-exclude",
default=None,
metavar="REGEX",
help="Skip skills whose name matches this regex.",
)
parser.add_argument(
"--skill-dry-run",
action="store_true",
help="Preview which skills would be installed without copying.",
)
parser.add_argument(
"--skill-force",
action="store_true",
help="Overwrite existing skill dirs at ~/.claude/skills/<name>/.",
)
parser.add_argument(
"--skill-preset",
choices=["offensive", "defensive", "ctiops", "minimal", "all"],
default=None,
help="Quick preset that sets --skill-subdomains and --skill-prefix together. "
"offensive=red-team+pentest+exploit verbs; defensive=DFIR+threat-hunting; "
"ctiops=threat-intel+APT; minimal=top categories only; all=no filters.",
)
parser.add_argument(
"--search",
@@ -1461,17 +1665,75 @@ def main():
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")
targets = (
["claude", "antigravity", "gemini", "openclaw", "paperclip"]
if args.install == "all"
else [args.install]
)
if args.install == "all":
targets = [
"claude",
"claude-skills",
"antigravity",
"gemini",
"openclaw",
"paperclip",
]
else:
targets = [args.install]
for target in targets:
if target == "claude":
install_claude(output_dir)
elif target == "claude-skills":
install_claude_skills(
shared_dir,
sources=skill_sources,
subdomains=skill_subdomains,
prefixes=skill_prefixes,
exclude_regex=args.skill_exclude,
dry_run=args.skill_dry_run,
force=args.skill_force,
)
elif target == "antigravity":
install_antigravity(output_dir)
elif target == "gemini":