#!/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 # Guard rm -rf: only remove $orig if it actually exists as a file or # directory. Harmless either way, but explicit is safer than brittle. if [ -d "$orig" ] || [ -f "$orig" ]; then rm -rf "$orig" fi 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 path: back up the pre-merge settings.json so rollback can restore # it if a later step ERR-traps. The "create new" path above exits before # reaching here, so backup_file is only invoked when $target exists. backup_file "$target" # 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 # Soft-warn on playwright — frontend primitives (design-scrape, live-preview, # mock-render) need the Playwright browser driver. Not used by the core fleet. if ! command -v playwright >/dev/null 2>&1 && ! command -v npx >/dev/null 2>&1; then warn "playwright/npx not found — frontend primitives (design-scrape, live-preview, mock-render) will fail. Install: npm i -g playwright && playwright install chromium" fi # Soft-warn on sqlite3 CLI — kei-ledger / kei-migrate embed rusqlite, so the # CLI is optional. Only surfaced so users can manually inspect the ledger DB. if ! command -v sqlite3 >/dev/null 2>&1; then warn "sqlite3 CLI not found — kei-ledger/kei-migrate work without it (rusqlite embedded). Install if you want manual DB inspection: brew install sqlite" 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) ------------- # Shell primitives live at _primitives/*.sh, Rust primitives under # _primitives/_rust/ (Cargo workspace). The Rust workspace is copied wholesale # but the compile artefacts (target/) are excluded — we rebuild locally. 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 if [[ -d "$KIT_DIR/_primitives/_rust" ]]; then say " copying Rust primitive workspace (excluding target/)" mkdir -p "$AGENTS_DIR/_primitives/_rust" # Copy workspace manifest + each crate source, skip target/ cp -f "$KIT_DIR/_primitives/_rust/Cargo.toml" "$AGENTS_DIR/_primitives/_rust/" if [[ -f "$KIT_DIR/_primitives/_rust/Cargo.lock" ]]; then cp -f "$KIT_DIR/_primitives/_rust/Cargo.lock" "$AGENTS_DIR/_primitives/_rust/" fi for crate_dir in "$KIT_DIR/_primitives/_rust/"*/; do [ -d "$crate_dir" ] || continue crate_name="$(basename "$crate_dir")" [ "$crate_name" = "target" ] && continue mkdir -p "$AGENTS_DIR/_primitives/_rust/$crate_name" # Copy Cargo.toml + src/ + tests/ (if present) cp -f "$crate_dir/Cargo.toml" "$AGENTS_DIR/_primitives/_rust/$crate_name/" 2>/dev/null || true if [[ -d "$crate_dir/src" ]]; then mkdir -p "$AGENTS_DIR/_primitives/_rust/$crate_name/src" cp -rf "$crate_dir/src/"* "$AGENTS_DIR/_primitives/_rust/$crate_name/src/" 2>/dev/null || true fi if [[ -d "$crate_dir/tests" ]]; then mkdir -p "$AGENTS_DIR/_primitives/_rust/$crate_name/tests" cp -rf "$crate_dir/tests/"* "$AGENTS_DIR/_primitives/_rust/$crate_name/tests/" 2>/dev/null || true fi done fi 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. # Discover hooks dynamically from $KIT_DIR/hooks/*.sh so new hooks land # automatically without editing install.sh. say "copying hooks -> $HOOKS_DIR/" hook_count=0 for hook_src in "$KIT_DIR/hooks/"*.sh; do [ -f "$hook_src" ] || continue h="$(basename "$hook_src")" backup_file "$HOOKS_DIR/$h" cp -f "$hook_src" "$HOOKS_DIR/$h" chmod +x "$HOOKS_DIR/$h" hook_count=$((hook_count+1)) done say " installed $hook_count hook(s)" # --- 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 # --- build Rust primitives workspace (8 crates) ---------------------------- # Offline-first like the assembler. Failure here is non-fatal: the fleet and # shell primitives still work without the 8 Rust binaries. if [[ -d "$AGENTS_DIR/_primitives/_rust" && -f "$AGENTS_DIR/_primitives/_rust/Cargo.toml" ]]; then say "building Rust primitive workspace (8 crates, cargo build --release)" if ! ( cd "$AGENTS_DIR/_primitives/_rust" && cargo build --workspace --release --offline ) 2>/tmp/keiseikit-primitives-offline.log; then say " offline build failed — fetching deps from crates.io" if ! ( cd "$AGENTS_DIR/_primitives/_rust" && cargo build --workspace --release ); then warn "Rust primitive workspace build failed; fleet still functional without binaries" warn " see log: /tmp/keiseikit-primitives-offline.log" fi fi # Report which binaries built successfully. built=0 for bin in kei-ledger kei-migrate kei-changelog ssh-check firewall-diff mock-render visual-diff tokens-sync; do if [[ -x "$AGENTS_DIR/_primitives/_rust/target/release/$bin" ]]; then built=$((built+1)) fi done say " $built / 8 Rust primitive binaries available" 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 a prior step ERR-trapped into rollback(), we MUST NOT keep writing into # $PWD — the install is now aborted, and bridges should not land as # collateral on a failed run. rollback() sets ROLLED_BACK=1 before returning. if [ "${ROLLED_BACK:-0}" = "1" ]; then exit 2 fi 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 <