#!/usr/bin/env bash # KeiSeiKit — Constructor-Pattern Agent Kit installer # Idempotent: safe to re-run. Never overwrites settings.json or existing user manifests. # # Usage: # ./install.sh # install agents + hooks + skills + bridges/ # ./install.sh --with-bridges # also render cross-tool bridges into $PWD # (AGENTS.md, .cursorrules, .cursor/rules/main.mdc, # .github/copilot-instructions.md, Windsurf, Junie, # Continue, Gemini, Aider, Replit — 11 files total) # Skipped if $PWD is the KeiSeiKit repo itself. set -euo pipefail KIT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" HOME_DIR="${HOME:?HOME not set}" AGENTS_DIR="$HOME_DIR/.claude/agents" HOOKS_DIR="$HOME_DIR/.claude/hooks" SKILLS_DIR="$HOME_DIR/.claude/skills" # --- flag parsing ---------------------------------------------------------- ACTIVATE_HOOKS=0 WITH_BRIDGES=0 for arg in "$@"; do case "$arg" in --activate-hooks) ACTIVATE_HOOKS=1 ;; --with-bridges) WITH_BRIDGES=1 ;; --help|-h) cat <&2; } else say() { printf '[install] %s\n' "$*"; } warn() { printf '[warn] %s\n' "$*"; } err() { printf '[error] %s\n' "$*" >&2; } fi # --- rollback bookkeeping --------------------------------------------------- # Every successful backup_dir / per-file backup appends a "ORIGINAL|BACKUP" # pair to BACKUP_PAIRS. On ERR the trap walks the list in reverse and atomically # swaps BACKUP back onto ORIGINAL. A boolean guard makes rollback idempotent. BACKUP_PAIRS=() ROLLED_BACK=0 rollback() { [ "$ROLLED_BACK" = "1" ] && return 0 ROLLED_BACK=1 if [ "${#BACKUP_PAIRS[@]}" -eq 0 ]; then err "install failed at line ${BASH_LINENO[0]:-?}; no backups to restore" return 0 fi warn "install failed — rolling back ${#BACKUP_PAIRS[@]} backup(s)" local i pair orig bak for (( i=${#BACKUP_PAIRS[@]}-1; i>=0; i-- )); do pair="${BACKUP_PAIRS[$i]}" orig="${pair%%|*}" bak="${pair#*|}" if [ -e "$bak" ]; then rm -rf "$orig" mv "$bak" "$orig" say " restored $orig from $bak" fi done err "install failed at line ${BASH_LINENO[0]:-?}; rolled back" } trap rollback ERR # Backup a populated target directory to a timestamped sibling before clobber. # No-op if target is absent or contains no regular files (recursively). This # means freshly-mkdir'd scaffolds are NOT backed up — only real user content. # Only called on $AGENTS_DIR/_blocks, _templates, _assembler, $SKILLS_DIR — # never on $KIT_DIR source. (hooks are now per-file; see backup_file.) backup_dir() { local target="$1" [ -d "$target" ] || return 0 # No regular files anywhere under target → nothing worth preserving if [ -z "$(find "$target" -type f -print -quit 2>/dev/null)" ]; then return 0 fi local backup="${target}.bak-$(date +%s)" cp -a "$target" "$backup" BACKUP_PAIRS+=("$target|$backup") say "backed up existing $target to $backup" } # Per-file backup for shared directories like $HOOKS_DIR, where other kits # may drop sibling files we must not touch. Only the specific file is moved # aside to .bak-TIMESTAMP. backup_file() { local target="$1" [ -f "$target" ] || return 0 local backup="${target}.bak-$(date +%s)" mv "$target" "$backup" BACKUP_PAIRS+=("$target|$backup") say "backed up existing $target to $backup" } # Activate KeiSeiKit hooks by merging settings-snippet.json into the user's # settings.json. Idempotent: # - If settings.json is absent, copy snippet verbatim (minus _comment key). # - If present, concatenate the snippet's PostToolUse / PreToolUse entries # onto existing arrays, then de-dupe by the nested hooks[].command field # so re-runs do not stack duplicate entries. # - .hooks itself (the root object key) is merged with `*` — snippet wins on # scalar keys, arrays are unioned then de-duped. # Requires jq (already checked earlier in prerequisites). Writes atomically # via a tmpfile in the same dir. activate_hooks() { local snippet="$KIT_DIR/settings-snippet.json" local target="$HOME_DIR/.claude/settings.json" local tmp [ -f "$snippet" ] || { warn "no snippet at $snippet"; return 0; } if [ ! -f "$target" ]; then # Strip _comment, keep the rest. Create atomically. tmp="$(mktemp "$target.XXXXXX")" jq 'del(._comment)' "$snippet" > "$tmp" mv "$tmp" "$target" say "created $target from snippet (no prior settings.json)" return 0 fi # Merge: walk each matcher-group in PostToolUse / PreToolUse, append hooks, # unique_by command. jq filter is written for readability, not golf. tmp="$(mktemp "$target.XXXXXX")" jq --slurpfile snip "$snippet" ' . as $orig | ($snip[0] | del(._comment)) as $add | reduce ($add.hooks | keys[]) as $phase ($orig; .hooks[$phase] = ( ((.hooks[$phase] // []) + ($add.hooks[$phase] // [])) | group_by(.matcher) | map({ matcher: .[0].matcher, hooks: (map(.hooks // []) | add | unique_by(.command)) }) ) ) ' "$target" > "$tmp" # Only replace if jq produced non-empty valid JSON if [ -s "$tmp" ] && jq -e . "$tmp" >/dev/null 2>&1; then mv "$tmp" "$target" say "merged hooks into $target (idempotent)" else rm -f "$tmp" err "jq-merge produced invalid output; $target unchanged" return 1 fi } # --- prerequisites ---------------------------------------------------------- say "checking prerequisites" if ! command -v cargo >/dev/null 2>&1; then err "cargo not found. Install Rust: https://rustup.rs/" exit 1 fi # Verify cargo actually runs (catches "rustup has no default toolchain") if ! cargo --version >/dev/null 2>&1; then err "cargo is installed but not functional. Run: rustup default stable" exit 1 fi if ! command -v jq >/dev/null 2>&1; then err "jq not found. jq is REQUIRED on any machine that will activate the" err "KeiSeiKit hooks — without it the hooks become dead weight and would" err "otherwise abort Claude Code's Edit/Write/Bash tool calls. Install it:" err " brew install jq (macOS)" err " apt install jq (Debian/Ubuntu)" exit 1 fi # Soft-warn on pandoc — the tomd primitive works without it for CSV / code / # JSON / images, but fails on .docx / .pptx / .html. Opt-in use, so not # promoted to a hard-fail. if ! command -v pandoc >/dev/null 2>&1; then warn "pandoc not found — tomd primitive will fail on .docx/.pptx. Install: brew install pandoc" fi # --- create target dirs ----------------------------------------------------- say "creating directories" mkdir -p \ "$AGENTS_DIR/_blocks" \ "$AGENTS_DIR/_manifests" \ "$AGENTS_DIR/_primitives" \ "$AGENTS_DIR/_templates" \ "$AGENTS_DIR/_assembler/src" \ "$AGENTS_DIR/_generated" \ "$HOOKS_DIR" \ "$SKILLS_DIR/new-agent" \ "$HOME_DIR/.claude/memory" # --- scaffold MEMORY.md placeholder (user-respecting) ---------------------- # _blocks/memory-protocol.md references ~/.claude/memory/MEMORY.md; without # this file the first agent following the protocol fails on read. MEMORY_INDEX="$HOME_DIR/.claude/memory/MEMORY.md" if [[ ! -f "$MEMORY_INDEX" ]]; then cat > "$MEMORY_INDEX" <<'EOF' # Auto Memory — Index > File-based memory index. Add entries as you save memory files under this directory. > See `_blocks/memory-protocol.md` for format. EOF say "scaffolded $MEMORY_INDEX" fi # --- copy blocks (overwrite ours; blocks are SSoT from kit) ---------------- say "copying shared blocks -> $AGENTS_DIR/_blocks/" backup_dir "$AGENTS_DIR/_blocks" cp -f "$KIT_DIR/_blocks/"*.md "$AGENTS_DIR/_blocks/" # --- copy primitives (overwrite; primitives are SSoT from kit) ------------- if [[ -d "$KIT_DIR/_primitives" ]]; then say "copying primitives -> $AGENTS_DIR/_primitives/" backup_dir "$AGENTS_DIR/_primitives" cp -f "$KIT_DIR/_primitives/"*.sh "$AGENTS_DIR/_primitives/" 2>/dev/null || true cp -f "$KIT_DIR/_primitives/README.md" "$AGENTS_DIR/_primitives/" 2>/dev/null || true chmod +x "$AGENTS_DIR/_primitives/"*.sh 2>/dev/null || true fi # --- copy bridges (overwrite; templates are SSoT from kit) ----------------- if [[ -d "$KIT_DIR/_bridges" ]]; then say "copying bridge templates -> $AGENTS_DIR/_bridges/" mkdir -p "$AGENTS_DIR/_bridges" backup_dir "$AGENTS_DIR/_bridges" cp -f "$KIT_DIR/_bridges/"*.tmpl "$AGENTS_DIR/_bridges/" cp -f "$KIT_DIR/_bridges/README.md" "$AGENTS_DIR/_bridges/" cp -f "$KIT_DIR/_bridges/emit.sh" "$AGENTS_DIR/_bridges/emit.sh" chmod +x "$AGENTS_DIR/_bridges/emit.sh" fi # --- copy generic manifests, DO NOT overwrite user's existing manifests ----- say "copying generic manifests -> $AGENTS_DIR/_manifests/ (skip if exists)" copied=0; skipped=0 for f in "$KIT_DIR/_manifests/"*.toml; do name="$(basename "$f")" if [[ -f "$AGENTS_DIR/_manifests/$name" ]]; then skipped=$((skipped+1)) else cp "$f" "$AGENTS_DIR/_manifests/$name" copied=$((copied+1)) fi done say " copied $copied, skipped $skipped (already present)" # --- copy template --------------------------------------------------------- # bash-3.2-portable glob detection: iterate, break on first hit. has_templates=0 for t in "$KIT_DIR/_templates/"*.template; do [ -f "$t" ] && { has_templates=1; break; } done if [ "$has_templates" = "1" ]; then say "copying specialist template" backup_dir "$AGENTS_DIR/_templates" cp -f "$KIT_DIR/_templates/"*.template "$AGENTS_DIR/_templates/" fi # --- copy assembler source (always refresh) -------------------------------- say "copying assembler source" backup_dir "$AGENTS_DIR/_assembler" cp -f "$KIT_DIR/_assembler/Cargo.toml" "$AGENTS_DIR/_assembler/" cp -f "$KIT_DIR/_assembler/src/"*.rs "$AGENTS_DIR/_assembler/src/" if [[ -f "$KIT_DIR/_assembler/.gitignore" ]]; then cp -f "$KIT_DIR/_assembler/.gitignore" "$AGENTS_DIR/_assembler/" fi # --- copy hooks (refresh; hooks are logic, not config) --------------------- # $HOOKS_DIR is shared with other kits — back up each KeiSeiKit-owned hook # individually rather than the whole directory, so foreign hooks are not # dragged into .bak-TIMESTAMP snapshots on every re-run. say "copying hooks -> $HOOKS_DIR/" for h in assemble-agents.sh assemble-validate.sh no-hand-edit-agents.sh tomd-preread.sh; do [ -f "$KIT_DIR/hooks/$h" ] || continue backup_file "$HOOKS_DIR/$h" cp -f "$KIT_DIR/hooks/$h" "$HOOKS_DIR/$h" chmod +x "$HOOKS_DIR/$h" done # --- copy skills ----------------------------------------------------------- if [[ -d "$KIT_DIR/skills" ]]; then say "copying skills" backup_dir "$SKILLS_DIR" for skill_dir in "$KIT_DIR/skills/"*/; do [ -d "$skill_dir" ] || continue skill_name="$(basename "$skill_dir")" mkdir -p "$SKILLS_DIR/$skill_name" cp -rf "$skill_dir"* "$SKILLS_DIR/$skill_name/" 2>/dev/null || true say " -> $skill_name" done fi # --- build assembler ------------------------------------------------------- # Prefer offline build (fresh-clone on a no-network machine should still work # if the registry cache is warm). Fall back to online fetch on failure. say "building Rust assembler (cargo build --release, offline first)" if ! ( cd "$AGENTS_DIR/_assembler" && cargo build --release --offline ) 2>/tmp/keiseikit-cargo-offline.log; then say "offline build failed — fetching deps from crates.io" ( cd "$AGENTS_DIR/_assembler" && cargo build --release ) fi if [[ ! -x "$AGENTS_DIR/_assembler/target/release/assemble" ]]; then err "build succeeded but binary not found at $AGENTS_DIR/_assembler/target/release/assemble" exit 2 fi # --- generate .md agents in-place ------------------------------------------ say "generating agent .md files (--in-place)" AGENT_ROOT="$AGENTS_DIR" "$AGENTS_DIR/_assembler/target/release/assemble" --in-place # --- activate hooks (flag, or interactive prompt on TTY) ------------------- SETTINGS_FILE="$HOME_DIR/.claude/settings.json" DID_ACTIVATE=0 if [ "$ACTIVATE_HOOKS" = "1" ]; then say "activating hooks (--activate-hooks)" activate_hooks && DID_ACTIVATE=1 elif [ ! -f "$SETTINGS_FILE" ]; then # No existing settings — merge is trivial, do it unconditionally. say "no existing settings.json; installing snippet" activate_hooks && DID_ACTIVATE=1 elif [ -t 0 ] && [ -t 1 ]; then if [ "$COLOR" = "1" ]; then printf '\033[1;36m[install]\033[0m activate hooks now? [y/N] ' else printf '[install] activate hooks now? [y/N] ' fi read -r reply case "$reply" in y|Y|yes|YES) activate_hooks && DID_ACTIVATE=1 ;; *) say "skipping hook activation" ;; esac fi # --- optional: render cross-tool bridges into $PWD ------------------------- if [[ "$WITH_BRIDGES" == "1" ]]; then if [[ -f "./install.sh" && -d "./_bridges" ]]; then warn "not generating bridges — you are in the KeiSeiKit repo, not a project directory" else say "rendering cross-tool bridges into $PWD" "$KIT_DIR/_bridges/emit.sh" "$PWD" fi fi # --- done ----------------------------------------------------------------- echo say "install complete" echo if [ "$DID_ACTIVATE" = "1" ]; then cat <