diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index fb2fc52..25eb9cf 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -124,3 +124,12 @@ jobs:
else
echo "scripts/smoke_run.sh missing; skipping run.sh smoke"
fi
+
+ - name: Smoke-test install.sh y/N prompts (noninteractive)
+ run: |
+ if [ -f scripts/smoke_prompts.sh ]; then
+ chmod +x scripts/smoke_prompts.sh
+ ./scripts/smoke_prompts.sh
+ else
+ echo "scripts/smoke_prompts.sh missing; skipping prompt smoke"
+ fi
diff --git a/README.md b/README.md
index 4df7d41..dd241db 100644
--- a/README.md
+++ b/README.md
@@ -81,7 +81,9 @@ first look at the UI; required for chat replies and code evaluation.
```bash
gh repo clone StewAlexander-com/python-tutor
cd python-tutor
-./install.sh # creates venv, installs deps, pulls model if Ollama is up
+./install.sh # creates venv, installs deps, then prompts y/N for
+ # any host-level setup (install Ollama, start daemon,
+ # pull model, launch app)
./run.sh # serves UI + API on http://localhost:8001/
```
@@ -89,6 +91,48 @@ Then open in your browser. You'll see the
lesson list, the inline code lab (Run / Evaluate), and the floating
"Ask tutor" chat panel.
+### Opt-in install prompts
+
+`install.sh` does the Python-side setup (venv + pip) without asking —
+those changes live inside the repository. Anything that touches the
+host system or your home directory is **opt-in** via a y/N prompt:
+
+| Detected condition | Prompt | Default |
+| --------------------------------------------- | -------------------------------------------------------- | ------- |
+| Ollama binary missing (macOS or Linux) | "Install Ollama now? (will run the official installer)" | **No** |
+| Ollama installed but daemon not on :11434 | "Start 'ollama serve' in the background now?" | **No** |
+| Default model (`gemma3:4b`) missing locally | "Pull '' now? (this can take several minutes)" | **No** |
+| Install finished | "Launch the tutor now (./run.sh)?" | **No** |
+
+Accepted "yes" answers are `y`, `Y`, `yes`, `Yes`, `YES`. Anything else
+— including pressing Enter on an empty line — is treated as "no". The
+upstream Ollama installer on Linux (`curl https://ollama.com/install.sh | sh`)
+may itself invoke `sudo`; that is the documented upstream path.
+
+### Non-interactive / CI usage
+
+The same flow runs unattended:
+
+```bash
+# CI: do not touch Ollama, do nothing system-level.
+TUTOR_SKIP_OLLAMA=1 TUTOR_NONINTERACTIVE=1 ./install.sh
+
+# Unattended local install where the operator has pre-approved everything.
+PYTHON_TUTOR_ASSUME_YES=1 ./install.sh
+```
+
+Relevant env vars:
+
+| Variable | Effect |
+| ------------------------------ | ------------------------------------------------------------------- |
+| `TUTOR_NONINTERACTIVE=1` | Never prompt; answer **no** to every install/start/pull/launch ask. |
+| `PYTHON_TUTOR_NONINTERACTIVE=1`| Alias for the above. |
+| `PYTHON_TUTOR_ASSUME_YES=1` | Never prompt; answer **yes** (use only if you trust the host). |
+| `PYTHON_TUTOR_AUTOLAUNCH=1` | After install, `exec ./run.sh` without asking. |
+| `TUTOR_SKIP_OLLAMA=1` | Skip every Ollama probe entirely. |
+| `TUTOR_SKIP_MODEL_PULL=1` | Skip the `ollama pull` step (binary/daemon checks still run). |
+| `TUTOR_MODEL=` | Override the default `gemma3:4b`. |
+
### Expected behaviour when Ollama is not running
- The web UI loads normally — you can read lessons and run code locally
@@ -96,21 +140,17 @@ lesson list, the inline code lab (Run / Evaluate), and the floating
- `/api/health` reports `status: "degraded"` and `ollama_reachable: false`.
- `Evaluate` and the chat panel return a clear 503 — they don't hang.
- As soon as you start `ollama serve`, everything works without a restart.
+- `run.sh` will offer to start `ollama serve` for you if it sees the
+ binary but not the daemon. Decline and it continues with the
+ degraded-mode warning.
### What if Ollama isn't installed?
-`install.sh` and `run.sh` **never** install system binaries on your
-behalf. If Ollama is missing, they print exactly what to run:
-
-```bash
-# macOS
-brew install ollama && ollama serve &
-
-# Linux
-curl -fsSL https://ollama.com/install.sh | sh && ollama serve &
-```
-
-Then `./install.sh` again to pull `gemma3:4b`.
+`install.sh` will offer to install it for you on macOS (`brew install
+ollama`) or Linux (`curl -fsSL https://ollama.com/install.sh | sh`).
+Answer `y` to proceed, anything else to skip. If you skip, the same
+commands are printed for you to run by hand, then re-run `./install.sh`
+to pull the model.
### Troubleshooting
@@ -122,6 +162,8 @@ Then `./install.sh` again to pull `gemma3:4b`.
| Model missing in chat replies | `ollama pull gemma3:4b` (or set `TUTOR_MODEL` to your model) |
| Service worker shows stale UI | Hard-refresh the browser (Cmd/Ctrl-Shift-R) |
| `install.sh` failed mid-`pip install` | Re-run it — it's idempotent and reuses the venv |
+| Prompts fire in CI / a script | Set `TUTOR_NONINTERACTIVE=1` (defaults to "no") or `PYTHON_TUTOR_ASSUME_YES=1` |
+| Ollama installer prompts for sudo | That's the upstream Linux installer; decline the prompt or install via your package manager |
For the design rationale behind the two-script flow (and the five flows
we evaluated), see
diff --git a/docs/install-runtime-workflow.md b/docs/install-runtime-workflow.md
index c522beb..abbd491 100644
--- a/docs/install-runtime-workflow.md
+++ b/docs/install-runtime-workflow.md
@@ -154,26 +154,60 @@ flowchart TD
## Decision
-We ship **Candidate D, blended with one ergonomic touch from C**.
+We ship **Candidate D, blended with the consent-gated ergonomics of C**.
- **From D**: two-script split (`install.sh`, `run.sh`); we *detect*
- Ollama rather than installing it; we never start daemons in
- `install.sh`; the run-time server still launches if Ollama is down so
- the UI is usable and the failure is observable in the chat panel.
-- **From C**: in `install.sh`, if Ollama *is* present, we offer to
- `ollama pull` the default model on the user's behalf — gated by
- `TUTOR_SKIP_MODEL_PULL` and skippable in CI. Pulling a model the user
- already chose to have Ollama for is low-risk and saves a step.
+ Ollama and the model rather than installing them silently; the
+ run-time server still launches if Ollama is down so the UI is usable
+ and the failure is observable in the chat panel.
+- **From C**: when something host-level is missing — the Ollama binary,
+ the `ollama serve` daemon, or the default model — `install.sh`
+ *offers* to handle it. The user types `y` to accept. The default
+ answer is **no**, so pressing Enter never installs a system binary.
This blend has:
- 2 commands typed (`./install.sh`, `./run.sh`) — same as B/D/E.
-- Zero hidden system-level installs.
-- One actionable error message if Ollama is missing (we print the
- install command for the user's platform).
+- Zero hidden system-level installs. Every system-touching action is
+ preceded by an explicit y/N confirmation.
+- A single non-interactive entry-point for CI
+ (`TUTOR_NONINTERACTIVE=1` defaults all prompts to "no";
+ `PYTHON_TUTOR_ASSUME_YES=1` defaults them to "yes" for pre-approved
+ unattended setup).
- A web UI that loads even when the LLM is unreachable — so the learner
always gets *something* to interact with.
+```mermaid
+flowchart TD
+ Clone[git clone repo] --> Install[./install.sh]
+ Install --> Py[Python 3.10+ check]
+ Py --> Venv[Create / reuse backend/.venv]
+ Venv --> Pip[pip install backend deps]
+ Pip --> HasOllama{ollama on PATH?}
+ HasOllama -- no --> AskInstall{Install Ollama now? y/N}
+ AskInstall -- y --> RunInstaller[Run upstream installer]
+ AskInstall -- N --> HintInstall[Print manual install hint]
+ RunInstaller --> Daemon{Daemon on :11434?}
+ HasOllama -- yes --> Daemon
+ HintInstall --> NextBanner
+ Daemon -- yes --> Model{Model present?}
+ Daemon -- no --> AskStart{Start 'ollama serve' now? y/N}
+ AskStart -- y --> Spawn[nohup ollama serve & probe up to 10s]
+ AskStart -- N --> HintStart[Print 'run: ollama serve' hint]
+ Spawn --> Model
+ HintStart --> NextBanner
+ Model -- yes --> NextBanner
+ Model -- no --> AskPull{"Pull model? y/N"}
+ AskPull -- y --> Pull[ollama pull TUTOR_MODEL]
+ AskPull -- N --> HintPull[Print 'ollama pull' hint]
+ Pull --> NextBanner
+ HintPull --> NextBanner
+ NextBanner --> AskLaunch{Launch ./run.sh? y/N}
+ AskLaunch -- y --> Run[./run.sh → uvicorn]
+ AskLaunch -- N --> Done[Print next-step banner]
+ Run --> Browser[Open http://localhost:8001]
+```
+
## How the scripts behave
### `install.sh`
@@ -181,20 +215,28 @@ This blend has:
1. Detect Python ≥3.10. If missing or too old, print install command,
exit 1.
2. Create `backend/.venv` if it doesn't exist; otherwise reuse it.
-3. `pip install -r backend/requirements-dev.txt` (idempotent).
-4. Check `ollama` on `PATH`. If missing, print install command and exit
- 0 (success — the Python side is set up). User can re-run install
- later, or just run.
-5. If `ollama` is present, probe `http://localhost:11434/api/tags`. If
- the daemon is up, pull the default model (skippable via
- `TUTOR_SKIP_MODEL_PULL=1`). If the daemon is down, print
- `ollama serve &` and continue.
-6. Print next-step banner: `./run.sh`.
+3. `pip install -r backend/requirements-dev.txt` (idempotent;
+ not gated by a prompt — those changes live inside the repo).
+4. Check `ollama` on `PATH`. If missing, **prompt** to install via the
+ OS-appropriate upstream installer (`brew install ollama` on macOS,
+ `curl https://ollama.com/install.sh | sh` on Linux). Decline and
+ the script prints the manual command and continues.
+5. If `ollama` is present, probe `http://localhost:11434/api/tags`.
+ If the daemon is down, **prompt** to start `ollama serve` in the
+ background (`nohup`, logged to `/tmp/ollama-serve.log`).
+6. If the daemon is up, check for the model
+ (`TUTOR_MODEL`, default `gemma3:4b`). If absent, **prompt** to
+ `ollama pull` it.
+7. After setup, **prompt** "Launch the tutor now (./run.sh)?".
+ `PYTHON_TUTOR_AUTOLAUNCH=1` or `PYTHON_TUTOR_ASSUME_YES=1` answers
+ yes without asking.
### `run.sh`
-1. Ensure venv exists (re-run `install.sh` if not).
-2. Probe Ollama; warn if unreachable but continue.
+1. Ensure venv exists (re-run `install.sh` in non-interactive,
+ skip-Ollama mode if not — this never installs system binaries).
+2. Probe Ollama; if installed but the daemon is down, **prompt** to
+ start `ollama serve`. If declined, warn and continue.
3. Launch uvicorn with `TUTOR_SERVE_FRONTEND=1` so the backend serves
the static frontend on the same port.
4. Print the URL: `http://localhost:8001/`.
@@ -206,25 +248,34 @@ This blend has:
- `TUTOR_MODEL` — Ollama model tag (default `gemma3:4b`).
- `TUTOR_SKIP_OLLAMA=1` — skip every Ollama probe (CI/offline-dev).
- `TUTOR_SKIP_MODEL_PULL=1` — skip `ollama pull` in install.
-- `TUTOR_NONINTERACTIVE=1` — never prompt; assume defaults.
+- `TUTOR_NONINTERACTIVE=1` — never prompt; auto-answer **no**.
+- `PYTHON_TUTOR_NONINTERACTIVE=1` — alias for the above.
+- `PYTHON_TUTOR_ASSUME_YES=1` — never prompt; auto-answer **yes**.
+- `PYTHON_TUTOR_AUTOLAUNCH=1` — `exec ./run.sh` after install
+ without asking.
## What the user does
+Default interactive flow:
+
```bash
gh repo clone StewAlexander-com/python-tutor
cd python-tutor
-./install.sh # ~2 min cold; reuses cache on re-run
-./run.sh # opens at http://localhost:8001/
+./install.sh # ~2 min cold; prompts y/N for each system action
+ # answer 'y' to install Ollama, start the daemon,
+ # pull the model, and launch the app
```
-If Ollama is missing, `install.sh` will tell them exactly what to type:
+If you'd rather drive it yourself, decline every prompt and the script
+still finishes successfully — only the Python side is set up, with
+clear hints for what to run next.
+
+Unattended:
```bash
-# macOS
-brew install ollama && ollama serve &
+# Pre-approved: install Ollama, start it, pull the model, exec run.sh.
+PYTHON_TUTOR_ASSUME_YES=1 ./install.sh
-# Linux
-curl -fsSL https://ollama.com/install.sh | sh && ollama serve &
+# CI: do not touch Ollama at all.
+TUTOR_SKIP_OLLAMA=1 TUTOR_NONINTERACTIVE=1 ./install.sh
```
-
-Then `./install.sh && ./run.sh` again.
diff --git a/install.sh b/install.sh
index 190fa7a..8abc18d 100755
--- a/install.sh
+++ b/install.sh
@@ -5,19 +5,31 @@
# 1. Verifies Python >= 3.10.
# 2. Creates backend/.venv if missing.
# 3. Installs backend dependencies (dev extras included for tests).
-# 4. Checks whether Ollama is installed and running, and offers to pull
-# the default model. Never installs Ollama itself.
+# 4. Detects Ollama, the Ollama daemon, and the default model. For each
+# missing prerequisite it prompts y/N before doing anything that
+# changes the host. Default answer is "no". Nothing is installed
+# silently.
+# 5. If everything is ready (or after install) optionally offers to
+# launch the app via ./run.sh — again gated by y/N.
#
# What this script does NOT do:
-# - Start a server.
-# - Install Ollama, brew, curl, or any system package.
+# - Install Ollama, Homebrew, curl, or any other system package without
+# the user typing "y" (or running with PYTHON_TUTOR_ASSUME_YES=1).
# - Modify files outside the repository.
#
# Environment overrides:
-# TUTOR_MODEL default "gemma3:4b"
-# TUTOR_SKIP_OLLAMA=1 skip every Ollama probe
-# TUTOR_SKIP_MODEL_PULL=1 skip the `ollama pull` step
-# TUTOR_NONINTERACTIVE=1 never prompt; assume defaults
+# TUTOR_MODEL default "gemma3:4b"
+# TUTOR_SKIP_OLLAMA=1 skip every Ollama probe
+# TUTOR_SKIP_MODEL_PULL=1 skip the `ollama pull` step
+# TUTOR_NONINTERACTIVE=1 never prompt; assume "no" to every
+# install/start/pull/launch question
+# PYTHON_TUTOR_NONINTERACTIVE=1 alias for TUTOR_NONINTERACTIVE
+# PYTHON_TUTOR_ASSUME_YES=1 non-interactive but assume "yes" —
+# suitable for unattended setup where
+# the operator has approved installs
+# PYTHON_TUTOR_AUTOLAUNCH=1 after install, exec ./run.sh
+# automatically (still respects the
+# Ollama probes)
set -euo pipefail
repo_root="$(cd "$(dirname "$0")" && pwd)"
@@ -37,7 +49,54 @@ err() { printf "%b%s%b\n" "$c_red" "[install] $*" "$c_off" >&2; }
TUTOR_MODEL="${TUTOR_MODEL:-gemma3:4b}"
TUTOR_SKIP_OLLAMA="${TUTOR_SKIP_OLLAMA:-0}"
TUTOR_SKIP_MODEL_PULL="${TUTOR_SKIP_MODEL_PULL:-0}"
-TUTOR_NONINTERACTIVE="${TUTOR_NONINTERACTIVE:-0}"
+TUTOR_NONINTERACTIVE="${TUTOR_NONINTERACTIVE:-${PYTHON_TUTOR_NONINTERACTIVE:-0}}"
+PYTHON_TUTOR_ASSUME_YES="${PYTHON_TUTOR_ASSUME_YES:-0}"
+PYTHON_TUTOR_AUTOLAUNCH="${PYTHON_TUTOR_AUTOLAUNCH:-0}"
+
+# ----- prompt helper ---------------------------------------------------------
+# confirm "Question" [default-no|default-yes]
+# Returns 0 for yes, 1 for no. Default is "no" unless overridden.
+# PYTHON_TUTOR_ASSUME_YES=1 always answers yes.
+# TUTOR_NONINTERACTIVE=1 (without ASSUME_YES) always answers no.
+# Accepted yes responses: y, Y, yes, Yes, YES. Anything else is "no".
+confirm() {
+ local question="$1"
+ local default_choice="${2:-default-no}"
+
+ if [ "$PYTHON_TUTOR_ASSUME_YES" = "1" ]; then
+ printf "%b%s%b %s [auto-yes]\n" "$c_blu" "[install]" "$c_off" "$question"
+ return 0
+ fi
+ if [ "$TUTOR_NONINTERACTIVE" = "1" ]; then
+ printf "%b%s%b %s [auto-no]\n" "$c_blu" "[install]" "$c_off" "$question"
+ return 1
+ fi
+ if [ ! -t 0 ]; then
+ # No TTY and no explicit choice — be conservative.
+ printf "%b%s%b %s [no TTY → no]\n" "$c_yel" "[install]" "$c_off" "$question"
+ return 1
+ fi
+
+ local hint
+ if [ "$default_choice" = "default-yes" ]; then
+ hint="[Y/n]"
+ else
+ hint="[y/N]"
+ fi
+
+ local reply=""
+ printf "%b%s%b %s %s " "$c_blu" "[install]" "$c_off" "$question" "$hint"
+ IFS= read -r reply || reply=""
+ case "$reply" in
+ y|Y|yes|Yes|YES) return 0 ;;
+ n|N|no|No|NO) return 1 ;;
+ "")
+ if [ "$default_choice" = "default-yes" ]; then return 0; fi
+ return 1
+ ;;
+ *) return 1 ;;
+ esac
+}
# ----- 1. Python -------------------------------------------------------------
PY=""
@@ -79,54 +138,189 @@ if ! "$venv_dir/bin/python" -c "import sys" >/dev/null 2>&1; then
fi
# ----- 3. dependencies -------------------------------------------------------
+# Python deps in the venv are non-destructive — install without asking.
say "upgrading pip and installing backend deps"
"$venv_dir/bin/python" -m pip install --upgrade --quiet pip
"$venv_dir/bin/pip" install --quiet -r backend/requirements-dev.txt
ok "backend dependencies installed"
# ----- 4. Ollama -------------------------------------------------------------
+# Steps 4a–4c only run when not skipped. Each system-level action prompts
+# the user first. Defaults are "no" so a stray Enter never installs.
+
+# Detect OS so we can suggest the right command.
+uname_s="$(uname -s 2>/dev/null || echo unknown)"
+case "$uname_s" in
+ Darwin) os_kind=macos ;;
+ Linux) os_kind=linux ;;
+ *) os_kind=other ;;
+esac
+
+ollama_install_cmd_macos='brew install ollama'
+ollama_install_cmd_linux='curl -fsSL https://ollama.com/install.sh | sh'
+
+print_ollama_manual_hint() {
+ warn "You can install Ollama manually any time:"
+ case "$os_kind" in
+ macos) warn " $ollama_install_cmd_macos" ;;
+ linux) warn " $ollama_install_cmd_linux" ;;
+ *) warn " See https://ollama.com/download for your platform." ;;
+ esac
+ warn "Then re-run ./install.sh to pull the default model."
+ warn "The web UI will still work — chat replies will fail until Ollama is up."
+}
+
+install_ollama_now() {
+ case "$os_kind" in
+ macos)
+ if ! command -v brew >/dev/null 2>&1; then
+ err "Homebrew is required to install Ollama on macOS automatically."
+ err "Install brew from https://brew.sh, then re-run ./install.sh."
+ return 1
+ fi
+ say "running: $ollama_install_cmd_macos"
+ if brew install ollama; then
+ ok "Ollama installed via Homebrew."
+ return 0
+ fi
+ err "brew install ollama failed."
+ return 1
+ ;;
+ linux)
+ if ! command -v curl >/dev/null 2>&1; then
+ err "curl is required to install Ollama on Linux automatically."
+ err "Install curl with your package manager, then re-run ./install.sh."
+ return 1
+ fi
+ say "running: $ollama_install_cmd_linux"
+ # The official installer is documented at https://ollama.com/download.
+ # It may use sudo internally; that is the upstream-documented path.
+ if curl -fsSL https://ollama.com/install.sh | sh; then
+ ok "Ollama installed."
+ return 0
+ fi
+ err "Ollama installer exited non-zero."
+ return 1
+ ;;
+ *)
+ err "Automatic Ollama install is only supported on macOS and Linux."
+ err "See https://ollama.com/download for your platform."
+ return 1
+ ;;
+ esac
+}
+
+ollama_daemon_up() {
+ curl -fsS --max-time 2 http://localhost:11434/api/tags >/dev/null 2>&1
+}
+
+start_ollama_now() {
+ # Start the daemon as a backgrounded process. We do not write any
+ # service-manager units; we just spawn `ollama serve`. The user can
+ # always run it manually instead.
+ say "starting 'ollama serve' in the background"
+ # shellcheck disable=SC2069
+ nohup ollama serve >/tmp/ollama-serve.log 2>&1 &
+ local pid=$!
+ # Give it a moment, then probe.
+ for _ in $(seq 1 20); do
+ if ollama_daemon_up; then
+ ok "ollama serve is up (pid $pid; log: /tmp/ollama-serve.log)"
+ return 0
+ fi
+ sleep 0.5
+ done
+ err "ollama serve did not become reachable on :11434 within 10s."
+ err "Inspect /tmp/ollama-serve.log or run 'ollama serve' in another terminal."
+ return 1
+}
+
+model_present() {
+ curl -fsS --max-time 2 http://localhost:11434/api/tags 2>/dev/null \
+ | grep -F -q "\"$TUTOR_MODEL\""
+}
+
+# Skip everything if the user asked us to.
if [ "$TUTOR_SKIP_OLLAMA" = "1" ]; then
warn "TUTOR_SKIP_OLLAMA=1 — skipping Ollama checks"
else
+ # 4a. Is the binary present?
if ! command -v ollama >/dev/null 2>&1; then
- warn "ollama is not installed."
- warn " macOS: brew install ollama && ollama serve &"
- warn " Linux: curl -fsSL https://ollama.com/install.sh | sh && ollama serve &"
- warn "Re-run ./install.sh after installing Ollama to pull the default model."
- warn "The web UI will still work — chat replies will fail until Ollama is up."
- else
+ warn "Ollama is not installed."
+ case "$os_kind" in
+ macos|linux)
+ if confirm "Install Ollama now? (will run the official upstream installer)" default-no; then
+ if install_ollama_now; then
+ ok "ollama is installed ($(command -v ollama 2>/dev/null || echo 'not on PATH yet'))"
+ else
+ print_ollama_manual_hint
+ fi
+ else
+ print_ollama_manual_hint
+ fi
+ ;;
+ *)
+ print_ollama_manual_hint
+ ;;
+ esac
+ fi
+
+ # 4b. Is the daemon reachable?
+ if command -v ollama >/dev/null 2>&1; then
ok "ollama is installed ($(command -v ollama))"
- # Probe the daemon.
- if curl -fsS --max-time 2 http://localhost:11434/api/tags >/dev/null 2>&1; then
+ if ollama_daemon_up; then
ok "ollama daemon is reachable on http://localhost:11434"
- if [ "$TUTOR_SKIP_MODEL_PULL" = "1" ]; then
- warn "TUTOR_SKIP_MODEL_PULL=1 — skipping model pull"
+ else
+ warn "Ollama is installed but the daemon is not running on :11434."
+ if confirm "Start 'ollama serve' in the background now?" default-no; then
+ if ! start_ollama_now; then
+ warn "Could not auto-start. Run 'ollama serve' in another terminal and re-run ./install.sh."
+ fi
else
- # Does the model already exist locally?
- if curl -fsS --max-time 2 http://localhost:11434/api/tags 2>/dev/null \
- | grep -F -q "\"$TUTOR_MODEL\""; then
- ok "model '$TUTOR_MODEL' already present"
+ warn "Skipping auto-start. Run 'ollama serve' yourself in another terminal."
+ fi
+ fi
+ fi
+
+ # 4c. Is the default model present?
+ if command -v ollama >/dev/null 2>&1 && ollama_daemon_up; then
+ if [ "$TUTOR_SKIP_MODEL_PULL" = "1" ]; then
+ warn "TUTOR_SKIP_MODEL_PULL=1 — skipping model pull"
+ elif model_present; then
+ ok "model '$TUTOR_MODEL' already present"
+ else
+ warn "Model '$TUTOR_MODEL' is not present locally."
+ if confirm "Pull '$TUTOR_MODEL' now? (this can take several minutes)" default-no; then
+ if ollama pull "$TUTOR_MODEL"; then
+ ok "model '$TUTOR_MODEL' ready"
else
- say "pulling model '$TUTOR_MODEL' (this can take several minutes)…"
- if ollama pull "$TUTOR_MODEL"; then
- ok "model '$TUTOR_MODEL' ready"
- else
- warn "ollama pull failed. You can retry later with: ollama pull $TUTOR_MODEL"
- fi
+ warn "ollama pull failed. You can retry later with: ollama pull $TUTOR_MODEL"
fi
+ else
+ warn "Skipping pull. Retry later with: ollama pull $TUTOR_MODEL"
fi
- else
- warn "ollama is installed but the daemon is not running."
- warn "Start it in another terminal: ollama serve"
- warn "Then re-run ./install.sh to pull the default model."
fi
fi
fi
-# ----- next step -------------------------------------------------------------
+# ----- 5. Optional auto-launch ----------------------------------------------
echo
ok "install complete."
echo
+
+launch_now=0
+if [ "$PYTHON_TUTOR_AUTOLAUNCH" = "1" ]; then
+ launch_now=1
+elif confirm "Launch the tutor now (./run.sh)?" default-no; then
+ # confirm() returns true when ASSUME_YES=1 or when the user typed y/yes.
+ launch_now=1
+fi
+
+if [ "$launch_now" = "1" ]; then
+ ok "launching ./run.sh"
+ exec "$repo_root/run.sh"
+fi
+
echo "Next step:"
echo " ./run.sh # starts the tutor at http://localhost:8001/"
echo
diff --git a/run.sh b/run.sh
index ced607f..a0f0fea 100755
--- a/run.sh
+++ b/run.sh
@@ -2,10 +2,13 @@
# run.sh — launch the Python tutor backend, which also serves the frontend.
#
# Reads:
-# TUTOR_HOST default 127.0.0.1
-# TUTOR_PORT default 8001
-# TUTOR_MODEL default gemma3:4b (forwarded to backend)
-# TUTOR_SKIP_OLLAMA=1 skip the Ollama probe (still launches the server)
+# TUTOR_HOST default 127.0.0.1
+# TUTOR_PORT default 8001
+# TUTOR_MODEL default gemma3:4b (forwarded to backend)
+# TUTOR_SKIP_OLLAMA=1 skip the Ollama probe (still launches the server)
+# TUTOR_NONINTERACTIVE=1 never prompt; auto-answer "no"
+# PYTHON_TUTOR_NONINTERACTIVE=1 alias for TUTOR_NONINTERACTIVE
+# PYTHON_TUTOR_ASSUME_YES=1 auto-answer "yes" to start/pull prompts
#
# If Ollama is unreachable we WARN but still start the server, so the user
# can browse lessons and exercises. Chat replies will fail with a clear
@@ -29,10 +32,70 @@ TUTOR_HOST="${TUTOR_HOST:-127.0.0.1}"
TUTOR_PORT="${TUTOR_PORT:-8001}"
TUTOR_MODEL="${TUTOR_MODEL:-gemma3:4b}"
TUTOR_SKIP_OLLAMA="${TUTOR_SKIP_OLLAMA:-0}"
+TUTOR_NONINTERACTIVE="${TUTOR_NONINTERACTIVE:-${PYTHON_TUTOR_NONINTERACTIVE:-0}}"
+PYTHON_TUTOR_ASSUME_YES="${PYTHON_TUTOR_ASSUME_YES:-0}"
+
+# ----- prompt helper (mirrors install.sh) -----------------------------------
+confirm() {
+ local question="$1"
+ local default_choice="${2:-default-no}"
+
+ if [ "$PYTHON_TUTOR_ASSUME_YES" = "1" ]; then
+ printf "%b%s%b %s [auto-yes]\n" "$c_blu" "[run]" "$c_off" "$question"
+ return 0
+ fi
+ if [ "$TUTOR_NONINTERACTIVE" = "1" ]; then
+ printf "%b%s%b %s [auto-no]\n" "$c_blu" "[run]" "$c_off" "$question"
+ return 1
+ fi
+ if [ ! -t 0 ]; then
+ printf "%b%s%b %s [no TTY → no]\n" "$c_yel" "[run]" "$c_off" "$question"
+ return 1
+ fi
+
+ local hint
+ if [ "$default_choice" = "default-yes" ]; then hint="[Y/n]"; else hint="[y/N]"; fi
+ local reply=""
+ printf "%b%s%b %s %s " "$c_blu" "[run]" "$c_off" "$question" "$hint"
+ IFS= read -r reply || reply=""
+ case "$reply" in
+ y|Y|yes|Yes|YES) return 0 ;;
+ n|N|no|No|NO) return 1 ;;
+ "")
+ if [ "$default_choice" = "default-yes" ]; then return 0; fi
+ return 1 ;;
+ *) return 1 ;;
+ esac
+}
+
+ollama_daemon_up() {
+ curl -fsS --max-time 2 http://localhost:11434/api/tags >/dev/null 2>&1
+}
+
+start_ollama_now() {
+ say "starting 'ollama serve' in the background"
+ # shellcheck disable=SC2069
+ nohup ollama serve >/tmp/ollama-serve.log 2>&1 &
+ local pid=$!
+ for _ in $(seq 1 20); do
+ if ollama_daemon_up; then
+ ok "ollama serve is up (pid $pid; log: /tmp/ollama-serve.log)"
+ return 0
+ fi
+ sleep 0.5
+ done
+ err "ollama serve did not become reachable on :11434 within 10s."
+ return 1
+}
venv_dir="backend/.venv"
if [ ! -x "$venv_dir/bin/uvicorn" ]; then
warn "venv not found or uvicorn missing — running ./install.sh first"
+ # Run install in noninteractive mode unless the operator already chose a
+ # mode. We must not silently install Ollama from inside run.sh.
+ TUTOR_NONINTERACTIVE="${TUTOR_NONINTERACTIVE:-1}" \
+ TUTOR_SKIP_OLLAMA=1 \
+ PYTHON_TUTOR_AUTOLAUNCH=0 \
./install.sh
fi
@@ -40,12 +103,18 @@ if [ "$TUTOR_SKIP_OLLAMA" = "1" ]; then
warn "TUTOR_SKIP_OLLAMA=1 — skipping Ollama reachability check"
elif ! command -v ollama >/dev/null 2>&1; then
warn "ollama is not installed; chat replies will fail (UI still works)."
- warn " macOS: brew install ollama && ollama serve &"
- warn " Linux: curl -fsSL https://ollama.com/install.sh | sh && ollama serve &"
-elif ! curl -fsS --max-time 2 http://localhost:11434/api/tags >/dev/null 2>&1; then
+ warn " Run ./install.sh and answer 'y' when asked to install Ollama, or:"
+ warn " macOS: brew install ollama"
+ warn " Linux: curl -fsSL https://ollama.com/install.sh | sh"
+elif ! ollama_daemon_up; then
warn "ollama is installed but the daemon is not reachable on :11434."
- warn "Start it in another terminal: ollama serve"
- warn "Chat replies will return 503 until Ollama is up."
+ if confirm "Start 'ollama serve' in the background now?" default-no; then
+ if ! start_ollama_now; then
+ warn "Could not auto-start. Chat replies will return 503 until you run 'ollama serve'."
+ fi
+ else
+ warn "Continuing without Ollama. Chat replies will return 503 until you run 'ollama serve'."
+ fi
else
ok "ollama daemon reachable on :11434"
fi
diff --git a/scripts/smoke_prompts.sh b/scripts/smoke_prompts.sh
new file mode 100755
index 0000000..4a0348d
--- /dev/null
+++ b/scripts/smoke_prompts.sh
@@ -0,0 +1,69 @@
+#!/usr/bin/env bash
+# scripts/smoke_prompts.sh — exercise the y/N prompt code paths in install.sh
+# without ever installing system binaries or starting daemons.
+#
+# We invoke install.sh with various env combos and assert:
+# - the install completes,
+# - prompts auto-resolve (no hangs waiting for stdin),
+# - the auto-no / auto-yes markers print as expected,
+# - backend/.venv is left usable.
+#
+# To keep the test fast and avoid actually starting uvicorn, we shadow
+# run.sh with a no-op stub when testing the ASSUME_YES auto-launch path.
+set -euo pipefail
+
+repo_root="$(cd "$(dirname "$0")/.." && pwd)"
+cd "$repo_root"
+
+log="$(mktemp)"
+stub_dir="$(mktemp -d)"
+trap 'rm -f "$log"; rm -rf "$stub_dir"' EXIT
+
+# Set TUTOR_SKIP_OLLAMA in every invocation so we never run apt/brew/curl.
+
+echo "--- Run 1: TUTOR_SKIP_OLLAMA=1 + TUTOR_NONINTERACTIVE=1 (CI baseline) ---"
+TUTOR_SKIP_OLLAMA=1 TUTOR_NONINTERACTIVE=1 ./install.sh &1 | tee "$log"
+grep -q "install complete." "$log" || { echo "FAIL: missing 'install complete.'" >&2; exit 1; }
+test -x backend/.venv/bin/python || { echo "FAIL: venv missing" >&2; exit 1; }
+grep -q "Launch the tutor now" "$log" || { echo "FAIL: launch prompt did not fire" >&2; exit 1; }
+grep -q "\[auto-no\]" "$log" || { echo "FAIL: auto-no marker missing" >&2; exit 1; }
+echo "ok: run 1"
+
+echo
+echo "--- Run 2: PYTHON_TUTOR_NONINTERACTIVE alias ---"
+PYTHON_TUTOR_NONINTERACTIVE=1 TUTOR_SKIP_OLLAMA=1 ./install.sh &1 | tee "$log"
+grep -q "install complete." "$log" || { echo "FAIL: alias produced no successful install" >&2; exit 1; }
+grep -q "\[auto-no\]" "$log" || { echo "FAIL: alias did not trigger auto-no" >&2; exit 1; }
+echo "ok: run 2"
+
+echo
+echo "--- Run 3: PYTHON_TUTOR_ASSUME_YES auto-launches (stubbed run.sh) ---"
+# Replace ./run.sh with a stub for the duration of this test. We can't
+# safely write to repo_root/run.sh (we'd corrupt the working copy), so we
+# wrap install.sh: copy it to a temp dir, point it at a stubbed run.sh.
+cat >"$stub_dir/run.sh" <<'STUB'
+#!/usr/bin/env bash
+echo "[stub-run] would launch uvicorn here"
+exit 0
+STUB
+chmod +x "$stub_dir/run.sh"
+# Run install.sh from its real location but with PATH-shadowed exec target.
+# install.sh execs "$repo_root/run.sh" by absolute path, so PATH shadowing
+# alone won't catch it. Instead we temporarily symlink run.sh aside.
+mv run.sh "$stub_dir/run.sh.real"
+ln -s "$stub_dir/run.sh" run.sh
+set +e
+PYTHON_TUTOR_ASSUME_YES=1 TUTOR_SKIP_OLLAMA=1 ./install.sh &1 | tee "$log"
+rc=${PIPESTATUS[0]}
+set -e
+rm -f run.sh
+mv "$stub_dir/run.sh.real" run.sh
+chmod +x run.sh
+test "$rc" -eq 0 || { echo "FAIL: ASSUME_YES install exited $rc" >&2; exit 1; }
+grep -q "\[auto-yes\]" "$log" || { echo "FAIL: auto-yes marker missing" >&2; exit 1; }
+grep -q "\[stub-run\] would launch uvicorn here" "$log" || \
+ { echo "FAIL: did not exec run.sh under ASSUME_YES" >&2; exit 1; }
+echo "ok: run 3"
+
+echo
+echo "prompt smoke ok"