app
hook
Public Made by Adomby adom
Registry-native cross-agent auto-update hook. Keeps adom/core apps and skills current from wiki.adom.inc on every Claude Code and Codex prompt, in the background.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
#!/usr/bin/env bash
# Wire the adom auto-update hook into Claude Code and Codex, idempotently, and
# retire any legacy update hook (gallia's, or the pre-rename check-updates.sh) so
# an existing workspace MIGRATES to the registry-native hook instead of stacking
# both. The current hook is adom-core-update.sh, which is preserved.
set -euo pipefail
PKG_DIR="$(cd "$(dirname "$0")" && pwd)"
HOOK_DIR="${HOME}/.adom/hooks"
HOOK="${HOOK_DIR}/adom-core-update.sh"
mkdir -p "$HOOK_DIR"
install -m 0755 "$PKG_DIR/check-updates.sh" "$HOOK"
HOOK_CMD="bash $HOOK"
# Drop the stale launcher file from older adom/hook installs (now adom-core-update.sh).
rm -f "${HOOK_DIR}/check-updates.sh" 2>/dev/null || true
if ! command -v python3 >/dev/null 2>&1; then
echo " warning: python3 not found; cannot wire agent configs. Hook script staged at $HOOK." >&2
exit 0
fi
# Wire HOOK_CMD into one agent config's hooks.UserPromptSubmit, removing any legacy
# auto-update hook first. $1 = config path, $2 = "1" to include a timeout field.
wire() {
local path="$1" with_timeout="$2"
mkdir -p "$(dirname "$path")"
python3 - "$path" "$HOOK_CMD" "$with_timeout" <<'PY'
import json, sys
path, cmd, with_timeout = sys.argv[1], sys.argv[2], sys.argv[3] == "1"
try:
with open(path) as f: d = json.load(f)
if not isinstance(d, dict): d = {}
except Exception:
d = {}
ups = d.setdefault("hooks", {}).setdefault("UserPromptSubmit", [])
# A legacy update hook is any command pointing at a check-updates.sh (gallia's
# hooks/check-updates.sh, or this package's own pre-rename name). adom-core-update.sh
# does not match, so the current hook is never removed.
def is_legacy(h):
return isinstance(h, dict) and "check-updates.sh" in str(h.get("command", ""))
removed = 0
kept_groups = []
for g in ups:
if not isinstance(g, dict):
kept_groups.append(g); continue
hooks = g.get("hooks", [])
keep = [h for h in hooks if not is_legacy(h)]
removed += len(hooks) - len(keep)
if keep:
g["hooks"] = keep
kept_groups.append(g)
ups[:] = kept_groups
present = any(
isinstance(h, dict) and h.get("command") == cmd
for g in ups if isinstance(g, dict)
for h in g.get("hooks", [])
)
changed = removed > 0
if not present:
entry = {"type": "command", "command": cmd}
if with_timeout:
entry["timeout"] = 15
ups.append({"hooks": [entry]})
changed = True
if changed:
with open(path, "w") as f:
json.dump(d, f, indent=2)
note = " (retired %d legacy hook(s))" % removed if removed else ""
print(" wired adom-core-update into %s%s" % (path, note))
else:
print(" adom-core-update already wired in %s" % path)
PY
}
wire "${HOME}/.claude/settings.json" 1
wire "${HOME}/.codex/hooks.json" 0
# ---------------------------------------------------------------------------
# Headless trigger (adom/hook #1). UserPromptSubmit only fires inside Claude Code
# / Codex, so a lite/service container -- arguably where currency matters MOST,
# since that is where prod releases run -- has no trigger at all and rots until
# someone SSHes in.
#
# Register cron ALWAYS, not only when Claude is absent. The script's throttle
# stamp (ADOM_HOOK_INTERVAL, default 30m) makes a second trigger a no-op, so this
# is strictly additive: an interactive box keeps the fast prompt trigger AND gains
# a backstop for when it sits idle for a day. Auto-detecting "no Claude here"
# would leave that idle gap open and adds a branch that can guess wrong.
#
# Note stdout is meaningless under cron (it becomes mail or /dev/null) -- the
# observable surface for a headless box is the status/log file the script writes
# (~/.adom/last-update.status, ~/.adom/last-update.log), plus the CLI version it
# reports to the registry on each update.
CRON_LINE_INTERVAL="*/30 * * * * $HOOK_CMD >/dev/null 2>&1"
CRON_LINE_REBOOT="@reboot $HOOK_CMD >/dev/null 2>&1"
if command -v crontab >/dev/null 2>&1; then
# Idempotent: strip any prior adom-core-update entries, then re-add both.
if { crontab -l 2>/dev/null | grep -v "adom-core-update.sh" || true; \
echo "$CRON_LINE_INTERVAL"; echo "$CRON_LINE_REBOOT"; } | crontab - 2>/dev/null; then
echo " registered cron for headless updates (*/30 + @reboot)"
if ! pgrep -x crond >/dev/null 2>&1 && ! pgrep -x cron >/dev/null 2>&1; then
echo " warning: crond is NOT running, so the schedule will not fire. Start cron in this image," >&2
echo " or have the app call '$HOOK' on its own loop." >&2
fi
else
echo " warning: could not write the crontab; this container will only update on a Claude/Codex prompt." >&2
fi
else
echo " note: no crontab in this image, so there is no headless trigger. Either install cron, or have the"
echo " app call '$HOOK' on its own interval (it is throttled + idempotent, safe to call often)."
fi
# Default model (fleet policy, 2026-07-17): new/unset workspaces default to Opus
# with the 1M context window. Sonnet cold-starts describe the platform skills
# without ACTING on them (observed: it never ran the identity/profile lookups an
# Opus session does), so an unconfigured container gives a visibly worse first
# session. ONLY-IF-ABSENT: a user's explicit model choice (or an org's managed
# settings) is never overwritten -- change it any time with /model, and this
# respects that choice on every later run. ADOM_HOOK_NO_MODEL_DEFAULT=1 skips.
if [ -z "${ADOM_HOOK_NO_MODEL_DEFAULT:-}" ]; then
python3 - "${HOME}/.claude/settings.json" <<'PY'
import json, sys
path = sys.argv[1]
try:
with open(path) as f: d = json.load(f)
if not isinstance(d, dict): d = {}
except Exception:
d = {}
if not d.get("model"):
d["model"] = "opus[1m]"
import os
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as f: json.dump(d, f, indent=2)
print(" set default Claude Code model: opus[1m] (override any time with /model)")
PY
fi
# Retire a gallia statusline if one is wired (gallia is gone; revert to default).
# Targeted: only removed when the statusline command points at gallia.
python3 - "${HOME}/.claude/settings.json" <<'PY'
import json, sys
path = sys.argv[1]
try:
with open(path) as f: d = json.load(f)
if not isinstance(d, dict): raise ValueError
except Exception:
sys.exit(0)
sl = d.get("statusLine")
if isinstance(sl, dict) and "gallia" in str(sl.get("command", "")):
d.pop("statusLine", None)
with open(path, "w") as f: json.dump(d, f, indent=2)
print(" retired the gallia statusline (reverted to default)")
PY
# Stage the skill doc for discovery.
if [ -f "$PKG_DIR/SKILL.md" ]; then
mkdir -p "$HOME/.claude/skills/adom-hook"
cp "$PKG_DIR/SKILL.md" "$HOME/.claude/skills/adom-hook/SKILL.md"
fi
echo "Installed adom-hook (auto-update wired for Claude + Codex; checks every ${ADOM_HOOK_INTERVAL:-1800}s)"