#!/usr/bin/env bash # KeiSeiKit — Constructor-Pattern Agent Kit installer # Idempotent: safe to re-run. Never overwrites settings.json or existing user manifests. 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 for arg in "$@"; do case "$arg" in --activate-hooks) ACTIVATE_HOOKS=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" } # 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 # --- create target dirs ----------------------------------------------------- say "creating directories" mkdir -p \ "$AGENTS_DIR/_blocks" \ "$AGENTS_DIR/_manifests" \ "$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 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) --------------------- say "copying hooks -> $HOOKS_DIR/" backup_dir "$HOOKS_DIR" for h in assemble-agents.sh assemble-validate.sh no-hand-edit-agents.sh; do 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 # --- done ----------------------------------------------------------------- echo say "install complete" echo if [ "$DID_ACTIVATE" = "1" ]; then cat <