KeiSeiKit-1.0/install.sh
Parfii-bot f04c9f38da fix(install): replace compgen with bash-3.2-portable glob check
compgen is unreliable on bash 3.2 (macOS stock). Replace with a simple
for-loop over the glob plus a file-existence test that breaks early.
2026-04-21 03:48:28 +08:00

320 lines
12 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.
set -euo pipefail
KIT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
HOME_DIR="${HOME:?HOME not set}"
AGENTS_DIR="$HOME_DIR/.claude/agents"
HOOKS_DIR="$HOME_DIR/.claude/hooks"
SKILLS_DIR="$HOME_DIR/.claude/skills"
# --- flag parsing ----------------------------------------------------------
ACTIVATE_HOOKS=0
for arg in "$@"; do
case "$arg" in
--activate-hooks) ACTIVATE_HOOKS=1 ;;
--help|-h)
cat <<EOF
Usage: ./install.sh [--activate-hooks]
--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.
EOF
exit 0
;;
esac
done
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; }
# --- 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
rm -rf "$orig"
mv "$bak" "$orig"
say " restored $orig from $bak"
fi
done
err "install failed at line ${BASH_LINENO[0]:-?}; rolled back"
}
trap rollback ERR
# Backup a populated target directory to a timestamped sibling before clobber.
# No-op if target is absent or contains no regular files (recursively). This
# means freshly-mkdir'd scaffolds are NOT backed up — only real user content.
# Only called on $AGENTS_DIR/_blocks, _templates, _assembler, $SKILLS_DIR —
# never on $KIT_DIR source. (hooks are now per-file; see backup_file.)
backup_dir() {
local target="$1"
[ -d "$target" ] || return 0
# No regular files anywhere under target → nothing worth preserving
if [ -z "$(find "$target" -type f -print -quit 2>/dev/null)" ]; then
return 0
fi
local backup="${target}.bak-$(date +%s)"
cp -a "$target" "$backup"
BACKUP_PAIRS+=("$target|$backup")
say "backed up existing $target to $backup"
}
# Activate KeiSeiKit hooks by merging settings-snippet.json into the user's
# settings.json. Idempotent:
# - If settings.json is absent, copy snippet verbatim (minus _comment key).
# - If present, concatenate the snippet's PostToolUse / PreToolUse entries
# onto existing arrays, then de-dupe by the nested hooks[].command field
# so re-runs do not stack duplicate entries.
# - .hooks itself (the root object key) is merged with `*` — snippet wins on
# scalar keys, arrays are unioned then de-duped.
# Requires jq (already checked earlier in prerequisites). Writes atomically
# via a tmpfile in the same dir.
activate_hooks() {
local snippet="$KIT_DIR/settings-snippet.json"
local target="$HOME_DIR/.claude/settings.json"
local tmp
[ -f "$snippet" ] || { warn "no snippet at $snippet"; return 0; }
if [ ! -f "$target" ]; then
# Strip _comment, keep the rest. Create atomically.
tmp="$(mktemp "$target.XXXXXX")"
jq 'del(._comment)' "$snippet" > "$tmp"
mv "$tmp" "$target"
say "created $target from snippet (no prior settings.json)"
return 0
fi
# Merge: walk each matcher-group in PostToolUse / PreToolUse, append hooks,
# unique_by command. jq filter is written for readability, not golf.
tmp="$(mktemp "$target.XXXXXX")"
jq --slurpfile snip "$snippet" '
. as $orig
| ($snip[0] | del(._comment)) as $add
| reduce ($add.hooks | keys[]) as $phase ($orig;
.hooks[$phase] = (
((.hooks[$phase] // []) + ($add.hooks[$phase] // []))
| group_by(.matcher)
| map({
matcher: .[0].matcher,
hooks: (map(.hooks // []) | add | unique_by(.command))
})
)
)
' "$target" > "$tmp"
# Only replace if jq produced non-empty valid JSON
if [ -s "$tmp" ] && jq -e . "$tmp" >/dev/null 2>&1; then
mv "$tmp" "$target"
say "merged hooks into $target (idempotent)"
else
rm -f "$tmp"
err "jq-merge produced invalid output; $target unchanged"
return 1
fi
}
# --- prerequisites ----------------------------------------------------------
say "checking prerequisites"
if ! command -v cargo >/dev/null 2>&1; then
err "cargo not found. Install Rust: https://rustup.rs/"
exit 1
fi
# Verify cargo actually runs (catches "rustup has no default toolchain")
if ! cargo --version >/dev/null 2>&1; then
err "cargo is installed but not functional. Run: rustup default stable"
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
err "jq not found. jq is REQUIRED on any machine that will activate the"
err "KeiSeiKit hooks — without it the hooks become dead weight and would"
err "otherwise abort Claude Code's Edit/Write/Bash tool calls. Install it:"
err " brew install jq (macOS)"
err " apt install jq (Debian/Ubuntu)"
exit 1
fi
# --- create target dirs -----------------------------------------------------
say "creating directories"
mkdir -p \
"$AGENTS_DIR/_blocks" \
"$AGENTS_DIR/_manifests" \
"$AGENTS_DIR/_templates" \
"$AGENTS_DIR/_assembler/src" \
"$AGENTS_DIR/_generated" \
"$HOOKS_DIR" \
"$SKILLS_DIR/new-agent" \
"$HOME_DIR/.claude/memory"
# --- scaffold MEMORY.md placeholder (user-respecting) ----------------------
# _blocks/memory-protocol.md references ~/.claude/memory/MEMORY.md; without
# this file the first agent following the protocol fails on read.
MEMORY_INDEX="$HOME_DIR/.claude/memory/MEMORY.md"
if [[ ! -f "$MEMORY_INDEX" ]]; then
cat > "$MEMORY_INDEX" <<'EOF'
# Auto Memory — Index
> File-based memory index. Add entries as you save memory files under this directory.
> See `_blocks/memory-protocol.md` for format.
EOF
say "scaffolded $MEMORY_INDEX"
fi
# --- copy blocks (overwrite ours; blocks are SSoT from kit) ----------------
say "copying shared blocks -> $AGENTS_DIR/_blocks/"
backup_dir "$AGENTS_DIR/_blocks"
cp -f "$KIT_DIR/_blocks/"*.md "$AGENTS_DIR/_blocks/"
# --- copy generic manifests, DO NOT overwrite user's existing manifests -----
say "copying generic manifests -> $AGENTS_DIR/_manifests/ (skip if exists)"
copied=0; skipped=0
for f in "$KIT_DIR/_manifests/"*.toml; do
name="$(basename "$f")"
if [[ -f "$AGENTS_DIR/_manifests/$name" ]]; then
skipped=$((skipped+1))
else
cp "$f" "$AGENTS_DIR/_manifests/$name"
copied=$((copied+1))
fi
done
say " copied $copied, skipped $skipped (already present)"
# --- copy template ---------------------------------------------------------
# bash-3.2-portable glob detection: iterate, break on first hit.
has_templates=0
for t in "$KIT_DIR/_templates/"*.template; do
[ -f "$t" ] && { has_templates=1; break; }
done
if [ "$has_templates" = "1" ]; then
say "copying specialist template"
backup_dir "$AGENTS_DIR/_templates"
cp -f "$KIT_DIR/_templates/"*.template "$AGENTS_DIR/_templates/"
fi
# --- copy assembler source (always refresh) --------------------------------
say "copying assembler source"
backup_dir "$AGENTS_DIR/_assembler"
cp -f "$KIT_DIR/_assembler/Cargo.toml" "$AGENTS_DIR/_assembler/"
cp -f "$KIT_DIR/_assembler/src/"*.rs "$AGENTS_DIR/_assembler/src/"
if [[ -f "$KIT_DIR/_assembler/.gitignore" ]]; then
cp -f "$KIT_DIR/_assembler/.gitignore" "$AGENTS_DIR/_assembler/"
fi
# --- copy hooks (refresh; hooks are logic, not config) ---------------------
say "copying hooks -> $HOOKS_DIR/"
backup_dir "$HOOKS_DIR"
for h in assemble-agents.sh assemble-validate.sh no-hand-edit-agents.sh; do
cp -f "$KIT_DIR/hooks/$h" "$HOOKS_DIR/$h"
chmod +x "$HOOKS_DIR/$h"
done
# --- copy skills -----------------------------------------------------------
if [[ -d "$KIT_DIR/skills" ]]; then
say "copying skills"
backup_dir "$SKILLS_DIR"
for skill_dir in "$KIT_DIR/skills/"*/; do
[ -d "$skill_dir" ] || continue
skill_name="$(basename "$skill_dir")"
mkdir -p "$SKILLS_DIR/$skill_name"
cp -rf "$skill_dir"* "$SKILLS_DIR/$skill_name/" 2>/dev/null || true
say " -> $skill_name"
done
fi
# --- build assembler -------------------------------------------------------
# Prefer offline build (fresh-clone on a no-network machine should still work
# if the registry cache is warm). Fall back to online fetch on failure.
say "building Rust assembler (cargo build --release, offline first)"
if ! ( cd "$AGENTS_DIR/_assembler" && cargo build --release --offline ) 2>/tmp/keiseikit-cargo-offline.log; then
say "offline build failed — fetching deps from crates.io"
( cd "$AGENTS_DIR/_assembler" && cargo build --release )
fi
if [[ ! -x "$AGENTS_DIR/_assembler/target/release/assemble" ]]; then
err "build succeeded but binary not found at $AGENTS_DIR/_assembler/target/release/assemble"
exit 2
fi
# --- generate .md agents in-place ------------------------------------------
say "generating agent .md files (--in-place)"
AGENT_ROOT="$AGENTS_DIR" "$AGENTS_DIR/_assembler/target/release/assemble" --in-place
# --- activate hooks (flag, or interactive prompt on TTY) -------------------
SETTINGS_FILE="$HOME_DIR/.claude/settings.json"
DID_ACTIVATE=0
if [ "$ACTIVATE_HOOKS" = "1" ]; then
say "activating hooks (--activate-hooks)"
activate_hooks && DID_ACTIVATE=1
elif [ ! -f "$SETTINGS_FILE" ]; then
# No existing settings — merge is trivial, do it unconditionally.
say "no existing settings.json; installing snippet"
activate_hooks && DID_ACTIVATE=1
elif [ -t 0 ] && [ -t 1 ]; then
printf '\033[1;36m[install]\033[0m activate hooks now? [y/N] '
read -r reply
case "$reply" in
y|Y|yes|YES) activate_hooks && DID_ACTIVATE=1 ;;
*) say "skipping hook activation" ;;
esac
fi
# --- done -----------------------------------------------------------------
echo
say "install complete"
echo
if [ "$DID_ACTIVATE" = "1" ]; then
cat <<EOF
==========================================================================
Hooks activated. Settings merged into $SETTINGS_FILE
==========================================================================
To verify install:
ls $AGENTS_DIR/*.md # should show ~14 generated agents
$AGENTS_DIR/_assembler/target/release/assemble --validate
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 3 hooks (assemble-agents, assemble-validate, no-hand-edit).
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 ~14 generated agents
$AGENTS_DIR/_assembler/target/release/assemble --validate
To create a new project-specialist agent:
/new-agent
==========================================================================
EOF
fi