diff --git a/install.sh b/install.sh index 707e0f3..4572459 100755 --- a/install.sh +++ b/install.sh @@ -12,9 +12,13 @@ # ./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) +# +# Internals: this file is a thin orchestrator. All implementation lives in +# install/lib-*.sh cubes (Constructor Pattern: 1 file = 1 concern, <200 LOC). set -euo pipefail +# --- paths ---------------------------------------------------------------- KIT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" HOME_DIR="${HOME:?HOME not set}" AGENTS_DIR="$HOME_DIR/.claude/agents" @@ -22,867 +26,65 @@ HOOKS_DIR="$HOME_DIR/.claude/hooks" SKILLS_DIR="$HOME_DIR/.claude/skills" MANIFEST="$KIT_DIR/_primitives/MANIFEST.toml" INSTALLED_FILE="$AGENTS_DIR/_primitives/.installed" +LIB_DIR="$KIT_DIR/install" -# --- flag parsing ---------------------------------------------------------- -ACTIVATE_HOOKS=0 -WITH_BRIDGES=0 -WITH_SLEEP_SYNC=0 -PROFILE="" -ADD_LIST="" -REMOVE_NAME="" -LIST_MODE=0 -ASSUME_YES=0 -NO_EXECUTE=0 +# --- source cubes (order matters: logs -> backup -> profile -> rest) ------ +# shellcheck source=install/lib-log.sh +source "$LIB_DIR/lib-log.sh" +# shellcheck source=install/lib-backup.sh +source "$LIB_DIR/lib-backup.sh" +# shellcheck source=install/lib-profile.sh +source "$LIB_DIR/lib-profile.sh" +# shellcheck source=install/lib-args.sh +source "$LIB_DIR/lib-args.sh" +# shellcheck source=install/lib-menu.sh +source "$LIB_DIR/lib-menu.sh" +# shellcheck source=install/lib-plan.sh +source "$LIB_DIR/lib-plan.sh" +# shellcheck source=install/lib-prereqs.sh +source "$LIB_DIR/lib-prereqs.sh" +# shellcheck source=install/lib-primitives.sh +source "$LIB_DIR/lib-primitives.sh" +# shellcheck source=install/lib-rust.sh +source "$LIB_DIR/lib-rust.sh" +# shellcheck source=install/lib-scaffold.sh +source "$LIB_DIR/lib-scaffold.sh" +# shellcheck source=install/lib-bridges.sh +source "$LIB_DIR/lib-bridges.sh" +# shellcheck source=install/lib-hooks.sh +source "$LIB_DIR/lib-hooks.sh" +# shellcheck source=install/lib-agents.sh +source "$LIB_DIR/lib-agents.sh" +# shellcheck source=install/lib-skills.sh +source "$LIB_DIR/lib-skills.sh" +# shellcheck source=install/lib-wizard.sh +source "$LIB_DIR/lib-wizard.sh" +# shellcheck source=install/lib-summary.sh +source "$LIB_DIR/lib-summary.sh" -for arg in "$@"; do - case "$arg" in - --activate-hooks) ACTIVATE_HOOKS=1 ;; - --with-bridges) WITH_BRIDGES=1 ;; - --with-sleep-sync) WITH_SLEEP_SYNC=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 (9 dev tools: kei-migrate / kei-memory / deep-sleep quartet / ...) - mcp (10 LBM-port tools: kei-router / kei-sage / kei-auth / ...) - full (all 36 primitives — MANIFEST source of truth) - - --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. - - --with-sleep-sync after core install, run the v0.11 sleep-layer - setup helper (kei-sleep-setup.sh). TTY-only — no-op - on CI / non-interactive invocations. Print a - reminder to finish via /sleep-setup either way. - - --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 ;; - 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 ;; - 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):" 22 78 8 \ - "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" "+ 9 dev tools (~60s, 40 MB)" OFF \ - "mcp" "+ 10 LBM-port MCP tools (~90s, 50 MB)" OFF \ - "full" "all 36 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 — + 9 dev tools (~60s, 40 MB)" >&2 - echo " 6) mcp — + 10 LBM-port MCP tools (~90s, 50 MB)" >&2 - echo " 7) full — all 36 primitives (~5 min, 200 MB)" >&2 - echo " 8) custom — pick individual primitives" >&2 - echo >&2 - local reply - printf 'Enter choice [1-8] (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 mcp ;; - 7) echo full ;; - 8) 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 --------------------------------------------------- +# --- --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. +# --- incremental --add / --remove short-circuit -------------------------- 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 + run_incremental_change exit 0 fi -# --- interactive menu (option C hybrid) ----------------------------------- +# --- 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|mcp|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 +# --list / --add / --remove short-circuits above did NOT fire. +run_menu_if_needed || exit 1 -# --- resolve profile ------------------------------------------------------ -# Default profile is minimal. +# --- resolve profile (default=minimal) ----------------------------------- PROFILE="${PROFILE:-minimal}" case "$PROFILE" in minimal|core|frontend|ops|dev|mcp|full|custom) ;; @@ -893,73 +95,10 @@ case "$PROFILE" in 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 +# --- prerequisites ------------------------------------------------------- +check_prereqs -# 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 screen + --no-execute --------------------------------------- CONFIRM_LABEL="$PROFILE" [ "$PROFILE" = "custom" ] && CONFIRM_LABEL="custom ($CUSTOM_PRIMS)" CONFIRM_INPUT="$(printf '%s\n' $PROFILE_PRIMS | grep -v '^$' || true)" @@ -973,266 +112,27 @@ if [ "$NO_EXECUTE" = "1" ]; then 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" +# --- execute install phases ---------------------------------------------- +setup_target_dirs +scaffold_memory_index +install_blocks +run_primitives_phase +install_bridges +install_manifests +build_assembler +generate_agents +install_hooks +install_skills +maybe_activate_hooks -# --- 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 - -# v0.11 sleep-sync + v0.12 sleep-on-it queue scripts — NOT listed in MANIFEST -# because they're always available regardless of profile (zero binary deps; -# enabled only when the user opts in via /sleep-setup + /sleep-on-it). Copy -# them every install. -for sleep_sh in kei-sleep-setup.sh kei-sleep-sync.sh kei-sleep-queue.sh; do - src="$KIT_DIR/_primitives/$sleep_sh" - if [ -f "$src" ]; then - cp -f "$src" "$AGENTS_DIR/_primitives/$sleep_sh" - chmod +x "$AGENTS_DIR/_primitives/$sleep_sh" - fi -done -if [ -d "$KIT_DIR/_primitives/templates" ]; then - mkdir -p "$AGENTS_DIR/_primitives/templates" - cp -f "$KIT_DIR/_primitives/templates/"*.md "$AGENTS_DIR/_primitives/templates/" 2>/dev/null || true -fi - -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 ----------------------- +# Bail out cleanly if the rollback trap already fired (activate_hooks err path). 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 -# --- optional: run sleep-sync setup helper (v0.11) ----------------------- -# The helper has its own TTY prompts + validation. We only kick it off when -# stdin+stdout are TTY; otherwise print the reminder so the user can finish -# later via /sleep-setup inside a Claude Code session. -if [[ "$WITH_SLEEP_SYNC" == "1" ]]; then - SLEEP_HELPER="$AGENTS_DIR/_primitives/kei-sleep-setup.sh" - if [[ -x "$SLEEP_HELPER" ]] && [ -t 0 ] && [ -t 1 ]; then - say "running sleep-sync setup helper" - "$SLEEP_HELPER" || warn "sleep-sync setup did not complete — re-run via /sleep-setup" - else - say "sleep-sync setup deferred (non-TTY or helper missing)" - say " run /sleep-setup inside Claude Code to finish configuration" - fi -fi +# --- optional post-install hooks ------------------------------------------ +[ "$WITH_BRIDGES" = "1" ] && render_bridges +[ "$WITH_SLEEP_SYNC" = "1" ] && run_sleep_wizard -# --- done ---------------------------------------------------------------- -echo -say "install complete (profile=$PROFILE)" -echo -if [ "$DID_ACTIVATE" = "1" ]; then - cat < $AGENTS_DIR/_manifests/ (skip if exists)" + local copied=0 skipped=0 f name t has_templates=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)" + + 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 +} + +# Refresh _blocks/*.md — SSoT is the kit, always overwritten after backup. +install_blocks() { + say "copying shared blocks -> $AGENTS_DIR/_blocks/" + backup_dir "$AGENTS_DIR/_blocks" + cp -f "$KIT_DIR/_blocks/"*.md "$AGENTS_DIR/_blocks/" +} + +# Copy the Rust assembler source (Cargo.toml + src/*.rs + .gitignore if any). +# Caller should run build_assembler afterwards. +copy_assembler_source() { + 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 +} + +# Build the assembler (release, offline-first, online fallback). +# Exits 2 if the binary is missing after a reported success (disk failure). +build_assembler() { + copy_assembler_source + 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 +} + +# Run the built assembler in --in-place mode to write the agent .md files. +generate_agents() { + say "generating agent .md files (--in-place)" + AGENT_ROOT="$AGENTS_DIR" "$AGENTS_DIR/_assembler/target/release/assemble" --in-place +} diff --git a/install/lib-args.sh b/install/lib-args.sh new file mode 100644 index 0000000..d4e4212 --- /dev/null +++ b/install/lib-args.sh @@ -0,0 +1,92 @@ +# shellcheck shell=bash +# lib-args.sh — flag parsing + --help text. +# +# Sets globals: ACTIVATE_HOOKS, WITH_BRIDGES, WITH_SLEEP_SYNC, PROFILE, +# ADD_LIST, REMOVE_NAME, LIST_MODE, ASSUME_YES, NO_EXECUTE. +# --help exits 0 immediately. + +ACTIVATE_HOOKS=0 +WITH_BRIDGES=0 +WITH_SLEEP_SYNC=0 +PROFILE="" +ADD_LIST="" +REMOVE_NAME="" +LIST_MODE=0 +ASSUME_YES=0 +NO_EXECUTE=0 + +print_help() { + 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 (9 dev tools: kei-migrate / kei-memory / deep-sleep quartet / ...) + mcp (10 LBM-port tools: kei-router / kei-sage / kei-auth / ...) + full (all 36 primitives — MANIFEST source of truth) + + --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. + + --with-sleep-sync after core install, run the v0.11 sleep-layer + setup helper (kei-sleep-setup.sh). TTY-only — no-op + on CI / non-interactive invocations. Print a + reminder to finish via /sleep-setup either way. + + --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 +} + +parse_args() { + local arg + for arg in "$@"; do + case "$arg" in + --activate-hooks) ACTIVATE_HOOKS=1 ;; + --with-bridges) WITH_BRIDGES=1 ;; + --with-sleep-sync) WITH_SLEEP_SYNC=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) print_help; exit 0 ;; + esac + done +} diff --git a/install/lib-backup.sh b/install/lib-backup.sh new file mode 100644 index 0000000..530cf10 --- /dev/null +++ b/install/lib-backup.sh @@ -0,0 +1,63 @@ +# shellcheck shell=bash +# lib-backup.sh — rollback trap + backup_dir / backup_file helpers. +# +# 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. +# +# Requires: say / warn / err from lib-log.sh. +# Sourced by install.sh; no top-level execution except global var init and +# `trap rollback ERR` inside setup_backup_trap. + +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" +} + +setup_backup_trap() { + 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" +} diff --git a/install/lib-bridges.sh b/install/lib-bridges.sh new file mode 100644 index 0000000..7a74fb6 --- /dev/null +++ b/install/lib-bridges.sh @@ -0,0 +1,31 @@ +# shellcheck shell=bash +# lib-bridges.sh — copy bridge templates + optional --with-bridges render into $PWD. +# +# Templates are SSoT from the kit (always refreshed). The render step is +# skipped when invoked inside the KeiSeiKit repo itself. +# +# Requires: say / warn from lib-log.sh. +# Requires: backup_dir from lib-backup.sh. +# Reads globals: $KIT_DIR, $AGENTS_DIR. + +install_bridges() { + [ -d "$KIT_DIR/_bridges" ] || return 0 + 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" +} + +# Render cross-tool bridges into $PWD via the kit's emit.sh script. +# No-op when the caller is sitting inside the KeiSeiKit repo itself. +render_bridges() { + if [[ -f "./install.sh" && -d "./_bridges" ]]; then + warn "not generating bridges — you are in the KeiSeiKit repo, not a project directory" + return 0 + fi + say "rendering cross-tool bridges into $PWD" + "$KIT_DIR/_bridges/emit.sh" "$PWD" +} diff --git a/install/lib-hooks.sh b/install/lib-hooks.sh new file mode 100644 index 0000000..fc4866f --- /dev/null +++ b/install/lib-hooks.sh @@ -0,0 +1,104 @@ +# shellcheck shell=bash +# lib-hooks.sh — hook file copy + settings.json jq-merge. +# +# Hooks are logic (not config) → always refreshed, every install. +# settings.json merge is idempotent: it groups by matcher and unions .hooks +# by unique command so repeated runs never duplicate entries. +# +# Requires: say / warn / err from lib-log.sh. +# Requires: backup_file from lib-backup.sh. +# Reads globals: $KIT_DIR, $HOOKS_DIR, $HOME_DIR. + +# Copy every *.sh hook from the kit into $HOOKS_DIR, +x, with per-file backup. +install_hooks() { + say "copying hooks -> $HOOKS_DIR/" + local hook_count=0 hook_src h + 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)" +} + +# Merge settings-snippet.json into ~/.claude/settings.json non-interactively +# via jq. On first run (no settings.json) we strip _comment and drop in the +# snippet verbatim. On subsequent runs we group by matcher and dedupe .hooks +# by command so re-runs are true no-ops. +# jq-merge snippet into existing target. group_by matcher + dedup by command +# so re-runs are no-ops. Args: $1=snippet, $2=target. +_jq_merge_hooks() { + local snippet="$1" target="$2" tmp + 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 +} + +activate_hooks() { + local snippet="$KIT_DIR/settings-snippet.json" + local target="$HOME_DIR/.claude/settings.json" + [ -f "$snippet" ] || { warn "no snippet at $snippet"; return 0; } + if [ ! -f "$target" ]; then + local tmp + 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" + _jq_merge_hooks "$snippet" "$target" +} + +# Flag-or-prompt dispatcher, mirroring the v0.15 behavior: +# --activate-hooks → always activate, no prompt +# no existing settings.json → activate silently (drop in snippet) +# TTY stdin+stdout → interactive [y/N] prompt +# otherwise → skip (manual-merge hint printed by summary) +# Sets global DID_ACTIVATE=1 when activation ran + succeeded. +maybe_activate_hooks() { + local 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 + local reply + read -r reply + case "$reply" in + y|Y|yes|YES) activate_hooks && DID_ACTIVATE=1 ;; + *) say "skipping hook activation" ;; + esac + fi +} diff --git a/install/lib-log.sh b/install/lib-log.sh new file mode 100644 index 0000000..4038ae7 --- /dev/null +++ b/install/lib-log.sh @@ -0,0 +1,21 @@ +# shellcheck shell=bash +# lib-log.sh — say / warn / err with optional ANSI color. +# Honors NO_COLOR (no-color.org) and TTY detection on fd 1. +# Sourced by install.sh; no top-level execution. + +# ANSI on iff stdout is a TTY and NO_COLOR is unset. +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 diff --git a/install/lib-menu.sh b/install/lib-menu.sh new file mode 100644 index 0000000..4d4abbc --- /dev/null +++ b/install/lib-menu.sh @@ -0,0 +1,183 @@ +# shellcheck shell=bash +# lib-menu.sh — interactive menu (option C hybrid). +# +# Hierarchy: 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. +# +# Requires: all_primitive_names, primitive_field from lib-profile.sh. +# Requires: err from lib-log.sh. +# Reads globals: PROFILE, ADD_LIST, REMOVE_NAME, LIST_MODE (set by install.sh). + +# menu_should_skip — return 0 if menu should be skipped, 1 if it should run. +# Skip reasons: any selection flag was passed, or stdin/stdout 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/dialog radiolist → profile name. Exits 1 on cancel. +menu_whiptail_profile() { + local tool="$1" + "$tool" --title "KeiSeiKit Installer" --radiolist \ + "Choose install profile (SPACE to select, ENTER to confirm):" 22 78 8 \ + "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" "+ 9 dev tools (~60s, 40 MB)" OFF \ + "mcp" "+ 10 LBM-port MCP tools (~90s, 50 MB)" OFF \ + "full" "all 36 primitives (~5 min, 200 MB)" OFF \ + "custom" "pick individual primitives" OFF \ + 3>&1 1>&2 2>&3 +} + +# whiptail/dialog 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 — + 9 dev tools (~60s, 40 MB)" >&2 + echo " 6) mcp — + 10 LBM-port MCP tools (~90s, 50 MB)" >&2 + echo " 7) full — all 36 primitives (~5 min, 200 MB)" >&2 + echo " 8) custom — pick individual primitives" >&2 + echo >&2 + local reply + printf 'Enter choice [1-8] (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 mcp ;; + 7) echo full ;; + 8) 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[*]}" +} + +# Run the menu and parse its output into PROFILE / CUSTOM_PRIMS globals. +# Returns 0 on success (incl. menu_should_skip), 1 on user cancel. +run_menu_if_needed() { + CUSTOM_PRIMS="" + CONFIRM_TOTAL=0 + CONFIRM_SECS=0 + CONFIRM_MB=0 + menu_should_skip && return 0 + [ -f "$MANIFEST" ] || { err "MANIFEST.toml missing: $MANIFEST"; exit 2; } + local menu_out + menu_out="$(show_interactive_menu)" || { say "menu cancelled — aborting"; return 1; } + if [ -z "$menu_out" ]; then + say "no selection — aborting" + return 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|mcp|full)$'; then + PROFILE="$menu_out" + else + # Single name from custom-with-one-item — treat as CUSTOM_PRIMS + CUSTOM_PRIMS="$menu_out" + PROFILE="custom" + fi + return 0 +} + +# 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 +} diff --git a/install/lib-plan.sh b/install/lib-plan.sh new file mode 100644 index 0000000..966f1c3 --- /dev/null +++ b/install/lib-plan.sh @@ -0,0 +1,150 @@ +# shellcheck shell=bash +# lib-plan.sh — install-plan estimation, soft-dep status, confirm screen. +# +# Per-primitive time/disk estimates are hardcoded here (not in MANIFEST) to +# keep the manifest declarative + UX hints local. Shell primitives are +# ~1s / 5 KB; rust primitives vary by dep weight. +# +# Requires: primitive_field from lib-profile.sh. +# Requires: say / warn / err from lib-log.sh. +# Reads globals: ASSUME_YES, CONFIRM_TOTAL, CONFIRM_SECS, CONFIRM_MB (set by install.sh). + +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 ;; + 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 ;; + visual-diff|tokens-sync|ssh-check) echo 5000 ;; + *) echo 8000 ;; + esac + ;; + *) echo 0 ;; + esac +} + +# 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 +} + +# 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 — prints "Install Plan" block for given label + names. +# Args: $1 = label, stdin = newline-separated primitive names. +# Sets globals: CONFIRM_TOTAL, CONFIRM_SECS, CONFIRM_MB. +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 +} diff --git a/install/lib-prereqs.sh b/install/lib-prereqs.sh new file mode 100644 index 0000000..13b6e67 --- /dev/null +++ b/install/lib-prereqs.sh @@ -0,0 +1,91 @@ +# shellcheck shell=bash +# lib-prereqs.sh — hard + soft prerequisite checks. +# +# HARD: cargo, jq. SOFT: deps based on the primitives that will be installed. +# A profile-aware soft-warn: only check deps for primitives actually in scope. +# +# Requires: err / warn / say from lib-log.sh. +# Requires: profile_members from lib-profile.sh. +# Reads globals: $PROFILE, $CUSTOM_PRIMS, $MANIFEST. +# Sets global: $PROFILE_PRIMS (space-separated primitive names). + +# Hard checks: cargo + jq. Exit 1 on missing — without them the install +# (or the installed hooks afterwards) cannot function. +check_hard_prereqs() { + 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 +} + +# Resolve primitive list for the current profile (or CUSTOM_PRIMS if custom) +# into PROFILE_PRIMS. Does not exit. +resolve_profile_prims() { + if [ "$PROFILE" = "custom" ]; then + PROFILE_PRIMS="$(echo "$CUSTOM_PRIMS" | tr ',' ' ')" + else + PROFILE_PRIMS="$(profile_members "$PROFILE" 2>/dev/null || true)" + fi +} + +# Scan PROFILE_PRIMS and echo a space-separated list of tool-need flags: +# pandoc playwright sqlite hcloud vultr yq — one per line, each "1" or "0". +_soft_dep_flags() { + local needs_pandoc=0 needs_playwright=0 needs_sqlite=0 + local needs_hcloud=0 needs_vultr=0 needs_yq=0 p + 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 + echo "$needs_pandoc $needs_playwright $needs_sqlite $needs_hcloud $needs_vultr $needs_yq" +} + +# Soft checks: only warn for tools needed by primitives actually being installed. +check_soft_prereqs() { + local n_pandoc n_playwright n_sqlite n_hcloud n_vultr n_yq + read -r n_pandoc n_playwright n_sqlite n_hcloud n_vultr n_yq <<< "$(_soft_dep_flags)" + if [ "$n_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 [ "$n_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 [ "$n_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 [ "$n_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 [ "$n_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 [ "$n_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 +} + +# Top-level orchestrator: hard first (exit on miss), then resolve + soft. +check_prereqs() { + check_hard_prereqs + resolve_profile_prims + check_soft_prereqs +} diff --git a/install/lib-primitives.sh b/install/lib-primitives.sh new file mode 100644 index 0000000..b4234f4 --- /dev/null +++ b/install/lib-primitives.sh @@ -0,0 +1,131 @@ +# shellcheck shell=bash +# lib-primitives.sh — shell-primitive copy + Rust workspace scoped build + +# .installed state helpers + --list printer. +# +# Requires: primitive_field from lib-profile.sh. +# Requires: say / warn / err from lib-log.sh. +# Reads globals: $AGENTS_DIR, $KIT_DIR, $INSTALLED_FILE, $MANIFEST. + +# --- .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" file src dst + file="$(primitive_field "$name" file)" + [ -n "$file" ] || { err "no 'file' for shell primitive $name"; return 1; } + src="$KIT_DIR/_primitives/$file" + 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" 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" crate src dst_root dst + crate="$(primitive_field "$name" crate)" + [ -n "$crate" ] || { err "no 'crate' for rust primitive $name"; return 1; } + src="$KIT_DIR/_primitives/_rust/$crate" + [ -d "$src" ] || { err "source missing: $src"; return 1; } + dst_root="$AGENTS_DIR/_primitives/_rust" + 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" crate + crate="$(primitive_field "$name" crate)" + [ -n "$crate" ] || return 0 + rm -rf "$AGENTS_DIR/_primitives/_rust/$crate" + say " - rust: $name (crate $crate)" +} + +# --- rust enumeration / manifest / build all live in install/lib-rust.sh +# (Constructor-Pattern split — keeps this cube under 200 LOC). + +# --- install / remove orchestrators -------------------------------------- +# Install primitives from a name list (newline-separated on stdin). +install_primitives() { + local names existing combined kind p any_rust=0 + names="$(cat)" + existing="$(read_installed)" + combined="$(printf '%s\n%s\n' "$existing" "$names" | grep -v '^$' || true)" + 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 existing + 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 + existing="$(read_installed)" + printf '%s\n' "$existing" | grep -vFx "$name" | grep -v '^$' | write_installed || true + 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 name kind desc status count + 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 + count="$(printf '%s\n' "$installed" | grep -c . || true)" + printf '%s primitives installed (state: %s)\n' "${count:-0}" "$INSTALLED_FILE" + echo +} diff --git a/install/lib-profile.sh b/install/lib-profile.sh new file mode 100644 index 0000000..d286f3e --- /dev/null +++ b/install/lib-profile.sh @@ -0,0 +1,115 @@ +# shellcheck shell=bash +# lib-profile.sh — MANIFEST.toml parser + profile resolver. +# +# Tiny awk-based TOML reader with optional Python fallback for robustness. +# Two shapes used: +# 1. profile. = ["a", "b", ...] +# 2. [primitive.] kind/file/crate/deps/desc +# +# If tomllib (python3.11+) or toml is available, prefer it. Otherwise awk. +# +# Requires: $MANIFEST (set by install.sh). +# Requires: err from lib-log.sh. + +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:]]*=" { + 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, deps } +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) + 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" +} diff --git a/install/lib-rust.sh b/install/lib-rust.sh new file mode 100644 index 0000000..fefc5cf --- /dev/null +++ b/install/lib-rust.sh @@ -0,0 +1,114 @@ +# shellcheck shell=bash +# lib-rust.sh — scoped Rust workspace manifest + build orchestrator. +# +# Splits out the "primitives rust workspace" concern from lib-primitives.sh +# to stay under the Constructor Pattern <200 LOC limit. Handles: +# - list rust crates currently installed +# - regenerate a scoped Cargo.toml (members = only installed crates) +# - honour KEI_SKIP_RUST_BUILD + pre-built-binary detection +# - cargo build --offline, fall back to online on miss +# +# Requires: primitive_field from lib-profile.sh. +# Requires: read_installed from lib-primitives.sh. +# Requires: say / warn from lib-log.sh. +# Reads globals: $AGENTS_DIR, $KIT_DIR. +# Honours env: $KEI_SKIP_RUST_BUILD (1 = force-skip cargo build). + +# Echo rust crates currently installed (by scanning .installed + MANIFEST). +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). +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 +} + +# Detect whether a usable set of pre-built release binaries already exists +# under `target/release/`. Returns 0 iff at least one expected crate-name +# executable is present AND executable. +have_prebuilt_binaries() { + local dst_root="$AGENTS_DIR/_primitives/_rust" + local target_dir="$dst_root/target/release" + [ -d "$target_dir" ] || return 1 + local members_nl + members_nl="$(installed_rust_crates)" + [ -n "$members_nl" ] || return 1 + local m found=0 + while IFS= read -r m; do + [ -n "$m" ] && [ -x "$target_dir/$m" ] && found=$((found+1)) + done <<< "$members_nl" + [ "$found" -gt 0 ] +} + +# Build the scoped rust workspace. Offline-first, online fallback. +# Honours KEI_SKIP_RUST_BUILD=1 (force-skip) and auto-detects pre-built +# binaries dropped into target/release/ by a release-asset extract. +build_rust_workspace() { + local dst_root="$AGENTS_DIR/_primitives/_rust" + if [ "${KEI_SKIP_RUST_BUILD:-0}" = "1" ]; then + say " KEI_SKIP_RUST_BUILD=1 — skipping cargo build" + return 0 + fi + if have_prebuilt_binaries; then + say " pre-built binaries detected in target/release/ — skipping cargo build" + say " (unset KEI_SKIP_RUST_BUILD or remove target/release to force rebuild)" + return 0 + fi + 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 -> +# per-crate "binary available?" report. No-op when no rust crates 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" +} diff --git a/install/lib-scaffold.sh b/install/lib-scaffold.sh new file mode 100644 index 0000000..51870d7 --- /dev/null +++ b/install/lib-scaffold.sh @@ -0,0 +1,144 @@ +# shellcheck shell=bash +# lib-scaffold.sh — directory scaffolding + MEMORY.md + primitives-meta copy +# + always-on sleep scripts + clean-slate primitive reset + profile install. +# +# These are phase orchestrators that glue lib-primitives + lib-profile together +# under the single top-level flow. Kept here (not in lib-primitives) so that +# cube stays <200 LOC and mono-concern (per-primitive ops + state + list). +# +# Requires: say / warn from lib-log.sh. +# Requires: primitive_field from lib-profile.sh. +# Requires: read_installed, install_primitives, regenerate_rust_workspace from lib-primitives.sh. +# Reads globals: $HOME_DIR, $AGENTS_DIR, $HOOKS_DIR, $SKILLS_DIR, $KIT_DIR, +# $INSTALLED_FILE, $PROFILE_PRIMS. + +# Create every directory we'll touch. Idempotent. +setup_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" +} + +# Write a stub MEMORY.md if the user has no index yet. We never overwrite. +scaffold_memory_index() { + local memory_index="$HOME_DIR/.claude/memory/MEMORY.md" + [[ -f "$memory_index" ]] && return 0 + 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" +} + +# Copy MANIFEST.toml + README.md so --list works after install. Best-effort. +copy_primitives_meta() { + 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 +} + +# v0.11 sleep-sync + v0.12 sleep-on-it queue scripts. Always available +# regardless of profile (zero binary deps); the user opts in at runtime +# via /sleep-setup + /sleep-on-it. Copy every install. +copy_sleep_scripts() { + local sleep_sh src + for sleep_sh in kei-sleep-setup.sh kei-sleep-sync.sh kei-sleep-queue.sh; do + src="$KIT_DIR/_primitives/$sleep_sh" + if [ -f "$src" ]; then + cp -f "$src" "$AGENTS_DIR/_primitives/$sleep_sh" + chmod +x "$AGENTS_DIR/_primitives/$sleep_sh" + fi + done + if [ -d "$KIT_DIR/_primitives/templates" ]; then + mkdir -p "$AGENTS_DIR/_primitives/templates" + cp -f "$KIT_DIR/_primitives/templates/"*.md "$AGENTS_DIR/_primitives/templates/" 2>/dev/null || true + fi +} + +# 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 install_primitives handles the final state. +clean_slate_primitives() { + local existing_installed n k f c + existing_installed="$(read_installed)" + [ -z "${existing_installed:-}" ] && return 0 + 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" +} + +# 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 via regenerate_rust_workspace. +install_profile_primitives() { + 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 +} + +# Top-level primitive phase: meta + sleep + clean + install. +run_primitives_phase() { + copy_primitives_meta + copy_sleep_scripts + say "resolving primitives for profile=$PROFILE" + clean_slate_primitives + install_profile_primitives +} + +# Expand one --add= token into newline-separated primitive name(s): +# if is a known profile, emit its members; otherwise emit itself. +_expand_add_token() { + local token="$1" local_members + 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 +} + +# Incremental --add/--remove short-circuit. Skips the full agent/hook/skills +# sync and just mutates the primitive set. Assumes a prior install already +# wrote _blocks etc. Reads $ADD_LIST / $REMOVE_NAME set by parse_args. +run_incremental_change() { + [ -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 + local token + { + tr ',' '\n' <<< "$ADD_LIST" | grep -v '^$' | while IFS= read -r token; do + _expand_add_token "$token" + done + } | grep -v '^$' | sort -u | install_primitives + say "added: $ADD_LIST" + fi + + echo + say "incremental change complete" + cmd_list +} diff --git a/install/lib-skills.sh b/install/lib-skills.sh new file mode 100644 index 0000000..102bb2d --- /dev/null +++ b/install/lib-skills.sh @@ -0,0 +1,23 @@ +# shellcheck shell=bash +# lib-skills.sh — skill directory copy loop. +# +# Skills live in $KIT_DIR/skills// and are synced into +# $SKILLS_DIR// on every install. +# +# Requires: say from lib-log.sh. +# Requires: backup_dir from lib-backup.sh. +# Reads globals: $KIT_DIR, $SKILLS_DIR. + +install_skills() { + [ -d "$KIT_DIR/skills" ] || return 0 + say "copying skills" + backup_dir "$SKILLS_DIR" + local skill_dir skill_name + 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 +} diff --git a/install/lib-summary.sh b/install/lib-summary.sh new file mode 100644 index 0000000..c9381af --- /dev/null +++ b/install/lib-summary.sh @@ -0,0 +1,59 @@ +# shellcheck shell=bash +# lib-summary.sh — final success banner with next-step hints. +# +# Two shapes: "hooks activated" vs "hooks pending manual merge". Both tell +# the user how to verify the install and how to create a new specialist. +# +# Requires: say from lib-log.sh. +# Reads globals: $PROFILE, $DID_ACTIVATE, $KIT_DIR, $AGENTS_DIR, $HOME_DIR. + +print_summary() { + local settings_file="$HOME_DIR/.claude/settings.json" + echo + say "install complete (profile=$PROFILE)" + echo + if [ "$DID_ACTIVATE" = "1" ]; then + cat <