install.sh: clone anywhere, run one command

A manifest declares repo path -> target, and the script walks it. Symlinks, not
copies, so an installed file edits the repo and one commit reaches every other
machine. Idempotent — a correct link is a no-op, safe after every pull. A real
file where a link should go is moved to .bak-<timestamp> and reported, because
on a new machine that may be the only copy of something written by hand.

Conditional targets are what make it portable: officer.dev/CLAUDE.md and the
officer-chat-events hook both name ~/officer-dev/platform, so a laptop with no
Officer install gets neither instead of getting a document that lies.

mask-secret is a command now rather than a path only true on this machine, and
user/CLAUDE.md says so. It is a generated wrapper, not a symlink, and that is
forced: mask-secret.py resolves its own path to find mask-hooks.d, so a symlink
would reach back into the repo and run every machine's hooks. The wrapper sets
MASK_HOOKS_DIR to the ones this machine installed and still runs the script from
the repo, so no code is copied.

Verified against a scratch HOME: re-run is all ok, the hand-written file
survives as .bak, and a machine without Officer skips both conditional lines.
This commit is contained in:
2026-08-21 00:24:22 +00:00
parent 63ab090bb2
commit eec6a4962e
5 changed files with 196 additions and 37 deletions
+53
View File
@@ -1,3 +1,56 @@
# claude-mds # claude-mds
CLAUDE.md files and Claude Code configuration, kept under version control. CLAUDE.md files and Claude Code configuration, kept under version control.
## Install, on any machine
```sh
git clone git@gitea.officer.dev:pastilhas/claude-mds.git ~/projects/claude-mds
~/projects/claude-mds/install.sh
```
`--dry-run` first if you want to see it before it happens.
Everything is **symlinked, not copied** — so editing an installed file edits the repo, and the change
is one `git commit` away from every other machine. Re-run it after each `git pull`; a correct link is
a no-op.
It will not overwrite anything in silence. A real file where a link should go is moved to
`<name>.bak-<timestamp>` and reported, because on a new machine that file may be the only copy of
something written by hand.
## What lands where
`manifest` is the whole answer, and it is the only place paths are declared. Today:
| repo | target | only if |
| ---- | ------ | ------- |
| `user/CLAUDE.md` | `~/.claude/CLAUDE.md` | always |
| `officer.dev/CLAUDE.md` | `~/officer-dev/CLAUDE.md` | `~/officer-dev/platform` exists |
| `tools/mask-hooks.d/officer-chat-events` | `~/.local/claude-scripts/mask-hooks.d/` | `~/officer-dev/platform` exists |
Plus `mask-secret` on `~/.local/bin`, as a command rather than a path.
Adding a file later is one manifest line, not an edit to the shell.
## The two kinds of file
- **`user/`** — who you are working with and how. Loads in every session on every machine.
- **`officer.dev/`** — one workspace on one machine. Conditional, so it never lands where it would be
a lie.
## mask-secret
Masks a pasted secret everywhere the chat history keeps it:
```sh
printf '%s' "$SECRET" | mask-secret
```
It owns Claude Code's own transcripts. Anywhere else that keeps a second copy of the chat — a
database, a mirror, a log shipper — is a **hook**: an executable in `tools/mask-hooks.d/`, listed in
the manifest with the condition that says which machines it belongs on.
The installed `mask-secret` is a small generated wrapper, and that is deliberate. The script finds its
hooks beside itself and resolves symlinks to do it, so a bare symlink would reach back into the repo
and run *every* machine's hooks. The wrapper points it at the ones this machine installed.
+24 -36
View File
@@ -1,47 +1,35 @@
# TODO # TODO
## Bootstrap installer (next session — 2026-08-14) ## Bootstrap installer — BUILT 2026-08-20
**Goal:** clone this repo on any machine, run one command, and every file lands `install.sh` plus `manifest`. Clone the repo, run one command, every file lands where the tooling
where the tooling expects it. expects it. See README.md.
git clone <this repo> ~/projects/claude-mds && ~/projects/claude-mds/install.sh Everything the plan asked for is in: a manifest rather than paths in shell, symlinks rather than
copies, idempotent, conditional targets, backups instead of silent clobber, `--dry-run`.
### Shape Verified on this machine and against a scratch `HOME`: a re-run is all `ok`, a real file in the way is
moved to `.bak-<timestamp>` and survives, and a machine with no `~/officer-dev/platform` skips both
the workspace file and its hook.
- **A manifest, not a script full of paths.** Declare `repo path -> target path` **`mask-secret` is a command now**, on `~/.local/bin`, and `user/CLAUDE.md` names it instead of the
in one file and let `install.sh` walk it. Adding a doc later should mean one hardcoded path that was only ever true here. It is a generated wrapper, not a symlink, and that is
manifest line, not editing shell. forced: `mask-secret.py` finds hooks at `Path(__file__).resolve().parent/mask-hooks.d`, and `.resolve()`
- **Symlinks, not copies.** Same reason as here: two copies drift, and the point follows a symlink back into the repo — which carries every machine's hooks, not this one's. The
of the repo is that an edit lands in git. `ln -sfn`. wrapper sets `MASK_HOOKS_DIR` and still executes the script from the repo, so no code is copied.
- **Idempotent.** Safe to re-run after every `git pull`. Re-linking an existing
correct link is a no-op.
- **Conditional targets.** `user/CLAUDE.md` installs everywhere. `officer.dev/`
only where that workspace exists. A manifest entry needs a "only if this path
exists" condition.
- **Never clobber silently.** If the target is a real file and not our link,
back it up (`.bak-<date>`) and say so. That file may be the only copy of
something written by hand on that machine.
- **Dry run by default, or at least `--dry-run`.** Show what it would link.
### Tools are installed, not referenced in place — DECIDED ### Still open
`tools/` stays in the repo as the source, and the installer puts it under - **Bootstrapping the bootstrap.** The machine needs git, an SSH key for Gitea, and the repo URL
`~/.local/claude-scripts/` (confirm the name), with `mask-secret` linked onto before any of this runs. A one-line `curl | sh` would close it, and may be deliberately not worth
`~/.local/bin` so it is a command rather than a path. having.
- **`settings.json` and hooks.** Not managed here yet. The repo is named for the docs but already
That fixes a real bug: `user/CLAUDE.md` currently hardcodes carries `tools/`, so the line is already crossed; the question is whether per-machine settings can
`~/projects/claude-mds/tools/mask-secret.py`, which is only true on this be expressed as one file plus conditions, or whether they are genuinely per-machine.
machine. Once installed, the doc says `mask-secret` and is true everywhere. - **Which files are universal.** `officer.dev/` is the only machine-specific one so far. The
`platform-appstore/` set is a candidate and was never folded in.
Carry `mask-hooks.d/` across with it, keeping the executable bits. The hooks are - **PATH.** `install.sh` warns when `~/.local/bin` is missing from `PATH` but does not edit a shell
the machine-specific half, so the installer should place only the hooks that profile. Editing someone's `.zshrc` from an installer is a bigger intrusion than the warning.
belong on that machine — same "only if this path exists" condition as the docs.
`officer-chat-events` should not land on a laptop with no Officer install, even
though it already exits cleanly when it finds no database.
Update `user/CLAUDE.md` to name the command in the same change, or the doc and
the layout disagree the moment this ships.
### Prior art to discuss: akitaonrails/ai-memory ### Prior art to discuss: akitaonrails/ai-memory
Executable
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
#
# Put every file in this repo where the tooling expects it, on any machine.
#
# git clone git@gitea.officer.dev:pastilhas/claude-mds.git ~/projects/claude-mds
# ~/projects/claude-mds/install.sh
#
# Symlinks, never copies. Two copies drift, and the point of the repo is that an edit lands in git.
# Safe to re-run after every pull: a correct link is a no-op. A real file in the way is backed up,
# never overwritten in silence.
#
# --dry-run say what would happen, change nothing
#
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
MANIFEST="$REPO/manifest"
CLAUDE_SCRIPTS="${CLAUDE_SCRIPTS:-$HOME/.local/claude-scripts}"
HOOKS_DIR="$CLAUDE_SCRIPTS/mask-hooks.d"
BIN_DIR="${BIN_DIR:-$HOME/.local/bin}"
STAMP="$(date +%Y%m%d-%H%M%S)"
DRY=0
[[ ${1:-} == --dry-run ]] && DRY=1
say() { printf ' %-7s %s\n' "$1" "$2"; }
run() { (( DRY )) || "$@"; }
# Symlink $2 -> $1, refusing to destroy anything that is not already our link.
link() {
local src=$1 dst=$2
if [[ ! -e $src ]]; then say "MISSING" "$src — not in the repo"; return 1; fi
if [[ -L $dst && "$(readlink -f "$dst")" == "$(readlink -f "$src")" ]]; then
say "ok" "$dst"; return 0
fi
# A real file, or a link somewhere else. It may be the only copy of something hand-written here.
if [[ -e $dst || -L $dst ]]; then
say "backup" "$dst -> $dst.bak-$STAMP"
run mv "$dst" "$dst.bak-$STAMP"
fi
run mkdir -p "$(dirname "$dst")"
run ln -sfn "$src" "$dst"
say "link" "$dst"
}
# `mask-secret` as a COMMAND, not a path — so user/CLAUDE.md can name it and be true everywhere.
#
# A wrapper rather than a symlink, and that is forced: mask-secret.py finds its hooks at
# `Path(__file__).resolve().parent/mask-hooks.d`, and .resolve() follows a symlink back into the repo
# — which holds EVERY machine's hooks. The wrapper points MASK_HOOKS_DIR at the ones this machine
# actually installed, and still executes the script from the repo, so the code is never copied.
install_command() {
local script="$REPO/tools/mask-secret.py"
[[ -e $script ]] || { say "MISSING" "$script"; return 1; }
run mkdir -p "$BIN_DIR" "$HOOKS_DIR"
if (( ! DRY )); then
cat > "$BIN_DIR/mask-secret" <<WRAPPER
#!/bin/sh
# Generated by claude-mds/install.sh — edit install.sh, not this.
export MASK_HOOKS_DIR="$HOOKS_DIR"
exec "$script" "\$@"
WRAPPER
chmod +x "$BIN_DIR/mask-secret"
fi
say "cmd" "$BIN_DIR/mask-secret"
}
main() {
(( DRY )) && echo "dry run — nothing will change"
echo "repo: $REPO"
echo
local kind src dst cond
while read -r kind src dst cond || [[ -n ${kind:-} ]]; do
[[ -z ${kind:-} || $kind == \#* ]] && continue
# A `hook` line has no target column: its destination is derived, so the condition shifts left.
if [[ $kind == hook ]]; then cond=$dst; dst="$HOOKS_DIR/$(basename "$src")"; fi
dst=${dst/#\~/$HOME}
cond=${cond:-}; cond=${cond/#\~/$HOME}
if [[ -n $cond && ! -e $cond ]]; then
say "skip" "$dst — no $cond on this machine"
continue
fi
link "$REPO/$src" "$dst" || true
done < "$MANIFEST"
echo
install_command
case ":$PATH:" in
*":$BIN_DIR:"*) ;;
*) echo; echo " note $BIN_DIR is not on PATH — add it, or mask-secret is not a command yet" ;;
esac
}
main "$@"
+16
View File
@@ -0,0 +1,16 @@
# What goes where. One line per file; install.sh walks this and nothing else.
#
# kind source (in this repo) target condition
#
# kind link — symlink target -> source. The file lives in git; the edit lands in git.
# hook — a mask-secret hook. Target is derived: $CLAUDE_SCRIPTS/mask-hooks.d/<name>.
#
# condition — optional. A path that must exist on this machine, or the line is skipped.
# This is what keeps officer.dev's workspace file off a laptop that has no Officer.
#
# ~ means $HOME. Columns are whitespace-separated, so no spaces in paths.
link user/CLAUDE.md ~/.claude/CLAUDE.md
link officer.dev/CLAUDE.md ~/officer-dev/CLAUDE.md ~/officer-dev/platform
hook tools/mask-hooks.d/officer-chat-events ~/officer-dev/platform
+4 -1
View File
@@ -87,7 +87,7 @@ When he pastes a secret, mask it in the stored history as soon as you have
finished using it — same turn, unprompted. He reloads and sometimes shares whole finished using it — same turn, unprompted. He reloads and sometimes shares whole
chat histories, and that is the copy that matters to him. chat histories, and that is the copy that matters to him.
printf '%s' '<secret>' | ~/projects/claude-mds/tools/mask-secret.py printf '%s' '<secret>' | mask-secret
It masks Claude Code's own transcripts (`~/.claude/projects/**/*.jsonl`) with It masks Claude Code's own transcripts (`~/.claude/projects/**/*.jsonl`) with
asterisks of the same length, overwriting in place so the live session's appends asterisks of the same length, overwriting in place so the live session's appends
@@ -100,6 +100,9 @@ chat — a database, a mirror, a log shipper — declares it as a hook** in
machine-specific stores to this file; add a hook, and describe it in that machine-specific stores to this file; add a hook, and describe it in that
project's own `CLAUDE.md`. project's own `CLAUDE.md`.
`mask-secret` is a command because `install.sh` made it one. It is missing only
where this repo was never installed — see its README.
**Do not mask a throwaway.** He sometimes sets a real password aside, hands you **Do not mask a throwaway.** He sometimes sets a real password aside, hands you
a temporary one so you can drive an API — Nginx Proxy Manager, a router, a a temporary one so you can drive an API — Nginx Proxy Manager, a router, a
service admin — and reverts it afterwards. Leave those in the history. They are service admin — and reverts it afterwards. Leave those in the history. They are