fix(install): restore /plannotator-* bash execution on Claude Code + harden unattended installs (#872)

Follow-up to #850 (unreleased), fixing two pre-release regressions vs v0.19.27.

- Claude Code: add apps/skills/claude/* skills using `!`plannotator <sub> $ARGUMENTS``
  + allowed-tools + disable-model-invocation, so /plannotator-review|annotate|last|
  archive auto-run with no permission prompt and pass arguments through — matching
  the old slash commands. Codex keeps the prose apps/skills/core/*. Installers route
  ~/.claude/skills <- apps/skills/claude and ~/.agents/skills <- apps/skills/core.

- Installers: gate the wizard so an unattended-but-open /dev/tty can't hang
  (PLANNOTATOR_PROMPT_TIMEOUT, default 30s; timeout/EOF resolves to safe "no").
  Don't gate on $CI (explicit --reconfigure/--extras must win). Don't persist
  timed-out wizard answers as install-prefs. Keep ask_yes_no's read in a tested
  context so set -e never aborts on a timeout.

72 installer tests pass.
This commit is contained in:
Michael Ramos
2026-06-08 10:28:18 -07:00
committed by GitHub
parent f08764063d
commit aac5aacbe8
8 changed files with 204 additions and 40 deletions
@@ -0,0 +1,20 @@
---
name: plannotator-annotate
description: Open Plannotator's annotation UI for a markdown file, converted HTML file, URL, or folder and then respond to the returned annotations.
allowed-tools: Bash(plannotator:*)
disable-model-invocation: true
---
# Plannotator Annotate
## Markdown annotations
!`plannotator annotate $ARGUMENTS`
## Your task
The output above will be one of:
1. The exact text `The user approved.`, OR a JSON object with `"decision": "approved"`. The user approved the markdown file(s). Acknowledge with a single sentence ("Approved.") and stop. Do not begin any work.
2. Empty, OR a JSON object with `"decision": "dismissed"`. The user closed the session without requesting changes. Acknowledge with a single sentence ("Annotation session closed.") and stop. Do not begin any work.
3. Plaintext annotation feedback, OR a JSON object with `"decision": "annotated"` and a `"feedback"` field. Address the feedback. The user has reviewed the markdown file(s) and provided specific annotations and comments.
@@ -0,0 +1,16 @@
---
name: plannotator-archive
description: Browse saved plan decisions in Plannotator's read-only archive UI in the browser.
allowed-tools: Bash(plannotator:*)
disable-model-invocation: true
---
# Plannotator Archive
## Plan archive
!`plannotator archive`
## Your task
The user has finished browsing the plan archive. Acknowledge and continue.
@@ -0,0 +1,20 @@
---
name: plannotator-last
description: Open Plannotator on the latest rendered assistant message and use the returned annotations to revise that message or continue.
allowed-tools: Bash(plannotator:*)
disable-model-invocation: true
---
# Plannotator Last
## Message annotations
!`plannotator annotate-last $ARGUMENTS`
## Your task
The output above will be one of:
1. The exact text `The user approved.`, OR a JSON object with `"decision": "approved"`. The user approved your last message. Acknowledge with a single sentence ("Approved.") and stop. Do not begin any work.
2. Empty, OR a JSON object with `"decision": "dismissed"`. The user closed the session without requesting changes. Acknowledge with a single sentence ("Annotation session closed.") and stop. Do not begin any work.
3. Plaintext annotation feedback, OR a JSON object with `"decision": "annotated"` and a `"feedback"` field. Address the feedback. The user has reviewed your last message and provided specific annotations and comments.
@@ -0,0 +1,16 @@
---
name: plannotator-review
description: Open Plannotator's browser-based code review UI for the current worktree or a pull request URL, then act on the feedback that comes back.
allowed-tools: Bash(plannotator:*)
disable-model-invocation: true
---
# Plannotator Review
## Code review feedback
!`plannotator review $ARGUMENTS`
## Your task
If the review above contains feedback or annotations, address them in the same conversation. If no changes were requested (an approval/LGTM-style result), acknowledge that review passed and continue.
+15 -5
View File
@@ -716,20 +716,30 @@ if !ERRORLEVEL! equ 0 (
pushd "!SKILLS_TMP!\repo"
git sparse-checkout set apps/skills apps/kiro-cli apps/opencode-plugin/commands apps/gemini/commands >nul 2>&1
REM Core skills -> Claude + shared agent scope (all 4, copy-if-present).
if exist "apps\skills\core" (
REM Claude Code reads apps\skills\claude\* (injection `!`plannotator … $ARGUMENTS``
REM + allowed-tools, so /plannotator-* run with no permission prompt); Codex
REM reads apps\skills\core\* (prose). The `!`…`` injection is Claude-Code-only,
REM so the two are sourced separately. Replace rather than merge on each run.
if exist "apps\skills\claude" (
if not exist "!CLAUDE_SKILLS_DIR!" mkdir "!CLAUDE_SKILLS_DIR!"
for %%S in (plannotator-review plannotator-annotate plannotator-last plannotator-archive) do (
if exist "apps\skills\claude\%%S" (
if exist "!CLAUDE_SKILLS_DIR!\%%S" rmdir /s /q "!CLAUDE_SKILLS_DIR!\%%S" >nul 2>&1
xcopy /s /i /y /q "apps\skills\claude\%%S" "!CLAUDE_SKILLS_DIR!\%%S\" >nul 2>&1
)
)
echo Installed Claude Code skills to !CLAUDE_SKILLS_DIR!\
)
if exist "apps\skills\core" (
if not exist "!AGENTS_SKILLS_DIR!" mkdir "!AGENTS_SKILLS_DIR!"
for %%S in (plannotator-review plannotator-annotate plannotator-last plannotator-archive) do (
if exist "apps\skills\core\%%S" (
REM Replace rather than merge so files removed upstream don't linger.
if exist "!CLAUDE_SKILLS_DIR!\%%S" rmdir /s /q "!CLAUDE_SKILLS_DIR!\%%S" >nul 2>&1
if exist "!AGENTS_SKILLS_DIR!\%%S" rmdir /s /q "!AGENTS_SKILLS_DIR!\%%S" >nul 2>&1
xcopy /s /i /y /q "apps\skills\core\%%S" "!CLAUDE_SKILLS_DIR!\%%S\" >nul 2>&1
xcopy /s /i /y /q "apps\skills\core\%%S" "!AGENTS_SKILLS_DIR!\%%S\" >nul 2>&1
)
)
echo Installed core skills to !CLAUDE_SKILLS_DIR!\ and !AGENTS_SKILLS_DIR!\
echo Installed shared agent skills to !AGENTS_SKILLS_DIR!\
) else (
echo Tag !TAG! predates the core/extra skill layout — skipping core skill install
)
+19 -7
View File
@@ -644,20 +644,32 @@ try {
try {
git sparse-checkout set apps/skills apps/kiro-cli apps/opencode-plugin/commands apps/gemini/commands 2>$null
# Core skills -> ~/.claude/skills and ~/.agents/skills (all 4).
# Claude Code and Codex consume different skill bodies. Claude Code
# reads apps/skills/claude/* (dynamic-context injection
# `!`plannotator … $ARGUMENTS`` + allowed-tools, so /plannotator-*
# run with no permission prompt — like the old slash commands).
# Codex reads apps/skills/core/* (prose the model follows via its
# own shell). The `!`…`` injection is a Claude-Code-only extension,
# so the two are sourced separately rather than sharing one body.
# Route each through Copy-SkillIfPresent (which pre-removes the
# existing target dir) so re-runs replace rather than nest,
# matching install.sh's per-skill structure.
if ((Test-Path "apps\skills\core") -and (Get-ChildItem "apps\skills\core" -ErrorAction SilentlyContinue)) {
# existing target dir) so re-runs replace rather than nest.
if ((Test-Path "apps\skills\claude") -and (Get-ChildItem "apps\skills\claude" -ErrorAction SilentlyContinue)) {
New-Item -ItemType Directory -Force -Path $claudeSkillsDir | Out-Null
foreach ($skill in @("plannotator-review", "plannotator-annotate", "plannotator-last", "plannotator-archive")) {
Copy-SkillIfPresent "apps\skills\claude\$skill" $claudeSkillsDir
}
Write-Host "Installed Claude Code skills to $claudeSkillsDir\"
} else {
Write-Host "Tag $latestTag predates the per-agent skill layout — skipping Claude Code skill install"
}
if ((Test-Path "apps\skills\core") -and (Get-ChildItem "apps\skills\core" -ErrorAction SilentlyContinue)) {
New-Item -ItemType Directory -Force -Path $agentsSkillsDir | Out-Null
foreach ($skill in @("plannotator-review", "plannotator-annotate", "plannotator-last", "plannotator-archive")) {
Copy-SkillIfPresent "apps\skills\core\$skill" $claudeSkillsDir
Copy-SkillIfPresent "apps\skills\core\$skill" $agentsSkillsDir
}
Write-Host "Installed core skills to $claudeSkillsDir\ and $agentsSkillsDir\"
Write-Host "Installed shared agent skills to $agentsSkillsDir\"
} else {
Write-Host "Tag $latestTag predates the core/extra skill layout — skipping core skill install"
Write-Host "Tag $latestTag predates the core/extra skill layout — skipping shared agent skill install"
}
# Kiro: hand-maintained skills (origin baked in) + two extras.
+73 -17
View File
@@ -730,13 +730,29 @@ if command -v glimpseui >/dev/null 2>&1; then
glimpse_present=1
fi
# A wizard needs a real keyboard. Piped installs (curl | bash) still have a
# terminal at /dev/tty even though stdin is the pipe; CI and scripts do not.
# A wizard needs a real human at the keyboard. Piped installs (curl | bash)
# still have a terminal at /dev/tty even though stdin is the pipe; CI and
# scripts do not. Some automated contexts (docker run -t, devcontainer and
# provisioner shells) DO expose an openable /dev/tty with nobody behind it —
# opening /dev/tty succeeds, yet a read would block forever. The per-prompt
# timeout below (see PROMPT_TIMEOUT / ask_yes_no) handles that: a mis-detected
# terminal falls through to the safe non-interactive defaults (extras=no,
# model-invocable=none, glimpse=no) instead of wedging. We deliberately do NOT
# gate on $CI here — an exported CI var must not silently suppress an explicit
# --reconfigure or --extras in an otherwise interactive shell.
can_prompt=0
if [ "$NON_INTERACTIVE" -eq 0 ] && { : < /dev/tty; } 2>/dev/null; then
can_prompt=1
fi
# Bound every interactive read so an unattended-but-open /dev/tty auto-takes
# the default rather than hanging. Set PLANNOTATOR_PROMPT_TIMEOUT=0 to wait
# indefinitely (restores the old unbounded behavior); non-numeric falls to 30.
PROMPT_TIMEOUT="${PLANNOTATOR_PROMPT_TIMEOUT:-30}"
case "$PROMPT_TIMEOUT" in
''|*[!0-9]*) PROMPT_TIMEOUT=30 ;;
esac
run_wizard=0
if [ "$can_prompt" -eq 1 ]; then
if [ "$RECONFIGURE" -eq 1 ] || [ ! -f "$PREFS_FILE" ]; then
@@ -746,11 +762,32 @@ fi
# Ask a y/n question on the terminal. $1 prompt, $2 default (yes/no).
ask_yes_no() {
local prompt="$1" default="$2" answer suffix
local prompt="$1" default="$2" answer suffix rc
suffix="[y/N]"
[ "$default" = "yes" ] && suffix="[Y/n]"
printf '%s %s ' "$prompt" "$suffix" > /dev/tty
IFS= read -r answer < /dev/tty || answer=""
# Bounded read so an unattended-but-open /dev/tty (e.g. docker run -t with
# no human) can't hang the install. Distinguish a human pressing Enter
# (read succeeds with an empty answer -> use the prompt's $default) from a
# timeout/EOF with nobody there (read fails -> use the SAFE "no", never the
# default). Otherwise a prompt whose default is "yes" (Glimpse) would
# silently install software on an unattended terminal.
# Keep the read in a tested context (`|| rc=$?`) so the read itself never
# trips `set -e` (active at the top of this script), without relying on the
# subtle rule that -e is suppressed inside a function called in a tested
# context. ask_yes_no still returns non-zero on timeout/EOF to signal "no
# human", so every caller consumes it with `|| wizard_timed_out=1`.
rc=0
if [ "$PROMPT_TIMEOUT" -gt 0 ]; then
IFS= read -r -t "$PROMPT_TIMEOUT" answer < /dev/tty || rc=$?
else
IFS= read -r answer < /dev/tty || rc=$?
fi
if [ "$rc" -ne 0 ]; then
printf '\n' > /dev/tty
echo "no"
return 1
fi
case "$answer" in
y|Y|yes|YES|Yes) echo "yes" ;;
n|N|no|NO|No) echo "no" ;;
@@ -811,6 +848,9 @@ select_skills_checkbox() {
extras_choice=""
invocable_choice=""
glimpse_choice=""
# Set if any wizard prompt times out / hits EOF (no human answered). A run whose
# answers are synthetic timeout fallbacks must not be persisted as install-prefs.
wizard_timed_out=0
if [ "$run_wizard" -eq 1 ]; then
{
@@ -827,7 +867,7 @@ if [ "$run_wizard" -eq 1 ]; then
# Flag already answered this question — don't ask and then ignore.
extras_choice="$EXTRAS_FLAG"
else
extras_choice=$(ask_yes_no "Install the extra skills (compound planning, setup-goal, visual explainer)?" "${saved_extras:-no}")
extras_choice=$(ask_yes_no "Install the extra skills (compound planning, setup-goal, visual explainer)?" "${saved_extras:-no}") || wizard_timed_out=1
fi
invocable_list="$CORE_SKILL_NAMES"
if [ "$extras_choice" = "yes" ]; then
@@ -837,7 +877,7 @@ if [ "$run_wizard" -eq 1 ]; then
# Flag already answered this question — don't ask and then ignore.
invocable_choice="$MODEL_INVOCABLE_FLAG"
else
want_invocable=$(ask_yes_no "Make any skills callable by the model (instead of user-invoked only)?" "no")
want_invocable=$(ask_yes_no "Make any skills callable by the model (instead of user-invoked only)?" "no") || wizard_timed_out=1
if [ "$want_invocable" = "yes" ]; then
invocable_choice=$(select_skills_checkbox "$invocable_list" "$saved_invocable")
else
@@ -851,7 +891,7 @@ if [ "$run_wizard" -eq 1 ]; then
# Flag already answered this question — don't ask and then ignore.
glimpse_choice="$GLIMPSE_FLAG"
else
glimpse_choice=$(ask_yes_no "Install Glimpse so Plannotator opens in a native window instead of a browser tab? (npm i -g glimpseui)" "${saved_glimpse:-yes}")
glimpse_choice=$(ask_yes_no "Install Glimpse so Plannotator opens in a native window instead of a browser tab? (npm i -g glimpseui)" "${saved_glimpse:-yes}") || wizard_timed_out=1
fi
fi
@@ -863,9 +903,11 @@ fi
[ -z "$invocable_choice" ] && invocable_choice="${saved_invocable:-none}"
[ -z "$glimpse_choice" ] && glimpse_choice="${saved_glimpse:-no}"
# Persist only when the wizard ran or a flag set something — silent re-runs
# must not clobber saved answers with defaults.
if [ "$run_wizard" -eq 1 ] || [ -n "$EXTRAS_FLAG" ] || [ -n "$MODEL_INVOCABLE_FLAG" ] || [ -n "$GLIMPSE_FLAG" ]; then
# Persist only when the wizard ran with real answers, or a flag set something.
# Silent re-runs must not clobber saved answers with defaults, and a wizard that
# timed out to synthetic fallbacks (unattended /dev/tty) must not become sticky
# prefs that suppress the wizard on a later genuine interactive install.
if [ "$wizard_timed_out" -eq 0 ] && { [ "$run_wizard" -eq 1 ] || [ -n "$EXTRAS_FLAG" ] || [ -n "$MODEL_INVOCABLE_FLAG" ] || [ -n "$GLIMPSE_FLAG" ]; }; then
mkdir -p "$_config_dir"
{
echo "extras=$extras_choice"
@@ -969,19 +1011,33 @@ checkout_failed=0
# via --version may predate the core/extra layout — skip core skills
# but keep installing the command files below (matches install.ps1 and
# install.cmd, which guard each block independently).
# Claude Code and Codex consume different skill bodies. Claude Code reads
# the apps/skills/claude/* copies, which use dynamic-context injection
# (`!`plannotator … $ARGUMENTS``) + allowed-tools so /plannotator-* run the
# binary directly with no permission prompt — matching the old slash
# commands. Codex (the OpenAI shared-agent path) reads apps/skills/core/*,
# whose prose bodies the model follows via its own shell; the `!`…``
# injection is a Claude-Code-only extension, so the two are sourced
# separately rather than sharing one body.
if [ -d "apps/skills/claude" ] && [ -n "$(ls -A apps/skills/claude 2>/dev/null)" ]; then
mkdir -p "$CLAUDE_SKILLS_DIR"
copy_skill_if_present apps/skills/claude/plannotator-review "$CLAUDE_SKILLS_DIR"
copy_skill_if_present apps/skills/claude/plannotator-annotate "$CLAUDE_SKILLS_DIR"
copy_skill_if_present apps/skills/claude/plannotator-last "$CLAUDE_SKILLS_DIR"
copy_skill_if_present apps/skills/claude/plannotator-archive "$CLAUDE_SKILLS_DIR"
echo "Installed Claude Code skills to ${CLAUDE_SKILLS_DIR}/"
else
echo "Tag ${latest_tag} predates the per-agent skill layout — skipping Claude Code skill install"
fi
if [ -d "apps/skills/core" ] && [ -n "$(ls -A apps/skills/core 2>/dev/null)" ]; then
mkdir -p "$CLAUDE_SKILLS_DIR" "$AGENTS_SKILLS_DIR"
copy_skill_if_present apps/skills/core/plannotator-review "$CLAUDE_SKILLS_DIR"
copy_skill_if_present apps/skills/core/plannotator-annotate "$CLAUDE_SKILLS_DIR"
copy_skill_if_present apps/skills/core/plannotator-last "$CLAUDE_SKILLS_DIR"
copy_skill_if_present apps/skills/core/plannotator-archive "$CLAUDE_SKILLS_DIR"
mkdir -p "$AGENTS_SKILLS_DIR"
copy_skill_if_present apps/skills/core/plannotator-review "$AGENTS_SKILLS_DIR"
copy_skill_if_present apps/skills/core/plannotator-annotate "$AGENTS_SKILLS_DIR"
copy_skill_if_present apps/skills/core/plannotator-last "$AGENTS_SKILLS_DIR"
copy_skill_if_present apps/skills/core/plannotator-archive "$AGENTS_SKILLS_DIR"
echo "Installed core skills to ${CLAUDE_SKILLS_DIR}/ and shared agent skills to ${AGENTS_SKILLS_DIR}/"
echo "Installed shared agent skills to ${AGENTS_SKILLS_DIR}/"
else
echo "Tag ${latest_tag} predates the core/extra skill layout — skipping core skill install"
echo "Tag ${latest_tag} predates the core/extra skill layout — skipping shared agent skill install"
fi
# OpenCode slash command stubs (the plugin intercepts execution) —
+25 -11
View File
@@ -78,9 +78,12 @@ describe("install.sh", () => {
expect(script).toContain("AGENTS_SKILLS_DIR");
expect(script).toContain("$HOME/.agents/skills");
expect(script).toContain("copy_skill_if_present");
// Core skills (all 4) -> Claude Code and the official OpenAI shared-agent path.
// Claude Code reads the injection-form skills from apps/skills/claude;
// the OpenAI shared-agent (Codex) path reads the prose skills from
// apps/skills/core. Sourced separately because `!`…`` injection is a
// Claude-Code-only extension.
for (const skill of CORE_SKILLS) {
expect(script).toContain(`copy_skill_if_present apps/skills/core/${skill} "$CLAUDE_SKILLS_DIR"`);
expect(script).toContain(`copy_skill_if_present apps/skills/claude/${skill} "$CLAUDE_SKILLS_DIR"`);
expect(script).toContain(`copy_skill_if_present apps/skills/core/${skill} "$AGENTS_SKILLS_DIR"`);
}
// Codex no longer receives a skills install (core skills live in ~/.agents/skills).
@@ -100,7 +103,7 @@ describe("install.sh", () => {
// fetch or an old pinned tag never deletes commands without replacement.
expect(script).toContain('if [ -d "$CLAUDE_SKILLS_DIR/$cmd" ] && [ -f "$CLAUDE_COMMANDS_DIR/$cmd.md" ]');
const cleanupIndex = script.indexOf('Removed legacy Claude command');
const installIndex = script.indexOf('copy_skill_if_present apps/skills/core/plannotator-review');
const installIndex = script.indexOf('copy_skill_if_present apps/skills/claude/plannotator-review');
expect(installIndex).toBeGreaterThan(0);
expect(cleanupIndex).toBeGreaterThan(installIndex);
});
@@ -343,10 +346,11 @@ describe("install.ps1", () => {
expect(script).toContain("agentsSkillsDir");
expect(script).toContain("$env:USERPROFILE\\.agents\\skills");
expect(script).toContain("Copy-SkillIfPresent");
// Core skills (all 4) copied verbatim to both Claude and the shared-agent
// scope, per-skill via Copy-SkillIfPresent so re-runs replace rather than
// nest (PowerShell's Copy-Item -Recurse into an existing dir nests).
expect(script).toContain('Copy-SkillIfPresent "apps\\skills\\core\\$skill" $claudeSkillsDir');
// Claude Code reads injection-form skills (apps\skills\claude); the
// shared-agent (Codex) scope reads the prose skills (apps\skills\core).
// Per-skill via Copy-SkillIfPresent so re-runs replace rather than nest
// (PowerShell's Copy-Item -Recurse into an existing dir nests).
expect(script).toContain('Copy-SkillIfPresent "apps\\skills\\claude\\$skill" $claudeSkillsDir');
expect(script).toContain('Copy-SkillIfPresent "apps\\skills\\core\\$skill" $agentsSkillsDir');
expect(script).toContain('"plannotator-review", "plannotator-annotate", "plannotator-last", "plannotator-archive"');
// Copy-SkillIfPresent pre-removes the destination to avoid nesting on upgrade.
@@ -467,8 +471,9 @@ describe("install.cmd", () => {
expect(script).toContain("CLAUDE_SKILLS_DIR");
expect(script).toContain("AGENTS_SKILLS_DIR");
expect(script).toContain("%USERPROFILE%\\.agents\\skills");
// Core skills (all 4) copied to both Claude and shared-agent scope.
expect(script).toContain('xcopy /s /i /y /q "apps\\skills\\core\\%%S" "!CLAUDE_SKILLS_DIR!\\%%S\\"');
// Claude Code reads injection-form skills (apps\skills\claude); the shared
// agent (Codex) scope reads the prose skills (apps\skills\core).
expect(script).toContain('xcopy /s /i /y /q "apps\\skills\\claude\\%%S" "!CLAUDE_SKILLS_DIR!\\%%S\\"');
expect(script).toContain('xcopy /s /i /y /q "apps\\skills\\core\\%%S" "!AGENTS_SKILLS_DIR!\\%%S\\"');
expect(script).toContain("for %%S in (plannotator-review plannotator-annotate plannotator-last plannotator-archive) do");
// No Codex skills install — only the cleanup loop references CODEX skills.
@@ -632,8 +637,17 @@ describe("install shared behavior", () => {
expect(cmdScript).toContain("timeout /t 0");
expect(cmdScript).toContain('if "!CAN_PROMPT!"=="1"');
expect(cmdScript).toContain("set /p");
// Silent re-runs must not clobber saved answers with defaults.
expect(sh).toContain('if [ "$run_wizard" -eq 1 ] || [ -n "$EXTRAS_FLAG" ] || [ -n "$MODEL_INVOCABLE_FLAG" ] || [ -n "$GLIMPSE_FLAG" ]');
// Silent re-runs must not clobber saved answers with defaults, and a wizard
// that timed out to synthetic fallbacks (unattended /dev/tty) must not be
// persisted — ask_yes_no returns non-zero on timeout/EOF, each prompt ORs
// that into wizard_timed_out, and the prefs write is gated on it.
expect(sh).toContain('if [ "$wizard_timed_out" -eq 0 ] && { [ "$run_wizard" -eq 1 ] || [ -n "$EXTRAS_FLAG" ] || [ -n "$MODEL_INVOCABLE_FLAG" ] || [ -n "$GLIMPSE_FLAG" ]; }');
expect(sh).toContain("wizard_timed_out=0");
expect(sh).toContain("|| wizard_timed_out=1");
expect(sh).toMatch(/echo "no"\s+return 1/);
// The bounded read stays in a tested context (`|| rc=$?`) so `set -e` never
// aborts ask_yes_no on a timeout/EOF, regardless of how it's called.
expect(sh).toContain('< /dev/tty || rc=$?');
expect(ps).toContain("if ($runWizard -or $Extras -or $NoExtras -or $ModelInvocable -or $Glimpse -or $NoGlimpse)");
expect(cmdScript).toContain('if "!DO_PERSIST!"=="1"');
// Glimpse question: detect-on-PATH skip + global npm install in all three.