From 309e389c65eb3e051e2a6814aa0e10bb994fdea2 Mon Sep 17 00:00:00 2001 From: salvacybersec Date: Mon, 13 Apr 2026 21:37:29 +0300 Subject: [PATCH] feat(install): add --install claude-skills with category filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New install target copies _shared/skills/**/SKILL.md directories to ~/.claude/skills// 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/ (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) --- build.py | 276 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 269 insertions(+), 7 deletions(-) diff --git a/build.py b/build.py index 1d987f0..81cedf5 100755 --- a/build.py +++ b/build.py @@ -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//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/ 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( "--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":