Skip to content
72 changes: 39 additions & 33 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,19 @@ STOW_FLAGS := --no-folding -t "$$HOME"

.PHONY: help
help: ## show this help
@grep -E '^[a-z-]+:.*##' $(MAKEFILE_LIST) | sed 's/:.*##/\t/'

@# Two patterns: `##@ Group` opens a section, `## text` after a target
@# documents it. Both are parsed out of this file, so a target is described
@# where it is defined and a target with no `##` stays out of the list.
@# ponytail: escapes inline, no tty check — `make help | less` shows the
@# codes as text. Gate on `test -t 1` the day anyone pipes this.
@awk 'BEGIN {FS = ":.*##"; print "usage: make <target>\n"} \
/^##@/ { printf "\n\033[1m%s:\033[0m\n", substr($$0, 5); next } \
/^[a-zA-Z0-9_.-]+:.*##/ { printf " \033[36m%-22s\033[0m %s\n", $$1, $$2 }' $(MAKEFILE_LIST)

##@ Quality assurance
#
# QA — every target here is read-only: it must not touch the machine it runs
# on, because CI runs the lot on macOS and Ubuntu on every pull request.
# Every target here is read-only: it must not touch the machine it runs on,
# because CI runs the lot on macOS and Ubuntu on every pull request.
#

.PHONY: qa qa-deps lint bin-test
Expand Down Expand Up @@ -71,10 +79,23 @@ bin-test: ## run every *.test.sh — self-contained, no machine state touched
echo "== $$t"; bash "$$t" || exit 1; \
done

.PHONY: claude-settings-test
claude-settings-test: ## check the stowed Claude settings parse and stay machine-independent
@# It is one file for every machine, so a path that only exists on one is a
@# bug the other machine finds silently — the setting is simply ignored
@# there. $$HOME instead, and anything genuinely local (a client checkout, a
@# per-machine socket) goes in ~/.claude/settings.local.json, which is not
@# tracked. That file is also why this repo being public is survivable.
@python3 -c 'import json;json.load(open("shared/.claude/settings.json"))'
@grep -nE '"[^"]*/(Users|home)/' shared/.claude/settings.json \
&& { echo "^ only true on one machine - use \$$HOME, or move it to ~/.claude/settings.local.json"; exit 1; } \
|| true

##@ Security
#
# Security — separate from qa because it is the same answer on every OS, so CI
# runs it once instead of once per matrix leg, and because it needs Docker,
# which qa deliberately does not.
# Separate from qa because it is the same answer on every OS, so CI runs it once
# instead of once per matrix leg, and because it needs Docker, which qa
# deliberately does not.
#

# Pinned: an unpinned scanner turns someone else's rule release into a red build
Expand Down Expand Up @@ -154,10 +175,11 @@ semgrep-mirror: ## copy a semgrep version into my GHCR (once per version bump)
@# There is no bash or shell pack in the registry (p/bash and p/shell both
@# 404), so `make lint` remains what checks the scripts themselves.

##@ Apps and packages
#
# Apps and packages — the Brewfile on every machine, plus whatever the distro
# has to supply itself. Called `apps` rather than `brew` because Homebrew is
# only most of it: the GUI half on Linux comes from vendor apt repos.
# The Brewfile on every machine, plus whatever the distro has to supply itself.
# Called `apps` rather than `brew` because Homebrew is only most of it: the GUI
# half on Linux comes from vendor apt repos.
#

.PHONY: apps apps-test
Expand All @@ -184,21 +206,7 @@ apps-test: ## parse the Brewfile without installing anything
@command -v brew >/dev/null || { echo "brew not installed - skipped"; exit 0; }; \
HOMEBREW_NO_AUTO_UPDATE=1 brew bundle list --file Brewfile >/dev/null

.PHONY: claude-settings-test
claude-settings-test: ## check the stowed Claude settings parse and stay machine-independent
@# It is one file for every machine, so a path that only exists on one is a
@# bug the other machine finds silently — the setting is simply ignored
@# there. $$HOME instead, and anything genuinely local (a client checkout, a
@# per-machine socket) goes in ~/.claude/settings.local.json, which is not
@# tracked. That file is also why this repo being public is survivable.
@python3 -c 'import json;json.load(open("shared/.claude/settings.json"))'
@grep -nE '"[^"]*/(Users|home)/' shared/.claude/settings.json \
&& { echo "^ only true on one machine - use \$$HOME, or move it to ~/.claude/settings.local.json"; exit 1; } \
|| true

#
# Dotfiles ($HOME) via GNU stow
#
##@ Dotfiles ($HOME) via GNU stow

.PHONY: stow restow unstow stow-backup stow-test
stow-backup:
Expand Down Expand Up @@ -256,9 +264,7 @@ stow-test: ## stow and unstow every package in the repo into a throwaway $HOME
@t=$$(mktemp -d); trap 'rm -rf "$$t"' EXIT; HOME=$$t $(MAKE) -s stow unstow
@# The conflict-backup half is bin/stow/backup.test.sh, which bin-test runs.

#
# Shell
#
##@ Shell

