aboutsummaryrefslogtreecommitdiff
path: root/Makefile
diff options
context:
space:
mode:
Diffstat (limited to 'Makefile')
-rw-r--r--Makefile402
1 files changed, 372 insertions, 30 deletions
diff --git a/Makefile b/Makefile
index e097cc2..38d5151 100644
--- a/Makefile
+++ b/Makefile
@@ -1,39 +1,381 @@
.DEFAULT_GOAL := help
+SHELL := /bin/bash
-PREFIX ?= $(HOME)/.local/bin
-SRC := $(abspath $(dir $(lastword $(MAKEFILE_LIST))))
-
-.PHONY: help install uninstall list test-scripts
-
-help:
- @printf 'claude-templates — session launcher + project seed\n\n'
- @printf 'Targets:\n'
- @printf ' make install symlink bin/ai into %s\n' "$(PREFIX)"
- @printf ' make uninstall remove the symlink\n'
- @printf ' make list show installed symlink\n'
- @printf ' make test-scripts run pytest + ERT suites under .ai/scripts/tests/\n'
-
-install:
- @mkdir -p $(PREFIX)
- @chmod +x $(SRC)/bin/ai
- @ln -sfn $(SRC)/bin/ai $(PREFIX)/ai
- @printf 'installed %s/ai -> %s/bin/ai\n' "$(PREFIX)" "$(SRC)"
-
-uninstall:
- @if [ -L $(PREFIX)/ai ]; then \
- rm $(PREFIX)/ai; \
- printf 'removed %s/ai\n' "$(PREFIX)"; \
+SKILLS_DIR := $(HOME)/.claude/skills
+RULES_DIR := $(HOME)/.claude/rules
+HOOKS_DIR := $(HOME)/.claude/hooks
+CLAUDE_DIR := $(HOME)/.claude
+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)
+LANGUAGES := $(notdir $(wildcard languages/*))
+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=<path> 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=<language> 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
+
+# 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 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<target>\033[0m [PROJECT=<path>] [LANG=<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 \
- printf '%s/ai is not a symlink; nothing to remove\n' "$(PREFIX)"; \
+ 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 and rules into ~/.claude/
+ @mkdir -p $(SKILLS_DIR) $(RULES_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 "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 "done"
+
+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=<lang>] [PROJECT=<path>] [FORCE=1])
+ @$(pick_lang_shell); \
+ $(pick_project_shell); \
+ bash scripts/install-lang.sh "$$L" "$$P" "$(FORCE)"
+
+install-elisp: ## Install Elisp bundle ([PROJECT=<path>] [FORCE=1])
+ @$(MAKE) install-lang LANG=elisp PROJECT="$(PROJECT)" FORCE="$(FORCE)"
+
+install-python: ## Install Python bundle ([PROJECT=<path>] [FORCE=1])
+ @$(MAKE) install-lang LANG=python PROJECT="$(PROJECT)" FORCE="$(FORCE)"
+
+##@ MCP servers (user scope)
+
+install-mcp: ## Decrypt mcp/secrets.env.gpg and register MCP servers at user scope (idempotent)
+ @python3 mcp/install.py
+
+##@ Compare & validate
+
+diff: ## Show drift between installed ruleset and repo source ([LANG=<lang>] [PROJECT=<path>])
+ @$(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
-list:
- @ls -la $(PREFIX)/ai 2>/dev/null || printf 'not installed\n'
+doctor: ## Verify ~/.claude/ live state matches repo + settings.json (drift detector)
+ @bash scripts/doctor.sh
-test-scripts:
- @cd $(SRC)/.ai/scripts/tests && python -m pytest
- @set -e; for f in $(SRC)/.ai/scripts/tests/test-*.el; do \
+test: ## Run the .ai/scripts/ test suites (pytest + ERT)
+ @cd .ai/scripts/tests && python3 -m pytest
+ @set -e; for f in .ai/scripts/tests/test-*.el; do \
[ -e "$$f" ] || continue; \
echo "ert: $$(basename "$$f")"; \
- emacs --batch -q -L $(SRC)/.ai/scripts -l ert -l "$$f" -f ert-run-tests-batch-and-exit; \
+ emacs --batch -q -L .ai/scripts -l ert -l "$$f" -f ert-run-tests-batch-and-exit; \
done