diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ddcf2d4d..4fa80aa0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -69,7 +69,7 @@ This project operates under professional standards of conduct. All contributors: | File/Directory | Purpose | | ----------------------------------- | ---------------------------------------------------------- | | `acq` | Entry point — pluggable-backend wrapper (msb by default, or sbx) | -| `acq.backends/` | Backend adapters (`common.sh`, `sbx.sh`, `msb.sh`, `kit-translate.sh`, `secret-store.sh`, `progress.sh`) | +| `acq.backends/` | Backend adapters (`common.sh`, `sbx.sh`, `msb.sh`, `kit-translate.sh`, `secret-store.sh`, `progress.sh`, `prompt.sh`) | | `install.sh` | One-line `curl \| sh` installer (auto-selects brew/npm/clone; direct `sh install.sh` still installs the default release tag from the repo, not the current checkout) | | `scripts/rotate-apikey` | Rotate your USAi API key secret (`acq usai-rotate-api-key`) | | `scripts/test-acq-bats` | Offline unit suite for acq (bats-core) | diff --git a/acq b/acq index 00584b6e..a37f1ded 100755 --- a/acq +++ b/acq @@ -111,6 +111,9 @@ Subcommands: backend list List detected backends (sbx, msb). backend set NAME Persist the default backend. backend unset Clear the persisted default (auto-detect). + configure Interactive setup: pick extra kits and + the per-sandbox token-scoping default + (persists to config.yaml). kit list Show the pinned kits (+ extras). kit validate PATH Validate a neutral hybrid/v1 kit. kit apply NAME KITREF Apply a kit to an existing sandbox. @@ -224,6 +227,26 @@ Usage: BACKENDUSAGE } +_acq_configure_usage() { + cat >&2 <<'CONFIGUSAGE' +acq configure — interactive setup for extra kits and token scoping (ADR-0031) + +Usage: + acq configure + +Opens an interactive picker to choose which OPT-IN kits to enable (the four +built-in kits are always applied) and to set the DEFAULT answer for the +per-sandbox GitHub-token-scoping prompt. Choices persist to +~/.config/acq/config.yaml and pre-populate the picker shown at `acq create`, +where a single sandbox may deviate without changing the global default. + +Non-interactive (CI / piped) invocations make no changes and just print the +current configuration. Set ACQ_NO_PROMPT=1 to force this behavior. The +GitHub-token preference only pre-answers the existing scoping prompt; the +fine-grained PAT is still minted per-sandbox (see `acq github-scope`). +CONFIGUSAGE +} + # _acq_simple_verb_usage VERB — acq's OWN one-verb usage for the remaining # acq-owned top-level verbs that are NOT the multi-word topics (secret/kit/ # backend) handled above. Kept as a single source (one case) so `acq VERB --help` @@ -428,10 +451,11 @@ _acq_print_topic_help() { local topic="$1" head head="${topic%% *}" case "$head" in - secret) _acq_secret_usage; return 0 ;; - kit) _acq_kit_usage; return 0 ;; - backend) _acq_backend_subcmd_usage; return 0 ;; - *) _acq_simple_verb_usage "$head"; return $? ;; + secret) _acq_secret_usage; return 0 ;; + kit) _acq_kit_usage; return 0 ;; + backend) _acq_backend_subcmd_usage; return 0 ;; + configure) _acq_configure_usage; return 0 ;; + *) _acq_simple_verb_usage "$head"; return $? ;; esac } @@ -684,17 +708,24 @@ case "$subcommand" in local_backend="${1:-}" [ -z "$local_backend" ] && { echo "acq: backend set: missing backend name" >&2; exit 1; } local_config_file="${XDG_CONFIG_HOME:-$HOME/.config}/acq/config.yaml" - mkdir -p "$(dirname "$local_config_file")" - printf 'backend: %s\n' "$local_backend" > "$local_config_file" + # Write only the `backend:` key, preserving any other config (extra_kits, + # scope_github_token) written by `acq configure` (ADR-0031). + _acq_config_write_field backend "$local_backend" \ + || { echo "acq: backend set: could not write ${local_config_file}" >&2; exit 1; } echo "acq: default backend set to '${local_backend}' in ${local_config_file}" >&2 ;; unset) - # Clear the persisted default so acq falls back to auto-detection (see - # the resolution order in `acq help`). Idempotent: succeeds whether or - # not a default was set. + # Clear ONLY the persisted default backend so acq falls back to + # auto-detection (see the resolution order in `acq help`). Preserves any + # other config keys (extra_kits, scope_github_token). Idempotent. local_config_file="${XDG_CONFIG_HOME:-$HOME/.config}/acq/config.yaml" - if [ -f "$local_config_file" ]; then - rm -f "$local_config_file" + if [ -f "$local_config_file" ] && [ -n "$(_acq_config_read_field backend)" ]; then + _acq_config_write_field backend "" # empty value removes the key + # If the config is now empty (backend was the only key), remove the + # file so a fresh checkout looks pristine — matching the historical + # behavior and the first-run detection (a stale empty file would + # suppress the first-run `acq configure` offer). + [ -s "$local_config_file" ] || rm -f "$local_config_file" 2>/dev/null || true echo "acq: cleared the persisted default backend (${local_config_file}); acq will auto-detect." >&2 else echo "acq: no persisted default backend was set; acq already auto-detects." >&2 @@ -858,6 +889,16 @@ esac # version/doctor: resolve backend best-effort (they work even if none detected). # --------------------------------------------------------------------------- case "$subcommand" in + configure) + # Interactive configuration (ADR-0031). Needs no backend — it only reads and + # writes config.yaml. Resolve the backend best-effort so a first-run decline + # can persist the detected default, but never fail on no backend. + shift + acq_resolve_backend "${explicit_backend}" 2>/dev/null || true + acq_configure + exit 0 + ;; + version|--version|-v) # Resolve backend best-effort so version can report it; don't fail. acq_resolve_backend "${explicit_backend}" 2>/dev/null || true @@ -959,6 +1000,15 @@ case "$subcommand" in create) shift acq_backend_prepare + # First-run onboarding (ADR-0031): offer `acq configure` once before we + # provision, so a brand-new user picks kits up front. No-op after first run + # and in non-interactive sessions. + maybe_first_run_configure + # Apply configured-default extra kits (config.yaml), then offer a create-time + # picker pre-populated with those defaults so THIS sandbox can deviate. + _acq_apply_configured_extra_kits || exit 1 + create_time_kit_picker + _build_kit_list # Pre-flight: validate the workspace path before provisioning (#issue). ws=$(workspace_path "$@") preflight_workspace_path "$ws" || exit 1 @@ -1078,6 +1128,17 @@ case "$subcommand" in # with no USAi binding — the exact split that made validation report 200 # (in a throwaway sandbox) yet 401 (in the real one). Gate on key # presence first; fail closed if the user declines. + + # First-run onboarding + extra-kit selection (ADR-0031), BEFORE the key + # gate so a first-time user configures kits up front. maybe_first_run_* + # is a no-op after first run / non-interactive; the picker pre-populates + # from the configured defaults and folds the result into ACQ_EXTRA_KITS + # for this create (persisted per-sandbox by the existing kit-record path). + maybe_first_run_configure + _acq_apply_configured_extra_kits || exit 1 + create_time_kit_picker + _build_kit_list + if ! ensure_key_present "$local_name"; then exit 1 fi diff --git a/acq.backends/common.sh b/acq.backends/common.sh index 8b2b5906..6ad521c1 100644 --- a/acq.backends/common.sh +++ b/acq.backends/common.sh @@ -58,6 +58,16 @@ GITSSHSIGN_KIT="${PATTERNS_KIT_REPO}#ref=${PATTERNS_KIT_REF}&dir=${PATTERNS_KIT_ # shellcheck disable=SC2034 # consumed by `acq kit list` in the acq entry point ACQ_KIT_NAMES=(zscaler-ca-certificate usai-provider agentic-coding-playbook git-ssh-sign) +# One-line descriptions for the built-in kits, parallel to ACQ_KIT_NAMES, shown +# as the frozen ("always applied") rows in the `acq configure` picker (ADR-0031). +# shellcheck disable=SC2034 # consumed by acq_configure / create_time_kit_picker +ACQ_KIT_DESCS=( + "Zscaler/corporate CA trust so TLS-intercepting proxies don't break fetches" + "USAi provider + model config for OpenCode (endpoints, key wiring)" + "Federal agent rules and skills from the agentic-coding playbook" + "SSH-based git commit signing for the agent's commits" +) + # Built-in bundle identity. This mirrors the `provenance` block the usai-provider # kit declares at the pinned PATTERNS_KIT_REF. acq records these in a sandbox's # record so a later `acq run` / `acq kit check` can tell whether an existing @@ -73,6 +83,93 @@ ACQ_BUILTIN_BUNDLE_REPO="GSA-TTS/agentic-coding-patterns" ACQ_EXTRA_KITS="${ACQ_EXTRA_KITS:-}" ACQ_EXTRA_KIT_SOURCES="${ACQ_EXTRA_KIT_SOURCES:-}" +# Record whether ACQ_EXTRA_KITS came from the ENVIRONMENT (vs. being empty or set +# later by acq itself). The configured-default and create-time picker paths honor +# env > config > interactive: a user who exported ACQ_EXTRA_KITS gets it verbatim +# and is never re-prompted or overridden (ADR-0031). Captured once at load time. +if [ -n "${ACQ_EXTRA_KITS:-}" ]; then + ACQ_EXTRA_KITS_FROM_ENV=1 +else + ACQ_EXTRA_KITS_FROM_ENV="" +fi + +# --------------------------------------------------------------------------- +# Opt-in kit catalog (for `acq configure`; ADR-0031) +# --------------------------------------------------------------------------- +# The OPT-IN community kits acq can offer in the interactive picker, sourced from +# the SAME pinned patterns bundle as the built-ins (PATTERNS_KIT_REF / +# PATTERNS_KIT_DIR) so their refs stay pinned in lockstep. The four built-in kits +# above are always applied and are NOT listed here (the picker shows them as +# locked/pre-checked, informational only). +# +# Parallel arrays (bash 3.2 safe — no associative arrays): NAME[i] is the kit +# directory under PATTERNS_KIT_DIR; DESC[i] is the one-line picker description. +# `prime-agent` is intentionally omitted: it is a skeleton at the current pin +# (see the patterns kits.yaml parity note) and would be non-functional if +# offered. Add it here once it is functional. +# shellcheck disable=SC2034 # consumed by `acq configure` (cross-function reader) +ACQ_OPTIN_KIT_NAMES=(openchamber paseo) +# shellcheck disable=SC2034 +ACQ_OPTIN_KIT_DESCS=( + "Browser UI for OpenCode alongside the terminal TUI (publishes ports 3000/4096)" + "Self-hosted Paseo browser web UI for coding agents (one port, loopback only)" +) + +# _acq_optin_kit_ref NAME — echo the fully-pinned git+https kit ref for an opt-in +# kit NAME, built exactly like the built-in *_KIT refs (same repo/ref/dir), so a +# picked kit is pinned to the same patterns bundle as everything else. +_acq_optin_kit_ref() { + printf '%s#ref=%s&dir=%s/%s\n' \ + "$PATTERNS_KIT_REPO" "$PATTERNS_KIT_REF" "$PATTERNS_KIT_DIR" "${1:?kit name required}" +} + +# _acq_apply_configured_extra_kits — fold the configured-default extra kits +# (config.yaml `extra_kits:`, written by `acq configure`) into ACQ_EXTRA_KITS for +# this invocation, so a durable default behaves like an env-supplied extra. An +# env-supplied ACQ_EXTRA_KITS is authoritative and is NOT overridden (env > config, +# matching the backend-resolution precedence and the `--kit`/env layering). No-op +# when nothing is configured. Idempotent (guarded so a second call is cheap). +_ACQ_CONFIGURED_KITS_APPLIED="" +_acq_apply_configured_extra_kits() { + [ -z "$_ACQ_CONFIGURED_KITS_APPLIED" ] || return 0 + # Env wins: if the user exported ACQ_EXTRA_KITS, respect it verbatim. + if [ -n "${ACQ_EXTRA_KITS_FROM_ENV:-}" ]; then + _ACQ_CONFIGURED_KITS_APPLIED=1 + return 0 + fi + local configured names ref + configured=$(_acq_config_read_field extra_kits) + if [ -z "$configured" ]; then + _ACQ_CONFIGURED_KITS_APPLIED=1 + return 0 + fi + # The stored value is a list of opt-in kit NAMES. Raw refs are intentionally + # not accepted from durable config; use ACQ_EXTRA_KITS or --kit for those. + names="$configured" + local out="" tok known k + for tok in $names; do + known="" + for k in ${ACQ_OPTIN_KIT_NAMES[@]+"${ACQ_OPTIN_KIT_NAMES[@]}"}; do + [ "$k" = "$tok" ] && { known=1; break; } + done + if [ -z "$known" ]; then + echo "acq: config extra_kits contains unknown kit '${tok}'" >&2 + echo " Run 'acq configure' to choose catalog kits, or use ACQ_EXTRA_KITS/--kit for custom refs." >&2 + return 1 + fi + ref=$(_acq_optin_kit_ref "$tok") + out="${out:+$out }$ref" + done + if [ -z "$out" ]; then + _ACQ_CONFIGURED_KITS_APPLIED=1 + return 0 + fi + ACQ_EXTRA_KITS="$out" + _ACQ_CONFIGURED_KITS_APPLIED=1 + acq_debug "config: applied configured extra_kits: $out" + return 0 +} + # Update-check opt-out. When ACQ_UPDATE_CHECK=0, `acq run` never # runs the stale-sandbox check (no provenance comparison, no prompt). The # explicit `acq kit check` / `acq kit update` commands still work — the opt-out @@ -139,6 +236,22 @@ if ! command -v acq_status >/dev/null 2>&1; then acq_spin_stop() { :; } fi +# Source the TTY-aware interactive prompt widgets (acq_prompt_multiselect / +# acq_prompt_confirm) used by `acq configure` and the create-time picker (see +# ADR-0031). Like progress.sh, all chrome is stderr-only and gated on an +# interactive TTY; non-interactive callers take documented defaults and never +# block. If the file is missing (older partial checkout), define no-op fallbacks +# so `acq configure` degrades to "take defaults" rather than breaking. +if [ -n "${ACQ_SCRIPT_DIR:-}" ] && [ -f "${ACQ_SCRIPT_DIR}/acq.backends/prompt.sh" ]; then + # shellcheck disable=SC1091 + . "${ACQ_SCRIPT_DIR}/acq.backends/prompt.sh" +fi +if ! command -v acq_prompt_confirm >/dev/null 2>&1; then + # Fallback: echo nothing selected / honor the passed default (arg 2 = yes|no). + acq_prompt_multiselect() { printf '\n'; } + acq_prompt_confirm() { [ "${2:-no}" = "yes" ] && return 0 || return 1; } +fi + # ============================================================================ # Utility functions # ============================================================================ @@ -1031,11 +1144,112 @@ _acq_config_file() { } _read_config_backend() { - local cfg + _acq_config_read_field backend +} + +# _acq_config_read_field KEY — echo the value of a flat `KEY: value` line from +# config.yaml (empty if the file or key is absent). Defensive awk parse, no YAML +# dependency — the same convention as the original single-key backend reader, +# generalized to any flat scalar key (backend, extra_kits, scope_github_token). +# KEY is matched literally at column 1. Indented keys are deliberately ignored: +# this is a flat config file, not a general YAML parser. Inline comments and +# simple single/double-quoted scalars are accepted for hand-edited YAML. If +# duplicate top-level keys exist, the last one wins, matching the writer's +# replace-all behavior. A non-identifier KEY is rejected so a caller can't inject +# an awk regex. +_acq_config_read_field() { + local key="${1:-}" cfg + case "$key" in + ''|*[!A-Za-z0-9_]*) return 0 ;; # fail closed on an unexpected key name + esac cfg=$(_acq_config_file) [ -f "$cfg" ] || return 0 - # Parse the single `backend: ` key defensively with awk (no YAML dep). - awk '/^[[:space:]]*backend[[:space:]]*:/ { gsub(/^[[:space:]]*backend[[:space:]]*:[[:space:]]*/,""); gsub(/[[:space:]]*$/,""); print; exit }' "$cfg" + awk -v k="$key" ' + { + # Match only a top-level flat key: key, optional space, colon. + pat = "^" k "[[:space:]]*:" + if ($0 ~ pat) { + sub(pat "[[:space:]]*", "") # strip through the colon + spaces + line = $0 + q = sprintf("%c", 39) + if (substr(line, 1, 1) == "\"" || substr(line, 1, 1) == q) { + in_single = 0 + in_double = 0 + for (i = 1; i <= length(line); i++) { + c = substr(line, i, 1) + if (c == q && !in_double) { + in_single = !in_single + } else if (c == "\"" && !in_single) { + in_double = !in_double + } else if (c == "#" && !in_single && !in_double && (i == 1 || substr(line, i - 1, 1) ~ /[[:space:]]/)) { + line = substr(line, 1, i - 1) + break + } + } + } else if (line ~ /^#/) { + line = "" + } else { + sub(/[[:space:]]+#.*$/, "", line) + } + $0 = line # tolerate inline comments outside quotes + sub(/[[:space:]]*$/, "") # strip trailing space + q = sprintf("%c", 39) + if ($0 ~ /^"[^"]*"$/) { + sub(/^"/, "") + sub(/"$/, "") + } else if (substr($0, 1, 1) == q && substr($0, length($0), 1) == q) { + $0 = substr($0, 2, length($0) - 2) + } + val = $0 # last duplicate key wins + } + } + END { + if (val != "") print val + } + ' "$cfg" +} + +# _acq_config_write_field KEY VALUE — set (or replace) a flat `KEY: VALUE` line in +# config.yaml, preserving every OTHER line (so writing extra_kits leaves backend +# untouched, and vice-versa). Creates the file + parent dir on first write. An +# empty VALUE removes the key entirely (so "no extras" is represented by absence, +# not an empty-valued line). Atomic (temp file + mv). Best-effort: a write failure +# warns via acq_debug and returns non-zero but never aborts the caller. +_acq_config_write_field() { + local key="${1:-}" value="${2:-}" cfg dir tmp + case "$key" in + ''|*[!A-Za-z0-9_]*) acq_debug "config: refusing to write unsafe key '$key'"; return 1 ;; + esac + cfg=$(_acq_config_file) + dir=$(dirname "$cfg") + if ! ( umask 077; mkdir -p "$dir" ) 2>/dev/null; then + acq_debug "config: could not create config dir: $dir" + return 1 + fi + chmod 700 "$dir" 2>/dev/null || { acq_debug "config: chmod failed: $dir"; return 1; } + tmp="${cfg}.tmp.$$" + { + # Re-emit every existing line except the one for KEY (which we replace/drop). + if [ -f "$cfg" ]; then + awk -v k="$key" ' + { + pat = "^" k "[[:space:]]*:" + if ($0 ~ pat) next # drop the old top-level KEY line; we re-add below + print + } + ' "$cfg" + fi + # Append the new value only when non-empty (empty VALUE => remove the key). + # Use an explicit if (not `[ ] &&`) so the group's exit status reflects the + # write, not a false test when VALUE is empty. + if [ -n "$value" ]; then + printf '%s: %s\n' "$key" "$value" + fi + } > "$tmp" 2>/dev/null || { acq_debug "config: write failed: $tmp"; rm -f "$tmp" 2>/dev/null; return 1; } + chmod 600 "$tmp" 2>/dev/null || { acq_debug "config: chmod failed: $tmp"; rm -f "$tmp" 2>/dev/null; return 1; } + mv -f "$tmp" "$cfg" 2>/dev/null || { acq_debug "config: mv failed: $cfg"; rm -f "$tmp" 2>/dev/null; return 1; } + acq_debug "config: wrote ${key}" + return 0 } # _ensure_local_bin_on_path — if a backend CLI is not on PATH but IS present in @@ -2267,13 +2481,20 @@ advise_github_scope() { return 0 fi - local ans="" - printf 'acq: scope a GitHub token for this sandbox now? [y/N] ' >&2 - read -r ans 2>/dev/null || ans="" - case "$ans" in - y|Y|yes|YES) github_scope_sandbox "$sandbox" "$ws" || true ;; - *) echo "acq: continuing without scoping (run 'acq github-scope $sandbox $ws' anytime)." >&2 ;; - esac + # The configured default (ADR-0031) pre-answers this prompt: `acq configure` + # can set scope_github_token: yes|no in config.yaml. Default is "no" (a bare + # ENTER declines), preserving the historical behavior when unset. This only + # changes the DEFAULT answer — the actual per-sandbox fine-grained-PAT minting + # still runs through github_scope_sandbox (ADR-0013 unchanged). + local scope_default + scope_default=$(_acq_config_read_field scope_github_token) + case "$scope_default" in yes|y|Y) scope_default="yes" ;; *) scope_default="no" ;; esac + + if acq_prompt_confirm "Scope a GitHub token for this sandbox now?" "$scope_default"; then + github_scope_sandbox "$sandbox" "$ws" + else + echo "acq: continuing without scoping (run 'acq github-scope $sandbox $ws' anytime)." >&2 + fi return 0 } @@ -2650,10 +2871,9 @@ acq_print_doctor() { read -r answer || true case "$answer" in [yY]|[yY][eE][sS]) - local cfg_dir - cfg_dir=$(dirname "$config_file") - mkdir -p "$cfg_dir" - printf 'backend: %s\n' "${ACQ_RESOLVED_BACKEND:-msb}" > "$config_file" + # Write only the backend key, preserving any other config (extra_kits, + # scope_github_token) written by `acq configure` (ADR-0031). + _acq_config_write_field backend "${ACQ_RESOLVED_BACKEND:-msb}" echo " Wrote default backend to ${config_file}." >&2 ;; *) @@ -2661,3 +2881,219 @@ acq_print_doctor() { ;; esac } + +# ============================================================================ +# Interactive configuration (`acq configure`; ADR-0031) +# ============================================================================ + +# _acq_configure_show_current — print the current durable configuration +# (config.yaml) to stderr, so `acq configure` opens by showing what is in effect. +_acq_configure_show_current() { + local cfg backend extras scope + cfg=$(_acq_config_file) + backend=$(_acq_config_read_field backend) + extras=$(_acq_config_read_field extra_kits) + scope=$(_acq_config_read_field scope_github_token) + echo "acq: current configuration (${cfg}):" >&2 + echo " default backend: ${backend:-}" >&2 + echo " extra kits: ${extras:-}" >&2 + echo " scope GitHub token: ${scope:-no}" >&2 + echo "" >&2 +} + +# acq_configure — the interactive configuration flow. Presents the opt-in kit +# catalog as a multiselect (pre-checked from the current config), then a confirm +# for the GitHub-token-scoping default, and persists both to config.yaml +# (preserving the `backend:` key). Non-interactive callers keep the current +# config unchanged (the widgets fail-open to defaults) and only re-print it. +# +# The kit selection is stored as a whitespace-separated list of the CHOSEN opt-in +# kit NAMES (e.g. "openchamber paseo") under extra_kits:, not the fully-expanded +# git refs — the ref is rebuilt from PATTERNS_KIT_REF at apply time so a later pin +# bump moves configured kits forward automatically. A user's own custom refs (set +# via ACQ_EXTRA_KITS) are layered separately and are not managed here. +acq_configure() { + _acq_configure_show_current + + # Non-interactive (CI / piped / ACQ_NO_PROMPT): make NO changes — just show the + # current config above and return. Writing here would clobber a hand-edited or + # previously-configured file with the widget defaults. + if ! _acq_prompt_interactive; then + echo "acq: non-interactive; configuration unchanged." >&2 + echo " Run 'acq configure' from a terminal to change kits/token scoping." >&2 + return 0 + fi + + local n="${#ACQ_OPTIN_KIT_NAMES[@]}" + if [ "$n" -eq 0 ]; then + echo "acq: no opt-in kits are available to configure." >&2 + return 0 + fi + + # Which opt-in kits are already selected (from config), as a set of names. + local current_extras selected_names="" + current_extras=$(_acq_config_read_field extra_kits) + + # Build the picker's argument list: the built-in kits FIRST as frozen + # ("always applied") rows, then the toggleable opt-in kits. The defaults CSV + # is RELATIVE to the toggleable block (index 1 = first opt-in kit), matching + # the multiselect contract, so it is unaffected by the number of locked rows. + local args=() defaults="" i name desc + local locked_n="${#ACQ_KIT_NAMES[@]}" + i=1 + while [ "$i" -le "$locked_n" ]; do + args+=("${ACQ_KIT_NAMES[$((i-1))]}" "${ACQ_KIT_DESCS[$((i-1))]}") + i=$((i + 1)) + done + i=1 + while [ "$i" -le "$n" ]; do + name="${ACQ_OPTIN_KIT_NAMES[$((i-1))]}" + desc="${ACQ_OPTIN_KIT_DESCS[$((i-1))]}" + args+=("$name" "$desc") + # Pre-check if this kit name appears in the configured extras (word match). + case " $current_extras " in *" $name "*) defaults="${defaults:+$defaults,}$i" ;; esac + i=$((i + 1)) + done + + # Run the picker in-process (NOT via $(...)) so the shared scripted-input + # cursor advances into the token-scoping confirm below; read the result from + # the _ACQ_PROMPT_SELECTION global. Chosen indices are relative to the opt-in + # block, so the index→name mapping below is unchanged by the frozen rows. + local chosen + acq_prompt_multiselect "$locked_n" "$defaults" "${args[@]}" >/dev/null + chosen="$_ACQ_PROMPT_SELECTION" + + # CANCELLED → leave config untouched. + if [ "$chosen" = "CANCELLED" ]; then + return 0 + fi + + # Map chosen indices back to kit names. + local idx + for idx in $chosen; do + case "$idx" in + ''|*[!0-9]*) continue ;; + esac + if [ "$idx" -ge 1 ] && [ "$idx" -le "$n" ]; then + name="${ACQ_OPTIN_KIT_NAMES[$((idx-1))]}" + selected_names="${selected_names:+$selected_names }$name" + fi + done + + # Persist the kit selection (empty removes the key). + _acq_config_write_field extra_kits "$selected_names" + + # GitHub-token-scoping default. + local scope_current scope_default + scope_current=$(_acq_config_read_field scope_github_token) + case "$scope_current" in yes|y|Y) scope_default="yes" ;; *) scope_default="no" ;; esac + if acq_prompt_confirm "Scope a per-sandbox GitHub token by default (on create)?" "$scope_default"; then + _acq_config_write_field scope_github_token yes + else + _acq_config_write_field scope_github_token no + fi + + echo "" >&2 + echo "acq: configuration saved to $(_acq_config_file)." >&2 + echo " extra kits: ${selected_names:-}" >&2 + return 0 +} + +# maybe_first_run_configure — on first run (no config.yaml yet) and interactive, +# offer to run `acq configure` once. Declining still writes a minimal config +# (an empty scope_github_token line is avoided; we write the resolved backend if +# known, else touch the file) so the offer never repeats. Fully skipped when +# non-interactive (CI / piped) — fail-open, preserving today's behavior. +maybe_first_run_configure() { + local cfg dir + cfg=$(_acq_config_file) + [ -f "$cfg" ] && return 0 # already configured (or previously offered) + _acq_prompt_interactive || return 0 # non-interactive: skip silently + + echo "acq: looks like this is your first run — let's set up your kits." >&2 + echo " (You can re-run this anytime with 'acq configure'.)" >&2 + echo "" >&2 + if acq_prompt_confirm "Configure extra kits and token scoping now?" "yes"; then + acq_configure + else + # Write a minimal config so the first-run offer does not repeat. Prefer the + # resolved backend if one is known; otherwise create an empty file. + if [ -n "${ACQ_RESOLVED_BACKEND:-}" ]; then + _acq_config_write_field backend "$ACQ_RESOLVED_BACKEND" + else + dir=$(dirname "$cfg") + ( umask 077; mkdir -p "$dir" && : > "$cfg" ) 2>/dev/null || true + chmod 700 "$dir" 2>/dev/null || true + chmod 600 "$cfg" 2>/dev/null || true + fi + echo "acq: skipped. Run 'acq configure' anytime to change kits/token scoping." >&2 + fi + return 0 +} + +# create_time_kit_picker — at `acq create`/`run` (fresh create only), offer the +# opt-in kit picker PRE-POPULATED from the configured global defaults, so a user +# can enable/disable a kit for THIS sandbox without changing the global default. +# The selection is folded into ACQ_EXTRA_KITS for this invocation (as fully- +# expanded refs), which flows through _build_kit_list and is persisted PER-SANDBOX +# by the existing acq_cli_kits_write path (ADR-0017) — reloaded on resume by +# acq_cli_kits_load. No global config is written here (that is `acq configure`). +# +# Fail-open: non-interactive (CI / piped) makes no prompt and simply lets the +# configured defaults apply via _acq_apply_configured_extra_kits (already called +# before provisioning). Idempotent per process. +_ACQ_CREATE_PICKER_DONE="" +create_time_kit_picker() { + [ -z "$_ACQ_CREATE_PICKER_DONE" ] || return 0 + _ACQ_CREATE_PICKER_DONE=1 + + # Only prompt interactively; otherwise the configured defaults already applied. + _acq_prompt_interactive || return 0 + + local n="${#ACQ_OPTIN_KIT_NAMES[@]}" + [ "$n" -gt 0 ] || return 0 + + # Defaults = the configured global extra_kits (kit NAMES). An env-supplied + # ACQ_EXTRA_KITS is authoritative and NOT re-prompted (env > interactive). + [ -z "${ACQ_EXTRA_KITS_FROM_ENV:-}" ] || return 0 + + local configured args=() defaults="" i name desc + local locked_n="${#ACQ_KIT_NAMES[@]}" + configured=$(_acq_config_read_field extra_kits) + # Built-in kits first as frozen rows (consistent with `acq configure`). + i=1 + while [ "$i" -le "$locked_n" ]; do + args+=("${ACQ_KIT_NAMES[$((i-1))]}" "${ACQ_KIT_DESCS[$((i-1))]}") + i=$((i + 1)) + done + i=1 + while [ "$i" -le "$n" ]; do + name="${ACQ_OPTIN_KIT_NAMES[$((i-1))]}" + desc="${ACQ_OPTIN_KIT_DESCS[$((i-1))]}" + args+=("$name" "$desc") + case " $configured " in *" $name "*) defaults="${defaults:+$defaults,}$i" ;; esac + i=$((i + 1)) + done + + echo "acq: extra kits for this sandbox (defaults from 'acq configure'):" >&2 + # In-process (not $(...)) so the selection lands in _ACQ_PROMPT_SELECTION. + local chosen + acq_prompt_multiselect "$locked_n" "$defaults" "${args[@]}" >/dev/null + chosen="$_ACQ_PROMPT_SELECTION" + [ "$chosen" = "CANCELLED" ] && return 0 + + # Map chosen indices → fully-expanded kit refs, and set ACQ_EXTRA_KITS for this + # invocation (replacing the configured-default application, which we override + # deliberately: the picker's result is authoritative for this create). + local refs="" idx + for idx in $chosen; do + case "$idx" in ''|*[!0-9]*) continue ;; esac + if [ "$idx" -ge 1 ] && [ "$idx" -le "$n" ]; then + name="${ACQ_OPTIN_KIT_NAMES[$((idx-1))]}" + refs="${refs:+$refs }$(_acq_optin_kit_ref "$name")" + fi + done + ACQ_EXTRA_KITS="$refs" + acq_debug "create-picker: ACQ_EXTRA_KITS set to: $refs" + return 0 +} diff --git a/acq.backends/prompt.sh b/acq.backends/prompt.sh new file mode 100644 index 00000000..3ab996cc --- /dev/null +++ b/acq.backends/prompt.sh @@ -0,0 +1,444 @@ +#!/bin/bash +# +# acq.backends/prompt.sh — TTY-aware interactive prompt widgets for acq +# +# Sourced by common.sh (next to progress.sh). Provides a small, dependency-free +# set of interactive widgets so `acq configure` (and the create-time picker) can +# offer a friendly, colorful selection UI — modeled on the multiselect installers +# users already like — WITHOUT pinning a TUI dependency (gum/fzf/whiptail). See +# ADR-0031 and issue GSA-TTS/agentic-coding-quickstart#499. +# +# Two public functions: +# +# acq_prompt_multiselect LOCKED_N DEFAULTS_CSV LABEL_1 DESC_1 [LABEL_2 DESC_2 ...] +# Present a checkbox list of items. The FIRST LOCKED_N items are shown as +# "frozen" rows: always checked, dimmed, tagged "(always applied)", skipped +# by the cursor, never toggleable, and never included in the result. The +# remaining items are the toggleable rows. DEFAULTS_CSV is a comma-separated +# list of 1-based indices — RELATIVE TO THE TOGGLEABLE BLOCK (so index 1 is +# the first toggleable item, regardless of LOCKED_N) — pre-checked on entry +# (empty = none). Interactive: the user moves with the arrow keys (or j/k), +# toggles with SPACE, confirms with ENTER, aborts with q/ESC. On confirm, +# echoes the CHOSEN indices (also relative to the toggleable block) as a +# space-separated list on STDOUT (empty line = nothing chosen; "CANCELLED" +# on abort). All chrome is written to STDERR so a caller can capture the +# selection cleanly. Pass LOCKED_N=0 for a plain, all-toggleable list. +# +# acq_prompt_confirm PROMPT DEFAULT (DEFAULT = yes|no) +# A colorized [Y/n] / [y/N] confirm. Returns 0 for yes, 1 for no. The +# default is used on a bare ENTER and in every non-interactive path. +# +# DESIGN — why a tiny in-repo helper and not a library (mirrors progress.sh): +# acq is a thin, dependency-light wrapper the user clones and runs; pulling in +# a selector package would add a pinned, CVE-scanned, license-checked host +# dependency for cosmetic input — disproportionate and contrary to the "one +# clone, no extra deps" onboarding this repo provides. Mature installers +# hand-roll the same handful of escape sequences. +# +# SAFETY / CI: +# - Interactivity is gated on STDIN being a TTY (`[ -t 0 ]`). Color is gated +# independently on STDOUT-for-chrome (STDERR here) being a TTY (`[ -t 2 ]`), +# matching install.sh's `[ -t 1 ]` model but for our stderr chrome stream. +# - ACQ_NO_PROMPT (or ACQ_NO_PROGRESS) forces the non-interactive path. +# - Non-interactive ALWAYS takes the supplied defaults and never blocks, so CI +# and piped runs behave exactly as before. +# - ACQ_PROMPT_TEST_INPUT feeds a scripted keystroke/answer string for the +# offline test harness (mirrors ACQ_SECRET_TEST_VALUE), so the widgets are +# testable without a real terminal. +# - Caller-supplied labels are sanitized of control bytes before display, so an +# untrusted kit description can't inject terminal escapes. + +# _acq_prompt_sanitize TEXT — strip raw terminal control bytes from caller data, +# so a label/description can never inject raw escapes. Keep valid UTF-8 intact; +# raw C1 bytes are invalid UTF-8 and are dropped by iconv when available. +_acq_prompt_sanitize() { + if command -v iconv >/dev/null 2>&1; then + { printf '%s' "$*" | iconv -f UTF-8 -t UTF-8 -c || true; } \ + | LC_ALL=C awk '{ gsub(/\302[\200-\237]/, ""); printf "%s", $0 }' \ + | LC_ALL=C tr -d '\000-\037\177' + else + printf '%s' "$*" | LC_ALL=C tr -d '\000-\037\177' + fi +} + +# _acq_prompt_interactive — 0 (true) when we may run an interactive widget: +# stdin is a TTY AND the user has not opted out. When ACQ_PROMPT_TEST_INPUT is +# set we ALSO treat the session as interactive (the harness drives the keys), +# so the widget logic itself is exercised offline. +_acq_prompt_interactive() { + if [ -n "${ACQ_PROMPT_TEST_INPUT:-}" ]; then + return 0 + fi + [ -z "${ACQ_NO_PROMPT:-}" ] || return 1 + [ -z "${ACQ_NO_PROGRESS:-}" ] || return 1 + [ -t 0 ] || return 1 + return 0 +} + +# _acq_prompt_color — 0 (true) when we may emit SGR color on the chrome stream +# (stderr). Matches install.sh's TTY gate, but for fd 2. Suppressed for the test +# harness so assertions match plain text. +_acq_prompt_color() { + [ -z "${ACQ_PROMPT_TEST_INPUT:-}" ] || return 1 + [ -t 2 ] || return 1 + return 0 +} + +# _acq_prompt_term_cols — best-effort terminal width (columns). Falls back to 80 +# when it can't be determined (non-TTY, no COLUMNS, no tput). Used to keep each +# rendered row on exactly ONE physical line: the redraw math moves the cursor up +# by the row count, so a row that soft-wraps to a second line would desync the +# repaint (the classic "row duplicates and pushes others down" artifact). +_acq_prompt_term_cols() { + local c="" + if [ -n "${COLUMNS:-}" ]; then + c="$COLUMNS" + elif command -v tput >/dev/null 2>&1; then + c=$(tput cols 2>/dev/null) + fi + case "$c" in + ''|*[!0-9]*) c=80 ;; + esac + [ "$c" -ge 20 ] || c=80 + printf '%s' "$c" +} + +# _acq_prompt_fit PLAIN MAXCOLS — echo PLAIN truncated to at most MAXCOLS display +# columns, appending a single-column ellipsis '…' when truncated. PLAIN MUST be +# free of escape sequences (callers colorize AFTER fitting). This uses byte/char +# length as a proxy for width — adequate here because all labels/descriptions are +# ASCII plus the odd box char, and it guarantees the printed cell never exceeds +# the terminal width, which is what keeps the no-wrap invariant. +_acq_prompt_fit() { + local s="$1" max="$2" + # Character count (locale-aware via ${#s} in bash). If it fits, return as-is. + if [ "${#s}" -le "$max" ]; then + printf '%s' "$s" + return 0 + fi + # Reserve one column for the ellipsis. + local keep=$((max - 1)) + [ "$keep" -lt 0 ] && keep=0 + printf '%s…' "${s:0:keep}" +} + +# Populate B/R/YEL/GRN/RED/DIM/CYAN for the duration of a widget. Mirrors the +# install.sh palette (B/R/YEL/GRN/RED) plus a dim + cyan accent for the cursor +# row. Empty strings when color is disabled, so every printf is color-safe. +_acq_prompt_set_colors() { + if _acq_prompt_color; then + _P_B=$(printf '\033[1m'); _P_R=$(printf '\033[0m') + _P_YEL=$(printf '\033[33m'); _P_GRN=$(printf '\033[32m'); _P_RED=$(printf '\033[31m') + _P_DIM=$(printf '\033[2m'); _P_CYAN=$(printf '\033[36m') + else + _P_B=""; _P_R=""; _P_YEL=""; _P_GRN=""; _P_RED=""; _P_DIM=""; _P_CYAN="" + fi +} + +# _acq_prompt_read_key — read a single logical keypress and store the normalized +# token in the global _ACQ_PROMPT_KEY (rather than echoing it), so the caller can +# invoke it WITHOUT a command substitution. That matters for the scripted test +# path: a `$(...)` subshell would discard the advance of _ACQ_PROMPT_TEST_POS, +# so a substitution-based reader replays the first key forever. The token is one +# of UP DOWN LEFT RIGHT SPACE ENTER QUIT, or a literal char. +# +# When ACQ_PROMPT_TEST_INPUT is set, keys are consumed from that string one token +# at a time via _ACQ_PROMPT_TEST_POS; tokens there may be the literal words +# UP/DOWN/SPACE/ENTER/QUIT (whitespace-separated) OR single chars. +_ACQ_PROMPT_TEST_POS=0 +_ACQ_PROMPT_KEY="" +_acq_prompt_read_key() { + _ACQ_PROMPT_KEY="" + if [ -n "${ACQ_PROMPT_TEST_INPUT:-}" ]; then + # Tokenized scripted input: split on whitespace, emit the next token. + local toks + # shellcheck disable=SC2206 # deliberate word-split of the scripted input + toks=(${ACQ_PROMPT_TEST_INPUT}) + if [ "$_ACQ_PROMPT_TEST_POS" -ge "${#toks[@]}" ]; then + _ACQ_PROMPT_KEY='ENTER' # exhausted script → confirm, so a test can never hang + return 0 + fi + _ACQ_PROMPT_KEY="${toks[$_ACQ_PROMPT_TEST_POS]}" + _ACQ_PROMPT_TEST_POS=$((_ACQ_PROMPT_TEST_POS + 1)) + return 0 + fi + + local k rest + IFS= read -rsn1 k 2>/dev/null || { _ACQ_PROMPT_KEY='QUIT'; return 0; } + case "$k" in + '') _ACQ_PROMPT_KEY='ENTER'; return 0 ;; # Enter yields empty with -n1 + ' ') _ACQ_PROMPT_KEY='SPACE'; return 0 ;; + q|Q) _ACQ_PROMPT_KEY='QUIT'; return 0 ;; + j|J) _ACQ_PROMPT_KEY='DOWN'; return 0 ;; + k|K) _ACQ_PROMPT_KEY='UP'; return 0 ;; + $'\033') + # Possible CSI sequence: read up to two more bytes non-blocking. + IFS= read -rsn2 -t 1 rest 2>/dev/null || rest="" + case "$rest" in + '[A') _ACQ_PROMPT_KEY='UP' ;; + '[B') _ACQ_PROMPT_KEY='DOWN' ;; + '[C') _ACQ_PROMPT_KEY='RIGHT' ;; + '[D') _ACQ_PROMPT_KEY='LEFT' ;; + *) _ACQ_PROMPT_KEY='QUIT' ;; # bare ESC (or unknown) → abort + esac + return 0 + ;; + *) _ACQ_PROMPT_KEY="$k"; return 0 ;; + esac +} + +# _acq_prompt_in_list NEEDLE HAYSTACK... — 0 if NEEDLE is one of the remaining +# args. Used to test membership in the space-separated "checked" set. +_acq_prompt_in_list() { + local needle="$1"; shift + local x + for x in "$@"; do + [ "$x" = "$needle" ] && return 0 + done + return 1 +} + +# acq_prompt_multiselect LOCKED_N DEFAULTS_CSV LABEL DESC [LABEL DESC ...] +# See the header for the full contract. The chosen indices (relative to the +# toggleable block) are placed in the global _ACQ_PROMPT_SELECTION (space- +# separated; "CANCELLED" on abort) AND echoed on stdout. Prefer the GLOBAL when +# composing widgets in one process (e.g. acq_configure): a `$(...)` capture forks +# a subshell, which would discard the shared scripted-input cursor advance and +# desync a following acq_prompt_confirm. +_ACQ_PROMPT_SELECTION="" +acq_prompt_multiselect() { + _ACQ_PROMPT_SELECTION="" + local locked_n="${1:-0}"; shift || true + local defaults_csv="${1:-}"; shift || true + case "$locked_n" in ''|*[!0-9]*) locked_n=0 ;; esac + + # Collect items into parallel index/label/desc arrays (bash 3.2 safe). Rows + # 1..locked_n are frozen (always-applied); rows locked_n+1..n are toggleable. + local labels=() descs=() n=0 + while [ "$#" -gt 0 ]; do + labels+=("$(_acq_prompt_sanitize "${1:-}")"); shift || true + descs+=("$(_acq_prompt_sanitize "${1:-}")"); shift || true + n=$((n + 1)) + done + [ "$n" -gt 0 ] || { _ACQ_PROMPT_SELECTION=""; printf '\n'; return 0; } + [ "$locked_n" -le "$n" ] || locked_n="$n" + local first_toggle=$((locked_n + 1)) # absolute index of the first toggleable row + + # Seed the checked set (ABSOLUTE indices) from DEFAULTS_CSV, whose values are + # RELATIVE to the toggleable block — so add locked_n to map them to absolute. + local checked="" idx abs + local _oldifs="$IFS"; IFS=',' + # shellcheck disable=SC2086 # intentional split on comma + set -- $defaults_csv + IFS="$_oldifs" + for idx in "$@"; do + case "$idx" in + ''|*[!0-9]*) : ;; # skip empties / non-numeric + *) + abs=$((idx + locked_n)) + [ "$abs" -ge "$first_toggle" ] && [ "$abs" -le "$n" ] && checked="$checked $abs" + ;; + esac + done + + # Non-interactive: emit the defaults verbatim (relative indices) without chrome. + if ! _acq_prompt_interactive; then + _ACQ_PROMPT_SELECTION="$(_acq_prompt_rel_indices "$locked_n" "$checked")" + printf '%s\n' "$_ACQ_PROMPT_SELECTION" + return 0 + fi + + _acq_prompt_set_colors + # A standalone multiselect starts a fresh scripted-input read. (acq_configure + # composes multiselect+confirm in one flow and relies on the SHARED cursor, but + # it does not pre-seed the cursor either — the first widget in a process resets + # it here, and the following confirm continues from where this left off.) + _ACQ_PROMPT_TEST_POS=0 + # Cursor starts on the first toggleable row (locked rows are display-only). If + # there are no toggleable rows, cursor stays at first_toggle (== n+1) and the + # only actions are ENTER/QUIT. + local cursor="$first_toggle" key i mark line plain cols + # Column budget: every row must fit on ONE physical line or the up-by-N-rows + # repaint desyncs (a wrapped row visually duplicates and shoves others down). + cols=$(_acq_prompt_term_cols) + + # Hide the cursor for the duration and ensure it is restored on any exit path. + local _prior_int _prior_term + _prior_int=$(trap -p INT); _prior_term=$(trap -p TERM) + # shellcheck disable=SC2064 + trap 'printf "\033[?25h" >&2; eval "${_prior_int#trap -- }" 2>/dev/null || true; trap - INT; kill -s INT $$' INT + [ -z "${ACQ_PROMPT_TEST_INPUT:-}" ] && printf '\033[?25l' >&2 + + local rendered=0 + while :; do + # Repaint: move the cursor up over the previously rendered block (if any), + # then redraw every row. Header is drawn once above the block. + if [ "$rendered" -gt 0 ]; then + printf '\033[%dA' "$rendered" >&2 + else + # Header is printed ONCE, above the repainted block, so the up-by-N repaint + # never touches it. Fit it to one physical line too (a wrapped header would + # otherwise shift everything below it by the wrap count). + local _hdr + _hdr=$(_acq_prompt_fit "? Select kits (dimmed rows are always applied · ↑/↓ move · SPACE toggle · ENTER confirm · q cancel)" "$cols") + printf '%s%s%s\n' "$_P_B" "$_hdr" "$_P_R" >&2 + fi + i=1 + while [ "$i" -le "$n" ]; do + # Assemble each row as ONE plain string (checkbox + label + description + # [+ tag]), fit it to the terminal width so it never soft-wraps, THEN wrap + # the whole thing in a single color span. Fitting the plain text (color + # codes are zero-width) is what keeps every row to exactly one physical + # line — the invariant the up-by-N repaint depends on. A single span per + # row (rather than per-field tinting) keeps that guarantee simple and + # avoids stray attributes bleeding across a truncation boundary. + if [ "$i" -le "$locked_n" ]; then + plain=" [x] ${labels[$((i-1))]} ${descs[$((i-1))]} (always applied)" + line="${_P_DIM}$(_acq_prompt_fit "$plain" "$cols")${_P_R}" + else + if _acq_prompt_in_list "$i" $checked; then mark="[x]"; else mark="[ ]"; fi + if [ "$i" -eq "$cursor" ]; then + plain="❯ ${mark} ${labels[$((i-1))]} ${descs[$((i-1))]}" + line="${_P_CYAN}${_P_B}$(_acq_prompt_fit "$plain" "$cols")${_P_R}" + else + plain=" ${mark} ${labels[$((i-1))]} ${descs[$((i-1))]}" + if [ "$mark" = "[x]" ]; then + line="${_P_GRN}$(_acq_prompt_fit "$plain" "$cols")${_P_R}" + else + line="$(_acq_prompt_fit "$plain" "$cols")" + fi + fi + fi + # Clear the line first (\033[K) so a shorter repaint can't leave residue. + printf '\033[K%s\n' "$line" >&2 + i=$((i + 1)) + done + rendered="$n" + + _acq_prompt_read_key + key="$_ACQ_PROMPT_KEY" + case "$key" in + UP) + # Move within the toggleable block only; wrap top<->bottom. No-op when + # there are no toggleable rows. + if [ "$first_toggle" -le "$n" ]; then + cursor=$((cursor - 1)); [ "$cursor" -lt "$first_toggle" ] && cursor="$n" + fi + ;; + DOWN) + if [ "$first_toggle" -le "$n" ]; then + cursor=$((cursor + 1)); [ "$cursor" -gt "$n" ] && cursor="$first_toggle" + fi + ;; + SPACE) + # Toggle only when the cursor is on a real toggleable row (guard locked). + if [ "$cursor" -ge "$first_toggle" ] && [ "$cursor" -le "$n" ]; then + if _acq_prompt_in_list "$cursor" $checked; then + local new="" c + for c in $checked; do [ "$c" != "$cursor" ] && new="$new $c"; done + checked="$new" + else + checked="$checked $cursor" + fi + fi + ;; + ENTER) + break + ;; + QUIT) + printf '\033[?25h' >&2 + # Restore prior signal traps and abort the SELECTION (empty) — the caller + # treats an empty line as "keep defaults / no change". + eval "${_prior_int#trap -- }" 2>/dev/null || trap - INT + eval "${_prior_term#trap -- }" 2>/dev/null || trap - TERM + printf '%sacq: selection cancelled; keeping current configuration.%s\n' "$_P_DIM" "$_P_R" >&2 + _ACQ_PROMPT_SELECTION="CANCELLED" + printf 'CANCELLED\n' + return 0 + ;; + *) : ;; # ignore any other key + esac + done + + [ -z "${ACQ_PROMPT_TEST_INPUT:-}" ] && printf '\033[?25h' >&2 + eval "${_prior_int#trap -- }" 2>/dev/null || trap - INT + eval "${_prior_term#trap -- }" 2>/dev/null || trap - TERM + + # Emit the chosen indices (relative to the toggleable block) in ascending order. + _ACQ_PROMPT_SELECTION="$(_acq_prompt_rel_indices "$locked_n" "$checked")" + printf '%s\n' "$_ACQ_PROMPT_SELECTION" + return 0 +} + +# _acq_prompt_rel_indices LOCKED_N CHECKED_ABS — echo the checked ABSOLUTE indices +# (a space-separated set) as ascending TOGGLEABLE-RELATIVE indices (abs-LOCKED_N), +# dropping any that fall in the locked range. Keeps the caller's index→name map +# stable regardless of how many rows are frozen. +_acq_prompt_rel_indices() { + local locked_n="$1" checked="$2" out="" j + # Ascending sort is unnecessary if we scan positions in order: + local total=0 c + for c in $checked; do [ "$c" -gt "$total" ] && total="$c"; done + j=$((locked_n + 1)) + while [ "$j" -le "$total" ]; do + _acq_prompt_in_list "$j" $checked && out="$out $((j - locked_n))" + j=$((j + 1)) + done + printf '%s' "$(printf '%s' "$out" | sed 's/^ *//')" +} + +# acq_prompt_confirm PROMPT DEFAULT (DEFAULT = yes|no) — colorized [Y/n]/[y/N]. +# Returns 0 for yes, 1 for no. Bare ENTER and every non-interactive path use the +# default. Honors ACQ_PROMPT_TEST_INPUT (first token: y/yes → yes, else no). +acq_prompt_confirm() { + local prompt default="${2:-no}" + prompt=$(_acq_prompt_sanitize "${1:-Proceed?}") + + local hint + case "$default" in + yes|y|Y) default="yes"; hint="[Y/n]" ;; + *) default="no"; hint="[y/N]" ;; + esac + + # Scripted test input takes precedence (the harness drives the answer). Two + # shapes are supported so tests can drive both a standalone confirm AND a + # confirm that follows a multiselect in one flow: + # - a SINGLE-token script ("y"/"n") is a standalone confirm answer, consumed + # without advancing the shared multiselect cursor (so repeated standalone + # confirms in one process each see their own single token); + # - a MULTI-token script is a whole flow (multiselect keys THEN the confirm + # answer): consume the NEXT token from the shared cursor so the confirm + # picks up where the multiselect left off. + # A recognized y/n token decides; anything else → default. + if [ -n "${ACQ_PROMPT_TEST_INPUT:-}" ]; then + local _toks answer + # shellcheck disable=SC2206 # deliberate word-split of the scripted input + _toks=(${ACQ_PROMPT_TEST_INPUT}) + if [ "${#_toks[@]}" -le 1 ]; then + answer="${_toks[0]:-}" + else + _acq_prompt_read_key + answer="$_ACQ_PROMPT_KEY" + fi + case "$answer" in + y|Y|yes|YES) return 0 ;; + n|N|no|NO) return 1 ;; + *) [ "$default" = "yes" ] && return 0 || return 1 ;; + esac + fi + + # Non-interactive (CI / piped / opted-out): take the default, never block. + if ! _acq_prompt_interactive; then + [ "$default" = "yes" ] && return 0 || return 1 + fi + + _acq_prompt_set_colors + local ans="" + printf '%s%s?%s %s %s%s%s ' "$_P_B" "$_P_GRN" "$_P_R" "$prompt" "$_P_DIM" "$hint" "$_P_R" >&2 + read -r ans 2>/dev/null || ans="" + case "$ans" in + y|Y|yes|YES) return 0 ;; + n|N|no|NO) return 1 ;; + *) [ "$default" = "yes" ] && return 0 || return 1 ;; + esac +} diff --git a/docs/CONCEPTS.md b/docs/CONCEPTS.md index 8f54ad42..f02ec909 100644 --- a/docs/CONCEPTS.md +++ b/docs/CONCEPTS.md @@ -191,6 +191,8 @@ repository for details. `acq` applies a fixed set of built-in kits, pinned to a commit of the patterns repo. To customize: +- **Pick opt-in kits interactively:** run `acq configure` (see + [Interactive setup: `acq configure`](#interactive-setup-acq-configure)). - **Add your own kits on every run:** see [Advanced: extra kits](#advanced-extra-kits). - **Change USAi models / provider config, rules, or skills:** contribute to the kits in the @@ -200,6 +202,39 @@ repo. To customize: - **Adopt newer kit versions:** bump `PATTERNS_KIT_REF` near the top of `acq.backends/common.sh`. +### Interactive setup: `acq configure` + +`acq configure` opens a small, colorful interactive picker (no extra tools to +install) that lets you choose which **opt-in** kits to enable and set the default +answer for the per-sandbox GitHub-token-scoping prompt. Choices persist to +`~/.config/acq/config.yaml`, so you set them once instead of repeating `--kit` +flags on every run. + +```text +? Select kits (dimmed rows are always applied · ↑/↓ move · SPACE toggle · ENTER confirm · q cancel) + [x] zscaler-ca-certificate Zscaler/corporate CA trust … (always applied) + [x] usai-provider USAi provider + model config … (always applied) + [x] agentic-coding-playbook Federal agent rules and skills … (always applied) + [x] git-ssh-sign SSH-based git commit signing … (always applied) +❯ [x] openchamber Browser UI for OpenCode alongside the terminal TUI + [ ] paseo Self-hosted Paseo browser web UI for coding agents +``` + +- The **four built-in kits** (`zscaler-ca-certificate`, `usai-provider`, + `agentic-coding-playbook`, `git-ssh-sign`) appear as **frozen rows** at the top + — always checked, dimmed, and tagged `(always applied)`. The cursor skips them + and they can't be toggled; the picker manages only the opt-in extras below. +- On your **first run**, `acq` offers to run this for you. Run it again anytime + with `acq configure`. +- At `acq create`, the picker is shown again **pre-populated with your saved + defaults**, so a single sandbox can enable or disable a kit without changing + the global default (the deviation is remembered for that sandbox). +- The **GitHub-token** preference only pre-answers the scoping prompt; the + fine-grained token is still minted per-sandbox (see + [`acq github-scope`](howto/acq.md)). +- Non-interactive/CI runs make no changes and simply print the current + configuration. Set `ACQ_NO_PROMPT=1` to force that behavior. + ### Advanced: extra kits `acq` always applies its built-in kits. To apply **additional** kits on @@ -210,6 +245,10 @@ whitespace-separated list of kit references (local paths or remote refs): export ACQ_EXTRA_KITS="./my-local-kit git+https://github.com/acme/kits.git#ref=&dir=some-kit" ``` +An explicitly-exported `ACQ_EXTRA_KITS` takes precedence over the kits saved by +`acq configure` (env wins): the interactive picker is skipped and your env value +is used verbatim. + Extras are applied **after** the built-in kits (so they win on any overlapping config). They also work when re-running against an existing sandbox: adding a new entry and re-running `acq run ` injects just the new kit. diff --git a/docs/adr/0031-interactive-acq-configure.md b/docs/adr/0031-interactive-acq-configure.md new file mode 100644 index 00000000..f24ef476 --- /dev/null +++ b/docs/adr/0031-interactive-acq-configure.md @@ -0,0 +1,136 @@ +--- +title: "Interactive `acq configure` for extra kits and token-scoping defaults" +status: accepted +date: 2026-09-20 +decision_makers: ["Bret Mogilefsky"] +category: development-process +nist_controls: ["AC-6", "CM-6", "IA-5", "SA-15", "SR-3"] +impact_level: low +ato_relevance: no +risk_treatment: n/a +supersedes: [] +--- + +# ADR-0031: Interactive `acq configure` for extra kits and token-scoping defaults + +## Context and Problem Statement + +`acq` always applies four built-in kits (`zscaler-ca-certificate`, +`usai-provider`, `agentic-coding-playbook`, `git-ssh-sign`). The only levers for +**opt-in** kits are `--kit ` on `run`/`create` and the `ACQ_EXTRA_KITS` env +var — both must be re-supplied on every invocation and neither is discoverable. +A new user has no way to learn that opt-in community kits (`openchamber`, +`paseo`) exist, and no way to enable one durably. Separately, per-sandbox GitHub +token scoping (ADR-0013) surfaces only as a bare `[y/N]` nudge inside the run +flow, with no persisted default. + +How should a user discover and durably configure opt-in kits and their +token-scoping default, without adding a heavyweight TUI dependency and without +changing the security model? + +## Decision Drivers + +- **Discoverability** — the opt-in kit catalog should be visible and + self-documenting, not tribal knowledge. +- **Durability** — a choice should persist across invocations rather than being + re-typed each run. +- **Right granularity** — a global default plus per-sandbox deviations matches + real usage (set once; override for the odd sandbox). +- **Supply-chain minimalism (SR-3)** — the repo posture is "one clone, no extra + deps"; a cosmetic picker must not pin `gum`/`fzf`/`whiptail`. +- **Least privilege (AC-6, IA-5)** — the token-scoping default must not weaken + ADR-0013: it sets only the *default answer* to the existing per-sandbox + scoping prompt; fine-grained-PAT minting stays per-sandbox. +- **Fail-open in CI (SA-15)** — non-interactive/piped runs must behave exactly + as they do today (take defaults, never block). + +## Considered Options + +1. **Status quo** — keep `--kit`/`ACQ_EXTRA_KITS` only. Zero work, but leaves the + discoverability and durability gaps unaddressed. +2. **A pinned TUI dependency** (`gum`/`fzf`/`whiptail`/`dialog`) driving a rich + selector. Nicer out of the box, but adds a CVE-scanned, license-checked host + dependency for purely cosmetic output — contrary to the onboarding this repo + exists to provide, and inconsistent with the `progress.sh` design note. +3. **Hand-rolled interactive picker + `acq configure` + `config.yaml` + persistence** (chosen). A ~40-line bash multiselect modeled on the existing + `progress.sh`/`install.sh` conventions, a new `acq configure` command, global + defaults in the file that already holds `backend:`, and per-sandbox deviations + reusing the existing per-sandbox kit records. + +## Decision Outcome + +Chosen: **option 3**. + +- **New `acq.backends/prompt.sh`** — `acq_prompt_multiselect` and + `acq_prompt_confirm`. Stderr-only chrome, gated on an interactive TTY + (`[ -t 0 ]` for input, `[ -t 2 ]` for stderr color), colored with the same + `install.sh` SGR scheme, bash-3.2 safe, caller labels escape-sanitized (as in + `progress.sh`). Honors an `ACQ_NO_PROMPT` opt-out and an `ACQ_PROMPT_TEST_INPUT` + test hook (mirroring `ACQ_SECRET_TEST_VALUE`). Non-TTY / opted-out degrades to + documented defaults and never blocks. +- **Opt-in kit catalog** — sourced from the already-pinned patterns bundle + (`PATTERNS_KIT_REF` / `PATTERNS_KIT_DIR`, `integrations/isolation/acq-kits/`): + `openchamber`, `paseo`. `prime-agent` is a skeleton at the current pin and is + intentionally omitted until functional. Custom refs remain available through + `ACQ_EXTRA_KITS` and `--kit`, but are not stored in durable config. The four + built-ins are shown **inline as frozen rows** at the top of the same picker — + always checked, dimmed, tagged + "(always applied)", cursor-skipped, and never toggleable — rather than in a + separate banner, so the full applied set reads as one list. +- **`acq configure` command** — shows current config, runs the multiselect over + the opt-in catalog, then a confirm for the token-scoping default, and writes + the result to `config.yaml`. Auto-offered once on first run (no `config.yaml` + yet AND interactive), writing a minimal config so it never nags again. +- **Config schema** — `config.yaml` gains flat keys `extra_kits:` and + `scope_github_token:` alongside the existing `backend:`. The single-key awk + parser is generalized to read/write named flat keys, preserving the existing + `backend:` line and the "no YAML dependency" convention. The config directory + and file are written private to the user, and the flat-key reader ignores + indented/nested YAML so hand-edited structure is not mistaken for a top-level + setting. +- **`acq create`/`run`** — pre-populate the picker from the global defaults, fold + the selection into the existing `ACQ_EXTRA_KITS`/`ACQ_CLI_KITS` path (so it + flows through `_build_kit_list` → `acq_cli_kits_write`), and thereby persist + per-sandbox deviations in the existing per-sandbox kit record (ADR-0017), + reloaded on resume by `acq_cli_kits_load`. +- **Token-scoping default** — the stored preference only pre-answers the existing + `advise_github_scope` prompt; `github_scope_sandbox` still mints a fine-grained + PAT scoped to the sandbox's repos (ADR-0013 unchanged). + +## Consequences + +**Positive** + +- Opt-in kits become discoverable and durable; onboarding improves with no new + dependency. +- Global-default + per-sandbox-deviation granularity is achieved by reusing + existing config and per-sandbox record mechanisms — minimal new surface. +- CI/non-interactive behavior is unchanged (fail-open by construction). + +**Negative / trade-offs** + +- A hand-rolled multiselect is less capable than a real TUI library (no mouse, + simple key handling). Accepted per the minimalism driver. +- `config.yaml` now carries more than one key; the awk parser is generalized but + remains a deliberately small, flat-only reader. + +**Compliance implications** + +- **AC-6 / IA-5 (least privilege, credentials):** the token-scoping preference is + a UI default only; no broadening of token scope. ADR-0013's per-sandbox + fine-grained PAT flow is authoritative. +- **SR-3 (supply chain):** no new runtime dependency is introduced. +- **SA-15 (development process):** covered by new offline bats tests driving the + prompt via `ACQ_PROMPT_TEST_INPUT` and asserting `config.yaml` round-trips and + non-TTY defaulting. + +## Links / tracking + +- Implements GSA-TTS/agentic-coding-quickstart#499. +- Builds on ADR-0013 (per-sandbox GitHub token downscoping), ADR-0017 (msb + create-time startup + persisted CLI/extra kit refs), ADR-0010/0011 (pluggable + backends and neutral kits). +- Complementary to, and distinct from, the per-repo `.acq/` config proposal in + GSA-TTS/agentic-coding-quickstart#232 (repo-committed config vs. user-global + config). diff --git a/docs/howto/acq.md b/docs/howto/acq.md index b1d63865..59414cdc 100644 --- a/docs/howto/acq.md +++ b/docs/howto/acq.md @@ -494,6 +494,30 @@ design and trust model. --- +## Interactive setup: `acq configure` + +`acq configure` is a colorful, dependency-free interactive picker for choosing +which **opt-in** kits to enable and the default answer for per-sandbox GitHub +token scoping. Choices persist to `~/.config/acq/config.yaml`. + +```bash +acq configure +``` + +- The four built-in kits are always applied; the picker only manages opt-in + extras (e.g. `openchamber`, `paseo`). +- `acq` offers to run this on your first run; run it again anytime. +- `acq create` re-shows the picker pre-filled with your saved defaults, so one + sandbox can deviate without changing the global default (the deviation is + remembered per-sandbox and re-applied on resume). +- The token preference only pre-answers the scoping prompt — the fine-grained + PAT is still minted per-sandbox (`acq github-scope`). +- Non-interactive/CI runs make no changes and just print the current config; set + `ACQ_NO_PROMPT=1` to force that. See + [ADR-0031](../adr/0031-interactive-acq-configure.md). + +--- + ## Advanced: extra kits ```bash @@ -504,6 +528,10 @@ export ACQ_EXTRA_KITS="./my-local-kit git+https://github.com/acme/kits.git#ref=< export ACQ_EXTRA_KIT_SOURCES="github.com/acme/" ``` +An exported `ACQ_EXTRA_KITS` takes precedence over the kits saved by +`acq configure` (env wins): the create-time picker is skipped and your env value +is used verbatim. + You can also apply an extra kit for a single `run`/`create` with `--kit` (repeatable), instead of the env var: diff --git a/test/bats/135-configure.bats b/test/bats/135-configure.bats new file mode 100644 index 00000000..11c30224 --- /dev/null +++ b/test/bats/135-configure.bats @@ -0,0 +1,405 @@ +#!/usr/bin/env bats +# +# 135-configure.bats — interactive `acq configure` + config.yaml + prompt widgets +# (ADR-0031; issue GSA-TTS/agentic-coding-quickstart#499). +# +# Covers, offline (no TTY, no backend CLI, no network): +# - the generalized config.yaml reader/writer (multi-key, preserve-others) +# - the opt-in kit catalog + ref expansion +# - the prompt widgets driven via ACQ_PROMPT_TEST_INPUT +# - `acq configure` dispatch, --help, and the non-interactive fail-open path +# - configured-default extra-kit application with env-wins precedence +# +# shellcheck shell=bats + +setup() { acq_setup_stubs; } +teardown() { acq_teardown_stubs; } + +load 'helper' + +# _cfg — source acq (definitions only) + the sbx adapter, so the configure/config +# helpers are in scope for in-process assertions. Mirrors 20-backend-resolution. +_cfg_src() { + # shellcheck source=acq + ACQ_SOURCE_ONLY=1 . "$ACQ" + # shellcheck source=/dev/null + . "${REPO_ROOT}/acq.backends/sbx.sh" +} + +# --------------------------------------------------------------------------- +# config.yaml reader / writer +# --------------------------------------------------------------------------- + +@test "config: write then read a flat key round-trips" { + export XDG_CONFIG_HOME="$STUBDIR/xdg" + run bash -c ' + ACQ_SOURCE_ONLY=1 . "'"$ACQ"'" >/dev/null 2>&1 + _acq_config_write_field backend msb + _acq_config_read_field backend + ' + assert_success + assert_output 'msb' +} + +@test "config: writing one key preserves the others" { + export XDG_CONFIG_HOME="$STUBDIR/xdg" + run bash -c ' + ACQ_SOURCE_ONLY=1 . "'"$ACQ"'" >/dev/null 2>&1 + _acq_config_write_field backend msb + _acq_config_write_field extra_kits "openchamber paseo" + _acq_config_write_field scope_github_token yes + # Rewriting extra_kits must not disturb backend / scope. + _acq_config_write_field extra_kits "paseo" + printf "b=%s\n" "$(_acq_config_read_field backend)" + printf "k=%s\n" "$(_acq_config_read_field extra_kits)" + printf "s=%s\n" "$(_acq_config_read_field scope_github_token)" + ' + assert_success + assert_line 'b=msb' + assert_line 'k=paseo' + assert_line 's=yes' +} + +@test "config: empty value removes the key but keeps the rest" { + export XDG_CONFIG_HOME="$STUBDIR/xdg" + run bash -c ' + ACQ_SOURCE_ONLY=1 . "'"$ACQ"'" >/dev/null 2>&1 + _acq_config_write_field backend msb + _acq_config_write_field extra_kits "openchamber" + _acq_config_write_field extra_kits "" + printf "b=%s\n" "$(_acq_config_read_field backend)" + printf "k=[%s]\n" "$(_acq_config_read_field extra_kits)" + ' + assert_success + assert_line 'b=msb' + assert_line 'k=[]' +} + +@test "config: an unsafe key name is rejected (fail closed)" { + export XDG_CONFIG_HOME="$STUBDIR/xdg" + run bash -c ' + ACQ_SOURCE_ONLY=1 . "'"$ACQ"'" >/dev/null 2>&1 + set +e + _acq_config_write_field "bad key" value ; echo "write-rc=$?" + _acq_config_read_field "bad/key" ; echo "read-rc=$?" + true + ' + assert_success + assert_line 'write-rc=1' + assert_line 'read-rc=0' +} + +@test "config: reader ignores nested YAML and uses last top-level duplicate" { + export XDG_CONFIG_HOME="$STUBDIR/xdg" + mkdir -p "$XDG_CONFIG_HOME/acq" + printf 'extra_kits: openchamber\nsome_block:\n extra_kits: nested-evil\nextra_kits: paseo\n' > "$XDG_CONFIG_HOME/acq/config.yaml" + run bash -c ' + ACQ_SOURCE_ONLY=1 . "'"$ACQ"'" >/dev/null 2>&1 + _acq_config_read_field extra_kits + ' + assert_success + assert_output 'paseo' +} + +@test "config: reader tolerates inline comments and quoted scalars" { + export XDG_CONFIG_HOME="$STUBDIR/xdg-comments" + mkdir -p "$XDG_CONFIG_HOME/acq" + printf 'extra_kits: "openchamber # browser UI"\nscope_github_token: "yes"\nbackend: '\''msb'\''\nlabel: don'\''t # plain scalar comment\nplain_kits: openchamber # browser UI\n' > "$XDG_CONFIG_HOME/acq/config.yaml" + run bash -c ' + ACQ_SOURCE_ONLY=1 . "'"$ACQ"'" >/dev/null 2>&1 + printf "k=%s\n" "$(_acq_config_read_field extra_kits)" + printf "s=%s\n" "$(_acq_config_read_field scope_github_token)" + printf "b=%s\n" "$(_acq_config_read_field backend)" + printf "l=%s\n" "$(_acq_config_read_field label)" + printf "p=%s\n" "$(_acq_config_read_field plain_kits)" + ' + assert_success + assert_line 'k=openchamber # browser UI' + assert_line 's=yes' + assert_line 'b=msb' + assert_line "l=don't" + assert_line 'p=openchamber' +} + +@test "config: writer creates private config directory and file" { + export XDG_CONFIG_HOME="$STUBDIR/xdg" + run bash -c ' + umask 022 + ACQ_SOURCE_ONLY=1 . "'"$ACQ"'" >/dev/null 2>&1 + _acq_config_write_field backend msb + cfg="$XDG_CONFIG_HOME/acq/config.yaml" + dir_mode=$(stat -c %a "$(dirname "$cfg")" 2>/dev/null || stat -f %Lp "$(dirname "$cfg")" 2>/dev/null || echo "?") + file_mode=$(stat -c %a "$cfg" 2>/dev/null || stat -f %Lp "$cfg" 2>/dev/null || echo "?") + printf "dir=%s\n" "$dir_mode" + printf "file=%s\n" "$file_mode" + ' + assert_success + assert_line 'dir=700' + assert_line 'file=600' +} + +# --------------------------------------------------------------------------- +# opt-in kit catalog + ref expansion +# --------------------------------------------------------------------------- + +@test "catalog: opt-in kit ref is pinned to PATTERNS_KIT_REF/DIR" { + run bash -c ' + ACQ_SOURCE_ONLY=1 . "'"$ACQ"'" >/dev/null 2>&1 + _acq_optin_kit_ref openchamber + ' + assert_success + assert_output --partial 'agentic-coding-patterns.git#ref=' + assert_output --partial 'dir=integrations/isolation/acq-kits/openchamber' +} + +@test "catalog: prime-agent is intentionally NOT offered (skeleton at pin)" { + run bash -c ' + ACQ_SOURCE_ONLY=1 . "'"$ACQ"'" >/dev/null 2>&1 + printf "%s\n" "${ACQ_OPTIN_KIT_NAMES[@]}" + ' + assert_success + assert_line 'openchamber' + assert_line 'paseo' + refute_line 'prime-agent' +} + +# --------------------------------------------------------------------------- +# prompt widgets (driven by ACQ_PROMPT_TEST_INPUT) +# --------------------------------------------------------------------------- + +@test "prompt: multiselect toggles the cursor item and returns its index" { + run bash -c ' + ACQ_SOURCE_ONLY=1 . "'"$ACQ"'" >/dev/null 2>&1 + ACQ_PROMPT_TEST_INPUT="SPACE ENTER" \ + acq_prompt_multiselect 0 "" "openchamber" "descA" "paseo" "descB" 2>/dev/null + ' + assert_success + assert_output '1' +} + +@test "prompt: multiselect can DOWN then toggle the second item" { + run bash -c ' + ACQ_SOURCE_ONLY=1 . "'"$ACQ"'" >/dev/null 2>&1 + ACQ_PROMPT_TEST_INPUT="DOWN SPACE ENTER" \ + acq_prompt_multiselect 0 "" "a" "d" "b" "d" 2>/dev/null + ' + assert_success + assert_output '2' +} + +@test "prompt: multiselect defaults pre-check, and can be toggled off" { + run bash -c ' + ACQ_SOURCE_ONLY=1 . "'"$ACQ"'" >/dev/null 2>&1 + # default-checked index 2; move to it and toggle it off -> empty result + ACQ_PROMPT_TEST_INPUT="DOWN SPACE ENTER" \ + acq_prompt_multiselect 0 "2" "a" "d" "b" "d" 2>/dev/null + ' + assert_success + assert_output '' +} + +@test "prompt: multiselect QUIT yields CANCELLED (no change sentinel)" { + run bash -c ' + ACQ_SOURCE_ONLY=1 . "'"$ACQ"'" >/dev/null 2>&1 + ACQ_PROMPT_TEST_INPUT="QUIT" \ + acq_prompt_multiselect 0 "1" "a" "d" "b" "d" 2>/dev/null + ' + assert_success + assert_output 'CANCELLED' +} + +@test "prompt: locked rows are frozen — cursor skips them, result excludes them" { + run bash -c ' + ACQ_SOURCE_ONLY=1 . "'"$ACQ"'" >/dev/null 2>&1 + set +e + # 2 locked rows (L1,L2) then 2 toggleable (T1,T2). Cursor starts on T1. + # SPACE toggles T1 -> result is the toggleable-relative index 1. + ACQ_PROMPT_TEST_INPUT="SPACE ENTER" \ + acq_prompt_multiselect 2 "" "L1" "d" "L2" "d" "T1" "d" "T2" "d" >/dev/null 2>&1 + echo "one=$_ACQ_PROMPT_SELECTION" + # UP from T1 wraps to T2 (never onto a locked row); SPACE toggles T2 -> index 2. + ACQ_PROMPT_TEST_INPUT="UP SPACE ENTER" \ + acq_prompt_multiselect 2 "" "L1" "d" "L2" "d" "T1" "d" "T2" "d" >/dev/null 2>&1 + echo "up=$_ACQ_PROMPT_SELECTION" + # A default pre-check is relative to the toggleable block (2 = T2); toggle it off. + ACQ_PROMPT_TEST_INPUT="DOWN SPACE ENTER" \ + acq_prompt_multiselect 2 "2" "L1" "d" "L2" "d" "T1" "d" "T2" "d" >/dev/null 2>&1 + echo "off=$_ACQ_PROMPT_SELECTION" + true + ' + assert_success + assert_line 'one=1' + assert_line 'up=2' + assert_line 'off=' +} + +@test "prompt: rows are fit to the terminal width (no soft-wrap desync)" { + # A row longer than the terminal width used to soft-wrap onto a second physical + # line, which desynced the up-by-N-rows repaint (rows visibly duplicated and + # pushed others down). Assert each rendered row's VISIBLE width is <= COLUMNS. + run bash -c ' + ACQ_SOURCE_ONLY=1 . "'"$ACQ"'" >/dev/null 2>&1 + set +e + export COLUMNS=60 + long="this is a deliberately very long description that exceeds sixty display columns easily" + ACQ_PROMPT_TEST_INPUT="SPACE ENTER" \ + acq_prompt_multiselect 1 "" "locked-kit" "$long" "toggle-kit" "$long" 2>/tmp/acq-rows.txt >/dev/null + # Widest visible row, measured in CHARACTERS (wc -m, locale-aware) so the + # multibyte cursor/ellipsis glyphs count as one display column each — matching + # the widget'"'"'s own char-based fit. Strip escapes and blank lines first. + max=0 + while IFS= read -r ln; do + w=$(printf "%s" "$ln" | LC_ALL=C.UTF-8 wc -m) + [ "$w" -gt "$max" ] && max="$w" + done < <(sed "s/\x1b\[[0-9;?]*[A-Za-z]//g" /tmp/acq-rows.txt | grep -v "^$") + rm -f /tmp/acq-rows.txt + if [ "$max" -le "$COLUMNS" ]; then echo "FIT ok ($max <= $COLUMNS)"; else echo "FIT BAD ($max > $COLUMNS)"; fi + true + ' + assert_success + assert_output --partial 'FIT ok' +} + +@test "prompt: confirm honors scripted y/n and the default" { + run bash -c ' + ACQ_SOURCE_ONLY=1 . "'"$ACQ"'" >/dev/null 2>&1 + set +e + ACQ_PROMPT_TEST_INPUT="y" acq_prompt_confirm "ok?" "no"; echo "y=$?" + ACQ_PROMPT_TEST_INPUT="n" acq_prompt_confirm "ok?" "yes"; echo "n=$?" + ACQ_NO_PROMPT=1 acq_prompt_confirm "ok?" "yes"; echo "dy=$?" + ACQ_NO_PROMPT=1 acq_prompt_confirm "ok?" "no"; echo "dn=$?" + true + ' + assert_success + assert_line 'y=0' + assert_line 'n=1' + assert_line 'dy=0' + assert_line 'dn=1' +} + +@test "prompt: sanitizer strips terminal controls without mangling UTF-8" { + run bash -c ' + ACQ_SOURCE_ONLY=1 . "'"$ACQ"'" >/dev/null 2>&1 + _acq_prompt_sanitize "$(printf "don\342\200\231t \342\200\246 a\033[31m\2331m\302\23332m\177b")" | od -An -tx1 | tr -d " \n" + ' + assert_success + assert_output '646f6ee280997420e280a620615b33316d316d33326d62' +} + +@test "prompt: a multi-token script drives multiselect THEN confirm in one flow" { + # This mirrors how acq_configure calls the widgets in-process (no subshell + # between them), so the shared scripted-input cursor advances across both. + run bash -c ' + ACQ_SOURCE_ONLY=1 . "'"$ACQ"'" >/dev/null 2>&1 + export ACQ_PROMPT_TEST_INPUT="SPACE ENTER y" + # Do NOT command-substitute the multiselect (that would fork a subshell and + # lose the cursor advance); redirect its stdout to a file instead. + acq_prompt_multiselect 0 "" "a" "d" "b" "d" >/dev/null 2>&1 + acq_prompt_confirm "scope?" "no"; echo "confirm=$?" + true + ' + assert_success + assert_line 'confirm=0' +} + +# --------------------------------------------------------------------------- +# `acq configure` dispatch + help + non-interactive fail-open +# --------------------------------------------------------------------------- + +@test "configure: --help documents the command" { + run env ACQ_BACKEND=sbx "$ACQ" configure --help + assert_success + assert_output --partial 'acq configure' + assert_output --partial 'ADR-0031' +} + +@test "configure: help banner lists the configure verb" { + run env ACQ_BACKEND=sbx "$ACQ" help + assert_success + assert_output --partial 'configure' +} + +@test "configure: non-interactive makes NO changes and just prints current" { + export XDG_CONFIG_HOME="$STUBDIR/xdg2" + run env ACQ_BACKEND=sbx ACQ_NO_PROMPT=1 "$ACQ" configure + assert_success + assert_output --partial 'current configuration' + assert_output --partial 'configuration unchanged' + # No config file should have been written by the non-interactive path. + [ ! -f "$XDG_CONFIG_HOME/acq/config.yaml" ] +} + +@test "configure: interactive selection persists to config.yaml" { + export XDG_CONFIG_HOME="$STUBDIR/xdg3" + # The built-in kits render as frozen rows; the cursor starts on the first + # opt-in (openchamber). SPACE toggles it, ENTER confirms; the token-scoping + # confirm then reads the next token (n). + run env ACQ_BACKEND=sbx ACQ_PROMPT_TEST_INPUT="SPACE ENTER n" "$ACQ" configure + assert_success + # Built-in kits appear inline in the picker (frozen rows), not in a separate + # banner. The trailing "(always applied)" tag may be truncated at narrow widths, + # so assert on the always-present frozen row prefix + the removed banner. + assert_output --partial '[x] zscaler-ca-certificate' + refute_output --partial 'Built-in kits (always applied):' + run cat "$XDG_CONFIG_HOME/acq/config.yaml" + assert_line 'extra_kits: openchamber' + assert_line 'scope_github_token: no' +} + +@test "configure: preserves an existing backend key when saving kits" { + export XDG_CONFIG_HOME="$STUBDIR/xdg4" + mkdir -p "$XDG_CONFIG_HOME/acq" + printf 'backend: sbx\n' > "$XDG_CONFIG_HOME/acq/config.yaml" + run env ACQ_BACKEND=sbx ACQ_PROMPT_TEST_INPUT="SPACE ENTER y" "$ACQ" configure + assert_success + run cat "$XDG_CONFIG_HOME/acq/config.yaml" + assert_line 'backend: sbx' + assert_line 'extra_kits: openchamber' + assert_line 'scope_github_token: yes' +} + +# --------------------------------------------------------------------------- +# configured-default extra-kit application + env precedence +# --------------------------------------------------------------------------- + +@test "apply: configured extra_kits expand to pinned refs" { + export XDG_CONFIG_HOME="$STUBDIR/xdg5" + mkdir -p "$XDG_CONFIG_HOME/acq" + printf 'extra_kits: openchamber paseo\n' > "$XDG_CONFIG_HOME/acq/config.yaml" + run bash -c ' + ACQ_SOURCE_ONLY=1 . "'"$ACQ"'" >/dev/null 2>&1 + ACQ_EXTRA_KITS=""; ACQ_EXTRA_KITS_FROM_ENV="" + _acq_apply_configured_extra_kits + printf "%s\n" "$ACQ_EXTRA_KITS" + ' + assert_success + assert_output --partial 'dir=integrations/isolation/acq-kits/openchamber' + assert_output --partial 'dir=integrations/isolation/acq-kits/paseo' +} + +@test "apply: unknown configured extra_kits names fail closed" { + export XDG_CONFIG_HOME="$STUBDIR/xdg6" + mkdir -p "$XDG_CONFIG_HOME/acq" + printf 'extra_kits: openchamber git+https://evil.example/x.git#ref=abc&dir=kit\n' > "$XDG_CONFIG_HOME/acq/config.yaml" + run bash -c ' + ACQ_SOURCE_ONLY=1 . "'"$ACQ"'" >/dev/null 2>&1 + ACQ_EXTRA_KITS=""; ACQ_EXTRA_KITS_FROM_ENV="" + _acq_apply_configured_extra_kits + ' + assert_failure + assert_output --partial "unknown kit 'git+https://evil.example/x.git#ref=abc&dir=kit'" +} + +@test "apply: an env-supplied ACQ_EXTRA_KITS wins over the configured default" { + export XDG_CONFIG_HOME="$STUBDIR/xdg7" + mkdir -p "$XDG_CONFIG_HOME/acq" + printf 'extra_kits: openchamber\n' > "$XDG_CONFIG_HOME/acq/config.yaml" + run bash -c ' + ACQ_SOURCE_ONLY=1 . "'"$ACQ"'" >/dev/null 2>&1 + ACQ_EXTRA_KITS="./mine"; ACQ_EXTRA_KITS_FROM_ENV=1 + _acq_apply_configured_extra_kits + printf "%s\n" "$ACQ_EXTRA_KITS" + ' + assert_success + assert_output './mine' +} diff --git a/test/bats/30-dispatch-routing.bats b/test/bats/30-dispatch-routing.bats index da8d4041..0835b5c8 100644 --- a/test/bats/30-dispatch-routing.bats +++ b/test/bats/30-dispatch-routing.bats @@ -166,6 +166,47 @@ _seed_usai() { assert [ -f "$STUBDIR/.created" ] } +@test "create: unknown configured extra_kits fails before backend create" { + local proj="$STUBDIR/bad-config-kit"; mkdir -p "$proj" + export XDG_CONFIG_HOME="$STUBDIR/xdg" + mkdir -p "$XDG_CONFIG_HOME/acq" + printf 'extra_kits: git+https://evil.example/x.git#ref=abc&dir=kit\n' > "$XDG_CONFIG_HOME/acq/config.yaml" + mkdir -p "$STUBDIR/secrets"; printf 'sk-stored-and-bound\n' > "$STUBDIR/secrets/acq.usai" + seed_sbx_usai_proxy_fixture + rm -f "$STUBDIR/.created" + run env STUB_KEY_STATUS=200 ACQ_BACKEND=sbx "$ACQ" create opencode "$proj" + assert_failure + assert_output --partial "unknown kit 'git+https://evil.example/x.git#ref=abc&dir=kit'" + refute_regex "$(cat "$CALLS")" 'sbx create' + assert [ ! -f "$STUBDIR/.created" ] +} + +@test "create(sbx): configured extra_kits reach sbx create kit flags" { + local proj="$STUBDIR/config-kit"; mkdir -p "$proj" + export XDG_CONFIG_HOME="$STUBDIR/xdg-config-kit" + mkdir -p "$XDG_CONFIG_HOME/acq" + printf 'extra_kits: openchamber\n' > "$XDG_CONFIG_HOME/acq/config.yaml" + mkdir -p "$STUBDIR/secrets"; printf 'sk-stored-and-bound\n' > "$STUBDIR/secrets/acq.usai" + seed_sbx_usai_proxy_fixture + run env STUB_KEY_STATUS=200 ACQ_BACKEND=sbx "$ACQ" create opencode "$proj" + assert_success + local create_line; create_line=$(grep '^sbx create' "$CALLS") + assert_regex "$create_line" '--kit git\+https://github.com/GSA-TTS/agentic-coding-patterns.git#ref=.*&dir=integrations/isolation/acq-kits/openchamber' +} + +@test "run(sbx): configured extra_kits reach fresh sbx create kit flags" { + local proj="$STUBDIR/run-config-kit"; mkdir -p "$proj" + export XDG_CONFIG_HOME="$STUBDIR/xdg-run-config-kit" + mkdir -p "$XDG_CONFIG_HOME/acq" + printf 'extra_kits: openchamber\n' > "$XDG_CONFIG_HOME/acq/config.yaml" + mkdir -p "$STUBDIR/secrets"; printf 'sk-stored-and-bound\n' > "$STUBDIR/secrets/acq.usai" + seed_sbx_usai_proxy_fixture + run env STUB_KEY_STATUS=200 ACQ_BACKEND=sbx STUB_OPENCODE_OK=1 "$ACQ" run opencode "$proj" + assert_success + local create_line; create_line=$(grep '^sbx create' "$CALLS") + assert_regex "$create_line" '--kit git\+https://github.com/GSA-TTS/agentic-coding-patterns.git#ref=.*&dir=integrations/isolation/acq-kits/openchamber' +} + @test "create(msb): host-exported USAI_API_KEY counts as present; provision proceeds" { local proj="$STUBDIR/kc-ci"; mkdir -p "$proj" rm -f "$STUBDIR/.msb_created"