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.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
## 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
where the tooling expects it.
`install.sh` plus `manifest`. Clone the repo, run one command, every file lands where the tooling
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`
in one file and let `install.sh` walk it. Adding a doc later should mean one
manifest line, not editing shell.
- **Symlinks, not copies.** Same reason as here: two copies drift, and the point
of the repo is that an edit lands in git. `ln -sfn`.
- **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.
**`mask-secret` is a command now**, on `~/.local/bin`, and `user/CLAUDE.md` names it instead of the
hardcoded path that was only ever true here. It is a generated wrapper, not a symlink, and that is
forced: `mask-secret.py` finds hooks at `Path(__file__).resolve().parent/mask-hooks.d`, and `.resolve()`
follows a symlink back into the repo — which carries every machine's hooks, not this one's. The
wrapper sets `MASK_HOOKS_DIR` and still executes the script from the repo, so no code is copied.
### Tools are installed, not referenced in place — DECIDED
### Still open
`tools/` stays in the repo as the source, and the installer puts it under
`~/.local/claude-scripts/` (confirm the name), with `mask-secret` linked onto
`~/.local/bin` so it is a command rather than a path.
That fixes a real bug: `user/CLAUDE.md` currently hardcodes
`~/projects/claude-mds/tools/mask-secret.py`, which is only true on this
machine. Once installed, the doc says `mask-secret` and is true everywhere.
Carry `mask-hooks.d/` across with it, keeping the executable bits. The hooks are
the machine-specific half, so the installer should place only the hooks that
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.
- **Bootstrapping the bootstrap.** The machine needs git, an SSH key for Gitea, and the repo URL
before any of this runs. A one-line `curl | sh` would close it, and may be deliberately not worth
having.
- **`settings.json` and hooks.** Not managed here yet. The repo is named for the docs but already
carries `tools/`, so the line is already crossed; the question is whether per-machine settings can
be expressed as one file plus conditions, or whether they are genuinely per-machine.
- **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.
- **PATH.** `install.sh` warns when `~/.local/bin` is missing from `PATH` but does not edit a shell
profile. Editing someone's `.zshrc` from an installer is a bigger intrusion than the warning.
### 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
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
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
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
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