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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
17 changes: 14 additions & 3 deletions bin/agbox
Original file line number Diff line number Diff line change
Expand Up @@ -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 <<EOF
Expand Down Expand Up @@ -46,7 +48,7 @@ Options (setup, doctor, config, modules):
-h, --help Show this help

Examples:
agbox setup # interactive, homelab defaults
agbox setup # shows the plan; answer c to customize
agbox setup --profile vps --set TAILSCALE_AUTHKEY=tskey-...
agbox setup --only agents --set AGENT_MODE=sandboxed
agbox doctor
Expand Down Expand Up @@ -174,8 +176,17 @@ cmd_setup() {
print_plan "${SELECTED[@]}"
if is_dry_run; then
printf '\n%sDry run: nothing will be changed.%s\n' "$_C_BOLD" "$_C_RESET" >&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
Expand Down
8 changes: 6 additions & 2 deletions lib/config.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand All @@ -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"
Expand Down
27 changes: 18 additions & 9 deletions lib/core.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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/tty; } 2>/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/tty || return 1
[[ -z $reply || ${reply,,} == y || ${reply,,} == yes ]]
is_interactive() { [[ ${AGBOX_YES:-false} != true ]] && [[ -r $AGBOX_TTY ]] && { : <"$AGBOX_TTY"; } 2>/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.
Expand Down
244 changes: 244 additions & 0 deletions lib/wizard.sh
Original file line number Diff line number Diff line change
@@ -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=$(</etc/timezone); fi
printf '%s' "${tz:-unset}"
}

# _ask_choice TITLE KEY VALUE|HINT... : pick exactly one value.
_ask_choice() {
local title=$1 key=$2 var="AGBOX_$2" entry reply value i=1 default=1
shift 2
local -a values=()
tty_printf '\n%s%s%s\n' "$_C_BOLD" "$title" "$_C_RESET"
for entry in "$@"; do
values+=("${entry%%|*}")
if [[ ${entry%%|*} == "${!var}" ]]; then default=$i; fi
tty_printf ' %d) %-10s %s%s%s\n' "$i" "${entry%%|*}" "$_C_DIM" "${entry#*|}" "$_C_RESET"
i=$((i + 1))
done
while :; do
tty_printf ' choice [%d]: ' "$default"
tty_read reply || _wizard_eof
if [[ -z ${reply// /} ]]; then reply=$default; fi
if value=$(wizard_pick "${reply// /}" "${values[@]}"); then break; fi
_wizard_warn "choose 1-${#values[@]}"
done
_wizard_set "$key" "$value"
}

# _ask_multi TITLE "CURRENT WORDS" VALUE|HINT... : toggle any number of values.
# Sets WIZ_ANSWER to the chosen values, in option order.
_ask_multi() {
local title=$1 selected=$2 entry reply next mark i
shift 2
local -a values=()
for entry in "$@"; do values+=("${entry%%|*}"); done
selected=$(wizard_toggle "$selected" '' "${values[@]}") # drop unknowns, normalize order
tty_printf '\n%s%s%s %s(type numbers to toggle, Enter to accept)%s\n' \
"$_C_BOLD" "$title" "$_C_RESET" "$_C_DIM" "$_C_RESET"
while :; do
i=1
for entry in "$@"; do
mark=' '
if [[ " $selected " == *" ${entry%%|*} "* ]]; then mark=x; fi
tty_printf ' %d [%s] %-10s %s%s%s\n' "$i" "$mark" "${entry%%|*}" "$_C_DIM" "${entry#*|}" "$_C_RESET"
i=$((i + 1))
done
tty_printf ' toggle: '
tty_read reply || _wizard_eof
if [[ -z ${reply// /} ]]; then break; fi
if next=$(wizard_toggle "$selected" "$reply" "${values[@]}"); then
selected=$next
else
_wizard_warn "choose from 1-${#values[@]}"
fi
done
WIZ_ANSWER=$selected
}

# _ask_words TITLE KEY VALUE|HINT... : a space-separated list setting (AGENTS, SERVICES).
_ask_words() {
local title=$1 key=$2 var="AGBOX_$2" entry current
shift 2
local -a values=()
for entry in "$@"; do values+=("${entry%%|*}"); done
current=$(wizard_toggle "${!var}" '' "${values[@]}")
_ask_multi "$title" "$current" "$@"
if [[ $WIZ_ANSWER != "$current" ]]; then WIZARD_SETS+=("$key=$WIZ_ANSWER"); fi
}

# Optional components, each an on/off setting.
_ask_components() {
local -a keys=(DOCKER TAILSCALE PLAYWRIGHT GH)
local key var current=''
for key in "${keys[@]}"; do
var="AGBOX_$key"
if [[ ${!var} == true ]]; then current+="${key,,} "; fi
done
_ask_multi "Components" "$current" \
"docker|Docker Engine + Compose, for databases and services" \
"tailscale|Tailscale + Tailscale SSH: reach the machine from anywhere" \
"playwright|headless Chromium for web testing" \
"gh|GitHub CLI"
for key in "${keys[@]}"; do
if [[ " $WIZ_ANSWER " == *" ${key,,} "* ]]; then _wizard_set "$key" true; else _wizard_set "$key" false; fi
done
}

# _ask_text TITLE KEY EMPTY_LABEL [VALIDATOR ERROR] : free text.
# Enter keeps the current value, '-' clears it.
_ask_text() {
local title=$1 key=$2 empty_label=$3 validator=${4:-} error=${5:-} var="AGBOX_$2" reply
tty_printf '\n'
while :; do
tty_printf '%s%s%s [%s]: ' "$_C_BOLD" "$title" "$_C_RESET" "${!var:-$empty_label}"
tty_read reply || _wizard_eof
reply=${reply#"${reply%%[![:space:]]*}"}
reply=${reply%"${reply##*[![:space:]]}"}
if [[ -z $reply ]]; then return 0; fi
if [[ $reply == - ]]; then reply=''; fi
if [[ -z $reply || -z $validator ]] || "$validator" "$reply"; then break; fi
_wizard_warn "$error"
done
_wizard_set "$key" "$reply"
}
Loading
Loading