User-global Claude Code hooks, loaded in place as global-hooks@skills-dir through the ~/.claude/skills symlink

This repository sets up a Mac: its shell, its tools, and the agent configuration every project on it loads. Using from Other Repositories covers what another repository takes from it.
Homebrew is left to the user: install Homebrew, and have it install what the Brewfile lists, mise and gh among them, from a clone of this repository. Sign gh in before mise runs: mise installs most tools from GitHub, whose API limits requests without a token, and MISE_GITHUB_CREDENTIAL_COMMAND hands mise gh's token.
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
brew bundle
gh auth login
mise sets up the rest. mise refuses to parse a mise.toml from a directory it has not been told to trust, so trust this one first:
mise trust
MISE_GITHUB_CREDENTIAL_COMMAND="$(brew --prefix)/bin/gh auth token" mise bootstrap
The first run sets that variable by hand, since .zshenv exports it only once this run has had it source the shell fragments. Both name gh by absolute path, for the reason zsh_fragments/zshenv.sh gives beside its export.
On a Mac already set up, rerun brew bundle before mise bootstrap, which installs nothing the Brewfile lists, gh included.
mise bootstrap applies what the root mise.toml declares, and a rerun changes only what has drifted:
[dotfiles] symlinks the global mise config and file tasks, and the agent instructions below, into place, and adds the line that imports them to ~/.claude/CLAUDE.md.mise.toml pin.bootstrap task runs last, on every run, as the sections below describe: setup:dotfiles, rtk init, apm install, and the basic-memory MCP server.The global config, .config/mise/config.toml, holds the tools, settings and tasks that reach every repository, and .config/mise/tasks/ its file tasks; the root mise.toml pins the tools this repository's own tasks use.
A few more steps stay by hand, since each needs a secret, a sign-in or a path only this machine knows: rclone's remotes below, fnox keys under Secrets, and K-Boat's project registration.
mise bootstrap runs mise run setup:dotfiles, which does the following:
~/.dircolors and ~/.config/git/ignore, each overwritten with canonical content.~/.zshenv, ~/.zprofile and ~/.zshrc source this repository's shell fragments.min-release-age in ~/.npmrc.general.shell and boot.executable in ~/.config/pitchfork/config.toml..zshenv, .zprofile and .zshrcEach of the three files sources the fragment of the same name in zsh_fragments/ (zshenv.sh, zprofile.sh, zshrc.sh); the .zshenv and .zprofile fragments say, line by line, why each line is in that file. scripts/setup_dotfiles.sh appends the sourcing line, pointing at the main checkout, to a file that does not already name its fragment, so an edit to a fragment reaches the next shell without rerunning setup, and lines an installer appends to the files stay.
.zprofile.zprofile is read by login shells, and only after macOS's /etc/zprofile has run /usr/libexec/path_helper — so PATH set anywhere earlier is already demoted by then. See Homebrew discussion #1127.
.zshenv.zshenv is read by every shell, which is what HOMEBREW_PREFIX and the nomatch guard need.
RCLONE_PASSWORD_COMMAND, which reads rclone's config password from the login keychain, so a config encrypted with it opens without a prompt in any shell, an agent's included.setup:dotfiles stores a random password where the command reads it, and encrypts the config with it, before any remote exists if need be; a remote added later with rclone config is saved encrypted.setup:dotfiles stops on it.env -u RCLONE_PASSWORD_COMMAND rclone config encryption remove, which asks for that password; for a config copied from another Mac, the password command prints it there.exclude-newer) pick no package version published less than a day ago, as the script makes npm do through its user config (min-release-age); mise and pnpm 11 already wait a day by default..npmrc, so a project can set a longer one.uvx and uv tool, through shell functions: uv writes a user-wide window into each project's uv.lock, which then fails uv lock --check for anyone locking without it (astral-sh/uv#18775).[tool.uv] exclude-newer = "1 day" in its own pyproject.toml, which every checkout then shares.uv run --with <pkg> and a script's inline dependencies, so run a one-off through uvx --with <pkg> instead.npm install --min-release-age=0, or UV_EXCLUDE_NEWER=false uvx ….general.shell to /bin/zsh -c, whose non-interactive zsh still reads .zshenv. Under the default sh -c, a daemon that runs rclone stalls on the password prompt and fails.CLAUDE.md — this repository's own project instructions, loaded only for sessions working inside it..claude/INSTRUCTIONS.md — user-scoped principles (shareable), symlinked to ~/.claude/INSTRUCTIONS.md..claude/rules/ — path-scoped rules, loaded when Claude works with files matching each rule's paths.~/.claude/CLAUDE.md is a thin, machine-local entry point that imports the user-scoped parts. mise bootstrap links them, adds the @INSTRUCTIONS.md line to that file, leaving any other line in it, and runs rtk init -g --hook-only --auto-patch for the rtk hook that .claude/INSTRUCTIONS.md assumes. Hook-only, because the ~/.claude/RTK.md a plain rtk init -g writes and imports is regenerated on every run, and it tells the agent to treat condensed output as complete, against what .claude/INSTRUCTIONS.md says of rtk proxy.
Most skills live under .claude/skills/, managed by APM, co-located with the instructions and rules above. GitHub Copilot also reads .claude/skills/, so this single directory serves both the primary Claude setup and Copilot as a secondary client. apm.yml declares the packages and apm.lock.yaml pins the resolved commits and content hashes.
The target: claude field in apm.yml is deliberately the singular target: key, not the plural targets:. apm uninstall reads only the singular field, so with target: set it honors the pin and touches .claude/skills/ alone. A plural targets: reads as unset, which makes uninstall auto-detect on-disk targets (.github/, .cursor/, …) and mirror skills into a stray .agents/skills/.
mise bootstrap symlinks ~/.claude/skills to it. That symlink carries user-global hooks. .claude/skills/global-hooks/ holds a .claude-plugin/plugin.json, so Claude Code loads it in place as the skills-directory plugin global-hooks@skills-dir in every project, and its hooks/hooks.json stays out of the machine-local ~/.claude/settings.json. An edit to it takes effect after /reload-plugins or a restart. The same hooks/hooks.json names a function-hooks module under modules, hooks/review-loop.ts, which supports the review-loop skill by showing the round of the branch's unclosed record in the status line. The function-hooks API is early access and may change with a Claude Code update, so mise run pre-commit runs the module's tests with claude plugin test and type-checks it against the installed CLI's declarations (mise run ts:types); Biome lints and formats it (mise run qa:ts, mise run format-ts), in CI as well. The rtk hook stays in ~/.claude/settings.json, since rtk init writes it there.
Restore pinned skills from apm.lock.yaml, which mise bootstrap also does:
apm install
Add a new skill package (owner/repo for a single-skill repo, owner/repo/path/to/skill for a monorepo entry):
apm install owner/repo
Remove installed packages (also strips them from apm.yml and apm.lock.yaml):
mise run skills:remove <package> [more...]
Audit deployed files against the lockfile, plus integrity and hidden-character checks:
apm audit
Show packages whose upstream advanced past the pinned ref, then update:
apm outdated
apm install --update
Some skills are published as a single-file GitHub gist holding a SKILL.md. APM installs one from the gist's git URL like any other package, and pins it in apm.lock.yaml. It deploys under a directory named after the gist hash unless the dependency carries an alias, so each is declared in apm.yml in the object form:
- git: https://gist.github.com/<owner>/<gist_id>.git
alias: <name>
Then run apm install.
The gist is the only durable copy of a skill's content, and apm install restores the pinned copy over a local edit, so push the edit back to the gist first:
mise run skills:push japanese-tech-writing
This commits the edit on top of the pinned revision in a temporary clone of the gist and pushes it with git, using gh's credential for that push alone. A gist that has moved past the pin rejects the push as non-fast-forward, which keeps the edit from dropping that revision. It then runs apm update on that skill, which moves the pin to the pushed commit, and fails if the pin lands anywhere else; until that apm.lock.yaml change reaches main, an apm install from main restores the old copy, and a push from it is refused.
A few skills are written here rather than pulled from an upstream, and this repository is their only copy. .gitignore excludes all of .claude/skills/*, which is what keeps APM output out of version control. A hand-written skill therefore needs one line to unignore it:
!/.claude/skills/<name>/
Re-including the directory is enough — the exclusion above uses a single *, which does not cross /, so it never matched the contents in the first place.
The only stdio MCP server in use is basic-memory, already configured in Claude Desktop and Claude Code. Claude Desktop's DXT extensions and remote connectors are managed in-app, not from this directory.
mise bootstrap registers basic-memory in Claude Code's user scope when it is not there yet:
claude mcp add-json -s user basic-memory '{"command":"uvx","args":["basic-memory","mcp"]}'
Another client is wired by hand.
K-Boat is a skill package that reads sources through NotebookLM and matures them into a concept graph. It owns the writing side; this repository only reads that graph, through the repo-tracked ask-kboat skill, which answers a question from the concept notes and keeps what they say distinct from general knowledge.
K-Boat stores that graph as a Basic Memory *project* — a name bound to a directory of Markdown notes — and the ask-kboat skill requires that project to be registered. Setting up the MCP server above is not enough on its own because project registration is per-machine local state that no clone carries.
basic-memory project add k-boat-knowledge <KBOAT_KNOWLEDGE_PATH>
basic-memory project list
The sections above set this Mac up; the two below are for work inside another repository. Secrets works only on a Mac set up from here, since its task comes with the global mise config, while the shared mise tasks come in through an include and run wherever mise does.
API keys stay out of every file a repository keeps, .env and mise's [env] included, so those hold only settings anyone may read, an agent included. fnox, which the global mise config installs, keeps each key in the macOS login keychain and hands it to one command at a time.
mise run secrets:add <NAME>
fnox.local.toml at the main checkout's root, which the global git ignore that mise run setup:dotfiles writes keeps out of every repository, and has fnox add the key's entry there.CLAUDE.local.md, likewise ignored, with a line telling an agent there to run what needs a key through fnox; if the file already exists without that line, the task prints it for you to place instead.fnox exec -- <command>, which puts it in that command's environment alone; fnox activate would export it to everything run in the directory.fnox.local.toml of its own. Claude Code puts worktrees under the main checkout's .claude/worktrees/, where fnox finds the main checkout's by searching upward; a worktree placed elsewhere does not..claude/rules/secrets.md tells an agent the same when it opens .env, mise config or fnox config in any repository.
tasks/ holds the repository-agnostic tasks this repository lends to others:
This repository runs the first three itself, the same way a consumer would, from the pre-commit and pre-push hooks that mise install sets up. tasks/README.md is where a repository taking them starts, and where the constraints on writing another are stated.
hooks/review-loop.ts 72 lines1import type { EngineInterface, Register } from "claude-code";
2import { readRecord } from "./review-loop-record";
3
4// Supports the review-loop skill (review-loop/SKILL.md): shows the state of the
5// loop's record on the current branch in the status line. It never writes the
6// record.
7
8const RECORD_DIR = "/.claude/review-loop/";
9
10// Where the skill keeps the current branch's record.
11const recordPath = async ($: EngineInterface): Promise<string | undefined> => {
12 const home = await $.env.get("HOME");
13 const git = await $.process.run(
14 [
15 "git",
16 "rev-parse",
17 "--path-format=absolute",
18 "--git-common-dir",
19 "--abbrev-ref",
20 "HEAD",
21 ],
22 { timeoutMs: 5000 },
23 );
24 // A failed git prints nothing, or on an unborn branch names a record that is not there.
25 const [commonDir, branch] = git.stdout.trim().split("\n");
26 if (home === undefined || !commonDir || !branch) return undefined;
27
28 return `${home}${RECORD_DIR}${commonDir.replaceAll("/", "-")}/${branch}.md`;
29};
30
31const statusText = async ($: EngineInterface): Promise<string | undefined> => {
32 const path = await recordPath($);
33 const text = path === undefined ? undefined : await $.fs.read(path);
34 const open = typeof text === "string" ? readRecord(text) : undefined;
35 if (open === undefined) return undefined;
36
37 return `review-loop: ${open.round > 0 ? `round ${open.round}` : "no round yet"}`;
38};
39
40// Any failure on the way, a missing record included, shows no status.
41const showStatus = async ($: EngineInterface): Promise<void> =>
42 $.ui.status(await statusText($).catch(() => undefined));
43
44export const register: Register = (on) => {
45 on("session.start", async ($, e, next) => {
46 const started = await next(e);
47 await showStatus($);
48
49 return started;
50 });
51
52 on("turn.start", async ($, e, next) => {
53 await showStatus($);
54
55 return next(e);
56 });
57
58 on("turn.complete", async ($, e, next) => {
59 await showStatus($);
60
61 return next(e);
62 });
63
64 // The loop writes its record mid-turn, so reread it as the write lands.
65 on("tool.call", { tool: ["Edit", "Write"] }, async ($, e, next) => {
66 const ran = await next(e);
67 if (e.file_path.includes(RECORD_DIR)) await showStatus($);
68
69 return ran;
70 });
71};
72hooks/review-loop-record.ts 49 lines1// Reads a review-loop record (review-loop/SKILL.md, "The record"). A model keeps
2// the file as prose, so this parses loosely and answers undefined, which shows
3// no status, wherever it cannot tell.
4
5export type OpenRecord = {
6 // The highest round a verdict group names; 0 before the first one.
7 round: number;
8};
9
10const CLOSED_HEADING = /^##\s+Closed\b/;
11// Records also end on a bare sentence in place of the heading.
12const CLOSED_SENTENCE = /^Closed\b/;
13const VERDICTS = /^##\s+Verdicts\b/;
14// "### Round 3 — held", "## Round 4 (endgame)", "**Round 2.**", and a group of
15// several: "### Rounds 1-6 — ...", "### Rounds 9 and 10", "### Rounds 1 to 3, ...".
16// A group counts as its last round; after the singular a second number is a
17// count, as in "### Round 2 - 3 findings".
18const ROUND =
19 /^(?:#{2,4}\s+|\*\*)Round(s?)\s+(\d+(?:\s*(?:[-–,]|and|to)\s*\d+)*)/;
20
21const isClosed = (lines: readonly string[]): boolean => {
22 const last = lines.at(-1) ?? "";
23 if (CLOSED_HEADING.test(last) || CLOSED_SENTENCE.test(last)) return true;
24 // The closing commit, or a note made after the close, often follows the
25 // heading; only a round group after it is a reopened loop.
26 const closedAt = lines.findLastIndex((line) => CLOSED_HEADING.test(line));
27
28 return (
29 closedAt >= 0 && !lines.slice(closedAt + 1).some((line) => ROUND.test(line))
30 );
31};
32
33export const readRecord = (text: string): OpenRecord | undefined => {
34 const lines = text.split("\n").filter((line) => line.trim() !== "");
35 const verdictsAt = lines.findIndex((line) => VERDICTS.test(line));
36 if (verdictsAt < 0 || isClosed(lines)) return undefined;
37
38 const rounds = lines.slice(verdictsAt + 1).flatMap((line) => {
39 const [, plural, list] = ROUND.exec(line) ?? [];
40 const numbers = list?.match(/\d+/g) ?? [];
41
42 return numbers.length > 0
43 ? [Number(plural ? numbers.at(-1) : numbers[0])]
44 : [];
45 });
46
47 return { round: Math.max(0, ...rounds) };
48};
49