Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,34 @@ configured with a command that errors reports `SKIPPED` while the loop looks gre
bash ~/.config/ai-tooling/install.sh install /path/to/project # stack auto-detected
bash ~/.config/ai-tooling/install.sh install . --stack python
bash ~/.config/ai-tooling/install.sh install . --no-hooks # skip the on-edit format hook
bash ~/.config/ai-tooling/install.sh doctor . # drift, unset commands, engines
bash ~/.config/ai-tooling/install.sh doctor . # version delta, drift, unset commands
bash ~/.config/ai-tooling/install.sh uninstall . # removes generated copies, keeps .tasks/
```

## Upgrading an install

The core is copied into each project, so a change here does not reach them on its own. `upgrade` takes
them forward:

```bash
bash ~/.config/ai-tooling/install.sh upgrade /path/to/project # one project
bash ~/.config/ai-tooling/install.sh upgrade /path/to/project --dry-run # classify, write nothing
bash ~/.config/ai-tooling/install.sh upgrade --all # every project ever installed
bash ~/.config/ai-tooling/install.sh installs # which ones, and at what version
```

It re-applies the shape the project was installed with — stack, tools, skills, hooks — read back from
`.ai-tooling.json`. Changing that shape is a re-install, deliberately.

The distinction that makes it safe: the manifest records a **hash per installed file**, so an upgrade
can tell a copy that is merely out of date from one this project edited. Untouched copies are replaced
silently; an edited one is reported and **kept**, and stays kept across later upgrades, until you pass
`--force`. An edited core file is either a local fix worth upstreaming or drift worth discarding, and
only you know which — so the exit code is non-zero while any are outstanding.

`.tasks/_STACK.md` is never regenerated. `VERSION` in `install.sh` moves with every core change, which
is what lets `doctor` and `installs` report which projects are behind.

Then, once per project:

```bash
Expand Down
287 changes: 279 additions & 8 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,19 @@
# ai-tooling — install a portable AI Developer Workflow (skills + agents + gates) into a project.
#
# bash ~/.config/ai-tooling/install.sh install <target-dir> [options]
# bash ~/.config/ai-tooling/install.sh upgrade <target-dir> [--dry-run] [--force]
# bash ~/.config/ai-tooling/install.sh upgrade --all [--dry-run]
# bash ~/.config/ai-tooling/install.sh installs # every project installed from this checkout
# bash ~/.config/ai-tooling/install.sh list
# bash ~/.config/ai-tooling/install.sh doctor <target-dir>
# bash ~/.config/ai-tooling/install.sh uninstall <target-dir>
# bash ~/.config/ai-tooling/install.sh self-install # put the /adw-install skill in ~/.claude/skills
#
# `upgrade` re-copies the core using the options the project was installed with, and knows the
# difference between a file that is merely out of date and one the project edited: the manifest records
# a hash per installed file, so an untouched copy is replaced silently and a modified one is reported
# and left alone until you pass --force.
#
# Options for `install`:
# --stack <name> stack profile from stacks/ (auto-detected when omitted)
# --skills a,b,c install only these skill ids (default: all)
Expand All @@ -25,12 +33,33 @@
set -uo pipefail

SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VERSION="2.2.0"
VERSION="2.3.0"
SCRIPT_FILES="lib.sh gate.sh guard.sh engines.sh intake.sh review.sh setup-worktree.sh worktree-alloc.sh"

REGISTRY="${ADW_REGISTRY:-$HOME/.config/ai-tooling/installs}"

die() { echo "[ai-tooling] ERROR: $*" >&2; exit 1; }
info() { echo "[ai-tooling] $*" >&2; }

# Content hash of one file. Every platform has one of these three; none of them is guaranteed, so a
# machine with no hasher degrades to "differs → ask" rather than silently overwriting.
file_hash() {
if command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" 2>/dev/null | awk '{print $1}'
elif command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" 2>/dev/null | awk '{print $1}'
elif command -v openssl >/dev/null 2>&1; then openssl dgst -sha256 "$1" 2>/dev/null | awk '{print $NF}'
else return 1; fi
}
have_hasher() { file_hash "${BASH_SOURCE[0]}" >/dev/null 2>&1; }

