#!/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 # interactive menu on TTY; profile=minimal on non-TTY # ./install.sh --profile= # minimal|core|frontend|ops|dev|full (skips menu) # ./install.sh --add=[,] # install one or more primitives on top of current state # ./install.sh --remove= # remove a single primitive # ./install.sh --list # list installed primitives (name | kind | desc | path) # ./install.sh --with-bridges # also render cross-tool bridges into $PWD # ./install.sh --activate-hooks # jq-merge settings-snippet.json into ~/.claude/settings.json # ./install.sh --yes # skip confirm screen after menu (automation) # ./install.sh --no-execute # parse menu+confirm, print plan, exit (testing) 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" MANIFEST="$KIT_DIR/_primitives/MANIFEST.toml" INSTALLED_FILE="$AGENTS_DIR/_primitives/.installed" # --- flag parsing ---------------------------------------------------------- ACTIVATE_HOOKS=0 WITH_BRIDGES=0 PROFILE="" ADD_LIST="" REMOVE_NAME="" LIST_MODE=0 ASSUME_YES=0 NO_EXECUTE=0 for arg in "$@"; do case "$arg" in --activate-hooks) ACTIVATE_HOOKS=1 ;; --with-bridges) WITH_BRIDGES=1 ;; --profile=*) PROFILE="${arg#--profile=}" ;; --add=*) ADD_LIST="${arg#--add=}" ;; --remove=*) REMOVE_NAME="${arg#--remove=}" ;; --list) LIST_MODE=1 ;; --yes|-y) ASSUME_YES=1 ;; --no-execute) NO_EXECUTE=1 ;; --help|-h) cat < set installed-primitive set to one of: minimal (no primitives) core (tomd) frontend (8 site tools: mock-render / visual-diff / ...) ops (8 infra tools: kei-ledger / ssh-check / ...) dev (4 dev tools: kei-migrate / kei-changelog / ...) full (all 21 primitives) --add=[,,...] add one or more primitives on top of current install. Name must match [primitive.] in _primitives/MANIFEST.toml. --remove= remove a single primitive (shell file or rust crate dir + scoped workspace Cargo.toml regenerated + rebuilt). --list list installed primitives from .installed state file. --with-bridges render the 11 cross-tool bridge files into \$PWD (Cursor / Copilot / Codex / Windsurf / Junie / Continue / Aider / Replit / Antigravity / Warp / Zed). Skipped if invoked inside the KeiSeiKit repo itself. --activate-hooks jq-merge settings-snippet.json into ~/.claude/settings.json non-interactively. Without this flag, a TTY prompt asks at the end; non-TTY runs print manual instructions. --yes, -y skip the interactive confirm screen after the menu (for automation). If no --profile was given the menu still runs; --yes only auto-accepts the Install Plan. --no-execute run flag parsing + menu + confirm, print the resolved plan, then exit before copying/building anything. Useful for dry-run / testing. --help, -h this help. EOF exit 0 ;; esac done # ANSI on iff stdout is a TTY and NO_COLOR is unset (respect no-color.org). if [ -t 1 ] && [ "${NO_COLOR:-}" = "" ]; then COLOR=1 else COLOR=0 fi if [ "$COLOR" = "1" ]; then say() { printf '\033[1;36m[install]\033[0m %s\n' "$*"; } warn() { printf '\033[1;33m[warn]\033[0m %s\n' "$*"; } err() { printf '\033[1;31m[error]\033[0m %s\n' "$*" >&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 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_dir() { local target="$1" [ -d "$target" ] || return 0 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" } 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" } # --- MANIFEST.toml parsing -------------------------------------------------- # Tiny awk-based TOML reader. We only need two shapes: # 1. profile. = ["a", "b", ...] # 2. [primitive.] ... kind = "..." file = "..." crate = "..." deps = [...] desc = "..." # If a real TOML parser (python -c "import tomllib" or python -c "import toml") is # available, prefer it for robustness. Otherwise fall back to awk. have_python_toml() { if command -v python3 >/dev/null 2>&1; then python3 -c 'import tomllib' >/dev/null 2>&1 && return 0 python3 -c 'import toml' >/dev/null 2>&1 && return 0 fi return 1 } # Echo space-separated primitive names for a given profile. # Usage: profile_members profile_members() { local profile="$1" [ -f "$MANIFEST" ] || { err "MANIFEST.toml not found at $MANIFEST"; return 1; } if have_python_toml; then python3 - "$MANIFEST" "$profile" <<'PY' 2>/dev/null || return 1 import sys try: import tomllib mode = "rb" except ImportError: import toml as tomllib mode = "r" path, prof = sys.argv[1], sys.argv[2] with open(path, mode) as f: data = tomllib.load(f) if mode == "rb" else tomllib.load(f) members = data.get("profile", {}).get(prof) if members is None: sys.exit(2) print(" ".join(members)) PY else # awk fallback — only handles `profile. = [...]` on one line awk -v prof="$profile" ' /^\[profile\]/ { in_profile=1; next } /^\[/ && !/^\[profile\]/ { in_profile=0 } in_profile && $0 ~ "^[[:space:]]*" prof "[[:space:]]*=" { # extract between [ and ] line = $0 sub(/^[^\[]*\[/, "", line) sub(/\].*$/, "", line) gsub(/"/, "", line) gsub(/,/, " ", line) print line exit } ' "$MANIFEST" fi } # Echo a field of a primitive. Usage: primitive_field # field ∈ { kind, file, crate, desc } primitive_field() { local name="$1" field="$2" [ -f "$MANIFEST" ] || return 1 if have_python_toml; then python3 - "$MANIFEST" "$name" "$field" <<'PY' 2>/dev/null import sys try: import tomllib mode = "rb" except ImportError: import toml as tomllib mode = "r" path, name, field = sys.argv[1], sys.argv[2], sys.argv[3] with open(path, mode) as f: data = tomllib.load(f) if mode == "rb" else tomllib.load(f) p = data.get("primitive", {}).get(name) if p is None: sys.exit(2) v = p.get(field, "") if isinstance(v, list): print("; ".join(v)) else: print(v) PY else awk -v pname="$name" -v fname="$field" ' $0 ~ "^\\[primitive\\." pname "\\]" { in_p=1; next } /^\[/ && in_p { in_p=0 } in_p && $0 ~ "^[[:space:]]*" fname "[[:space:]]*=" { line = $0 sub(/^[^=]*=[[:space:]]*/, "", line) # strip surrounding quotes gsub(/^"/, "", line) gsub(/"$/, "", line) print line exit } ' "$MANIFEST" fi } # Echo all primitive names defined in MANIFEST. all_primitive_names() { [ -f "$MANIFEST" ] || return 1 awk ' /^\[primitive\./ { name = $0 sub(/^\[primitive\./, "", name) sub(/\]$/, "", name) print name } ' "$MANIFEST" } # --- interactive menu (option C hybrid) ------------------------------------ # whiptail > dialog > plain-text bash-select. Stdout contract: # - one-line output = profile name OR comma-separated custom primitive list # - empty stdout + exit 1 = user cancelled # Menu is ONLY triggered from the top-level flow: never from --add/--remove/--list. # Per-primitive rough estimates. Used by estimate_install + menu descriptions. # Shell primitives are ~1s / 5KB; rust primitives vary by dep weight. # Hardcoded here (not in MANIFEST) to keep manifest declarative + UI hints local. primitive_time_secs() { local name="$1" kind kind="$(primitive_field "$name" kind 2>/dev/null || true)" case "$kind" in shell) echo 1 ;; rust) case "$name" in mock-render|kei-migrate|kei-ledger) echo 20 ;; kei-changelog|firewall-diff) echo 15 ;; genesis-scan) echo 10 ;; visual-diff|tokens-sync|ssh-check) echo 5 ;; *) echo 10 ;; esac ;; *) echo 0 ;; esac } primitive_disk_kb() { local name="$1" kind kind="$(primitive_field "$name" kind 2>/dev/null || true)" case "$kind" in shell) echo 5 ;; rust) case "$name" in mock-render|kei-migrate|kei-ledger) echo 30000 ;; kei-changelog|firewall-diff) echo 10000 ;; genesis-scan) echo 6000 ;; visual-diff|tokens-sync|ssh-check) echo 5000 ;; *) echo 8000 ;; esac ;; *) echo 0 ;; esac } # menu_should_skip — return 0 if menu should be skipped, 1 if it should run. # Skip reasons: any selection flag was passed, or stdin is not a TTY. menu_should_skip() { [ -n "$PROFILE" ] && return 0 [ -n "$ADD_LIST" ] && return 0 [ -n "$REMOVE_NAME" ] && return 0 [ "$LIST_MODE" = "1" ] && return 0 [ ! -t 0 ] && return 0 [ ! -t 1 ] && return 0 return 1 } # whiptail radiolist → profile name. Exits 1 on cancel. menu_whiptail_profile() { local tool="$1" # whiptail or dialog "$tool" --title "KeiSeiKit Installer" --radiolist \ "Choose install profile (SPACE to select, ENTER to confirm):" 20 78 7 \ "minimal" "agents + hooks + skills + bridges (~5s)" ON \ "core" "+ tomd (~5s)" OFF \ "frontend" "+ 8 site tools (~60s, 80 MB)" OFF \ "ops" "+ 8 infra tools (~90s, 50 MB)" OFF \ "dev" "+ 4 dev tools (~60s, 40 MB)" OFF \ "full" "all 21 primitives (~5 min, 200 MB)" OFF \ "custom" "pick individual primitives" OFF \ 3>&1 1>&2 2>&3 } # whiptail checklist → comma-separated primitive names. Exits 1 on cancel. menu_whiptail_custom() { local tool="$1" local args=() name desc while IFS= read -r name; do [ -z "$name" ] && continue desc="$(primitive_field "$name" desc 2>/dev/null || echo '')" # truncate long descs so whiptail doesn't wrap awkwardly desc="${desc:0:48}" args+=("$name" "$desc" "OFF") done < <(all_primitive_names) local picked picked="$("$tool" --title "Custom — pick primitives" --checklist \ "SPACE to toggle, ENTER to confirm:" 24 78 16 \ "${args[@]}" 3>&1 1>&2 2>&3)" || return 1 # whiptail emits quoted names separated by spaces; normalize to csv echo "$picked" | tr -d '"' | tr ' ' ',' | sed 's/^,//;s/,$//' } # plain-text profile picker → profile name. Exits 1 on cancel. menu_plain_profile() { echo "================================" >&2 echo " KeiSeiKit Installer" >&2 echo "================================" >&2 echo >&2 echo "Choose install profile:" >&2 echo >&2 echo " 1) minimal — agents + hooks + skills + bridges only (~5s)" >&2 echo " 2) core — + tomd (~5s)" >&2 echo " 3) frontend — + 8 site tools (~60s, 80 MB)" >&2 echo " 4) ops — + 8 infra tools (~90s, 50 MB)" >&2 echo " 5) dev — + 4 dev tools (~60s, 40 MB)" >&2 echo " 6) full — all 21 primitives (~5 min, 200 MB)" >&2 echo " 7) custom — pick individual primitives" >&2 echo >&2 local reply printf 'Enter choice [1-7] (default 1): ' >&2 read -r reply || return 1 case "${reply:-1}" in 1) echo minimal ;; 2) echo core ;; 3) echo frontend ;; 4) echo ops ;; 5) echo dev ;; 6) echo full ;; 7) echo custom ;; *) err "invalid choice: $reply"; return 1 ;; esac } # Print the numbered primitive list to stderr (helper for plain custom picker). _print_primitive_list() { local -a names=("$@") local i desc echo >&2 echo "Select primitives (space-separated numbers, 'a' for all, 'n' for none):" >&2 echo >&2 for (( i=0; i<${#names[@]}; i++ )); do desc="$(primitive_field "${names[$i]}" desc 2>/dev/null || echo '')" printf " %2d) [ ] %-20s — %s\n" "$((i+1))" "${names[$i]}" "$desc" >&2 done echo >&2 } # plain-text custom picker → comma-separated primitive names. menu_plain_custom() { local -a names=() picked=() local name reply tok while IFS= read -r name; do [ -z "$name" ] && continue names+=("$name") done < <(all_primitive_names) _print_primitive_list "${names[@]}" printf 'Selection: ' >&2 read -r reply || return 1 case "$reply" in a|A|all) picked=("${names[@]}") ;; n|N|none|'') picked=() ;; *) for tok in $reply; do [[ "$tok" =~ ^[0-9]+$ ]] && (( tok >= 1 && tok <= ${#names[@]} )) \ && picked+=("${names[$((tok-1))]}") done ;; esac local IFS=,; echo "${picked[*]}" } # show_interactive_menu — master dispatcher. Echoes profile name OR csv list. show_interactive_menu() { local tool="" if command -v whiptail >/dev/null 2>&1; then tool="whiptail" elif command -v dialog >/dev/null 2>&1; then tool="dialog" fi local choice if [ -n "$tool" ]; then choice="$(menu_whiptail_profile "$tool")" || return 1 if [ "$choice" = "custom" ]; then menu_whiptail_custom "$tool" || return 1 else echo "$choice" fi else choice="$(menu_plain_profile)" || return 1 if [ "$choice" = "custom" ]; then menu_plain_custom else echo "$choice" fi fi } # --- install plan helpers -------------------------------------------------- # estimate_install — reads newline-separated primitive names from stdin, # prints "time_secs disk_kb" to stdout. estimate_install() { local total_secs=0 total_kb=0 name s d while IFS= read -r name; do [ -z "$name" ] && continue s="$(primitive_time_secs "$name")" d="$(primitive_disk_kb "$name")" total_secs=$(( total_secs + s )) total_kb=$(( total_kb + d )) done echo "$total_secs $total_kb" } # Consumers-of-tool — list primitives (from $2..$N) whose deps mention $1. _consumers_of() { local tool="$1"; shift local n deps_raw out="" for n in "$@"; do deps_raw="$(primitive_field "$n" deps 2>/dev/null || true)" echo "$deps_raw" | grep -qiE "(^|[^a-zA-Z])${tool}([^a-zA-Z]|$)" \ && out="${out}${n}," done echo "${out%,}" } # check_soft_deps — reads newline-separated primitive names from stdin, # prints one OK/MISS per unique soft-dep tool used by any listed primitive. check_soft_deps() { local names_nl names_nl="$(cat)" [ -z "$names_nl" ] && return 0 local -a tools=(jq pandoc playwright npx cargo hcloud vultr-cli yq sqlite3 curl) local -a names_arr=() local n tool consumers printed_header=0 while IFS= read -r n; do [ -n "$n" ] && names_arr+=("$n"); done <<< "$names_nl" for tool in "${tools[@]}"; do consumers="$(_consumers_of "$tool" "${names_arr[@]}")" [ -z "$consumers" ] && continue [ "$printed_header" = "0" ] && echo "Soft-dep status:" && printed_header=1 if command -v "$tool" >/dev/null 2>&1; then echo " [OK] $tool installed" else echo " [MISS] $tool missing (needed for: $consumers)" fi done } # print_plan_body — prints the "Install Plan" block for the given label + names. # Args: $1 = label, stdin = newline-separated primitive names. # Per-primitive row (helper for print_plan_body). Stdin: newline names. _print_primitive_rows() { local name kind extra while IFS= read -r name; do [ -z "$name" ] && continue kind="$(primitive_field "$name" kind 2>/dev/null || echo '?')" extra="$(primitive_time_secs "$name")s, $(( $(primitive_disk_kb "$name") / 1024 )) MB" printf ' + %-22s (%s, ~%s)\n' "$name" "$kind" "$extra" done } print_plan_body() { local profile_label="$1" local names total est_secs est_kb est_mb names="$(cat)" total="$(printf '%s\n' "$names" | grep -c . || true)" read -r est_secs est_kb <<< "$(printf '%s\n' "$names" | estimate_install)" est_mb=$(( est_kb / 1024 )) echo echo "================================" echo " Install Plan" echo "================================" echo echo "Profile: $profile_label" echo "Primitives: ${total:-0} to add" [ "${total:-0}" -gt 0 ] && printf '%s\n' "$names" | _print_primitive_rows echo printf '%s\n' "$names" | check_soft_deps || true echo printf 'Estimated time: ~%ss\n' "$est_secs" printf 'Estimated disk: ~%s MB\n' "$est_mb" echo CONFIRM_TOTAL="$total"; CONFIRM_SECS="$est_secs"; CONFIRM_MB="$est_mb" } # show_confirm_screen — prints plan body, then asks y/N (or whiptail --yesno). # Stdin: newline-separated primitive names. Returns 0=confirmed, 1=declined. show_confirm_screen() { local profile_label="$1" print_plan_body "$profile_label" [ "$ASSUME_YES" = "1" ] && { echo "(--yes: auto-confirming)"; return 0; } [ ! -t 0 ] && { echo "(non-TTY: auto-confirming)"; return 0; } if command -v whiptail >/dev/null 2>&1; then whiptail --yesno "Install ${CONFIRM_TOTAL:-0} primitive(s) for profile '$profile_label'?\n\nTime: ~${CONFIRM_SECS}s, disk: ~${CONFIRM_MB} MB" 14 70 return $? fi local reply printf 'Proceed? [Y/n]: ' read -r reply || return 1 case "${reply:-Y}" in y|Y|yes|YES|'') return 0 ;; *) return 1 ;; esac } # --- .installed state helpers --------------------------------------------- read_installed() { [ -f "$INSTALLED_FILE" ] && cat "$INSTALLED_FILE" || true } write_installed() { # stdin = newline-separated names; writes sorted-unique to INSTALLED_FILE. mkdir -p "$(dirname "$INSTALLED_FILE")" sort -u > "$INSTALLED_FILE" } # --- per-primitive install/remove ------------------------------------------ copy_shell_primitive() { local name="$1" local file file="$(primitive_field "$name" file)" [ -n "$file" ] || { err "no 'file' for shell primitive $name"; return 1; } local src="$KIT_DIR/_primitives/$file" local dst="$AGENTS_DIR/_primitives/$file" [ -f "$src" ] || { err "source missing: $src"; return 1; } mkdir -p "$AGENTS_DIR/_primitives" cp -f "$src" "$dst" chmod +x "$dst" say " + shell: $name ($file)" } remove_shell_primitive() { local name="$1" local file file="$(primitive_field "$name" file)" [ -n "$file" ] || return 0 rm -f "$AGENTS_DIR/_primitives/$file" say " - shell: $name ($file)" } copy_rust_primitive() { local name="$1" local crate crate="$(primitive_field "$name" crate)" [ -n "$crate" ] || { err "no 'crate' for rust primitive $name"; return 1; } local src="$KIT_DIR/_primitives/_rust/$crate" [ -d "$src" ] || { err "source missing: $src"; return 1; } local dst_root="$AGENTS_DIR/_primitives/_rust" local dst="$dst_root/$crate" mkdir -p "$dst/src" cp -f "$src/Cargo.toml" "$dst/Cargo.toml" [ -d "$src/src" ] && cp -rf "$src/src/"* "$dst/src/" 2>/dev/null || true if [ -d "$src/tests" ]; then mkdir -p "$dst/tests" cp -rf "$src/tests/"* "$dst/tests/" 2>/dev/null || true fi say " + rust: $name (crate $crate)" } remove_rust_primitive() { local name="$1" local crate crate="$(primitive_field "$name" crate)" [ -n "$crate" ] || return 0 rm -rf "$AGENTS_DIR/_primitives/_rust/$crate" say " - rust: $name (crate $crate)" } # Echo the list of rust crates currently installed (by scanning .installed + # cross-checking MANIFEST kind = "rust" + dir presence). installed_rust_crates() { local dst_root="$AGENTS_DIR/_primitives/_rust" local name kind crate while IFS= read -r name; do [ -z "$name" ] && continue kind="$(primitive_field "$name" kind)" [ "$kind" = "rust" ] || continue crate="$(primitive_field "$name" crate)" [ -n "$crate" ] && [ -d "$dst_root/$crate" ] && echo "$crate" done <<< "$(read_installed)" } # Write a scoped Cargo.toml listing only the given members (stdin: one per line). # The workspace.package / workspace.dependencies / profile.release blocks are # copied verbatim from the kit source so shared deps stay in sync. write_rust_workspace_manifest() { local dst_root="$AGENTS_DIR/_primitives/_rust" local src_wkspc="$KIT_DIR/_primitives/_rust/Cargo.toml" local tmp="$dst_root/Cargo.toml.tmp" { echo '[workspace]' echo 'resolver = "2"' echo 'members = [' local m while IFS= read -r m; do [ -n "$m" ] && echo " \"$m\"," done echo ']' awk '/^\[workspace\.package\]/,0' "$src_wkspc" } > "$tmp" mv "$tmp" "$dst_root/Cargo.toml" if [ -f "$KIT_DIR/_primitives/_rust/Cargo.lock" ]; then cp -f "$KIT_DIR/_primitives/_rust/Cargo.lock" "$dst_root/Cargo.lock" fi } # Build the scoped rust workspace. Offline-first, online fallback. build_rust_workspace() { local dst_root="$AGENTS_DIR/_primitives/_rust" if ! ( cd "$dst_root" && cargo build --workspace --release --offline ) 2>/tmp/keiseikit-primitives-offline.log; then say " offline build failed — fetching deps from crates.io" if ! ( cd "$dst_root" && cargo build --workspace --release ); then warn "Rust primitive workspace build failed; shell primitives still work" warn " see log: /tmp/keiseikit-primitives-offline.log" return 0 fi fi } # Orchestrator: installed rust crates -> scoped manifest -> cargo build -> report. # No-op when no rust primitives are installed. regenerate_rust_workspace() { local dst_root="$AGENTS_DIR/_primitives/_rust" mkdir -p "$dst_root" local members_nl members_nl="$(installed_rust_crates)" if [ -z "$members_nl" ]; then rm -f "$dst_root/Cargo.toml" "$dst_root/Cargo.lock" return 0 fi local n n="$(printf '%s\n' "$members_nl" | grep -c .)" printf '%s\n' "$members_nl" | write_rust_workspace_manifest say "building Rust primitives ($n crate(s))" build_rust_workspace local built=0 m while IFS= read -r m; do [ -n "$m" ] && [ -x "$dst_root/target/release/$m" ] && built=$((built+1)) done <<< "$members_nl" say " $built / $n Rust primitive binaries available" } # Install primitives from a name list (newline-separated on stdin). # Updates .installed as a superset. install_primitives() { local names existing combined new_file names="$(cat)" existing="$(read_installed)" combined="$(printf '%s\n%s\n' "$existing" "$names" | grep -v '^$' || true)" local kind local any_rust=0 while IFS= read -r p; do [ -z "$p" ] && continue kind="$(primitive_field "$p" kind)" case "$kind" in shell) copy_shell_primitive "$p" ;; rust) copy_rust_primitive "$p"; any_rust=1 ;; *) warn "unknown primitive: $p (skipping)"; continue ;; esac done <<< "$names" printf '%s\n' "$combined" | write_installed if [ "$any_rust" = "1" ]; then regenerate_rust_workspace fi } # Remove a single primitive by name. remove_primitive() { local name="$1" kind kind="$(primitive_field "$name" kind)" case "$kind" in shell) remove_shell_primitive "$name" ;; rust) remove_rust_primitive "$name" ;; *) err "unknown primitive: $name"; return 1 ;; esac local existing existing="$(read_installed)" printf '%s\n' "$existing" | grep -vFx "$name" | grep -v '^$' | write_installed || true # Rust removal => rewrite scoped workspace if [ "$kind" = "rust" ]; then regenerate_rust_workspace fi } # --- --list implementation -------------------------------------------------- cmd_list() { echo printf '%-22s %-6s %-10s %s\n' "NAME" "KIND" "STATUS" "DESCRIPTION" printf '%-22s %-6s %-10s %s\n' "----" "----" "------" "-----------" local installed names kind desc status installed="$(read_installed)" while IFS= read -r name; do [ -z "$name" ] && continue kind="$(primitive_field "$name" kind)" desc="$(primitive_field "$name" desc)" if printf '%s\n' "$installed" | grep -qFx "$name"; then status="INSTALLED" else status="-" fi printf '%-22s %-6s %-10s %s\n' "$name" "$kind" "$status" "$desc" done < <(all_primitive_names) echo local count count="$(printf '%s\n' "$installed" | grep -c . || true)" printf '%s primitives installed (state: %s)\n' "${count:-0}" "$INSTALLED_FILE" echo } # --- hook activation (unchanged jq-merge) ---------------------------------- 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 tmp="$(mktemp "$target.XXXXXX")" jq 'del(._comment)' "$snippet" > "$tmp" mv "$tmp" "$target" say "created $target from snippet (no prior settings.json)" return 0 fi backup_file "$target" 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" 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 } # --- --list short-circuit --------------------------------------------------- if [ "$LIST_MODE" = "1" ]; then [ -f "$MANIFEST" ] || { err "MANIFEST.toml missing: $MANIFEST"; exit 2; } cmd_list exit 0 fi # --- incremental --add / --remove short-circuit --------------------------- # If either flag is set, skip the full agent/hook/skills sync and just mutate # the primitive set. Assumes a prior install already wrote _blocks etc. if [ -n "$ADD_LIST" ] || [ -n "$REMOVE_NAME" ]; then [ -f "$MANIFEST" ] || { err "MANIFEST.toml missing: $MANIFEST"; exit 2; } mkdir -p "$AGENTS_DIR/_primitives" if [ -n "$REMOVE_NAME" ]; then say "removing primitive: $REMOVE_NAME" remove_primitive "$REMOVE_NAME" fi if [ -n "$ADD_LIST" ]; then # Resolve --add=x,y,z OR --add= (profile expands in-place) tr ',' '\n' <<< "$ADD_LIST" | grep -v '^$' | while IFS= read -r token; do # Is token a known profile? local_members="$(profile_members "$token" 2>/dev/null || true)" if [ -n "$local_members" ]; then printf '%s\n' "$local_members" | tr ' ' '\n' else printf '%s\n' "$token" fi done | grep -v '^$' | sort -u | install_primitives say "added: $ADD_LIST" fi echo say "incremental change complete" cmd_list exit 0 fi # --- interactive menu (option C hybrid) ----------------------------------- # Runs ONLY when: no selection flag passed AND stdin+stdout are TTY AND # --list / --add / --remove short-circuits above did NOT fire. Sets either # PROFILE (for a standard profile choice) or CUSTOM_PRIMS (comma-list). CUSTOM_PRIMS="" CONFIRM_TOTAL=0 CONFIRM_SECS=0 CONFIRM_MB=0 if ! menu_should_skip; then [ -f "$MANIFEST" ] || { err "MANIFEST.toml missing: $MANIFEST"; exit 2; } menu_out="$(show_interactive_menu)" || { say "menu cancelled — aborting"; exit 1; } if [ -z "$menu_out" ]; then say "no selection — aborting" exit 1 fi if echo "$menu_out" | grep -q ','; then CUSTOM_PRIMS="$menu_out" PROFILE="custom" elif echo "$menu_out" | grep -qE '^(minimal|core|frontend|ops|dev|full)$'; then PROFILE="$menu_out" else # Single name from custom-with-one-item — treat as CUSTOM_PRIMS CUSTOM_PRIMS="$menu_out" PROFILE="custom" fi fi # --- resolve profile ------------------------------------------------------ # Default profile is minimal. PROFILE="${PROFILE:-minimal}" case "$PROFILE" in minimal|core|frontend|ops|dev|full|custom) ;; *) err "unknown profile: $PROFILE. Valid: minimal | core | frontend | ops | dev | full" exit 1 ;; esac say "profile: $PROFILE" # --- prerequisites --------------------------------------------------------- # HARD: cargo, jq. SOFT: deps based on the primitives that will be installed. say "checking prerequisites" if ! command -v cargo >/dev/null 2>&1; then err "cargo not found. Install Rust: https://rustup.rs/" exit 1 fi 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 # Profile-aware soft-warn: only check deps for primitives actually being installed. # Build a unique set of substrings to check. if [ "$PROFILE" = "custom" ]; then PROFILE_PRIMS="$(echo "$CUSTOM_PRIMS" | tr ',' ' ')" else PROFILE_PRIMS="$(profile_members "$PROFILE" 2>/dev/null || true)" fi needs_pandoc=0 needs_playwright=0 needs_sqlite=0 needs_hcloud=0 needs_vultr=0 needs_yq=0 for p in $PROFILE_PRIMS; do case "$p" in tomd) needs_pandoc=1 ;; design-scrape|live-preview|mock-render) needs_playwright=1 ;; kei-ledger|kei-migrate) needs_sqlite=1 ;; provision-hetzner) needs_hcloud=1 ;; provision-vultr) needs_vultr=1 ;; kei-ci-lint) needs_yq=1 ;; esac done if [ "$needs_pandoc" = "1" ] && ! command -v pandoc >/dev/null 2>&1; then warn "pandoc not found — tomd primitive will fail on .docx/.pptx. Install: brew install pandoc" fi if [ "$needs_playwright" = "1" ] \ && ! command -v playwright >/dev/null 2>&1 \ && ! command -v npx >/dev/null 2>&1; then warn "playwright/npx not found — frontend primitives need them. Install: npm i -g playwright && playwright install chromium" fi if [ "$needs_sqlite" = "1" ] && ! command -v sqlite3 >/dev/null 2>&1; then warn "sqlite3 CLI not found — kei-ledger/kei-migrate work without it (rusqlite embedded). Install for manual DB inspection: brew install sqlite" fi if [ "$needs_hcloud" = "1" ] && ! command -v hcloud >/dev/null 2>&1; then warn "hcloud CLI not found — provision-hetzner requires it. Install: brew install hcloud" fi if [ "$needs_vultr" = "1" ] && ! command -v vultr-cli >/dev/null 2>&1; then warn "vultr-cli not found — provision-vultr requires it. Install: brew install vultr/vultr-cli/vultr-cli" fi if [ "$needs_yq" = "1" ] && ! command -v yq >/dev/null 2>&1; then warn "yq not found — kei-ci-lint requires yq v4+ (mikefarah/yq). Install: brew install yq" fi # --- confirm screen + --no-execute short-circuit -------------------------- # Always show the plan; skip confirm when --yes / non-TTY / --add/--remove path. # The menu branch and the --profile branch both flow through here. CONFIRM_LABEL="$PROFILE" [ "$PROFILE" = "custom" ] && CONFIRM_LABEL="custom ($CUSTOM_PRIMS)" CONFIRM_INPUT="$(printf '%s\n' $PROFILE_PRIMS | grep -v '^$' || true)" if ! printf '%s\n' "$CONFIRM_INPUT" | show_confirm_screen "$CONFIRM_LABEL"; then say "install declined at confirm screen — aborting" exit 1 fi if [ "$NO_EXECUTE" = "1" ]; then say "--no-execute: plan resolved, exiting before install" exit 0 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 -------------------------------------- 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 (profile-driven) ------------------------------------- # Always copy MANIFEST.toml + README.md so subsequent --list works. mkdir -p "$AGENTS_DIR/_primitives" cp -f "$KIT_DIR/_primitives/MANIFEST.toml" "$AGENTS_DIR/_primitives/MANIFEST.toml" 2>/dev/null || true cp -f "$KIT_DIR/_primitives/README.md" "$AGENTS_DIR/_primitives/" 2>/dev/null || true say "resolving primitives for profile=$PROFILE" # Clean slate: drop every shell .sh + rust crate dir from the installed set # FAST (no per-rust rebuild). A single regenerate_rust_workspace at the end # of the install phase handles the final state. existing_installed="$(read_installed)" if [ -n "${existing_installed:-}" ]; then while IFS= read -r n; do [ -z "$n" ] && continue k="$(primitive_field "$n" kind 2>/dev/null || true)" case "$k" in shell) f="$(primitive_field "$n" file)"; [ -n "$f" ] && rm -f "$AGENTS_DIR/_primitives/$f" ;; rust) c="$(primitive_field "$n" crate)"; [ -n "$c" ] && rm -rf "$AGENTS_DIR/_primitives/_rust/$c" ;; esac done <<< "$existing_installed" : > "$INSTALLED_FILE" fi # Install fresh per profile. install_primitives rebuilds rust workspace once # at the end if any rust crate was added; for minimal we still need to scrub # any stale workspace Cargo.toml. if [ -n "${PROFILE_PRIMS:-}" ]; then printf '%s\n' "$PROFILE_PRIMS" | tr ' ' '\n' | grep -v '^$' | install_primitives else regenerate_rust_workspace say " (no primitives — minimal profile)" 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 -------------------------------------------------------- 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/" 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 ------------------------------------------------------ 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 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 [ "${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 (profile=$PROFILE)" echo if [ "$DID_ACTIVATE" = "1" ]; then cat <