diff --git a/README.md b/README.md index bea9ac9..83d30d0 100644 --- a/README.md +++ b/README.md @@ -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 +`.bak-` 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. diff --git a/TODO.md b/TODO.md index 1fe4cf7..85850f9 100644 --- a/TODO.md +++ b/TODO.md @@ -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 ~/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-` 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-`) 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 diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..11f2dea --- /dev/null +++ b/install.sh @@ -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" < source. The file lives in git; the edit lands in git. +# hook — a mask-secret hook. Target is derived: $CLAUDE_SCRIPTS/mask-hooks.d/. +# +# 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 diff --git a/user/CLAUDE.md b/user/CLAUDE.md index 1a45e06..73819cb 100644 --- a/user/CLAUDE.md +++ b/user/CLAUDE.md @@ -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' '' | ~/projects/claude-mds/tools/mask-secret.py + printf '%s' '' | 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