diff --git a/apps/skills/claude/plannotator-annotate/SKILL.md b/apps/skills/claude/plannotator-annotate/SKILL.md new file mode 100644 index 00000000..a91967ec --- /dev/null +++ b/apps/skills/claude/plannotator-annotate/SKILL.md @@ -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. diff --git a/apps/skills/claude/plannotator-archive/SKILL.md b/apps/skills/claude/plannotator-archive/SKILL.md new file mode 100644 index 00000000..d09ebe25 --- /dev/null +++ b/apps/skills/claude/plannotator-archive/SKILL.md @@ -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. diff --git a/apps/skills/claude/plannotator-last/SKILL.md b/apps/skills/claude/plannotator-last/SKILL.md new file mode 100644 index 00000000..ddd4170d --- /dev/null +++ b/apps/skills/claude/plannotator-last/SKILL.md @@ -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. diff --git a/apps/skills/claude/plannotator-review/SKILL.md b/apps/skills/claude/plannotator-review/SKILL.md new file mode 100644 index 00000000..d5865538 --- /dev/null +++ b/apps/skills/claude/plannotator-review/SKILL.md @@ -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. diff --git a/scripts/install.cmd b/scripts/install.cmd index 804962f8..0c655953 100644 --- a/scripts/install.cmd +++ b/scripts/install.cmd @@ -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 ) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 977b3e9b..f17a60e1 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -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. diff --git a/scripts/install.sh b/scripts/install.sh index 6ce1c257..4c3053d7 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -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) — diff --git a/scripts/install.test.ts b/scripts/install.test.ts index 9626d400..429a199b 100644 --- a/scripts/install.test.ts +++ b/scripts/install.test.ts @@ -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.