diff options
Diffstat (limited to 'Makefile')
| -rw-r--r-- | Makefile | 402 |
1 files changed, 372 insertions, 30 deletions
@@ -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 |
