Default changed: ./install.sh now installs minimal (no primitives) — ~5s, ~2 MB. Old full behavior available via --profile=full. Profiles: minimal / core / frontend / ops / dev / full. Incremental: --add=name[,name] / --remove=name / --list. Rust workspace scoped per install — only selected crates built.
844 lines
29 KiB
Bash
Executable file
844 lines
29 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 # profile=minimal (agents + hooks + skills + bridges, NO primitives)
|
|
# ./install.sh --profile=<name> # minimal|core|frontend|ops|dev|full
|
|
# ./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
|
|
|
|
set -euo pipefail
|
|
|
|
KIT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
HOME_DIR="${HOME:?HOME not set}"
|
|
AGENTS_DIR="$HOME_DIR/.claude/agents"
|
|
HOOKS_DIR="$HOME_DIR/.claude/hooks"
|
|
SKILLS_DIR="$HOME_DIR/.claude/skills"
|
|
MANIFEST="$KIT_DIR/_primitives/MANIFEST.toml"
|
|
INSTALLED_FILE="$AGENTS_DIR/_primitives/.installed"
|
|
|
|
# --- flag parsing ----------------------------------------------------------
|
|
ACTIVATE_HOOKS=0
|
|
WITH_BRIDGES=0
|
|
PROFILE=""
|
|
ADD_LIST=""
|
|
REMOVE_NAME=""
|
|
LIST_MODE=0
|
|
|
|
for arg in "$@"; do
|
|
case "$arg" in
|
|
--activate-hooks) ACTIVATE_HOOKS=1 ;;
|
|
--with-bridges) WITH_BRIDGES=1 ;;
|
|
--profile=*) PROFILE="${arg#--profile=}" ;;
|
|
--add=*) ADD_LIST="${arg#--add=}" ;;
|
|
--remove=*) REMOVE_NAME="${arg#--remove=}" ;;
|
|
--list) LIST_MODE=1 ;;
|
|
--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)
|
|
frontend (8 site tools: mock-render / visual-diff / ...)
|
|
ops (8 infra tools: kei-ledger / ssh-check / ...)
|
|
dev (4 dev tools: kei-migrate / kei-changelog / ...)
|
|
full (all 21 primitives)
|
|
|
|
--add=<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.
|
|
|
|
--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.
|
|
|
|
--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"
|
|
}
|
|
|
|
# --- .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
|
|
|
|
# --- resolve profile ------------------------------------------------------
|
|
# Default profile is minimal.
|
|
PROFILE="${PROFILE:-minimal}"
|
|
case "$PROFILE" in
|
|
minimal|core|frontend|ops|dev|full) ;;
|
|
*)
|
|
err "unknown profile: $PROFILE. Valid: minimal | core | frontend | ops | dev | full"
|
|
exit 1
|
|
;;
|
|
esac
|
|
say "profile: $PROFILE"
|
|
|
|
# --- prerequisites ---------------------------------------------------------
|
|
# HARD: cargo, jq. SOFT: deps based on the primitives that will be installed.
|
|
say "checking prerequisites"
|
|
if ! command -v cargo >/dev/null 2>&1; then
|
|
err "cargo not found. Install Rust: https://rustup.rs/"
|
|
exit 1
|
|
fi
|
|
if ! cargo --version >/dev/null 2>&1; then
|
|
err "cargo is installed but not functional. Run: rustup default stable"
|
|
exit 1
|
|
fi
|
|
if ! command -v jq >/dev/null 2>&1; then
|
|
err "jq not found. jq is REQUIRED on any machine that will activate the"
|
|
err "KeiSeiKit hooks — without it the hooks become dead weight and would"
|
|
err "otherwise abort Claude Code's Edit/Write/Bash tool calls. Install it:"
|
|
err " brew install jq (macOS)"
|
|
err " apt install jq (Debian/Ubuntu)"
|
|
exit 1
|
|
fi
|
|
|
|
# Profile-aware soft-warn: only check deps for primitives actually being installed.
|
|
# Build a unique set of substrings to check.
|
|
PROFILE_PRIMS="$(profile_members "$PROFILE" 2>/dev/null || true)"
|
|
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
|
|
|
|
# --- create target dirs ---------------------------------------------------
|
|
say "creating directories"
|
|
mkdir -p \
|
|
"$AGENTS_DIR/_blocks" \
|
|
"$AGENTS_DIR/_manifests" \
|
|
"$AGENTS_DIR/_primitives" \
|
|
"$AGENTS_DIR/_templates" \
|
|
"$AGENTS_DIR/_assembler/src" \
|
|
"$AGENTS_DIR/_generated" \
|
|
"$HOOKS_DIR" \
|
|
"$SKILLS_DIR/new-agent" \
|
|
"$HOME_DIR/.claude/memory"
|
|
|
|
# --- scaffold MEMORY.md placeholder --------------------------------------
|
|
MEMORY_INDEX="$HOME_DIR/.claude/memory/MEMORY.md"
|
|
if [[ ! -f "$MEMORY_INDEX" ]]; then
|
|
cat > "$MEMORY_INDEX" <<'EOF'
|
|
# Auto Memory — Index
|
|
|
|
> File-based memory index. Add entries as you save memory files under this directory.
|
|
> See `_blocks/memory-protocol.md` for format.
|
|
EOF
|
|
say "scaffolded $MEMORY_INDEX"
|
|
fi
|
|
|
|
# --- copy blocks (overwrite ours; blocks are SSoT from kit) --------------
|
|
say "copying shared blocks -> $AGENTS_DIR/_blocks/"
|
|
backup_dir "$AGENTS_DIR/_blocks"
|
|
cp -f "$KIT_DIR/_blocks/"*.md "$AGENTS_DIR/_blocks/"
|
|
|
|
# --- copy primitives (profile-driven) -------------------------------------
|
|
# Always copy MANIFEST.toml + README.md so subsequent --list works.
|
|
mkdir -p "$AGENTS_DIR/_primitives"
|
|
cp -f "$KIT_DIR/_primitives/MANIFEST.toml" "$AGENTS_DIR/_primitives/MANIFEST.toml" 2>/dev/null || true
|
|
cp -f "$KIT_DIR/_primitives/README.md" "$AGENTS_DIR/_primitives/" 2>/dev/null || true
|
|
|
|
say "resolving primitives for profile=$PROFILE"
|
|
# Clean slate: drop every shell .sh + rust crate dir from the installed set
|
|
# FAST (no per-rust rebuild). A single regenerate_rust_workspace at the end
|
|
# of the install phase handles the final state.
|
|
existing_installed="$(read_installed)"
|
|
if [ -n "${existing_installed:-}" ]; then
|
|
while IFS= read -r n; do
|
|
[ -z "$n" ] && continue
|
|
k="$(primitive_field "$n" kind 2>/dev/null || true)"
|
|
case "$k" in
|
|
shell) f="$(primitive_field "$n" file)"; [ -n "$f" ] && rm -f "$AGENTS_DIR/_primitives/$f" ;;
|
|
rust) c="$(primitive_field "$n" crate)"; [ -n "$c" ] && rm -rf "$AGENTS_DIR/_primitives/_rust/$c" ;;
|
|
esac
|
|
done <<< "$existing_installed"
|
|
: > "$INSTALLED_FILE"
|
|
fi
|
|
|
|
# Install fresh per profile. install_primitives rebuilds rust workspace once
|
|
# at the end if any rust crate was added; for minimal we still need to scrub
|
|
# any stale workspace Cargo.toml.
|
|
if [ -n "${PROFILE_PRIMS:-}" ]; then
|
|
printf '%s\n' "$PROFILE_PRIMS" | tr ' ' '\n' | grep -v '^$' | install_primitives
|
|
else
|
|
regenerate_rust_workspace
|
|
say " (no primitives — minimal profile)"
|
|
fi
|
|
|
|
# --- copy bridges (overwrite; templates are SSoT from kit) ----------------
|
|
if [[ -d "$KIT_DIR/_bridges" ]]; then
|
|
say "copying bridge templates -> $AGENTS_DIR/_bridges/"
|
|
mkdir -p "$AGENTS_DIR/_bridges"
|
|
backup_dir "$AGENTS_DIR/_bridges"
|
|
cp -f "$KIT_DIR/_bridges/"*.tmpl "$AGENTS_DIR/_bridges/"
|
|
cp -f "$KIT_DIR/_bridges/README.md" "$AGENTS_DIR/_bridges/"
|
|
cp -f "$KIT_DIR/_bridges/emit.sh" "$AGENTS_DIR/_bridges/emit.sh"
|
|
chmod +x "$AGENTS_DIR/_bridges/emit.sh"
|
|
fi
|
|
|
|
# --- copy generic manifests, DO NOT overwrite user's existing manifests ---
|
|
say "copying generic manifests -> $AGENTS_DIR/_manifests/ (skip if exists)"
|
|
copied=0; skipped=0
|
|
for f in "$KIT_DIR/_manifests/"*.toml; do
|
|
name="$(basename "$f")"
|
|
if [[ -f "$AGENTS_DIR/_manifests/$name" ]]; then
|
|
skipped=$((skipped+1))
|
|
else
|
|
cp "$f" "$AGENTS_DIR/_manifests/$name"
|
|
copied=$((copied+1))
|
|
fi
|
|
done
|
|
say " copied $copied, skipped $skipped (already present)"
|
|
|
|
# --- copy template --------------------------------------------------------
|
|
has_templates=0
|
|
for t in "$KIT_DIR/_templates/"*.template; do
|
|
[ -f "$t" ] && { has_templates=1; break; }
|
|
done
|
|
if [ "$has_templates" = "1" ]; then
|
|
say "copying specialist template"
|
|
backup_dir "$AGENTS_DIR/_templates"
|
|
cp -f "$KIT_DIR/_templates/"*.template "$AGENTS_DIR/_templates/"
|
|
fi
|
|
|
|
# --- copy assembler source (always refresh) -------------------------------
|
|
say "copying assembler source"
|
|
backup_dir "$AGENTS_DIR/_assembler"
|
|
cp -f "$KIT_DIR/_assembler/Cargo.toml" "$AGENTS_DIR/_assembler/"
|
|
cp -f "$KIT_DIR/_assembler/src/"*.rs "$AGENTS_DIR/_assembler/src/"
|
|
if [[ -f "$KIT_DIR/_assembler/.gitignore" ]]; then
|
|
cp -f "$KIT_DIR/_assembler/.gitignore" "$AGENTS_DIR/_assembler/"
|
|
fi
|
|
|
|
# --- copy hooks (refresh; hooks are logic, not config) --------------------
|
|
say "copying hooks -> $HOOKS_DIR/"
|
|
hook_count=0
|
|
for hook_src in "$KIT_DIR/hooks/"*.sh; do
|
|
[ -f "$hook_src" ] || continue
|
|
h="$(basename "$hook_src")"
|
|
backup_file "$HOOKS_DIR/$h"
|
|
cp -f "$hook_src" "$HOOKS_DIR/$h"
|
|
chmod +x "$HOOKS_DIR/$h"
|
|
hook_count=$((hook_count+1))
|
|
done
|
|
say " installed $hook_count hook(s)"
|
|
|
|
# --- copy skills ----------------------------------------------------------
|
|
if [[ -d "$KIT_DIR/skills" ]]; then
|
|
say "copying skills"
|
|
backup_dir "$SKILLS_DIR"
|
|
for skill_dir in "$KIT_DIR/skills/"*/; do
|
|
[ -d "$skill_dir" ] || continue
|
|
skill_name="$(basename "$skill_dir")"
|
|
mkdir -p "$SKILLS_DIR/$skill_name"
|
|
cp -rf "$skill_dir"* "$SKILLS_DIR/$skill_name/" 2>/dev/null || true
|
|
say " -> $skill_name"
|
|
done
|
|
fi
|
|
|
|
# --- build assembler ------------------------------------------------------
|
|
say "building Rust assembler (cargo build --release, offline first)"
|
|
if ! ( cd "$AGENTS_DIR/_assembler" && cargo build --release --offline ) 2>/tmp/keiseikit-cargo-offline.log; then
|
|
say "offline build failed — fetching deps from crates.io"
|
|
( cd "$AGENTS_DIR/_assembler" && cargo build --release )
|
|
fi
|
|
if [[ ! -x "$AGENTS_DIR/_assembler/target/release/assemble" ]]; then
|
|
err "build succeeded but binary not found at $AGENTS_DIR/_assembler/target/release/assemble"
|
|
exit 2
|
|
fi
|
|
|
|
# --- generate .md agents in-place -----------------------------------------
|
|
say "generating agent .md files (--in-place)"
|
|
AGENT_ROOT="$AGENTS_DIR" "$AGENTS_DIR/_assembler/target/release/assemble" --in-place
|
|
|
|
# --- activate hooks (flag, or interactive prompt on TTY) ------------------
|
|
SETTINGS_FILE="$HOME_DIR/.claude/settings.json"
|
|
DID_ACTIVATE=0
|
|
if [ "$ACTIVATE_HOOKS" = "1" ]; then
|
|
say "activating hooks (--activate-hooks)"
|
|
activate_hooks && DID_ACTIVATE=1
|
|
elif [ ! -f "$SETTINGS_FILE" ]; then
|
|
say "no existing settings.json; installing snippet"
|
|
activate_hooks && DID_ACTIVATE=1
|
|
elif [ -t 0 ] && [ -t 1 ]; then
|
|
if [ "$COLOR" = "1" ]; then
|
|
printf '\033[1;36m[install]\033[0m activate hooks now? [y/N] '
|
|
else
|
|
printf '[install] activate hooks now? [y/N] '
|
|
fi
|
|
read -r reply
|
|
case "$reply" in
|
|
y|Y|yes|YES) activate_hooks && DID_ACTIVATE=1 ;;
|
|
*) say "skipping hook activation" ;;
|
|
esac
|
|
fi
|
|
|
|
# --- optional: render cross-tool bridges into $PWD -----------------------
|
|
if [ "${ROLLED_BACK:-0}" = "1" ]; then
|
|
exit 2
|
|
fi
|
|
if [[ "$WITH_BRIDGES" == "1" ]]; then
|
|
if [[ -f "./install.sh" && -d "./_bridges" ]]; then
|
|
warn "not generating bridges — you are in the KeiSeiKit repo, not a project directory"
|
|
else
|
|
say "rendering cross-tool bridges into $PWD"
|
|
"$KIT_DIR/_bridges/emit.sh" "$PWD"
|
|
fi
|
|
fi
|
|
|
|
# --- done ----------------------------------------------------------------
|
|
echo
|
|
say "install complete (profile=$PROFILE)"
|
|
echo
|
|
if [ "$DID_ACTIVATE" = "1" ]; then
|
|
cat <<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 6 hooks (assemble-agents, assemble-validate, no-hand-edit,
|
|
tomd-preread, agent-fork-logger, site-wysiwyd-check). 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
|