|
#!/usr/bin/env bash |
|
# install-agent-guidance.sh |
|
# |
|
# Universal guidance installer / remover for existing coding-agent config dirs. |
|
# Bash 3.2 compatible (macOS). |
|
# |
|
# No arguments: auto-toggle detected default targets. |
|
# - If every detected target is managed by this script, remove it from all. |
|
# - Otherwise, install it into all unmanaged detected targets. |
|
# |
|
# The canonical policy is never copied into a missing target: missing targets |
|
# become symlinks. Existing regular target files are extended non-destructively. |
|
|
|
set -euo pipefail |
|
IFS=$'\n\t' |
|
|
|
CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" |
|
POLICY="${AGENT_GUIDANCE_POLICY:-$CONFIG_HOME/agent-guidance/AGENTS.md}" |
|
HERMES_POLICY="" |
|
STATE="auto" # auto | on | off |
|
MODE="auto" # auto | snapshot | import |
|
TARGET_SPEC="all" # all | comma-separated list |
|
DRY_RUN=0 |
|
REFRESH=0 |
|
|
|
BEGIN_MARKER='<!-- BEGIN install-agent-guidance -->' |
|
END_MARKER='<!-- END install-agent-guidance -->' |
|
|
|
usage() { |
|
cat <<'USAGE' |
|
Usage: install-agent-guidance.sh [options] |
|
|
|
No options means: auto-toggle all detected global coding-agent targets. |
|
|
|
Options: |
|
--on | --off | --auto Explicit desired state (default: --auto). |
|
--dry-run Show actions without writing anything. |
|
--targets LIST all (default), or comma-separated: |
|
codex,zcode,claude,gemini,hermes |
|
--policy PATH Canonical shared policy. Default: |
|
~/.config/agent-guidance/AGENTS.md |
|
--hermes-policy PATH A SHORT Hermes-only SOUL addendum. Required only |
|
when targeting Hermes. It is appended to an |
|
existing ~/.hermes/SOUL.md; this script never |
|
creates SOUL.md from a coding-policy file. |
|
--mode auto|snapshot|import |
|
Override handling for existing regular files. |
|
auto = snapshot for Codex/ZCode; import for |
|
Claude/Gemini; snapshot for Hermes. |
|
--refresh Rebuild the managed block from the current source |
|
when running --on. Does not touch other content. |
|
-h, --help Show this help. |
|
|
|
Detection: |
|
Existing ~/.codex, ~/.zcode, ~/.claude, ~/.gemini, and ~/.hermes directories |
|
are treated as installed/configured. No executable probing is performed. |
|
|
|
Safety: |
|
- Missing target files become symlinks to the canonical source. |
|
- Existing regular files are backed up before this script changes them. |
|
- Existing foreign symlinks and malformed managed blocks are never changed. |
|
USAGE |
|
} |
|
|
|
die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } |
|
info() { printf '%s\n' "$*"; } |
|
|
|
canonical_existing_file() { |
|
local path="$1" |
|
[[ -f "$path" && -r "$path" ]] || die "not a readable regular file: $path" |
|
( |
|
cd -P "$(dirname "$path")" |
|
printf '%s/%s\n' "$PWD" "$(basename "$path")" |
|
) |
|
} |
|
|
|
agent_dir() { |
|
case "$1" in |
|
codex) printf '%s\n' "$HOME/.codex" ;; |
|
zcode) printf '%s\n' "$HOME/.zcode" ;; |
|
claude) printf '%s\n' "$HOME/.claude" ;; |
|
gemini) printf '%s\n' "$HOME/.gemini" ;; |
|
hermes) printf '%s\n' "$HOME/.hermes" ;; |
|
*) die "unknown target: $1" ;; |
|
esac |
|
} |
|
|
|
agent_file() { |
|
case "$1" in |
|
codex|zcode) printf '%s\n' 'AGENTS.md' ;; |
|
claude) printf '%s\n' 'CLAUDE.md' ;; |
|
gemini) printf '%s\n' 'GEMINI.md' ;; |
|
hermes) printf '%s\n' 'SOUL.md' ;; |
|
*) die "unknown target: $1" ;; |
|
esac |
|
} |
|
|
|
default_mode() { |
|
case "$1" in |
|
codex|zcode|hermes) printf '%s\n' 'snapshot' ;; |
|
claude|gemini) printf '%s\n' 'import' ;; |
|
*) die "unknown target: $1" ;; |
|
esac |
|
} |
|
|
|
source_for() { |
|
if [[ "$1" == 'hermes' ]]; then |
|
printf '%s\n' "$HERMES_POLICY" |
|
else |
|
printf '%s\n' "$POLICY" |
|
fi |
|
} |
|
|
|
target_path() { |
|
local agent="$1" |
|
printf '%s/%s\n' "$(agent_dir "$agent")" "$(agent_file "$agent")" |
|
} |
|
|
|
is_valid_agent() { |
|
case "$1" in codex|zcode|claude|gemini|hermes) return 0;; *) return 1;; esac |
|
} |
|
|
|
managed_state() { |
|
# Prints: on | off | blocked |
|
local target="$1" |
|
local begin_count end_count |
|
|
|
if [[ -L "$target" ]]; then |
|
# The caller compares it against its source separately. |
|
printf '%s\n' 'symlink' |
|
return |
|
fi |
|
|
|
if [[ ! -e "$target" ]]; then |
|
printf '%s\n' 'off' |
|
return |
|
fi |
|
|
|
if [[ ! -f "$target" ]]; then |
|
printf '%s\n' 'blocked' |
|
return |
|
fi |
|
|
|
begin_count="$(grep -Fxc "$BEGIN_MARKER" "$target" 2>/dev/null || true)" |
|
end_count="$(grep -Fxc "$END_MARKER" "$target" 2>/dev/null || true)" |
|
|
|
if [[ "$begin_count" == '0' && "$end_count" == '0' ]]; then |
|
printf '%s\n' 'off' |
|
elif [[ "$begin_count" == '1' && "$end_count" == '1' ]]; then |
|
printf '%s\n' 'on' |
|
else |
|
printf '%s\n' 'blocked' |
|
fi |
|
} |
|
|
|
backup_file() { |
|
local target="$1" |
|
local backup="${target}.bak.$(date +%Y%m%d-%H%M%S).$$" |
|
cp -p "$target" "$backup" || die "could not create backup: $backup" |
|
info " backup: $backup" |
|
} |
|
|
|
strip_managed_block_to_tmp() { |
|
local target="$1" |
|
local tmp="$2" |
|
|
|
awk -v begin="$BEGIN_MARKER" -v end="$END_MARKER" ' |
|
$0 == begin { in_block = 1; next } |
|
$0 == end { |
|
if (!in_block) exit 42 |
|
in_block = 0 |
|
next |
|
} |
|
!in_block { print } |
|
END { if (in_block) exit 42 } |
|
' "$target" > "$tmp" |
|
} |
|
|
|
append_block() { |
|
local target="$1" |
|
local source="$2" |
|
local mode="$3" |
|
|
|
{ |
|
printf '\n\n%s\n' "$BEGIN_MARKER" |
|
printf '<!-- mode: %s; source: %s -->\n\n' "$mode" "$source" |
|
if [[ "$mode" == 'import' ]]; then |
|
printf '@%s\n' "$source" |
|
else |
|
cat "$source" |
|
fi |
|
printf '\n%s\n' "$END_MARKER" |
|
} >> "$target" |
|
} |
|
|
|
remove_block() { |
|
local target="$1" |
|
local tmp |
|
tmp="$(mktemp "${target}.tmp.XXXXXX")" |
|
|
|
strip_managed_block_to_tmp "$target" "$tmp" || { |
|
rm -f "$tmp" |
|
die "managed block is malformed in: $target" |
|
} |
|
|
|
if [[ "$DRY_RUN" -eq 1 ]]; then |
|
rm -f "$tmp" |
|
info " would remove managed block" |
|
return |
|
fi |
|
|
|
backup_file "$target" |
|
cat "$tmp" > "$target" |
|
rm -f "$tmp" |
|
info " removed managed block" |
|
} |
|
|
|
resolve_mode() { |
|
local agent="$1" |
|
if [[ "$MODE" == 'auto' ]]; then |
|
default_mode "$agent" |
|
else |
|
printf '%s\n' "$MODE" |
|
fi |
|
} |
|
|
|
status_for() { |
|
local agent="$1" |
|
local target source raw |
|
target="$(target_path "$agent")" |
|
source="$(source_for "$agent")" |
|
raw="$(managed_state "$target")" |
|
|
|
if [[ "$raw" == 'symlink' ]]; then |
|
if [[ -n "$source" && "$target" -ef "$source" ]]; then |
|
printf '%s\n' 'on' |
|
else |
|
printf '%s\n' 'blocked' |
|
fi |
|
else |
|
printf '%s\n' "$raw" |
|
fi |
|
} |
|
|
|
install_one() { |
|
local agent="$1" |
|
local target source mode status tmp |
|
target="$(target_path "$agent")" |
|
source="$(source_for "$agent")" |
|
mode="$(resolve_mode "$agent")" |
|
status="$(status_for "$agent")" |
|
|
|
[[ -f "$source" && -r "$source" ]] || die "source for $agent is not readable: $source" |
|
|
|
if [[ "$agent" == 'hermes' && ! -f "$target" && ! -L "$target" ]]; then |
|
info "SKIP [Hermes]: SOUL.md does not exist; let Hermes seed it first, then rerun." |
|
return |
|
fi |
|
|
|
case "$status" in |
|
blocked) |
|
info "SKIP [$agent]: foreign symlink, non-regular target, or malformed managed block: $target" |
|
return |
|
;; |
|
on) |
|
if [[ "$REFRESH" -eq 0 ]]; then |
|
info "OK [$agent]: already managed" |
|
return |
|
fi |
|
if [[ -L "$target" ]]; then |
|
info "OK [$agent]: canonical symlink needs no refresh" |
|
return |
|
fi |
|
;; |
|
off) |
|
;; |
|
*) die "unexpected state for $agent: $status" ;; |
|
esac |
|
|
|
if [[ ! -e "$target" && ! -L "$target" ]]; then |
|
if [[ "$DRY_RUN" -eq 1 ]]; then |
|
info "PLAN [$agent]: symlink $target -> $source" |
|
else |
|
ln -s "$source" "$target" |
|
info "DONE [$agent]: symlinked $target -> $source" |
|
fi |
|
return |
|
fi |
|
|
|
[[ -f "$target" ]] || { |
|
info "SKIP [$agent]: target is not a regular file: $target" |
|
return |
|
} |
|
|
|
if [[ "$status" == 'on' ]]; then |
|
if [[ "$DRY_RUN" -eq 1 ]]; then |
|
info "PLAN [$agent]: refresh managed $mode block in $target" |
|
return |
|
fi |
|
backup_file "$target" |
|
tmp="$(mktemp "${target}.tmp.XXXXXX")" |
|
strip_managed_block_to_tmp "$target" "$tmp" || { |
|
rm -f "$tmp" |
|
die "managed block is malformed in: $target" |
|
} |
|
cat "$tmp" > "$target" |
|
rm -f "$tmp" |
|
append_block "$target" "$source" "$mode" |
|
info "DONE [$agent]: refreshed managed $mode block" |
|
return |
|
fi |
|
|
|
if [[ "$DRY_RUN" -eq 1 ]]; then |
|
info "PLAN [$agent]: append managed $mode block to $target" |
|
else |
|
backup_file "$target" |
|
append_block "$target" "$source" "$mode" |
|
info "DONE [$agent]: appended managed $mode block" |
|
fi |
|
} |
|
|
|
remove_one() { |
|
local agent="$1" |
|
local target source status |
|
target="$(target_path "$agent")" |
|
source="$(source_for "$agent")" |
|
status="$(status_for "$agent")" |
|
|
|
case "$status" in |
|
off) |
|
info "OK [$agent]: not managed" |
|
;; |
|
blocked) |
|
info "SKIP [$agent]: foreign symlink, non-regular target, or malformed managed block: $target" |
|
;; |
|
on) |
|
if [[ -L "$target" ]]; then |
|
if [[ "$DRY_RUN" -eq 1 ]]; then |
|
info "PLAN [$agent]: remove managed symlink $target" |
|
else |
|
rm "$target" |
|
info "DONE [$agent]: removed managed symlink $target" |
|
fi |
|
else |
|
if [[ "$DRY_RUN" -eq 1 ]]; then |
|
info "PLAN [$agent]: remove managed block from $target" |
|
else |
|
remove_block "$target" |
|
info "DONE [$agent]: removed managed block" |
|
fi |
|
fi |
|
;; |
|
esac |
|
} |
|
|
|
add_agent_once() { |
|
local candidate="$1" |
|
local existing |
|
for existing in "${SELECTED[@]:-}"; do |
|
[[ "$existing" == "$candidate" ]] && return |
|
done |
|
SELECTED+=("$candidate") |
|
} |
|
|
|
while [[ $# -gt 0 ]]; do |
|
case "$1" in |
|
--on) STATE='on'; shift ;; |
|
--off) STATE='off'; shift ;; |
|
--auto) STATE='auto'; shift ;; |
|
--dry-run) DRY_RUN=1; shift ;; |
|
--refresh) REFRESH=1; shift ;; |
|
--targets) |
|
[[ $# -ge 2 ]] || die '--targets requires a value' |
|
TARGET_SPEC="$2"; shift 2 ;; |
|
--policy) |
|
[[ $# -ge 2 ]] || die '--policy requires a value' |
|
POLICY="$2"; shift 2 ;; |
|
--hermes-policy) |
|
[[ $# -ge 2 ]] || die '--hermes-policy requires a value' |
|
HERMES_POLICY="$2"; shift 2 ;; |
|
--mode) |
|
[[ $# -ge 2 ]] || die '--mode requires a value' |
|
MODE="$2"; shift 2 ;; |
|
-h|--help) usage; exit 0 ;; |
|
*) die "unknown option: $1" ;; |
|
esac |
|
done |
|
|
|
case "$STATE" in auto|on|off) ;; *) die '--on, --off, or --auto expected' ;; esac |
|
case "$MODE" in auto|snapshot|import) ;; *) die '--mode must be auto, snapshot, or import' ;; esac |
|
|
|
POLICY="$(canonical_existing_file "$POLICY")" |
|
if [[ -n "$HERMES_POLICY" ]]; then |
|
HERMES_POLICY="$(canonical_existing_file "$HERMES_POLICY")" |
|
fi |
|
|
|
SELECTED=() |
|
IFS=',' read -r -a requested <<< "$TARGET_SPEC" |
|
for item in "${requested[@]}"; do |
|
if [[ "$item" == 'all' ]]; then |
|
add_agent_once codex |
|
add_agent_once zcode |
|
add_agent_once claude |
|
add_agent_once gemini |
|
else |
|
is_valid_agent "$item" || die "unknown target in --targets: $item" |
|
add_agent_once "$item" |
|
fi |
|
done |
|
|
|
ELIGIBLE=() |
|
for agent in "${SELECTED[@]}"; do |
|
dir="$(agent_dir "$agent")" |
|
if [[ ! -d "$dir" ]]; then |
|
info "SKIP [$agent]: config directory not present: $dir" |
|
continue |
|
fi |
|
if [[ "$agent" == 'hermes' && -z "$HERMES_POLICY" ]]; then |
|
info "SKIP [hermes]: provide --hermes-policy PATH to manage a short SOUL addendum" |
|
continue |
|
fi |
|
ELIGIBLE+=("$agent") |
|
done |
|
|
|
[[ ${#ELIGIBLE[@]} -gt 0 ]] || { |
|
info 'No eligible configured targets found.' |
|
exit 0 |
|
} |
|
|
|
if [[ "$STATE" == 'auto' ]]; then |
|
all_on=1 |
|
for agent in "${ELIGIBLE[@]}"; do |
|
status="$(status_for "$agent")" |
|
[[ "$status" == 'on' ]] || all_on=0 |
|
done |
|
if [[ "$all_on" -eq 1 ]]; then |
|
STATE='off' |
|
else |
|
STATE='on' |
|
fi |
|
fi |
|
|
|
if [[ "$DRY_RUN" -eq 1 ]]; then |
|
info "State: $STATE (dry run)" |
|
else |
|
info "State: $STATE" |
|
fi |
|
for agent in "${ELIGIBLE[@]}"; do |
|
if [[ "$STATE" == 'on' ]]; then |
|
install_one "$agent" |
|
else |
|
remove_one "$agent" |
|
fi |
|
done |