From 1224865051d3abd9f83de889c85f7a1f5caa942f Mon Sep 17 00:00:00 2001 From: Antonio Mancini Date: Sat, 10 Oct 2026 12:27:55 +0200 Subject: [PATCH] feat(setup): customize step at the Apply? prompt `agbox setup` now asks "Apply? [Y]es / [n]o / [c]ustomize". Customize asks about the settings that vary from machine to machine (profile, agents, agent mode, services, components, user, hostname, timezone, git identity), then shows the updated plan and asks again. Answers are applied as --set overrides and reloaded through load_config, so profiles, validation, --dry-run and the saved config work unchanged. Runs with --yes, --dry-run or no terminal are not affected. Prompts read the terminal through tty_read/tty_printf; AGBOX_TTY lets tests supply answers from a file. Also: the devtools plan line lists gh and Playwright only when enabled, and username/hostname validators are pure functions shared with the wizard. Claude-Session: https://claude.ai/code/session_01LAwbf3NkNHZhz9kMSZZJMC --- CHANGELOG.md | 11 ++ CONTRIBUTING.md | 1 + README.md | 11 +- bin/agbox | 17 ++- lib/config.sh | 8 +- lib/core.sh | 27 +++-- lib/wizard.sh | 244 +++++++++++++++++++++++++++++++++++++++++ modules/devtools.sh | 8 +- tests/cli.bats | 22 ++++ tests/test_helper.bash | 2 + tests/wizard.bats | 117 ++++++++++++++++++++ 11 files changed, 449 insertions(+), 19 deletions(-) create mode 100644 lib/wizard.sh create mode 100644 tests/wizard.bats diff --git a/CHANGELOG.md b/CHANGELOG.md index c61f06c..771ada4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +### Added +- `agbox setup` asks `Apply? [Y]es / [n]o / [c]ustomize`. Customize goes through the + settings that vary between machines (profile, agents, agent mode, services, components, + user, hostname, timezone, git identity), then shows the updated plan. Answers are applied + like `--set` and saved to the config file. Runs with `--yes`, `--dry-run` or without a + terminal never ask. + +### Changed +- The `devtools` line in the plan only lists the GitHub CLI and Playwright when they are + enabled. + ## [0.1.0] - 2026-10-10 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 753721a..169bbf7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,6 +27,7 @@ lib/config.sh schema, profiles, config parsing and validation lib/os.sh distro detection, host facts lib/modules.sh module registry and selection lib/preseed.sh `agbox preseed` +lib/wizard.sh the Apply?/customize prompt of `agbox setup` (answers become --set overrides) modules/*.sh one file per feature docs/ user docs (configuration.md's table is generated) tests/*.bats tests diff --git a/README.md b/README.md index 3fad7e5..4b70907 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,10 @@ walk away. [→ docs/unattended-install.md](docs/unattended-install.md) agbox preseed --hostname home-server --ssh-key ~/.ssh/id_ed25519.pub -o preseed.cfg ``` -agbox shows its plan and asks before changing anything. When it's done, it prints the few -things only you can do (agent logins, `gh auth login`), then: +agbox shows its plan and asks before changing anything. Answer `c` to **customize**: a few +quick questions (profile, agents, permissions, services, components, user, hostname, +timezone, git identity), then the updated plan. When it's done, it prints the few things only +you can do (agent logins, `gh auth login`), then: ```console $ agbox doctor @@ -91,8 +93,9 @@ Useful flags: `--dry-run` (show every command, change nothing), `--only agents,m ## Configure -Zero config works. Everything is a setting: pass `--set` or edit `/etc/agbox/agbox.conf` -and re-run. +Zero config works. For the common choices, answer `c` at the `Apply?` prompt. Everything is a +setting: pass `--set` or edit `/etc/agbox/agbox.conf` and re-run. Your choices are saved +there, so the next run starts from them. ```bash agbox setup --set AGENTS="claude opencode" --set SERVICES=t3code --set PLAYWRIGHT=false diff --git a/bin/agbox b/bin/agbox index 8627bbe..3a0071f 100755 --- a/bin/agbox +++ b/bin/agbox @@ -19,6 +19,8 @@ source "$AGBOX_ROOT/lib/os.sh" source "$AGBOX_ROOT/lib/modules.sh" # shellcheck source=lib/preseed.sh source "$AGBOX_ROOT/lib/preseed.sh" +# shellcheck source=lib/wizard.sh +source "$AGBOX_ROOT/lib/wizard.sh" usage() { cat <&2 - elif ! confirm $'\nApply?'; then - die "aborted" + else + ask_apply + while [[ $WIZ_ANSWER == customize ]]; do + run_wizard + # Answers are overrides like --set: reload so profiles and validation apply to them. + OPT_SETS+=("${WIZARD_SETS[@]}") + init + print_plan "${SELECTED[@]}" + ask_apply + done + [[ $WIZ_ANSWER == yes ]] || die "aborted" fi preflight diff --git a/lib/config.sh b/lib/config.sh index 2f8c6fd..d5df0c9 100644 --- a/lib/config.sh +++ b/lib/config.sh @@ -211,6 +211,10 @@ _validate_words() { done } +# Pure validators, shared with the setup wizard. +is_valid_username() { [[ $1 =~ ^[a-z_][a-z0-9_-]{0,31}$ ]]; } +is_valid_hostname() { [[ $1 =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]]; } + validate_config() { local key for key in UPGRADE AUTO_UPDATES ZRAM PASSWORDLESS_SUDO FIREWALL TAILSCALE TAILSCALE_SSH \ @@ -222,12 +226,12 @@ validate_config() { _validate_words AGENTS "claude codex opencode" _validate_words SERVICES "t3code hermes" case $AGBOX_AGENT_MODE in yolo | sandboxed) ;; *) die "AGENT_MODE must be yolo or sandboxed" ;; esac - [[ $AGBOX_USER =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] || die "invalid USER '$AGBOX_USER'" + is_valid_username "$AGBOX_USER" || die "invalid USER '$AGBOX_USER'" [[ $AGBOX_USER != root ]] || die "USER must not be root: agents refuse to run unattended as root" if ! [[ $AGBOX_ZRAM_PERCENT =~ ^[0-9]+$ ]] || ((AGBOX_ZRAM_PERCENT < 10 || AGBOX_ZRAM_PERCENT > 150)); then die "ZRAM_PERCENT must be a number between 10 and 150" fi - [[ -z $AGBOX_HOSTNAME || $AGBOX_HOSTNAME =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]] || + [[ -z $AGBOX_HOSTNAME ]] || is_valid_hostname "$AGBOX_HOSTNAME" || die "invalid HOSTNAME '$AGBOX_HOSTNAME' (lowercase letters, digits and '-')" [[ -z $AGBOX_DOCKER_DATA_ROOT || $AGBOX_DOCKER_DATA_ROOT == /* ]] || die "DOCKER_DATA_ROOT must be an absolute path" diff --git a/lib/core.sh b/lib/core.sh index 29c8ebb..ffc5464 100644 --- a/lib/core.sh +++ b/lib/core.sh @@ -33,16 +33,25 @@ has_cmd() { command -v "$1" >/dev/null 2>&1; } is_root() { [[ $EUID -eq 0 ]]; } is_dry_run() { [[ ${AGBOX_DRY_RUN:-false} == true ]]; } +# Questions are read from the terminal, even under `curl | bash`. AGBOX_TTY points +# elsewhere only in tests, which feed answers from a file. +AGBOX_TTY=${AGBOX_TTY:-/dev/tty} + # Can we ask the operator a question? -is_interactive() { [[ ${AGBOX_YES:-false} != true ]] && [[ -r /dev/tty ]] && { : /dev/null; } - -# confirm "Question?" -> success on yes. Non-interactive runs answer yes. -confirm() { - is_interactive || return 0 - local reply - printf '%s [Y/n] ' "$1" >/dev/tty - read -r reply /dev/null; } + +# tty_printf FORMAT ARGS... : print a prompt where the operator sees it. +tty_printf() { + # shellcheck disable=SC2059 # FORMAT is the caller's format string + if [[ $AGBOX_TTY == /dev/tty ]]; then printf "$@" >/dev/tty; else printf "$@" >&2; fi +} + +# tty_read VAR : read one answer line. Fails on end of input. The descriptor stays open, +# so answers fed from a file are consumed in order. +_TTY_FD='' +tty_read() { + if [[ -z $_TTY_FD ]]; then exec {_TTY_FD}<"$AGBOX_TTY"; fi + IFS= read -r "$1" <&"$_TTY_FD" } # Render a command line for display, shell-quoted. diff --git a/lib/wizard.sh b/lib/wizard.sh new file mode 100644 index 0000000..cdba719 --- /dev/null +++ b/lib/wizard.sh @@ -0,0 +1,244 @@ +# shellcheck shell=bash +# The "customize" step of `agbox setup`: a few questions about the settings that differ from +# machine to machine. Answers become KEY=VALUE overrides, exactly like --set, so validation, +# --dry-run and the saved config all work unchanged. Nothing here changes the system. + +WIZ_ANSWER='' +WIZARD_SETS=() + +# ask_apply : the question after the plan. Sets WIZ_ANSWER to yes, no or customize. +# Non-interactive runs answer yes. +ask_apply() { + WIZ_ANSWER=yes + is_interactive || return 0 + local reply + while :; do + tty_printf '\nApply? [Y]es / [n]o / [c]ustomize: ' + if ! tty_read reply; then + tty_printf '\n' + WIZ_ANSWER=no + return 0 + fi + case ${reply,,} in + '' | y | yes) WIZ_ANSWER=yes ;; + n | no) WIZ_ANSWER=no ;; + c | customize) WIZ_ANSWER=customize ;; + *) continue ;; + esac + return 0 + done +} + +# run_wizard : ask, then leave the overrides in WIZARD_SETS (KEY=VALUE, only real changes). +run_wizard() { + WIZARD_SETS=() + tty_printf '\n%sCustomize%s %s(Enter keeps the current value, - clears a text one; Ctrl-C quits, nothing has changed yet)%s\n' \ + "$_C_BOLD" "$_C_RESET" "$_C_DIM" "$_C_RESET" + + _ask_choice "Profile" PROFILE \ + "homelab|machine on a network you trust" \ + "vps|public server: firewall on, key-only SSH" + _ask_words "Coding agents" AGENTS \ + "claude|Claude Code" \ + "codex|OpenAI Codex CLI" \ + "opencode|opencode" + if [[ -n $(_wizard_value AGENTS) ]]; then + _ask_choice "Agent permissions" AGENT_MODE \ + "yolo|no prompts, no sandbox: the machine is the sandbox" \ + "sandboxed|keep each agent's own prompts and sandbox" + fi + _ask_words "Always-on services" SERVICES \ + "t3code|web UI for coding agents, on your tailnet" \ + "hermes|Nous Research's Hermes Agent" + _ask_components + _ask_text "User the agents run as" USER '' _wizard_valid_user \ + "lowercase letters, digits, '-' and '_'; not root" + _ask_text "Hostname" HOSTNAME "keep $(uname -n)" is_valid_hostname \ + "lowercase letters, digits and '-'" + _ask_text "Timezone, e.g. Europe/Rome" TIMEZONE "keep $(_current_timezone)" _wizard_valid_timezone \ + "unknown timezone (see: timedatectl list-timezones)" + _ask_text "git user.name" GIT_NAME "keep current" + _ask_text "git user.email" GIT_EMAIL "keep current" _wizard_valid_email "expected name@domain" + + if ((${#WIZARD_SETS[@]} == 0)); then + tty_printf '\nNo changes.\n' + return 0 + fi + local kv + tty_printf '\n%sChanges:%s\n' "$_C_BOLD" "$_C_RESET" + for kv in "${WIZARD_SETS[@]}"; do tty_printf ' %s="%s"\n' "${kv%%=*}" "${kv#*=}"; done +} + +# ----------------------------------------------------------- pure helpers ---- + +# wizard_pick INPUT OPTION... -> the option INPUT names, by number or by name. Fails if none. +wizard_pick() { + local input=${1,,} opt + shift + if [[ $input =~ ^[1-9][0-9]*$ ]] && ((input <= $#)); then + printf '%s' "${!input}" + return 0 + fi + for opt in "$@"; do + if [[ $opt == "$input" ]]; then + printf '%s' "$opt" + return 0 + fi + done + return 1 +} + +# wizard_toggle "CURRENT WORDS" INPUT OPTION... -> CURRENT with each option named in INPUT +# (numbers or names, separated by spaces or commas) switched on or off, in OPTION order. +# Fails without output if INPUT names an unknown option. +wizard_toggle() { + local current=" $1 " opt out='' token + local -a tokens=() + read -ra tokens <<<"${2//,/ }" + shift 2 + for token in "${tokens[@]}"; do + opt=$(wizard_pick "$token" "$@") || return 1 + if [[ $current == *" $opt "* ]]; then current=${current/" $opt "/ }; else current+="$opt "; fi + done + for opt in "$@"; do + if [[ $current == *" $opt "* ]]; then out+="$opt "; fi + done + printf '%s' "${out% }" +} + +_wizard_valid_user() { is_valid_username "$1" && [[ $1 != root ]]; } +_wizard_valid_email() { [[ $1 =~ ^[^@[:space:]]+@[^@[:space:]]+$ ]]; } +_wizard_valid_timezone() { + [[ $1 =~ ^[A-Za-z0-9_+-]+(/[A-Za-z0-9_+-]+)*$ && -f /usr/share/zoneinfo/$1 ]] +} + +# --------------------------------------------------------------- prompts ---- + +# _wizard_set KEY VALUE : record an override, unless it's what the config already says. +_wizard_set() { + local var="AGBOX_$1" + if [[ $2 != "${!var}" ]]; then WIZARD_SETS+=("$1=$2"); fi +} + +# _wizard_value KEY -> the answer given so far, else the current value. +_wizard_value() { + local var="AGBOX_$1" value i + value=${!var} + for ((i = 0; i < ${#WIZARD_SETS[@]}; i++)); do + if [[ ${WIZARD_SETS[i]%%=*} == "$1" ]]; then value=${WIZARD_SETS[i]#*=}; fi + done + printf '%s' "$value" +} + +_wizard_eof() { + tty_printf '\n' + die "aborted: no answer" +} + +_wizard_warn() { tty_printf ' %s! %s%s\n' "$_C_YELLOW" "$1" "$_C_RESET"; } + +_current_timezone() { + local tz + tz=$(timedatectl show -p Timezone --value 2>/dev/null) || tz='' + if [[ -z $tz && -r /etc/timezone ]]; then tz=$("$BATS_TEST_TMPDIR/answers" + AGBOX_TTY="$BATS_TEST_TMPDIR/answers" run "$AGBOX" setup --set USER=agbox-dryrun-user + [ "$status" -ne 0 ] && [[ "$output" == *"error: aborted"* ]] + ! id agbox-dryrun-user 2>/dev/null +} + +@test "setup: customize updates the plan, then asks again" { + # profile, agents (toggle codex off), agent mode, services, components (gh off), user, + # hostname, timezone, git name, git email; then decline. + printf '%s\n' c vps 2 "" "" "" 4 "" "" "" "" "" "" n >"$BATS_TEST_TMPDIR/answers" + AGBOX_TTY="$BATS_TEST_TMPDIR/answers" run "$AGBOX" setup --set USER=agbox-dryrun-user + [ "$status" -ne 0 ] && [[ "$output" == *"error: aborted"* ]] + [[ "$output" == *"(homelab profile"* && "$output" == *"(vps profile"* ]] + [[ "$output" == *'AGENTS="claude"'* && "$output" == *'GH="false"'* ]] + [[ "$output" == *"coding agents (claude) in yolo mode"* ]] + [[ "$output" == *"✓ firewall"* ]] + [[ "$output" == *"devtools git defaults, Playwright"* ]] + [ "$(grep -c 'Apply?' <<<"$output")" -eq 2 ] + ! id agbox-dryrun-user 2>/dev/null +} diff --git a/tests/test_helper.bash b/tests/test_helper.bash index 5080fb4..f02ea83 100644 --- a/tests/test_helper.bash +++ b/tests/test_helper.bash @@ -20,6 +20,8 @@ load_agbox() { source "$AGBOX_ROOT/lib/modules.sh" # shellcheck source=lib/preseed.sh source "$AGBOX_ROOT/lib/preseed.sh" + # shellcheck source=lib/wizard.sh + source "$AGBOX_ROOT/lib/wizard.sh" load_modules } diff --git a/tests/wizard.bats b/tests/wizard.bats new file mode 100644 index 0000000..38df3b1 --- /dev/null +++ b/tests/wizard.bats @@ -0,0 +1,117 @@ +#!/usr/bin/env bats +# The setup wizard: pure helpers, then the prompts fed from an answers file. + +setup() { + load test_helper + clean_env + load_agbox + AGBOX_USER=tester + export AGBOX_USER + load_config "" +} + +# answers LINE... : what the operator will type, one answer per line. +answers() { + printf '%s\n' "$@" >"$BATS_TEST_TMPDIR/answers" + AGBOX_TTY="$BATS_TEST_TMPDIR/answers" + _TTY_FD='' +} + +@test "wizard_pick: by number or name, case-insensitive" { + run wizard_pick 2 homelab vps + [ "$status" -eq 0 ] && [ "$output" = vps ] + run wizard_pick VPS homelab vps + [ "$status" -eq 0 ] && [ "$output" = vps ] +} + +@test "wizard_pick: rejects unknown names and out-of-range numbers" { + run wizard_pick 0 homelab vps + [ "$status" -eq 1 ] + run wizard_pick 3 homelab vps + [ "$status" -eq 1 ] + run wizard_pick 01 homelab vps + [ "$status" -eq 1 ] + run wizard_pick cloud homelab vps + [ "$status" -eq 1 ] && [ -z "$output" ] +} + +@test "wizard_toggle: switches options on and off, keeping option order" { + run wizard_toggle "claude codex" "3" claude codex opencode + [ "$output" = "claude codex opencode" ] + run wizard_toggle "claude codex" "1, codex" claude codex opencode + [ "$output" = "" ] + run wizard_toggle "opencode" "1" claude codex opencode + [ "$output" = "claude opencode" ] +} + +@test "wizard_toggle: empty input normalizes order and drops unknown words" { + run wizard_toggle "opencode bogus claude" "" claude codex opencode + [ "$status" -eq 0 ] && [ "$output" = "claude opencode" ] +} + +@test "wizard_toggle: fails on an unknown option" { + run wizard_toggle "claude" "1 9" claude codex opencode + [ "$status" -eq 1 ] && [ -z "$output" ] +} + +@test "ask_apply: yes, no and customize" { + answers "" + ask_apply 2>/dev/null + [ "$WIZ_ANSWER" = yes ] + answers N + ask_apply 2>/dev/null + [ "$WIZ_ANSWER" = no ] + answers what c + ask_apply 2>/dev/null + [ "$WIZ_ANSWER" = customize ] +} + +@test "ask_apply: end of input means no; --yes means yes without asking" { + answers + : >"$AGBOX_TTY" + ask_apply 2>/dev/null + [ "$WIZ_ANSWER" = no ] + AGBOX_YES=true + AGBOX_TTY=/nonexistent + ask_apply + [ "$WIZ_ANSWER" = yes ] +} + +@test "run_wizard: Enter everywhere changes nothing" { + answers "" "" "" "" "" "" "" "" "" "" + run_wizard 2>/dev/null + [ "${#WIZARD_SETS[@]}" -eq 0 ] +} + +@test "run_wizard: answers become overrides, invalid ones are asked again" { + answers \ + vps \ + "2 3" "" \ + sandboxed \ + t3code "" \ + "1,4" "" \ + root dev \ + Bad_Host box-1 \ + "" \ + "Ada Lovelace" \ + nope ada@example.com + run_wizard 2>"$BATS_TEST_TMPDIR/prompts" + local IFS='|' + [ "${WIZARD_SETS[*]}" = "PROFILE=vps|AGENTS=claude opencode|AGENT_MODE=sandboxed|SERVICES=t3code|DOCKER=false|GH=false|USER=dev|HOSTNAME=box-1|GIT_NAME=Ada Lovelace|GIT_EMAIL=ada@example.com" ] + grep -q "not root" "$BATS_TEST_TMPDIR/prompts" + grep -q "expected name@domain" "$BATS_TEST_TMPDIR/prompts" +} + +@test "run_wizard: no agents skips the permissions question; - clears a text value" { + AGBOX_GIT_NAME="Old Name" + answers "" "1 2" "" "" "" "" "" "" - "" + run_wizard 2>/dev/null + local IFS='|' + [ "${WIZARD_SETS[*]}" = "AGENTS=|GIT_NAME=" ] +} + +@test "run_wizard: running out of answers aborts" { + answers "" + run run_wizard + [ "$status" -ne 0 ] && [[ "$output" == *"aborted"* ]] +}