diff --git a/host-setup/README.md b/host-setup/README.md index a54ed996..f079d8de 100644 --- a/host-setup/README.md +++ b/host-setup/README.md @@ -6,6 +6,7 @@ What a machine needs before it can be worked in, and the tooling that puts it th - [`bootstrap.sh`][bootstrap] stands a Debian or Ubuntu host up from nothing. It is the one file fetched on its own, because a host with no git and no checkout is what it exists to fix. It fetches this repository and runs the tooling from that tree. - [`bootstrap.ps1`][bootstrap-ps1] does the same for native Windows. It runs under Windows PowerShell 5.1, the version every fresh Windows host guarantees, and hands off to PowerShell 7 once it has found or installed it, since every script it drives requires that version. +- [`menu.sh`][menu] is a human-facing front end over this repository's tooling: the host actions above, plus the repo-level tools ([`spec/audit.py`][audit-runner], [`scripts/carry.py`][carry], [`scripts/build_dist.py`][build-dist]) most of this repository authors for an agent following instructions rather than for a person choosing from a menu. It has no Windows counterpart yet. - [`linux/`][linux] holds the tooling itself, for Debian and Ubuntu based hosts, Proxmox and WSL included. `install-tools.sh` installs and upgrades the host tools, `upgrade-host.sh` upgrades the packages of the current release or moves to the next one, `setup-github.sh` configures the SSH key, git, and commit signing, and `install-skills.sh` drives the hub's skills installer from the same tree. - [`windows/`][windows] holds the tooling for native Windows, through `winget` and PowerShell 7. `install-tools.ps1` installs and upgrades the host tools, `upgrade-host.ps1` upgrades the winget packages and updates the WSL platform, `setup-github.ps1` configures the SSH key, git, and commit signing, `setup-wsl.ps1` installs a WSL distribution and reports the Docker Desktop integration, and `install-skills.ps1` drives the hub's skills installer from the same tree. - [`agent-safety/`][agent-safety] holds the write-safety guards, deployed per machine and per account. @@ -67,6 +68,20 @@ host-setup\windows\setup-github.ps1 -Status host-setup\windows\setup-wsl.ps1 -Status ``` +## The Human Menu + +`menu.sh` is fetchable on its own too, the same three-line shape as `bootstrap.sh`, and it also runs directly from a checkout that already has one: + +```shell +curl -fsSLo menu.sh https://raw.githubusercontent.com/ptr727/ProjectTemplate/main/host-setup/menu.sh +bash menu.sh +host-setup/menu.sh # from a checkout of this repository, or of any other repo in the fleet +``` + +It answers a different question than `bootstrap.sh` does. `bootstrap.sh` stands a host up and stops. `menu.sh` loops, because a person sitting down with it usually wants more than one thing done in a sitting, and it tells the hub apart from whichever repo it happens to be run from: a checkout of `ptr727/ProjectTemplate` gets the hub tasks (audit a cataloged repo, check the generated Skills distributions), a checkout of any other repo gets the downstream tasks too (check or pull the hub's verbatim-owned files into that repo's own worktree, per [`scripts/carry.py`][carry]), and every run gets the host tasks this directory's `linux/` tooling already provides. Only the downstream tasks need a repo to run from. The hub tasks fetch the hub themselves when there is no local checkout to reuse, so they still show and still work when the menu is run entirely standalone, off no checkout at all. + +Reaching `spec/audit.py` and `scripts/carry.py` from outside a hub checkout means fetching one, the same "hosted and reached, never carried" model [`scripts/README.md`][scripts-readme] states for those tools generally. `menu.sh` clones fresh rather than reusing `bootstrap.sh`'s tarball, since `scripts/carry.py` itself checks that its hub argument is a real git checkout on a freshly fetched `origin/main` with no local changes, and a full clone rather than a shallow one, since `spec/audit.py` walks the hub's own commit history to judge whether a carried copy is trailing the file it was copied from. Run from inside the hub itself, that same freshness is confirmed against the local checkout before a hub task uses it, falling back to a fresh clone when the local checkout has moved on, so an audit or a Skills-distribution check never silently reads a stale or feature-branch tree. + ## Which Revision a Run Used `bootstrap.sh` resolves the ref it was given to the commit it names, prints that commit, and downloads that exact revision. A run therefore says which revision of the tooling it used, and a second run of the same ref cannot silently be a different tree. Where the resolve fails, which an unauthenticated rate limit can cause, the run says it cannot attribute itself and continues, since the download itself is unaffected. @@ -77,7 +92,7 @@ host-setup\windows\setup-wsl.ps1 -Status **Group by whichever axis has one member.** `agent-safety/` is one concern across three platforms, so it is a concern directory holding `install.sh`, `install.ps1` and `install.py`. `linux/` is three concerns on one platform, so it is a platform directory. Windows host tooling therefore sits at `windows/` rather than beside the Linux scripts, because the `winget` equivalent of `install-tools.sh` is a different program rather than a translation of one. It carries one registry record per tool where the Linux script carries four functions, since every Windows source is `winget` and the per-tool variation those functions exist for does not arise. `windows/` also carries a fourth script with no Linux peer, because WSL is a Windows-side concern. The loader is the same shape as `agent-safety/`, not as `linux/`/`windows/`: one concern, two platforms, so `bootstrap.ps1` sits beside `bootstrap.sh` at the top level rather than inside `windows/`. -**Nothing here needs Python to stand a host up, and neither loader needs an interpreter to fetch what it drives.** [`docs/host-setup.md`][host-setup] carries that as part of the contract, with the reasoning. `bootstrap.sh` needs only `curl` and `tar`. `bootstrap.ps1` needs only `tar.exe`, which has shipped with Windows since 1803, and installs its one further dependency, `pwsh`, itself through `winget`. The one exception is the `install-skills` pair, which drives the Python installer at `scripts/skills_install.py` and runs last in a stand-up for exactly that reason: `install-tools` has provided the interpreter by then, and run alone on a host without one it stops and names the tools step as its prerequisite. Neither loader runs a gate as a closing step: [`scripts/host_gate.py`][host-gate] measures a host against the floors and is not called from here, and nothing here is called from it. A host set up by hand years ago is an ordinary host, so the gate reports what it is missing and running this tooling is a remedy a person chooses. The two are joined at code time instead, by [`scripts/tests/test_bootstrap.py`][test-bootstrap] asserting that every tool the spec requires is one this tooling can provide. +**Nothing here needs Python to stand a host up, and neither loader needs an interpreter to fetch what it drives.** [`docs/host-setup.md`][host-setup] carries that as part of the contract, with the reasoning. `bootstrap.sh` needs only `curl` and `tar`. `bootstrap.ps1` needs only `tar.exe`, which has shipped with Windows since 1803, and installs its one further dependency, `pwsh`, itself through `winget`. The one exception is the `install-skills` pair, which drives the Python installer at `scripts/skills_install.py` and runs last in a stand-up for exactly that reason: `install-tools` has provided the interpreter by then, and run alone on a host without one it stops and names the tools step as its prerequisite. `menu.sh` needs `git` to fetch the hub, and it checks for `python3` the same lazy way: only the tasks that call a Python tool ask for it, and every host task still works without one. Neither loader runs a gate as a closing step: [`scripts/host_gate.py`][host-gate] measures a host against the floors and is not called from here, and nothing here is called from it. A host set up by hand years ago is an ordinary host, so the gate reports what it is missing and running this tooling is a remedy a person chooses. The two are joined at code time instead, by [`scripts/tests/test_bootstrap.py`][test-bootstrap] asserting that every tool the spec requires is one this tooling can provide. **The scripts under `linux/` and `windows/` share no file, and the duplication is deliberate.** Each is independently fetchable and runnable on its own, which is the property that lets a host with no checkout use one without the others. A shared helper file would take that away: the moment one script sources a sibling, fetching it alone yields a script that dies on a missing file. What is duplicated is about thirty lines each of logging, the dry-run wrapper, the confirmation prompt, and a temporary directory, and those copies are identical rather than merely similar. Do not factor them out. `bootstrap.ps1` now exercises the same fetchability argument `bootstrap.sh` always has, rather than merely being written to allow for it. The `install-skills` pair is the one recorded exception to independent fetchability: it drives `scripts/skills_install.py` at the tree root, because the skills content lives in the tree, so a copy fetched alone has nothing to install and the property cannot apply to it. @@ -85,10 +100,15 @@ host-setup\windows\setup-wsl.ps1 -Status [agent-safety]: ./agent-safety/ [audit]: ../AUDIT.md +[audit-runner]: ../spec/audit.py [bootstrap]: ./bootstrap.sh [bootstrap-ps1]: ./bootstrap.ps1 +[build-dist]: ../scripts/build_dist.py +[carry]: ../scripts/carry.py [host-gate]: ../scripts/host_gate.py [host-setup]: ../docs/host-setup.md [linux]: ./linux/ +[menu]: ./menu.sh +[scripts-readme]: ../scripts/README.md [test-bootstrap]: ../scripts/tests/test_bootstrap.py [windows]: ./windows/ diff --git a/host-setup/menu.sh b/host-setup/menu.sh new file mode 100755 index 00000000..6c094679 --- /dev/null +++ b/host-setup/menu.sh @@ -0,0 +1,425 @@ +#!/usr/bin/env bash + +# A human-facing front end over the scripts this fleet otherwise authors for an agent following instructions: the host tooling in host-setup/linux/, and the repo-level tools in scripts/ and spec/ that ptr727/ProjectTemplate hosts and every other repo reaches rather than carries. +# Menu options rather than a command a human has to already know, and a forcing function on the tools it fronts: a task with no discoverable menu entry is a gap in the tools themselves. +# +# Fetchable on its own, like bootstrap.sh: run from a hub checkout directly, or curl this one file into a downstream repo and it clones the hub itself. +# Where bootstrap.sh stands a host up and stops, this loops so a human answers more than one question in a sitting, and it knows the difference between "the hub" and "a repo this host happens to be sitting in" so it can offer each their own tasks. + +set -Eeuo pipefail + +readonly HUB_REPO="ptr727/ProjectTemplate" +readonly HUB_URL="https://github.com/$HUB_REPO" +readonly DEFAULT_REF="main" + +REF="$DEFAULT_REF" +DIR="${XDG_CACHE_HOME:-$HOME/.cache}/host-setup" +KEEP=false +DRY_RUN=false +ASSUME_YES=false + +HUB_ROOT="" +HUB_FETCHED=false +DOWNSTREAM_ROOT="" +DOWNSTREAM_NAME="" + +# --- Output --- + +log() { printf '%s\n' "$*"; } +info() { printf ' %s\n' "$*"; } +step() { printf '\n==> %s\n' "$*"; } +warn() { printf 'WARNING: %s\n' "$*" >&2; } +die() { + printf 'ERROR: %s\n' "$*" >&2 + exit 1 +} +# Reports a task-time error without ending the process, unlike die: a dispatched action's failure returns to the menu, and only a startup failure (bad arguments, no git) is fatal. +fail() { printf 'ERROR: %s\n' "$*" >&2; } + +usage() { + cat <<'EOF' +Usage: menu.sh [options] + +An interactive menu over this fleet's host and repo tooling: update the host tools, upgrade the +OS, install the fleet skills, audit a cataloged repo, and pull the hub's verbatim-owned files into +a downstream repo's own worktree. Run from a hub checkout or from any other repo. The menu shows +each the tasks that apply to it. + +Options: + -y, --yes Pass --yes to each tool this menu runs, so a tool does not prompt. The menu's + own choice, confirmation, and repo-name prompts still ask. + -n, --dry-run Print what each step would run, change nothing + --ref REF Hub branch, tag, pull request ref, or commit to run from, default main + --dir PATH Where a fetched hub checkout is cloned, default ${XDG_CACHE_HOME:-~/.cache}/host-setup + --keep Leave a fetched hub checkout in place, which is removed by default + -h, --help Show this help + +With no terminal to ask on, this prints the same reminder bootstrap.sh does and exits, since a +pipe is not a place to answer a menu. +EOF +} + +# --- Hub resolution --- + +# The owner/name from a remote.origin.url in any of the shapes git or the GitHub UI hand out, or empty where the checkout carries no origin at all (a fresh init, or a worktree add mid-flight). +origin_slug() { + local root="$1" url slug + url=$(git -C "$root" config --get remote.origin.url 2>/dev/null) || return 0 + slug="${url#git@github.com:}" + slug="${slug#ssh://git@github.com/}" + slug="${slug#https://github.com/}" + slug="${slug%.git}" + printf '%s\n' "$slug" +} + +# A marker sitting beside the clone rather than inside it, the same convention bootstrap.sh's tree_is_ours uses, so a --dir pointed at a directory this run does not own is never the one removed on exit. +# Inside the clone it would be an untracked file, and carry.py's own hub-is-clean check would then refuse the tree this run just fetched for it. +marker_path() { printf '%s\n' "$DIR/hub.owned"; } + +# Refuses to remove an existing $DIR/hub this run did not create, rather than trusting the name, mirroring bootstrap.sh's own remove_tree. +remove_unowned_hub_check() { + [[ -e "$DIR/hub" || -L "$DIR/hub" ]] || return 0 + [[ -e "$(marker_path)" ]] && return 0 + fail "$DIR/hub exists and this run did not create it, so it will not be removed. Pass --dir to choose another cache location." + return 1 +} + +# The lock lives only here, in the wrapper, so the locked body below can use its ordinary fail/return pattern with no awareness of it. +# A RETURN trap was tried and dropped: bash does not scope one to the function that set it, so it re-fires (against an already-unset local by then) on whatever function returns next, which surfaced as this script's own "unbound variable" crash on the very next return up the call chain. +fetch_hub() { + # --dry-run promises to change nothing, and fetching is the one real change this whole script makes to the host. + [[ $DRY_RUN == true ]] && { + fail "This task needs a fetched hub checkout, and fetching one is itself a change --dry-run does not make. Run without --dry-run, or from inside a hub checkout already on $DEFAULT_REF." + return 1 + } + mkdir -p "$DIR" + # Held for the rest of this fetch, so a second menu.sh sharing this --dir blocks here instead of passing remove_unowned_hub_check and deleting the tree this one is still cloning into. + # Closed unconditionally on the way out, success or failure, rather than left open for the rest of this process: interactive_menu's loop keeps it alive well past this one fetch otherwise, and a lock nothing ever releases blocks every other menu.sh sharing this --dir until this session quits. + local lock_fd rc + exec {lock_fd}>"$DIR/hub.lock" + if ! flock "$lock_fd"; then + fail "Could not lock $DIR/hub.lock" + exec {lock_fd}>&- + return 1 + fi + rc=0 + fetch_hub_locked || rc=$? + exec {lock_fd}>&- + return "$rc" +} + +fetch_hub_locked() { + step "Fetching $HUB_REPO at $REF" + remove_unowned_hub_check || return 1 + rm -rf "$DIR/hub" + # A full clone of the default branch first, whatever $REF names: spec/audit.py walks the hub's own history to judge whether a carried copy is trailing the file it was copied from, and a shallow clone would read every file as changed at the truncation boundary and misreport every repo as stale. + git clone --quiet --branch "$DEFAULT_REF" --single-branch "$HUB_URL" "$DIR/hub" || + { + fail "Could not clone $HUB_REPO. Check that this host reaches github.com." + return 1 + } + # Marked as ours the moment the clone lands rather than only once every later step also succeeds, so a failure below still leaves a tree remove_unowned_hub_check will clean up on the next run instead of blocking every retry as somebody else's. + touch "$(marker_path)" + # A branch name is already checked out by the clone above. + # A tag, a pull request ref, or a commit needs an explicit fetch and checkout, since "git clone --branch" only takes a branch or a tag, not an arbitrary commit. + if [[ $REF != "$DEFAULT_REF" ]]; then + git -C "$DIR/hub" fetch --quiet origin "$REF" || + { + fail "Could not fetch $REF from $HUB_REPO. Check the ref exists." + return 1 + } + git -C "$DIR/hub" checkout --quiet FETCH_HEAD || + { + fail "Could not check out $REF" + return 1 + } + fi + HUB_ROOT="$DIR/hub" + HUB_FETCHED=true + info "Cloned to $HUB_ROOT" +} + +# Whether the current checkout is the hub, by origin identity alone, independent of --ref or of whether that checkout is fresh enough to reuse. +# Read by detect_downstream_root so the hub is never misclassified as a downstream repo, whatever ref was asked for. +IS_HUB_CHECKOUT=false + +# A checkout already sitting on the hub is a candidate to reuse as is, so a maintainer working in their own ProjectTemplate tree never pays for a second clone of the repo they are standing in. +# Only for the default ref: naming any other --ref always fetches fresh, even from inside the hub itself, since AUDIT.md and the sync procedure both rely on this loader reaching a ref other than whatever happens to be checked out locally. +# HUB_ROOT set here is tentative, verified against a freshly fetched origin/main by ensure_hub_root before any tool actually reads it, since a candidate this stale is exactly the wrong answer for an audit or a Skills-distribution check. +detect_hub_root() { + local top + top=$(git rev-parse --show-toplevel 2>/dev/null) || return 0 + [[ $(origin_slug "$top") == "$HUB_REPO" ]] || return 0 + IS_HUB_CHECKOUT=true + [[ $REF == "$DEFAULT_REF" ]] && HUB_ROOT="$top" + return 0 +} + +# Confirms a tentative local HUB_ROOT still matches a clean, freshly fetched origin/main before any tool reads it, checked here rather than at startup so opening the menu costs no network call until a hub-dependent task actually runs. +# A local checkout that has moved on (a feature branch, a commit behind, an uncommitted edit) falls back to a real fetch rather than being trusted, the same freshness and cleanliness carry.py's own verify_hub already requires of its own hub argument. +ensure_hub_root() { + # The freshness check below itself fetches, which updates FETCH_HEAD and the remote-tracking ref even though it touches no working file, so it is as much a change as fetch_hub's own clone and is refused for the same reason. + [[ $DRY_RUN == true ]] && { + fail "This task needs to confirm the hub checkout is fresh, and confirming it means fetching, which --dry-run does not do. Run without --dry-run." + return 1 + } + if [[ -z $HUB_ROOT ]]; then + fetch_hub + return + fi + if git -C "$HUB_ROOT" fetch --quiet origin "$DEFAULT_REF" && + [[ -z $(git -C "$HUB_ROOT" status --porcelain) ]] && + [[ $(git -C "$HUB_ROOT" rev-parse HEAD) == "$(git -C "$HUB_ROOT" rev-parse "origin/$DEFAULT_REF")" ]]; then + return 0 + fi + HUB_ROOT="" + fetch_hub +} + +# A downstream repo is whatever git repo the menu is run from, when that repo is not the hub itself. +# It stays unset from inside the hub or off a checkout entirely, and the downstream section of the menu is what reads that. +# Gated on IS_HUB_CHECKOUT rather than HUB_ROOT: the hub is never a downstream repo, even when --ref left HUB_ROOT unset. +detect_downstream_root() { + [[ $IS_HUB_CHECKOUT == true ]] && return 0 + local top + top=$(git rev-parse --show-toplevel 2>/dev/null) || return 0 + DOWNSTREAM_ROOT="$top" + DOWNSTREAM_NAME=$(basename "$(origin_slug "$top")") +} + +cleanup() { + [[ $KEEP == true || $HUB_FETCHED == false ]] && return 0 + [[ -e "$(marker_path)" ]] || return 0 + rm -rf "$DIR/hub" "$(marker_path)" +} + +# --- Running a tool --- + +# Every host tool runs from inside the hub tree, and this is the only place a path inside it is named, matching bootstrap.sh's run_tool. +# Resolves the hub root itself rather than assuming a caller already did: a host task must work standalone, off a downstream checkout, or off no checkout at all, none of which set HUB_ROOT on their own. +host_tool() { + local tool="$1" + shift + ensure_hub_root || return 1 + local path="$HUB_ROOT/host-setup/linux/$tool" + [[ -x $path ]] || { + fail "$HUB_ROOT carries no $tool at host-setup/linux, so this ref is not one to run tasks from" + return 1 + } + + local -a flags=() + [[ $ASSUME_YES == true ]] && flags+=(--yes) + [[ $DRY_RUN == true ]] && flags+=(--dry-run) + "$path" "$@" "${flags[@]}" +} + +# The Python tools under scripts/ and spec/ resolve their own root from __file__ rather than the working directory, so they are called by absolute path from wherever this script runs and need no cd. +# Checked here rather than upfront in main, the same reasoning host-setup/linux/install-skills.sh already carries: a host with no interpreter yet can still use every host action, and only the actions that need one name it as their own prerequisite. +# The prerequisite failure returns 127, bash's own "command not found" convention, so a caller reading a specific exit code from the tool itself (build_dist.py's 0-clean/1-stale contract) can tell "python3 never ran" apart from "python3 ran and returned 1". +hub_python() { + command -v python3 >/dev/null || { + fail "python3 is required for this task. host-setup/linux/install-tools.sh provides it." + return 127 + } + local script="$1" + shift + python3 "$HUB_ROOT/$script" "$@" +} + +# --- Actions --- + +audit_repo() { + local default="${DOWNSTREAM_NAME:-$HUB_REPO}" + default="${default##*/}" + local name + read -r -p "Repo to audit [$default]: " name + name="${name:-$default}" + ensure_hub_root || return 1 + hub_python spec/audit.py "$name" +} + +check_skills_dist() { + ensure_hub_root || return 1 + local rc=0 + hub_python scripts/build_dist.py --check || rc=$? + # Only 0 (clean) and 1 (stale) are outcomes scripts/build_dist.py --check documents for itself, so only those two read as a check result. + # Anything else, 127 included, is hub_python or the tool itself failing to run rather than a finding, and is reported as the task error it is. + case "$rc" in + 0) info "Every generated Skills distribution matches .agents/skills/" ;; + 1) info "A generated Skills distribution is stale. This menu does not regenerate it from a fetched checkout, since the result has to be committed in the hub itself." ;; + *) + fail "scripts/build_dist.py --check did not run to completion (exit $rc)" + return 1 + ;; + esac +} + +carry_action() { + local mode="$1" + [[ -n $DOWNSTREAM_ROOT ]] || + { + fail "No downstream repo checkout found. Run this menu from inside the target repo's own worktree." + return 1 + } + # Its hub argument must be exactly on origin/main by carry.py's own requirement, and a non-default --ref checks out something else entirely, so this would always fail deep inside carry.py with no clue why. + # Refused here instead, with the actual reason. + [[ $REF == "$DEFAULT_REF" ]] || + { + fail "Pulling hub files needs the hub's $DEFAULT_REF branch, and this session was started with --ref $REF. Run without --ref, or start a separate session on $DEFAULT_REF for this task." + return 1 + } + local default="$DOWNSTREAM_NAME" + local name + read -r -p "Repo name as cataloged in registry/repos.json [$default]: " name + name="${name:-$default}" + ensure_hub_root || return 1 + hub_python scripts/carry.py "$mode" "$name" --target "$DOWNSTREAM_ROOT" +} + +# --- Menu --- + +menu_heading() { + log "Hub: $HUB_REPO${HUB_ROOT:+ ($HUB_ROOT)}" + if [[ -n $DOWNSTREAM_ROOT ]]; then + log "Downstream: $DOWNSTREAM_NAME ($DOWNSTREAM_ROOT)" + else + log "Downstream: none (run from inside a repo's own checkout for the pull-from-hub tasks)" + fi +} + +print_menu() { + log "" + menu_heading + log "" + log "Host, on this machine:" + log " 1 Report installed host tools" + log " 2 Install missing host tools" + log " 3 Upgrade the host tools that trail upstream" + log " 4 Report the host OS upgrade status" + log " 5 Upgrade the host OS packages" + log " 6 Report git and GitHub setup" + log " 7 Configure git and GitHub" + log " 8 Report fleet Skills install status" + log " 9 Install or update the fleet Skills" + log "" + log "Hub, ptr727/ProjectTemplate:" + log " 10 Audit a cataloged repo" + log " 11 Check the generated Skills distributions are current" + # Also gated on REF: carry.py always rejects a hub checkout that is not exactly on the default ref, so these tasks cannot work in a non-default --ref session regardless of a downstream repo being detected. + if [[ -n $DOWNSTREAM_ROOT && $REF == "$DEFAULT_REF" ]]; then + log "" + log "Downstream, the repo this menu is run from:" + log " 12 Check what the hub would change here, change nothing" + log " 13 Pull the hub's verbatim-owned files into this repo" + fi + log "" + log " q Quit" + log "" +} + +# A failing task (a real install error, a network hiccup) is reported and returns to the menu rather than ending the session, so dispatch's own exit status cannot double as "quit": QUIT is a separate flag the q/Q case sets, read by the loop after every dispatch regardless of whether the task it ran succeeded. +QUIT=false + +dispatch() { + case "$1" in + 1) host_tool install-tools.sh --report ;; + 2) host_tool install-tools.sh --install ;; + 3) host_tool install-tools.sh --upgrade ;; + 4) host_tool upgrade-host.sh --status ;; + 5) host_tool upgrade-host.sh --packages ;; + 6) host_tool setup-github.sh --status ;; + 7) host_tool setup-github.sh --configure ;; + 8) host_tool install-skills.sh --report ;; + 9) host_tool install-skills.sh ;; + 10) audit_repo ;; + 11) check_skills_dist ;; + 12) carry_action check ;; + # --dry-run changes nothing, and carry.py itself has no dry-run mode, so a dry-run apply reads as its own check instead of silently mutating the downstream worktree. + 13) + if [[ $DRY_RUN == true ]]; then + carry_action check + else + carry_action apply + fi + ;; + q | Q) QUIT=true ;; + *) + warn "Not one of the choices" + return 2 + ;; + esac +} + +interactive_menu() { + local choice rc + while true; do + print_menu + read -r -p "Choose: " choice + QUIT=false + rc=0 + dispatch "$choice" || rc=$? + [[ $QUIT == true ]] && break + # An unrecognized choice is rc 2, already warned by dispatch, so this loops straight back rather than reading a pointless confirmation. + ((rc == 2)) && continue + if ((rc == 0)); then + step "Done" + else + warn "That task ended with an error" + fi + read -r -p "Press Enter to return to the menu... " _ + done +} + +# --- Entry --- + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + -y | --yes) ASSUME_YES=true ;; + -n | --dry-run) DRY_RUN=true ;; + --keep) KEEP=true ;; + --ref) + [[ $# -ge 2 ]] || die "--ref takes a branch, tag, pull request ref, or commit" + REF="$2" + shift + ;; + --dir) + [[ $# -ge 2 ]] || die "--dir takes a path" + [[ $2 == /* ]] || die "--dir takes an absolute path, and \"$2\" is relative" + # Canonicalized before the root check, since a literal "/tmp/.." is not the string "/" but resolves to it the moment anything below opens a path under it. + local canonical + canonical=$(readlink -m -- "$2") || die "--dir could not be resolved: \"$2\"" + [[ $canonical != "/" ]] || die "--dir may not be the root directory" + DIR="$canonical" + shift + ;; + -h | --help) + usage + exit 0 + ;; + *) die "Unknown option \"$1\", --help lists the options" ;; + esac + shift + done +} + +main() { + parse_args "$@" + + if [[ ! -t 0 ]]; then + warn "No terminal to ask on, so there is no menu to show" + info "Download the file and run it from a terminal:" + info " curl -fsSLo menu.sh https://raw.githubusercontent.com/$HUB_REPO/$DEFAULT_REF/host-setup/menu.sh" + info " bash menu.sh" + exit 0 + fi + + command -v git >/dev/null || die "git is required" + + trap cleanup EXIT + detect_hub_root + detect_downstream_root + interactive_menu +} + +main "$@" diff --git a/scripts/README.md b/scripts/README.md index 264b182c..f416a0a8 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -221,7 +221,7 @@ The match is on the block's heading rather than anywhere in the body, and on the Regenerates [`.github/skills/`][github-skills-dist] and [`.claude-plugin/fleet-skills/`][fleet-skills-dist] from [`.agents/skills/`][agents-skills], the hub's own hand-authored fleet Skills. Codex and opencode read `.agents/skills/` directly, GitHub Copilot reads `.github/skills/`, and Claude Code reads the generated plugin published through [`.claude-plugin/marketplace.json`][marketplace]. `.agents/skills/` stays the one place a skill is hand-edited. Both generated trees are never hand-edited. -`--check` is the read-only mode: it exits `1` when either generated tree differs from `.agents/skills/`, comparing a digest over every file rather than a file count or timestamp. CI runs `--check` rather than trusting a contributor to have run the generator, the same reason `spec/audit.py` exists rather than trusting a hand-carried file. +`--check` is the read-only mode: it exits `1` when either generated tree differs from `.agents/skills/`, comparing a digest over every file rather than a file count or timestamp, and `2` on a real failure (a symlink under `.agents/skills/`, an unreadable file), so a caller reading the exit code can tell that apart from the stale finding. CI runs `--check` rather than trusting a contributor to have run the generator, the same reason `spec/audit.py` exists rather than trusting a hand-carried file. ## `carry.py` diff --git a/scripts/build_dist.py b/scripts/build_dist.py index 74cf18ba..ea3787db 100755 --- a/scripts/build_dist.py +++ b/scripts/build_dist.py @@ -9,7 +9,11 @@ place a skill's content is ever hand-edited. Usage: python3 scripts/build_dist.py regenerate distributions from .agents/skills/ - python3 scripts/build_dist.py --check read-only: exit 1 if a distribution is stale + python3 scripts/build_dist.py --check read-only: exit 0 clean, 1 stale, 2 on a real + failure (a symlink under .agents/skills/, an + unreadable file), so a caller reading the exit + code can tell a finding apart from the check + itself not having run. """ from __future__ import annotations @@ -162,8 +166,10 @@ def is_stale(): names = skill_names() try: manifest = json.loads(PLUGIN_MANIFEST.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): + except json.JSONDecodeError: + # A corrupted or hand-edited manifest is exactly the stale case this function exists to catch. return True + # OSError (an unreadable file, one removed between the is_file() check above and this read) is deliberately not caught here: --check's own caller needs it to propagate as the execution failure it is, not read as this function's ordinary stale result. # The full manifest, not only "skills". # A hand-edited description/author/version is exactly as much a corrupted-plugin case as a hand-edited skills list. # The manifest is entirely deterministic from `names`, so comparing all of it costs nothing extra to get right. @@ -190,16 +196,18 @@ def main(): parser.add_argument( "--check", action="store_true", - help="read-only: exit 1 if a generated skill distribution is stale", + help="read-only: exit 0 clean, 1 stale, 2 on a real failure", ) args = parser.parse_args() if args.check: try: stale = is_stale() - except ValueError as exc: + except (ValueError, OSError) as exc: + # 2 rather than 1, so a caller reading the exit code (host-setup/menu.sh among them) can tell this apart from the stale result below, which also exits 1 by this flag's own documented contract. + # OSError alongside ValueError: is_stale() reads several files beyond the one call already wrapped in its own try/except, and a permissions problem or a file removed out from under it raises that, not ValueError. print(exc, file=sys.stderr) - return 1 + return 2 if stale: print( "Generated skill distributions are stale: run `python3 scripts/build_dist.py`.", diff --git a/scripts/tests/test_build_dist.py b/scripts/tests/test_build_dist.py index 82959074..3ca2f74c 100755 --- a/scripts/tests/test_build_dist.py +++ b/scripts/tests/test_build_dist.py @@ -7,6 +7,7 @@ from __future__ import annotations import json +import os import sys import unittest from pathlib import Path @@ -265,6 +266,52 @@ def test_main_reports_a_symlink_cleanly_instead_of_a_raw_traceback(self) -> None exit_code = build_dist.main() self.assertEqual(exit_code, 1) + def test_check_reports_a_symlink_as_2_not_1(self) -> None: + """1 is --check's own documented "stale" result, so a caller reading the exit code (host-setup/menu.sh among them) needs a different code to tell a real failure apart from that finding. + + is_stale() short-circuits to True (stale, exit 1) the moment the distribution stamp is + missing, so the symlink has to be introduced only after a clean regenerate() already + produced one, reaching the digest walk that actually raises rather than the early return. + """ + self.make_skill("foo") + build_dist.regenerate() + (self.skills_src / "foo" / "escape").symlink_to(self.tmp) + from unittest import mock + + with mock.patch("sys.argv", ["build_dist.py", "--check"]), mock.patch("builtins.print"): + exit_code = build_dist.main() + self.assertEqual(exit_code, 2) + + def test_check_reports_an_os_error_as_2_not_1(self) -> None: + """is_stale() reads several files beyond the one already wrapped in its own try/except, and a permissions problem or a file removed out from under it raises OSError there, not ValueError.""" + from unittest import mock + + with ( + mock.patch("sys.argv", ["build_dist.py", "--check"]), + mock.patch("builtins.print"), + mock.patch.object(build_dist, "is_stale", side_effect=OSError("permission denied")), + ): + exit_code = build_dist.main() + self.assertEqual(exit_code, 2) + + def test_check_reports_an_unreadable_manifest_as_2_not_1(self) -> None: + """The manifest read has its own try/except inside is_stale() (JSONDecodeError, a genuinely stale manifest), and an OSError there has to propagate through it rather than being caught by the same clause, or an unreadable file reads as the ordinary stale result this test would otherwise miss.""" + if os.name != "posix": + self.skipTest( + "chmod does not carry POSIX unreadable-file semantics, and os.geteuid() does not exist, on this platform" + ) + if os.geteuid() == 0: + self.skipTest("running as root ignores the permission bits this test depends on") + self.make_skill("foo") + build_dist.regenerate() + build_dist.PLUGIN_MANIFEST.chmod(0o000) + self.addCleanup(build_dist.PLUGIN_MANIFEST.chmod, 0o644) + from unittest import mock + + with mock.patch("sys.argv", ["build_dist.py", "--check"]), mock.patch("builtins.print"): + exit_code = build_dist.main() + self.assertEqual(exit_code, 2) + if __name__ == "__main__": unittest.main()