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:
276
build.py
276
build.py
@@ -1058,6 +1058,154 @@ def install_claude(output_dir: Path):
|
|||||||
return cmd_count
|
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):
|
def install_antigravity(output_dir: Path):
|
||||||
"""Install personas to Antigravity IDE system prompts."""
|
"""Install personas to Antigravity IDE system prompts."""
|
||||||
# Antigravity stores system prompts in ~/.config/antigravity/prompts/ or project .antigravity/
|
# Antigravity stores system prompts in ~/.config/antigravity/prompts/ or project .antigravity/
|
||||||
@@ -1380,8 +1528,64 @@ def main():
|
|||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--install",
|
"--install",
|
||||||
choices=["claude", "antigravity", "gemini", "openclaw", "paperclip", "all"],
|
choices=[
|
||||||
help="Install generated personas to a target platform",
|
"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(
|
parser.add_argument(
|
||||||
"--search",
|
"--search",
|
||||||
@@ -1461,17 +1665,75 @@ def main():
|
|||||||
personas_dir, output_dir, config, flat_config, shared_dir
|
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
|
# Platform installation
|
||||||
if args.install:
|
if args.install:
|
||||||
print(f"\n--- Installing to: {args.install} ---\n")
|
print(f"\n--- Installing to: {args.install} ---\n")
|
||||||
targets = (
|
if args.install == "all":
|
||||||
["claude", "antigravity", "gemini", "openclaw", "paperclip"]
|
targets = [
|
||||||
if args.install == "all"
|
"claude",
|
||||||
else [args.install]
|
"claude-skills",
|
||||||
)
|
"antigravity",
|
||||||
|
"gemini",
|
||||||
|
"openclaw",
|
||||||
|
"paperclip",
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
targets = [args.install]
|
||||||
for target in targets:
|
for target in targets:
|
||||||
if target == "claude":
|
if target == "claude":
|
||||||
install_claude(output_dir)
|
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":
|
elif target == "antigravity":
|
||||||
install_antigravity(output_dir)
|
install_antigravity(output_dir)
|
||||||
elif target == "gemini":
|
elif target == "gemini":
|
||||||
|
|||||||
Reference in New Issue
Block a user