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