# Every install appends itself here so `upgrade --all` has something to iterate. One line per path,
# deduplicated; a path that no longer exists is reported and skipped, never removed behind your back.
register_install() {
local path="$1"
mkdir -p "$(dirname "$REGISTRY")"
[[ -f "$REGISTRY" ]] && grep -qxF "$path" "$REGISTRY" && return 0
printf '%s\n' "$path" >> "$REGISTRY"
}

all_skills() { ls -1 "$SRC/skills/core" 2>/dev/null; }
all_agents() { ls -1 "$SRC/agents" 2>/dev/null | sed 's/\.md$//'; }
all_stacks() { ls -1 "$SRC/stacks" 2>/dev/null | sed 's/\.stack$//'; }
Expand Down Expand Up @@ -87,6 +116,67 @@ copy_file() {
fi
[[ "$DRY_RUN" == 1 ]] && { info "would write: ${dest#$TARGET/}"; return 0; }
mkdir -p "$(dirname "$dest")"; cp "$src" "$dest"
# Recorded so a later `upgrade` can tell "out of date" from "the project edited this".
WROTE_FILES+=("${dest#$TARGET/}")
}

# The manifest is what `upgrade` reads: the options this project was installed with, plus a hash of
# every file we wrote. Without the hashes an upgrade cannot tell a stale copy from an edited one, and
# has to choose between clobbering the project's changes and refusing to move at all.
write_manifest() {
local skills_csv; skills_csv="$(printf '%s,' "$@" | sed 's/,$//')"
# Hashes carried over from an upgrade that deliberately did NOT write a file come first; a fresh hash
# of a file we wrote overrides them.
local hashes="${KEEP_HASHES:-}" f h
if have_hasher; then
for f in ${WROTE_FILES[@]+"${WROTE_FILES[@]}"}; do
h="$(file_hash "$TARGET/$f")" || continue
hashes="$hashes$f $h
"
done
fi
ADW_VERSION="$VERSION" ADW_SRC="$SRC" ADW_STACK="$STACK" ADW_TOOLS="$TOOLS" \
ADW_HOOKS="$([[ $WITH_HOOKS == 1 ]] && echo true || echo false)" \
ADW_AGENTS="$([[ $WITH_AGENTS == 1 ]] && echo true || echo false)" \
ADW_SCRIPTS="$([[ $WITH_SCRIPTS == 1 ]] && echo true || echo false)" \
ADW_RULES="$([[ $WITH_RULES == 1 ]] && echo true || echo false)" \
ADW_SKILLS="$skills_csv" ADW_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" ADW_HASHES="$hashes" \
python3 - "$TARGET/.ai-tooling.json" <<'PY'
import json, os, sys
files = {}
for line in os.environ.get("ADW_HASHES", "").splitlines():
if "\t" in line:
path, h = line.split("\t", 1)
files[path] = h
doc = {
"version": os.environ["ADW_VERSION"],
"source": os.environ["ADW_SRC"],
"stack": os.environ["ADW_STACK"],
"tools": os.environ["ADW_TOOLS"],
"hooks": os.environ["ADW_HOOKS"] == "true",
"agents": os.environ["ADW_AGENTS"] == "true",
"scripts": os.environ["ADW_SCRIPTS"] == "true",
"rules": os.environ["ADW_RULES"] == "true",
"installedAt": os.environ["ADW_AT"],
"skills": [s for s in os.environ["ADW_SKILLS"].split(",") if s],
"files": files,
}
with open(sys.argv[1], "w") as fh:
json.dump(doc, fh, indent=2, sort_keys=True)
fh.write("\n")
PY
}

manifest_get() { # <target> <key> — one scalar, or a csv for a list. Empty when absent.
python3 - "$1/.ai-tooling.json" "$2" <<'PY' 2>/dev/null
import json, sys
try:
doc = json.load(open(sys.argv[1]))
except Exception:
sys.exit(0)
v = doc.get(sys.argv[2], "")
print(",".join(v) if isinstance(v, list) else ("true" if v is True else "false" if v is False else v))
PY
}