.PHONY: shell
shell: ## source this repo's .bashrc fragment from ~/.bashrc (idempotent)
Expand All @@ -272,8 +278,9 @@ shell: ## source this repo's .bashrc fragment from ~/.bashrc (idempotent)
&& echo "NOTE: an older copy of the fragment is still pasted into ~/.bashrc - delete that block, it shadows the repo" \
|| true

##@ JetBrains IDEs
#
# JetBrains IDEs — see bin/jetbrains/README.md for why this is a script, not stow
# See bin/jetbrains/README.md for why this is a script, not stow
#

.PHONY: jetbrains
Expand All @@ -284,8 +291,9 @@ jetbrains: ## set the JVM options this repo owns in every JetBrains config dir
@echo
@echo "plugins: open $(CURDIR) in the IDE and accept the 'required plugins' prompt"

##@ macOS System Settings
#
# macOS System Settings — the panes stow cannot reach, see bin/macos/defaults.sh
# The panes stow cannot reach, see bin/macos/defaults.sh
#

.PHONY: macos macos-touchid
Expand Down Expand Up @@ -314,9 +322,7 @@ macos-touchid: ## authenticate sudo with Touch ID (root-owned, so its own target
cat "$$f"; \
sudo -k; echo "now run any sudo command - it should ask for a fingerprint"

#
# GIT config
#
##@ GIT config

.PHONY: git git-config-test git-config-format
git: ## set up this machine: hook in .gitconfig, set email, set up commit signing
Expand Down
5 changes: 3 additions & 2 deletions shared/.claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ Every rule lives in [`rules/`](rules/), one file each. This file is the index.
A rule there loads unconditionally unless it declares `paths:` frontmatter,
which scopes it to the files it is about. A rule earns that scoping only when
its trigger is a file path *and* breaking it shows up in a diff — GitHub Actions
is the only one so far. The rest override a default I would otherwise fall back
to, so they have to be in context before the mistake, not after.
and Makefiles are the two so far. The rest override a default I would otherwise
fall back to, so they have to be in context before the mistake, not after.

| Rule | Applies when |
|------|--------------|
Expand All @@ -14,6 +14,7 @@ to, so they have to be in context before the mistake, not after.
| [Commit messages](rules/commit-messages.md) | writing a commit |
| [Git history](rules/git-history.md) | amend, squash, rebase, force-push |
| [GitHub Actions](rules/github-actions.md) | writing under `.github/` — scoped, loads itself |
| [Makefiles](rules/makefiles.md) | writing a `Makefile` — scoped, loads itself |
| [Voice](rules/voice.md) | background, not a rule: the plugin that reads answers aloud |

The folder name is not a preference: `rules/` and `CLAUDE.md` are the only two
Expand Down
57 changes: 57 additions & 0 deletions shared/.claude/rules/makefiles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
---
paths:
- "**/Makefile"
- "**/makefile"
- "**/GNUmakefile"
- "**/*.mk"
---

# Makefiles

Plain `make` must answer "what can I do here?", so every Makefile gets a `help`
target, and `help` is the **first target in the file** — Make's default goal is
the first target, which is what makes the bare `make` print it. Set
`.DEFAULT_GOAL := help` as well, not instead: it is what keeps the bare `make`
answering the day someone adds a target above `help` without noticing that
position was load-bearing.

`help` parses the Makefile rather than repeating it, so a target is described
where it is defined and the list cannot drift from the targets. Two markers:
`## text` at the end of a target line documents that target, `##@ Group` opens a
section. The colon after a heading is printed, not written — nine source lines
cannot each remember it.

.PHONY: help
help: ## show this help
@awk 'BEGIN {FS = ":.*##"; print "usage: make <target>\n"} \
/^##@/ { printf "\n\033[1m%s:\033[0m\n", substr($$0, 5); next } \
/^[a-zA-Z0-9_.-]+:.*##/ { printf " \033[36m%-22s\033[0m %s\n", $$1, $$2 }' $(MAKEFILE_LIST)

##@ Quality assurance
lint: ## parse every shell file without running it

Bold the heading, colour the target name: 30 targets in one column is the thing
the grouping was meant to fix, and the section titles have to win over the rows
under them. The escapes go inline in the format string rather than through
`tput`, and there is no `test -t 1` gate — piping `make help` into `less` shows
the codes as text, which is a cost worth one line of awk rather than five.

The parser is deliberately naive — it assumes `##@ ` with the space, one `##`
per line, and a target name inside 22 columns. Widen it when a line in the file
actually breaks it, not in advance.

Group targets by what they are *for*, not by what they call — checks, packages,
dotfiles, deploy — and keep the file in that order, because the section a target
sits under is the section it prints under. Spell the group name out: the heading
is read by someone who does not yet know what `QA` stands for here, which is the
whole reason they ran `make`.

Prefix a target with its group when the bare name would collide or read
ambiguously across groups. Four checks here each want the name `test`, so they
become `apps-test`, `stow-test`, `git-config-test` and `claude-settings-test`.
Prefix for that reason only: `stow`, `restow` and `unstow` are unambiguous as
they are.

A target with no `##` is deliberately unlisted — a prerequisite of another
target is not something to reach for by hand. Every target that is not a file is
`.PHONY`.
Loading