KeiSeiKit-1.0/install.sh
Parfii-bot a25796df76 docs(readme + install): reconcile all count drift (F4 RELEASE BLOCKER)
Disk reality:
- blocks: 73, manifests: 12, skills: 38 (was 34/35), hooks: 10 (was 6/9)
- shell primitives: 16 (13 opt-in + 3 always-copied)
- bridges: 11, rust crates: 24 (was 8/9/14), MANIFEST full profile: 37

Updated: README.md lines 31, 70, 94, 111, 119-125, 254, 307 and install.sh --help + whiptail.
2026-04-22 13:36:17 +08:00

1238 lines
44 KiB
Bash
Executable file

#!/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=<name> # minimal|core|frontend|ops|dev|mcp|full (skips menu)
# ./install.sh --add=<name>[,<name>] # install one or more primitives on top of current state
# ./install.sh --remove=<name> # 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
WITH_SLEEP_SYNC=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 ;;
--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 <<EOF
Usage: ./install.sh [flags]
(no flags) install profile=minimal (agents + hooks + skills + bridges,
no primitives). ~5s, no Rust compile for primitives.
--profile=<name> set installed-primitive set to one of:
minimal (no primitives)
core (tomd, genesis-scan)
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 37 primitives — MANIFEST source of truth)
--add=<a>[,<b>,...] add one or more primitives on top of current install.
Name must match [primitive.<name>] in _primitives/MANIFEST.toml.
--remove=<name> 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.<name> = ["a", "b", ...]
# 2. [primitive.<name>] ... 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-name>
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.<name> = [...]` 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 <name> <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 + genesis-scan (~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 37 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 + genesis-scan (~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 37 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 ---------------------------------------------------
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> (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|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
# --- resolve profile ------------------------------------------------------
# Default profile is minimal.
PROFILE="${PROFILE:-minimal}"
case "$PROFILE" in
minimal|core|frontend|ops|dev|mcp|full|custom) ;;
*)
err "unknown profile: $PROFILE. Valid: minimal | core | frontend | ops | dev | mcp | 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
# 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 -----------------------
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
# --- done ----------------------------------------------------------------
echo
say "install complete (profile=$PROFILE)"
echo
if [ "$DID_ACTIVATE" = "1" ]; then
cat <<EOF
==========================================================================
Hooks activated. Settings merged into $SETTINGS_FILE
==========================================================================
To verify install:
ls $AGENTS_DIR/*.md # should show 12 generated agents
$AGENTS_DIR/_assembler/target/release/assemble --validate
./install.sh --list # show installed primitives
To create a new project-specialist agent:
/new-agent
==========================================================================
EOF
else
cat <<EOF
==========================================================================
NEXT STEP: merge settings-snippet.json into ~/.claude/settings.json
==========================================================================
KeiSeiKit ships 10 hooks (assemble-agents, assemble-validate, no-hand-edit,
tomd-preread, agent-fork-logger, site-wysiwyd-check, session-end-dump,
milestone-commit-hook, error-spike-detector, git-pre-commit-genesis).
To activate them, merge entries from:
$KIT_DIR/settings-snippet.json
into your:
$SETTINGS_FILE
Or re-run with automatic activation:
./install.sh --activate-hooks
To verify install:
ls $AGENTS_DIR/*.md # should show 12 generated agents
$AGENTS_DIR/_assembler/target/release/assemble --validate
./install.sh --list # show installed primitives
To create a new project-specialist agent:
/new-agent
==========================================================================
EOF
fi