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:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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 "$@"
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user