.DEFAULT_GOAL := help SHELL := /bin/bash SKILLS_DIR := $(HOME)/.claude/skills RULES_DIR := $(HOME)/.claude/rules HOOKS_DIR := $(HOME)/.claude/hooks CLAUDE_DIR := $(HOME)/.claude LOCAL_BIN := $(HOME)/.local/bin AI_LAUNCHER := $(CURDIR)/claude-templates/bin/ai SKILLS := $(patsubst %/SKILL.md,%,$(wildcard */SKILL.md)) RULES := $(wildcard claude-rules/*.md) HOOKS := $(wildcard hooks/*.sh hooks/*.py) # Opt-in hooks: present in the repo but not installed by default. Users link # them manually if they want the behavior. Add new opt-ins to this list. OPTIN_HOOKS := hooks/destructive-bash-confirm.py DEFAULT_HOOKS := $(filter-out $(OPTIN_HOOKS),$(HOOKS)) CLAUDE_CONFIG := $(wildcard .claude/*.json) $(wildcard .claude/.*.json) $(wildcard .claude/*.sh) LANGUAGES := $(notdir $(wildcard languages/*)) TEAMS := $(notdir $(wildcard teams/*)) PDFTOOLS_VENV ?= $(HOME)/.local/venvs/pdftools # LANG is also the standard POSIX locale env var. Make inherits env vars into # its variable namespace, so $(LANG) would silently expand to "en_US.UTF-8" (or # similar) and bypass the lang picker. Blank it unless set on the command line # or in this file. ifeq ($(origin LANG),environment) LANG := endif # Pick target project — use PROJECT= or interactive fzf over local .git dirs. # Defined inline in each recipe (not via $(shell)) so fzf only runs when needed. define pick_project_shell P="$(PROJECT)"; \ if [ -z "$$P" ]; then \ if ! command -v fzf >/dev/null 2>&1; then \ echo "ERROR: PROJECT= not set and fzf is not installed" >&2; \ exit 1; \ fi; \ P=$$(find $$HOME -maxdepth 4 -name .git -type d 2>/dev/null \ | sed 's|/\.git$$||' | sort \ | fzf --prompt="Target project> " 2>/dev/null); \ test -n "$$P" || { echo "No target selected." >&2; exit 1; }; \ fi endef # Pick language bundle — use LANG= or interactive fzf over languages/. define pick_lang_shell L="$(LANG)"; \ if [ -z "$$L" ]; then \ if ! command -v fzf >/dev/null 2>&1; then \ echo "ERROR: LANG= not set and fzf is not installed" >&2; \ exit 1; \ fi; \ L=$$(printf '%s\n' $(LANGUAGES) | fzf --prompt="Language> " 2>/dev/null); \ test -n "$$L" || { echo "No language selected." >&2; exit 1; }; \ fi endef define pick_team_shell T="$(TEAM)"; \ if [ -z "$$T" ]; then \ if ! command -v fzf >/dev/null 2>&1; then \ echo "ERROR: TEAM= not set and fzf is not installed" >&2; \ exit 1; \ fi; \ T=$$(printf '%s\n' $(TEAMS) | fzf --prompt="Team> " 2>/dev/null); \ test -n "$$T" || { echo "No team selected." >&2; exit 1; }; \ fi endef # Cross-platform package install helper (brew/apt/pacman) define install_pkg $(if $(shell command -v brew 2>/dev/null),brew install $(1),\ $(if $(shell command -v apt-get 2>/dev/null),sudo apt-get install -y $(1),\ $(if $(shell command -v pacman 2>/dev/null),sudo pacman -S --noconfirm $(1),\ $(error No supported package manager found (brew/apt-get/pacman))))) endef .PHONY: help install uninstall remove list install-hooks uninstall-hooks \ install-lang install-elisp install-python list-languages \ install-mcp diff lint doctor test deps ##@ General help: ## Show this help @awk 'BEGIN {FS = ":.*##"; printf "\nrulesets — Claude Code skills, rules, and language bundles\n\nUsage: make \033[36m\033[0m [PROJECT=] [LANG=] [FORCE=1]\n"} \ /^[a-zA-Z_-]+:.*##/ { printf " \033[36m%-20s\033[0m %s\n", $$1, $$2 } \ /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) }' $(MAKEFILE_LIST) @printf "\nAvailable languages: %s\n" "$(LANGUAGES)" ##@ Dependencies deps: ## Install required tools (claude, node, jq, fzf, ripgrep, emacs, playwright, pdftools) @echo "Checking dependencies..." @command -v claude >/dev/null 2>&1 && echo " claude: installed" || \ { echo " claude: installing via npm..."; npm install -g @anthropic-ai/claude-code; } @command -v node >/dev/null 2>&1 && echo " node: installed ($$(node --version))" || \ { echo " node: installing..."; $(call install_pkg,nodejs); } @command -v npm >/dev/null 2>&1 && echo " npm: installed" || \ { echo " npm: installing..."; $(call install_pkg,npm); } @command -v jq >/dev/null 2>&1 && echo " jq: installed" || \ { echo " jq: installing..."; $(call install_pkg,jq); } @command -v fzf >/dev/null 2>&1 && echo " fzf: installed" || \ { echo " fzf: installing..."; $(call install_pkg,fzf); } @command -v rg >/dev/null 2>&1 && echo " ripgrep: installed" || \ { echo " ripgrep: installing..."; $(call install_pkg,ripgrep); } @command -v emacs >/dev/null 2>&1 && echo " emacs: installed" || \ { echo " emacs: installing..."; $(call install_pkg,emacs); } @command -v uv >/dev/null 2>&1 && echo " uv: installed ($$(uv --version | awk '{print $$NF}'))" || \ { echo " uv: installing..."; $(call install_pkg,uv); } @# Pre-warm script-level Python deps (PEP 723 inline metadata in scripts/). @# First-time invocations otherwise download their deps on demand; warming @# the cache here keeps interactive use snappy. @command -v uv >/dev/null 2>&1 \ && uv run --quiet --with textstat python -c "" >/dev/null 2>&1 \ && echo " textstat: cached (scripts/readability)" \ || echo " textstat: skipped (uv missing or offline)" @# pdftools: poppler (pdftoppm for coordinate-finding) plus a venv with @# pypdf/reportlab/pillow for overlay/stamp edits on flattened PDFs — @# the use case is court forms and the like with no AcroForm fields. @command -v pdftoppm >/dev/null 2>&1 && echo " poppler: installed" || { \ echo " poppler: installing..."; \ if command -v brew >/dev/null 2>&1; then brew install poppler; \ elif command -v apt-get >/dev/null 2>&1; then sudo apt-get install -y poppler-utils; \ elif command -v pacman >/dev/null 2>&1; then sudo pacman -S --noconfirm poppler; \ else echo " poppler: ERROR — no supported package manager (brew/apt-get/pacman)"; exit 1; \ fi; \ } @if [ -d "$(PDFTOOLS_VENV)" ]; then \ echo " pdftools: installed ($(PDFTOOLS_VENV))"; \ else \ echo " pdftools: creating venv + installing pypdf/reportlab/pillow..."; \ python3 -m venv "$(PDFTOOLS_VENV)"; \ "$(PDFTOOLS_VENV)/bin/pip" install --quiet --upgrade pip pypdf reportlab pillow; \ fi @if [ -d "$(CURDIR)/playwright-js" ]; then \ if [ -d "$(CURDIR)/playwright-js/node_modules/playwright" ]; then \ echo " playwright (js): installed (skill node_modules present)"; \ else \ echo " playwright (js): running skill setup (npm install + chromium download ~300 MB)..."; \ (cd "$(CURDIR)/playwright-js" && npm run setup); \ fi \ else \ echo " playwright (js): skipped (playwright-js/ not present)"; \ fi @if [ -d "$(CURDIR)/playwright-py" ]; then \ if command -v playwright >/dev/null 2>&1; then \ echo " playwright (py): CLI installed ($$(playwright --version 2>&1 | head -1))"; \ elif command -v uv >/dev/null 2>&1; then \ echo " playwright (py): installing via uv tool (isolated venv)..."; \ uv tool install playwright; \ echo " (Chromium already cached by playwright-js step; no re-download.)"; \ else \ echo " playwright (py): skipped — install uv, then re-run 'make deps'."; \ fi; \ echo " Per-project library import: add 'playwright' to your project's venv"; \ echo " (e.g. 'uv add playwright' or 'pip install playwright' inside .venv)."; \ else \ echo " playwright (py): skipped (playwright-py/ not present)"; \ fi @echo "Done." ##@ Fresh-machine bootstrap bootstrap: ## First-time machine setup: install + install-hooks + install-mcp @$(MAKE) install @echo "" @$(MAKE) install-hooks @echo "" @$(MAKE) install-mcp ##@ Global install (symlinks into ~/.claude/) install: ## Symlink skills, rules, config, hooks, and bin scripts into place @mkdir -p $(SKILLS_DIR) $(RULES_DIR) $(HOOKS_DIR) @echo "Skills:" @for skill in $(SKILLS); do \ if [ -L "$(SKILLS_DIR)/$$skill" ]; then \ echo " skip $$skill (already linked)"; \ elif [ -e "$(SKILLS_DIR)/$$skill" ]; then \ echo " WARN $$skill exists and is not a symlink — skipping"; \ else \ ln -s "$(CURDIR)/$$skill" "$(SKILLS_DIR)/$$skill"; \ echo " link $$skill → $(SKILLS_DIR)/$$skill"; \ fi \ done @echo "" @echo "Rules:" @for rule in $(RULES); do \ name=$$(basename $$rule); \ if [ -L "$(RULES_DIR)/$$name" ]; then \ echo " skip $$name (already linked)"; \ elif [ -e "$(RULES_DIR)/$$name" ]; then \ echo " WARN $$name exists and is not a symlink — skipping"; \ else \ ln -s "$(CURDIR)/$$rule" "$(RULES_DIR)/$$name"; \ echo " link $$name → $(RULES_DIR)/$$name"; \ fi \ done @echo "" @echo "Claude config:" @for f in $(CLAUDE_CONFIG); do \ name=$$(basename $$f); \ if [ -L "$(CLAUDE_DIR)/$$name" ]; then \ echo " skip $$name (already linked)"; \ elif [ -e "$(CLAUDE_DIR)/$$name" ]; then \ echo " WARN $$name exists and is not a symlink — skipping"; \ else \ ln -s "$(CURDIR)/$$f" "$(CLAUDE_DIR)/$$name"; \ echo " link $$name → $(CLAUDE_DIR)/$$name"; \ fi \ done @if [ -d ".claude/commands" ]; then \ if [ -L "$(CLAUDE_DIR)/commands" ]; then \ echo " skip commands (already linked)"; \ elif [ -e "$(CLAUDE_DIR)/commands" ]; then \ echo " WARN commands exists and is not a symlink — skipping"; \ else \ ln -s "$(CURDIR)/.claude/commands" "$(CLAUDE_DIR)/commands"; \ echo " link commands → $(CLAUDE_DIR)/commands"; \ fi \ fi @echo "" @echo "Hooks (default):" @for hook in $(DEFAULT_HOOKS); do \ name=$$(basename $$hook); \ if [ -L "$(HOOKS_DIR)/$$name" ]; then \ echo " skip $$name (already linked)"; \ elif [ -e "$(HOOKS_DIR)/$$name" ]; then \ echo " WARN $$name exists and is not a symlink — skipping"; \ else \ ln -s "$(CURDIR)/$$hook" "$(HOOKS_DIR)/$$name"; \ echo " link $$name → $(HOOKS_DIR)/$$name"; \ fi \ done @echo "" @echo "bin scripts:" @mkdir -p "$(LOCAL_BIN)" @for src in $(CURDIR)/claude-templates/bin/*; do \ [ -f "$$src" ] || continue; \ name=$$(basename "$$src"); \ chmod +x "$$src"; \ if [ -L "$(LOCAL_BIN)/$$name" ]; then \ target=$$(readlink "$(LOCAL_BIN)/$$name"); \ if [ "$$target" = "$$src" ]; then \ echo " skip $$name (already linked)"; \ else \ rm "$(LOCAL_BIN)/$$name"; \ ln -s "$$src" "$(LOCAL_BIN)/$$name"; \ echo " relink $$name → $$src (was: $$target)"; \ fi \ elif [ -e "$(LOCAL_BIN)/$$name" ]; then \ echo " WARN $$name exists and is not a symlink — skipping"; \ else \ ln -s "$$src" "$(LOCAL_BIN)/$$name"; \ echo " link $$name → $(LOCAL_BIN)/$$name"; \ fi \ done @echo "" @echo "done" uninstall: ## Remove global symlinks from ~/.claude/ @echo "Skills:" @for skill in $(SKILLS); do \ if [ -L "$(SKILLS_DIR)/$$skill" ]; then \ rm "$(SKILLS_DIR)/$$skill"; \ echo " rm $$skill"; \ else \ echo " skip $$skill (not a symlink)"; \ fi \ done @echo "" @echo "Rules:" @for rule in $(RULES); do \ name=$$(basename $$rule); \ if [ -L "$(RULES_DIR)/$$name" ]; then \ rm "$(RULES_DIR)/$$name"; \ echo " rm $$name"; \ else \ echo " skip $$name (not a symlink)"; \ fi \ done @echo "" @echo "Claude config:" @for f in $(CLAUDE_CONFIG); do \ name=$$(basename $$f); \ if [ -L "$(CLAUDE_DIR)/$$name" ]; then \ rm "$(CLAUDE_DIR)/$$name"; \ echo " rm $$name"; \ else \ echo " skip $$name (not a symlink)"; \ fi \ done @if [ -L "$(CLAUDE_DIR)/commands" ]; then \ rm "$(CLAUDE_DIR)/commands"; \ echo " rm commands"; \ else \ echo " skip commands (not a symlink)"; \ fi @echo "" @echo "ai launcher:" @if [ -L "$(LOCAL_BIN)/ai" ]; then \ rm "$(LOCAL_BIN)/ai"; \ echo " rm ai"; \ else \ echo " skip ai (not a symlink)"; \ fi @echo "" @echo "done" remove: ## Interactively pick installed rulesets entries to remove (fzf) @bash scripts/remove.sh list: ## Show global install status @echo "Skills:" @for skill in $(SKILLS); do \ if [ -L "$(SKILLS_DIR)/$$skill" ]; then \ echo " ✓ $$skill (installed)"; \ else \ echo " - $$skill"; \ fi \ done @echo "" @echo "Rules:" @for rule in $(RULES); do \ name=$$(basename $$rule); \ if [ -L "$(RULES_DIR)/$$name" ]; then \ echo " ✓ $$name (installed)"; \ else \ echo " - $$name"; \ fi \ done @echo "" @echo "Hooks:" @for hook in $(HOOKS); do \ name=$$(basename $$hook); \ if [ -L "$(HOOKS_DIR)/$$name" ]; then \ echo " ✓ $$name (installed)"; \ else \ echo " - $$name"; \ fi \ done @echo "" @echo "Claude config:" @for f in $(CLAUDE_CONFIG); do \ name=$$(basename $$f); \ if [ -L "$(CLAUDE_DIR)/$$name" ]; then \ echo " ✓ $$name (installed)"; \ else \ echo " - $$name"; \ fi \ done @if [ -L "$(CLAUDE_DIR)/commands" ]; then \ echo " ✓ commands (installed)"; \ else \ echo " - commands"; \ fi install-hooks: ## Symlink default hooks into ~/.claude/hooks/ + print settings.json snippet (opt-in hooks excluded) @mkdir -p $(HOOKS_DIR) @echo "Hooks (default):" @for hook in $(DEFAULT_HOOKS); do \ name=$$(basename $$hook); \ if [ -L "$(HOOKS_DIR)/$$name" ]; then \ echo " skip $$name (already linked)"; \ elif [ -e "$(HOOKS_DIR)/$$name" ]; then \ echo " WARN $$name exists and is not a symlink — skipping"; \ else \ ln -s "$(CURDIR)/$$hook" "$(HOOKS_DIR)/$$name"; \ echo " link $$name → $(HOOKS_DIR)/$$name"; \ fi \ done @if [ -n "$(strip $(OPTIN_HOOKS))" ]; then \ echo ""; \ echo "Opt-in hooks (not installed by default — link manually if you want them):"; \ for hook in $(OPTIN_HOOKS); do \ name=$$(basename $$hook); \ echo " - $$name"; \ echo " ln -s $(CURDIR)/$$hook $(HOOKS_DIR)/$$name"; \ done; \ fi @echo "" @echo "Merge this into ~/.claude/settings.json (preserve any existing hooks arrays):" @echo "" @cat hooks/settings-snippet.json @echo "" @echo "After merging, reload Claude Code (open /hooks menu once, or restart the session)." uninstall-hooks: ## Remove global hook symlinks from ~/.claude/hooks/ @for hook in $(HOOKS); do \ name=$$(basename $$hook); \ if [ -L "$(HOOKS_DIR)/$$name" ]; then \ rm "$(HOOKS_DIR)/$$name"; \ echo " rm $$name"; \ else \ echo " skip $$name (not a symlink)"; \ fi \ done @echo "" @echo "Note: this does NOT edit ~/.claude/settings.json — remove the hook entries manually." ##@ Per-project language bundles list-languages: ## List available language bundles @echo "Available language rulesets (languages/):" @for lang in $(LANGUAGES); do echo " - $$lang"; done install-lang: ## Install language ruleset ([LANG=] [PROJECT=] [FORCE=1]) @$(pick_lang_shell); \ $(pick_project_shell); \ bash scripts/install-lang.sh "$$L" "$$P" "$(FORCE)" install-elisp: ## Install Elisp bundle ([PROJECT=] [FORCE=1]) @$(MAKE) install-lang LANG=elisp PROJECT="$(PROJECT)" FORCE="$(FORCE)" install-python: ## Install Python bundle ([PROJECT=] [FORCE=1]) @$(MAKE) install-lang LANG=python PROJECT="$(PROJECT)" FORCE="$(FORCE)" list-teams: ## List available team overlays @echo "Available team overlays (teams/):" @for team in $(TEAMS); do echo " - $$team"; done install-team: ## Install a team publishing overlay into one project ([TEAM=] [PROJECT=]) @$(pick_team_shell); \ $(pick_project_shell); \ bash scripts/install-team.sh "$$T" "$$P" ##@ MCP servers (user scope) install-mcp: ## Decrypt mcp/secrets.env.gpg and register MCP servers at user scope (idempotent) @python3 mcp/install.py uninstall-mcp: ## Remove every server listed in mcp/servers.json from `claude mcp list` (idempotent) @python3 mcp/install.py --uninstall check-mcp: ## Dry-run drift report of mcp/servers.json vs registered MCP servers @python3 mcp/install.py --check ##@ Compare & validate diff: ## Show drift between installed ruleset and repo source ([LANG=] [PROJECT=]) @$(pick_lang_shell); \ $(pick_project_shell); \ bash scripts/diff-lang.sh "$$L" "$$P" lint: ## Validate ruleset structure (headings, Applies-to, shebangs, exec bits) @bash scripts/lint.sh doctor: ## Verify ~/.claude/ live state matches repo + settings.json (drift detector) @bash scripts/doctor.sh audit: ## Verify project .ai/ dirs against canonical ([APPLY=1] [FORCE=1] [NO_DOCTOR=1]) @bash scripts/audit.sh \ $(if $(APPLY),--apply) \ $(if $(FORCE),--force) \ $(if $(NO_DOCTOR),--no-doctor) sync-check: ## Verify claude-templates/.ai/ canonical matches .ai/ mirror ([FIX=1]) @bash scripts/sync-check.sh $(if $(FIX),--fix) status: ## Compact health summary (audit + doctor + sync + todo + inbox + git) @bash scripts/status.sh install-githooks: ## Point this repo's core.hooksPath at githooks/ (idempotent — enables pre-commit sync-check) @git config core.hooksPath githooks @echo "core.hooksPath=githooks (pre-commit will run scripts/sync-check.sh)" install-ai: ## Bootstrap .ai/ in a fresh project ([PROJECT=] [TRACK=1 | GITIGNORE=1]) @bash scripts/install-ai.sh \ $(if $(TRACK),--track) \ $(if $(GITIGNORE),--gitignore) \ $(PROJECT) catchup-machine: ## Pull rulesets, refresh install, sync .ai/ across projects, verify doctor @bash scripts/catchup-machine.sh test: ## Run all test suites (pytest + ERT + bats) @cd .ai/scripts/tests && python3 -m pytest @cd hooks/tests && python3 -m pytest @set -e; for d in languages/*/tests; do \ ls "$$d"/test_*.py >/dev/null 2>&1 || continue; \ echo "pytest: $$d"; \ ( cd "$$d" && python3 -m pytest -q ); \ done @if command -v go >/dev/null 2>&1; then \ set -e; for d in languages/*/tests; do \ ls "$$d"/*_test.go >/dev/null 2>&1 || continue; \ echo "go test: $$d"; \ ( cd "$$d" && go test ./... ); \ done; \ else \ echo "go test: skipped (go not installed)"; \ fi @if command -v node >/dev/null 2>&1; then \ set -e; for d in languages/*/tests; do \ ls "$$d"/*.test.js >/dev/null 2>&1 || continue; \ echo "node test: $$d"; \ ( cd "$$d" && node --test ); \ done; \ else \ echo "node test: skipped (node not installed)"; \ fi @set -e; for f in .ai/scripts/tests/test-*.el; do \ [ -e "$$f" ] || continue; \ echo "ert: $$(basename "$$f")"; \ emacs --batch -q -L .ai/scripts -l ert -l "$$f" -f ert-run-tests-batch-and-exit; \ done @set -e; for f in languages/*/tests/test-*.el; do \ [ -e "$$f" ] || continue; \ echo "ert: $$(basename "$$f")"; \ emacs --batch -q -l ert -l "$$f" -f ert-run-tests-batch-and-exit; \ done @set -e; for f in scripts/tests/*.bats .ai/scripts/tests/*.bats; do \ [ -e "$$f" ] || continue; \ echo "bats: $$(basename "$$f")"; \ bats "$$f"; \ done