seed_file() {
Expand Down Expand Up @@ -242,6 +332,7 @@ cmd_install() {

STACK=""; SKILLS=""; TOOLS="claude,codex"
WITH_AGENTS=1; WITH_SCRIPTS=1; WITH_RULES=1; WITH_HOOKS=1; FORCE=0; DRY_RUN=0; REGEN_STACK=0
WROTE_FILES=(); KEEP_HASHES=""

while (( $# )); do
case "$1" in
Expand Down Expand Up @@ -307,10 +398,8 @@ cmd_install() {
(( WITH_HOOKS )) && install_hooks

if [[ "$DRY_RUN" != 1 ]]; then
printf '{\n "version": "%s",\n "source": "%s",\n "stack": "%s",\n "tools": "%s",\n "hooks": %s,\n "installedAt": "%s",\n "skills": [%s]\n}\n' \
"$VERSION" "$SRC" "$STACK" "$TOOLS" "$([[ $WITH_HOOKS == 1 ]] && echo true || echo false)" \
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$(printf '"%s",' "${installed[@]}" | sed 's/,$//')" \
> "$TARGET/.ai-tooling.json"
write_manifest "${installed[@]}"
register_install "$TARGET"
fi

# Files the installer writes can be silently swallowed by a global gitignore: the .codex copies get
Expand Down Expand Up @@ -341,11 +430,191 @@ cmd_install() {
EOF
}

# Classify one destination file against the new canonical and against what we last wrote there.
# current — already identical to canonical, nothing to do
# stale — differs from canonical, matches the hash we recorded → the project never touched it
# modified — differs from both → somebody edited it here; that edit is theirs to keep or discard
# unknown — no recorded hash (older manifest, or a file install skipped) → treated as modified
classify_file() {
local src="$1" dest="$2" recorded="$3"
[[ -f "$dest" ]] || { printf 'missing'; return; }
cmp -s "$src" "$dest" && { printf 'current'; return; }
[[ -n "$recorded" ]] || { printf 'unknown'; return; }
local now; now="$(file_hash "$dest")" || { printf 'unknown'; return; }
[[ "$now" == "$recorded" ]] && printf 'stale' || printf 'modified'
}

cmd_upgrade() {
local first="${1:-}"; [[ -n "$first" ]] || die "usage: upgrade <target-dir> [--dry-run] [--force] | upgrade --all"
if [[ "$first" == "--all" ]]; then shift; cmd_upgrade_all "$@"; return $?; fi

TARGET="$(cd "$first" 2>/dev/null && pwd)" || die "no such directory: $first"
shift
FORCE=0; DRY_RUN=0; REGEN_STACK=0; WROTE_FILES=(); KEEP_HASHES=""
while (( $# )); do
case "$1" in
--force) FORCE=1; shift ;;
--dry-run) DRY_RUN=1; shift ;;
*) die "unknown option: $1 (upgrade takes --dry-run and --force)" ;;
esac
done

[[ -f "$TARGET/.ai-tooling.json" ]] \
|| die "no .ai-tooling.json in $TARGET — this project was never installed from here. Use: install $TARGET"

local from; from="$(manifest_get "$TARGET" version)"
info "upgrade: $TARGET"
info " ${from:-unknown} → $VERSION (source: $SRC)"
[[ "$from" == "$VERSION" ]] && info " already at $VERSION — re-copying anyway, in case the checkout moved ahead of its own version"
have_hasher || info " WARN: no shasum/sha256sum/openssl here. Cannot tell a stale copy from an edited one;"
have_hasher || info " every differing file will be reported as modified and left alone."

# The shape of the install is the project's decision, made at install time. An upgrade re-applies the
# same shape; changing it (adding codex, dropping hooks) is a re-install, deliberately.
STACK="$(manifest_get "$TARGET" stack)"; [[ -n "$STACK" ]] || STACK="$(detect_stack "$TARGET")"
TOOLS="$(manifest_get "$TARGET" tools)"; [[ -n "$TOOLS" ]] || TOOLS="claude,codex"
SKILLS="$(manifest_get "$TARGET" skills)"
[[ -n "$SKILLS" ]] || SKILLS="$(all_skills | tr '\n' ',' | sed 's/,$//')"
WITH_HOOKS=1; [[ "$(manifest_get "$TARGET" hooks)" == "false" ]] && WITH_HOOKS=0
WITH_AGENTS=1; [[ "$(manifest_get "$TARGET" agents)" == "false" ]] && WITH_AGENTS=0
WITH_SCRIPTS=1; [[ "$(manifest_get "$TARGET" scripts)" == "false" ]] && WITH_SCRIPTS=0
WITH_RULES=1; [[ "$(manifest_get "$TARGET" rules)" == "false" ]] && WITH_RULES=0

local recorded; recorded="$(python3 - "$TARGET/.ai-tooling.json" <<'PY' 2>/dev/null
import json, sys
try:
doc = json.load(open(sys.argv[1]))
except Exception:
sys.exit(0)
for path, h in (doc.get("files") or {}).items():
print("%s\t%s" % (path, h))
PY
)"
hash_of() { printf '%s\n' "$recorded" | awk -F'\t' -v p="$1" '$1==p {print $2; exit}'; }

# Build the canonical list: source path <tab> destination path, exactly as install would write it.
local plan="" id tool a f rel
IFS=',' read -ra _skills <<< "$SKILLS"
IFS=',' read -ra _tools <<< "$TOOLS"
for id in "${_skills[@]}"; do
id="$(printf '%s' "$id" | tr -d ' ')"
[[ -d "$SRC/skills/core/$id" ]] || { info " GONE skill '$id' no longer exists in the source — left in place"; continue; }
for tool in "${_tools[@]}"; do
case "$tool" in claude) rel=".claude/skills/$id" ;; codex) rel=".codex/skills/$id" ;; *) continue ;; esac
while IFS= read -r f; do plan="$plan$SRC/skills/core/$id/$f $rel/$f
"; done < <(cd "$SRC/skills/core/$id" && find . -type f | sed 's|^\./||')
done
done
(( WITH_AGENTS )) && for a in $(all_agents); do plan="$plan$SRC/agents/$a.md .claude/agents/$a.md
"; done
(( WITH_SCRIPTS )) && for f in $SCRIPT_FILES; do plan="$plan$SRC/scripts/$f scripts/ai/$f
"; done
plan="$plan$SRC/templates/_dev-prompt-template.md .tasks/_dev-prompt-template.md
$SRC/templates/GROOM_LOG.md .tasks/_templates/GROOM_LOG.md
"

local n_current=0 n_stale=0 n_modified=0 n_missing=0 modified_list="" verdict src dest
while IFS=' ' read -r src dest; do
[[ -n "$dest" ]] || continue
verdict="$(classify_file "$src" "$TARGET/$dest" "$(hash_of "$dest")")"
case "$verdict" in
current) n_current=$(( n_current + 1 )); WROTE_FILES+=("$dest") ;;
missing) n_missing=$(( n_missing + 1 ))
[[ "$DRY_RUN" == 1 ]] && info " ADD $dest" || { mkdir -p "$TARGET/$(dirname "$dest")"; cp "$src" "$TARGET/$dest"; WROTE_FILES+=("$dest"); } ;;
stale) n_stale=$(( n_stale + 1 ))
[[ "$DRY_RUN" == 1 ]] && info " UPDATE $dest" || { cp "$src" "$TARGET/$dest"; WROTE_FILES+=("$dest"); } ;;
*) n_modified=$(( n_modified + 1 )); modified_list="$modified_list $dest
"
if (( FORCE )) && [[ "$DRY_RUN" != 1 ]]; then
cp "$src" "$TARGET/$dest"; WROTE_FILES+=("$dest")
else
# Carry the OLD hash forward. Re-hashing a file we chose not to write would record the
# local edit as ours, and the next upgrade would read it as merely stale and clobber
# it — losing exactly the edit this branch exists to protect.
KEEP_HASHES="$KEEP_HASHES$dest $(hash_of "$dest")
"
fi
if [[ "$DRY_RUN" == 1 ]]; then
info " MODIFIED $dest $( (( FORCE )) && echo '(--force would overwrite)' || echo '(kept)')"
fi ;;
esac
done <<< "$plan"

[[ "$DRY_RUN" != 1 && "$WITH_SCRIPTS" == 1 ]] && chmod +x "$TARGET"/scripts/ai/*.sh 2>/dev/null
info " $n_current current · $n_stale updated · $n_missing added · $n_modified modified here"
if (( n_modified )); then
if (( FORCE )); then
info " --force: the local edits above were overwritten."
else
info " Kept as they are — an edited core file is either a local fix worth upstreaming or drift"
info " worth discarding, and only you know which:"
printf '%s' "$modified_list" >&2
info " Overwrite them with: install.sh upgrade $TARGET --force"
fi
fi

if [[ "$DRY_RUN" == 1 ]]; then info " DRY RUN — nothing was written"; return 0; fi

# .tasks/_STACK.md is never regenerated: it is the project's own configuration.
(( WITH_RULES )) && install_rules
(( WITH_HOOKS )) && install_hooks

IFS=',' read -ra _installed <<< "$SKILLS"
write_manifest "${_installed[@]}"
register_install "$TARGET"
info " manifest updated → .ai-tooling.json (version $VERSION)"
# Files left behind means the upgrade is not fully applied; --force leaves nothing behind.
(( n_modified )) && (( ! FORCE )) && return 1
return 0
}

cmd_upgrade_all() {
local args=("$@")
[[ -f "$REGISTRY" ]] || die "no installs recorded yet ($REGISTRY). Install somewhere first, or upgrade one path by name."
local path rc=0 seen=0
while IFS= read -r path; do
[[ -n "$path" ]] || continue
seen=$(( seen + 1 ))
if [[ ! -f "$path/.ai-tooling.json" ]]; then
info "SKIP $path — gone, moved, or uninstalled (still listed in $REGISTRY)"; continue
fi
echo >&2
cmd_upgrade "$path" ${args[@]+"${args[@]}"} || rc=1
done < "$REGISTRY"
echo >&2
info "upgrade --all: $seen path(s) in the registry"
(( rc )) && info "at least one project has locally modified core files — see above"
return $rc
}

cmd_installs() {
[[ -f "$REGISTRY" ]] || { echo "no installs recorded ($REGISTRY)"; return 0; }
echo "ai-tooling installs — canonical $VERSION at $SRC"
local path v
while IFS= read -r path; do
[[ -n "$path" ]] || continue
if [[ ! -f "$path/.ai-tooling.json" ]]; then printf ' %-52s %s\n' "$path" "GONE"; continue; fi
v="$(manifest_get "$path" version)"
printf ' %-52s %s%s\n' "$path" "${v:-unknown}" \
"$([[ "$v" == "$VERSION" ]] && echo "" || echo " ← differs from canonical")"
done < "$REGISTRY"
}

cmd_doctor() {
local target="${1:-}"; [[ -n "$target" ]] || die "usage: doctor <target-dir>"
target="$(cd "$target" && pwd)"
echo "ai-tooling doctor — $target"
[[ -f "$target/.ai-tooling.json" ]] && cat "$target/.ai-tooling.json" || echo " (never installed here)"
if [[ -f "$target/.ai-tooling.json" ]]; then
local iv; iv="$(manifest_get "$target" version)"
echo " installed: ${iv:-unknown} canonical: $VERSION ($SRC)"
if [[ -n "$iv" && "$iv" != "$VERSION" ]]; then
echo " BEHIND — bring it forward with: install.sh upgrade $target"
fi
echo " stack: $(manifest_get "$target" stack) tools: $(manifest_get "$target" tools) hooks: $(manifest_get "$target" hooks)"
echo " installed at: $(manifest_get "$target" installedAt)"
else
echo " (never installed here)"
fi
echo
local drift=0
for id in $(all_skills); do
Expand Down Expand Up @@ -431,10 +700,12 @@ cmd_uninstall() {

case "${1:-}" in
install) shift; cmd_install "$@" ;;
upgrade) shift; cmd_upgrade "$@" ;;
installs) cmd_installs ;;
self-install) cmd_self_install ;;
list|"") cmd_list ;;
doctor) shift; cmd_doctor "$@" ;;
uninstall) shift; cmd_uninstall "$@" ;;
-h|--help) sed -n '2,24p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' ;;
*) die "unknown command: $1 (install|self-install|list|doctor|uninstall)" ;;
-h|--help) sed -n '2,30p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' ;;
*) die "unknown command: $1 (install|upgrade|installs|self-install|list|doctor|uninstall)" ;;
esac
Loading
Loading