fix(install): let Skip in the AI CLI menu continue instead of aborting the install

Choosing "s" (Skip) in the new catalogue-driven install menu warned, printed the
install hints and then fell into the shared "The selected AI CLI failed to install"
gate one line below, because CLI_FOUND_COUNT is 0 by construction inside that block
and skipping does not change it. The AI CLI check runs before the clone and the
build, so a user who picked the documented skip option ended up with nothing
installed. The code this replaced guarded the gate with an elif on the skip choice.

The menu moves out of main() into offer_ai_cli_install() and the gate moves inside
the install branch: skipping continues to the clone, a chosen install that leaves
nothing behind is still fatal. Being a function, the interactive path can now be
driven with a stubbed read_reply, which is what nothing reached before: two
behavioural tests in test/install-sh-invariants.test.ts run the real function in a
real bash (skip continues with exit 0, a failed install dies with exit 1), and the
bash 3.2 CI step drives the skip path in the container as well.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-09-14 15:55:20 +02:00
parent e6e5a62d9b
commit 3566e8b5ff
3 changed files with 192 additions and 104 deletions
+12
View File
@@ -61,6 +61,18 @@ jobs:
echo "bash $BASH_VERSION: ${#CLI_IDS[@]} CLIs, $CLI_FOUND_COUNT found"
cli_catalog_names >/dev/null
cli_catalog_print_install_hints >/dev/null
# The install menu with nothing installed and the user answering "s":
# skipping must warn and continue, never trip the "failed to install"
# gate (it did once, aborting the install before the clone).
has_tty() { return 0; }
headless_guard() { return 0; }
read_reply() { eval "$1=s"; }
NONINTERACTIVE=0
k=0; while [[ $k -lt ${#CLI_ALL_BINS[@]} ]]; do CLI_ALL_BINS[$k]="no-such-cli-$k"; k=$((k + 1)); done
k=0; while [[ $k -lt ${#CLI_ALL_PATHS[@]} ]]; do CLI_ALL_PATHS[$k]="/nonexistent/$k"; k=$((k + 1)); done
CLI_DETECT_DONE=""; detect_all_clis
offer_ai_cli_install >/dev/null 2>&1
echo "bash $BASH_VERSION: skipping the AI CLI install menu continues"
'
- name: CLI catalogue artifacts are in sync with stock.ts
+116 -103
View File
@@ -609,6 +609,121 @@ cli_catalog_print_install_hints() {
# the first time a hint is printed.
cli_catalog_select_platform
# Offer to install one AI CLI from the catalogue, or let the user skip.
#
# Split out of main() so the bash 3.2 CI step and test/install-sh-invariants.test.ts
# can drive the menu with a stubbed read_reply: the interactive path is the one
# part of this script no static check reaches, and it is where choosing "s" (Skip)
# once fell into the "failed to install" gate and aborted the whole installer.
# That gate therefore lives INSIDE the install branch: skipping is a documented
# choice that continues to the clone and build (sessions just need a CLI later),
# while a chosen install that leaves nothing behind is still fatal.
offer_ai_cli_install() {
local i
echo ""
warn "No AI CLI found. Codeman needs at least one: $(cli_catalog_names)."
headless_guard "install an AI CLI (curl | bash from its vendor)"
echo ""
# The menu is built from the catalogue: every enabled CLI that is not
# installed and ships an install command we can run. It used to be a
# fixed four-option prompt offering Claude Code and OpenCode only, so the
# other seven were unreachable even though the registry knows how to
# install five of them.
#
# ⚠️ TRUST BOUNDARY: the command executed comes from CLI_INSTALL_CMD_TRUSTED,
# the only array the generated block above writes and the only one the
# installer ever runs or displays — see cli_catalog_select_platform.
#
# ⚠️ The registry's install commands are a MIX: some call `curl` directly
# (vendor one-liners), others are `npm install -g …`, which never needed
# curl at all. A wget-only host used to lose the WHOLE menu over this,
# including every npm entry — the two literals this replaced went through
# download_to_stdout and so honoured `wget`, and CODEMAN_NONINTERACTIVE=1
# silently stopped defaulting to Claude Code as documented. Filter per
# entry instead: only a command that actually starts with `curl ` is
# curl-dependent, so only THOSE are held back on a wget-only host.
# Rewriting curl to wget inside a string about to be executed is the
# wrong instinct either way — the ones we can't run, we show as a hint.
local -a offer_idx=()
local curl_only_skipped=0
for ((i = 0; i < ${#CLI_IDS[@]}; i++)); do
[[ "${CLI_ENABLED[$i]}" == "1" ]] || continue
[[ "${CLI_BIN_LEN[$i]}" -gt 0 ]] || continue
[[ -z "${CLI_FOUND_PATH[$i]}" ]] || continue
[[ -n "${CLI_INSTALL_CMD_TRUSTED[$i]}" ]] || continue
if [[ "${DOWNLOADER:-}" != "curl" ]] && [[ "${CLI_INSTALL_CMD_TRUSTED[$i]}" == curl\ * ]]; then
curl_only_skipped=$((curl_only_skipped + 1))
continue
fi
offer_idx[${#offer_idx[@]}]=$i
done
if [[ "$curl_only_skipped" -gt 0 ]]; then
warn "curl is not available, so $curl_only_skipped install command(s) that need it were left out of the menu below (still shown as hints if you skip)."
fi
if [[ ${#offer_idx[@]} -eq 0 ]]; then
warn "No AI CLI can be installed automatically here. Codeman will run, but sessions need a CLI to drive."
cli_catalog_print_install_hints
else
echo -e " ${BOLD}Which AI CLI would you like to install?${NC}"
local n=0 idx
for idx in "${offer_idx[@]}"; do
n=$((n + 1))
echo -e " ${CYAN}${n})${NC} ${CLI_LABELS[$idx]}"
done
echo -e " ${CYAN}s)${NC} Skip (I'll install one myself)"
echo ""
local cli_choice=""
if [[ "$NONINTERACTIVE" == "1" ]] || ! has_tty; then
# Explicit automation opt-in: default to the first offered entry,
# which is registry order, which is Claude Code (order 0) — the
# same default this prompt has always taken non-interactively.
cli_choice="1"
info "CODEMAN_NONINTERACTIVE=1: defaulting to ${CLI_LABELS[${offer_idx[0]}]}"
else
while true; do
echo -en "${CYAN}Choose [1-${n}, or s to skip]:${NC} " >&2
read_reply cli_choice || { cli_choice="1"; break; }
case "$cli_choice" in
s|S) break ;;
''|*[!0-9]*) echo "Please enter a number between 1 and ${n}, or s." >&2 ;;
*)
if [[ "$cli_choice" -ge 1 ]] && [[ "$cli_choice" -le "$n" ]]; then
break
fi
echo "Please enter a number between 1 and ${n}, or s." >&2
;;
esac
done
fi
if [[ "$cli_choice" == "s" ]] || [[ "$cli_choice" == "S" ]]; then
warn "Skipping AI CLI install. Codeman will run, but sessions need a CLI to drive."
cli_catalog_print_install_hints
else
idx="${offer_idx[$((cli_choice - 1))]}"
info "Installing ${CLI_LABELS[$idx]}..."
# </dev/null: under `curl | bash` a child that reads stdin would
# consume the rest of this script.
bash -c "${CLI_INSTALL_CMD_TRUSTED[$idx]}" </dev/null || true
hash -r 2>/dev/null || true
CLI_DETECT_DONE=""
detect_all_clis
if [[ -n "${CLI_FOUND_PATH[$idx]}" ]]; then
success "${CLI_LABELS[$idx]} installed at ${CLI_FOUND_PATH[$idx]}"
else
warn "${CLI_LABELS[$idx]} installation failed."
fi
if [[ "$CLI_FOUND_COUNT" -eq 0 ]]; then
die "The selected AI CLI failed to install. Install one manually and re-run the installer."
fi
fi
fi
}
check_cloudflared() {
# Check ~/.local/bin first (matches tunnel-manager.ts resolution order)
@@ -2260,109 +2375,7 @@ main() {
done
if [[ "$CLI_FOUND_COUNT" -eq 0 ]]; then
echo ""
warn "No AI CLI found. Codeman needs at least one: $(cli_catalog_names)."
headless_guard "install an AI CLI (curl | bash from its vendor)"
echo ""
# The menu is built from the catalogue: every enabled CLI that is not
# installed and ships an install command we can run. It used to be a
# fixed four-option prompt offering Claude Code and OpenCode only, so the
# other seven were unreachable even though the registry knows how to
# install five of them.
#
# ⚠️ TRUST BOUNDARY: the command executed comes from CLI_INSTALL_CMD_TRUSTED,
# the only array the generated block above writes and the only one the
# installer ever runs or displays — see cli_catalog_select_platform.
#
# ⚠️ The registry's install commands are a MIX: some call `curl` directly
# (vendor one-liners), others are `npm install -g …`, which never needed
# curl at all. A wget-only host used to lose the WHOLE menu over this,
# including every npm entry — the two literals this replaced went through
# download_to_stdout and so honoured `wget`, and CODEMAN_NONINTERACTIVE=1
# silently stopped defaulting to Claude Code as documented. Filter per
# entry instead: only a command that actually starts with `curl ` is
# curl-dependent, so only THOSE are held back on a wget-only host.
# Rewriting curl to wget inside a string about to be executed is the
# wrong instinct either way — the ones we can't run, we show as a hint.
local -a offer_idx=()
local curl_only_skipped=0
for ((i = 0; i < ${#CLI_IDS[@]}; i++)); do
[[ "${CLI_ENABLED[$i]}" == "1" ]] || continue
[[ "${CLI_BIN_LEN[$i]}" -gt 0 ]] || continue
[[ -z "${CLI_FOUND_PATH[$i]}" ]] || continue
[[ -n "${CLI_INSTALL_CMD_TRUSTED[$i]}" ]] || continue
if [[ "${DOWNLOADER:-}" != "curl" ]] && [[ "${CLI_INSTALL_CMD_TRUSTED[$i]}" == curl\ * ]]; then
curl_only_skipped=$((curl_only_skipped + 1))
continue
fi
offer_idx[${#offer_idx[@]}]=$i
done
if [[ "$curl_only_skipped" -gt 0 ]]; then
warn "curl is not available, so $curl_only_skipped install command(s) that need it were left out of the menu below (still shown as hints if you skip)."
fi
if [[ ${#offer_idx[@]} -eq 0 ]]; then
warn "No AI CLI can be installed automatically here. Codeman will run, but sessions need a CLI to drive."
cli_catalog_print_install_hints
else
echo -e " ${BOLD}Which AI CLI would you like to install?${NC}"
local n=0 idx
for idx in "${offer_idx[@]}"; do
n=$((n + 1))
echo -e " ${CYAN}${n})${NC} ${CLI_LABELS[$idx]}"
done
echo -e " ${CYAN}s)${NC} Skip (I'll install one myself)"
echo ""
local cli_choice=""
if [[ "$NONINTERACTIVE" == "1" ]] || ! has_tty; then
# Explicit automation opt-in: default to the first offered entry,
# which is registry order, which is Claude Code (order 0) — the
# same default this prompt has always taken non-interactively.
cli_choice="1"
info "CODEMAN_NONINTERACTIVE=1: defaulting to ${CLI_LABELS[${offer_idx[0]}]}"
else
while true; do
echo -en "${CYAN}Choose [1-${n}, or s to skip]:${NC} " >&2
read_reply cli_choice || { cli_choice="1"; break; }
case "$cli_choice" in
s|S) break ;;
''|*[!0-9]*) echo "Please enter a number between 1 and ${n}, or s." >&2 ;;
*)
if [[ "$cli_choice" -ge 1 ]] && [[ "$cli_choice" -le "$n" ]]; then
break
fi
echo "Please enter a number between 1 and ${n}, or s." >&2
;;
esac
done
fi
if [[ "$cli_choice" == "s" ]] || [[ "$cli_choice" == "S" ]]; then
warn "Skipping AI CLI install. Codeman will run, but sessions need a CLI to drive."
cli_catalog_print_install_hints
else
idx="${offer_idx[$((cli_choice - 1))]}"
info "Installing ${CLI_LABELS[$idx]}..."
# </dev/null: under `curl | bash` a child that reads stdin would
# consume the rest of this script.
bash -c "${CLI_INSTALL_CMD_TRUSTED[$idx]}" </dev/null || true
hash -r 2>/dev/null || true
CLI_DETECT_DONE=""
detect_all_clis
if [[ -n "${CLI_FOUND_PATH[$idx]}" ]]; then
success "${CLI_LABELS[$idx]} installed at ${CLI_FOUND_PATH[$idx]}"
else
warn "${CLI_LABELS[$idx]} installation failed."
fi
fi
if [[ "$CLI_FOUND_COUNT" -eq 0 ]]; then
die "The selected AI CLI failed to install. Install one manually and re-run the installer."
fi
fi
offer_ai_cli_install
fi
+64 -1
View File
@@ -11,10 +11,12 @@
*/
import { describe, expect, it } from 'vitest';
import { spawnSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const SOURCE = readFileSync(fileURLToPath(new URL('../install.sh', import.meta.url)), 'utf-8');
const INSTALL_SH = fileURLToPath(new URL('../install.sh', import.meta.url));
const SOURCE = readFileSync(INSTALL_SH, 'utf-8');
/** Lines with the leading `#` comments removed, so prose quoting a banned form is not a hit. */
const CODE_LINES = SOURCE.split('\n').filter((line) => !/^\s*#/.test(line));
@@ -164,3 +166,64 @@ describe('install.sh runtime safety', () => {
expect(SOURCE).toMatch(/^set -euo pipefail$/m);
});
});
describe('install.sh AI CLI install menu', () => {
// The menu is the one interactive path in the script, which is why it used to be the
// only part nothing exercised: choosing "s" (Skip) once fell straight into the shared
// "failed to install" gate and aborted the installer before the clone. These drive the
// real function (offer_ai_cli_install) in a real bash, with detection pointed at
// nothing so the menu appears, and read_reply scripted.
const DRIVER = `
set -euo pipefail
export CODEMAN_INSTALL_SH_LIB=1
. "$1"
k=0; while [[ $k -lt \${#CLI_ALL_BINS[@]} ]]; do CLI_ALL_BINS[$k]="codeman-test-no-such-bin-$k"; k=$((k + 1)); done
k=0; while [[ $k -lt \${#CLI_ALL_PATHS[@]} ]]; do CLI_ALL_PATHS[$k]="/nonexistent/codeman-test/$k"; k=$((k + 1)); done
if [[ -n "\${MENU_INSTALL_CMD:-}" ]]; then
k=0; while [[ $k -lt \${#CLI_INSTALL_CMD_TRUSTED[@]} ]]; do CLI_INSTALL_CMD_TRUSTED[$k]="$MENU_INSTALL_CMD"; k=$((k + 1)); done
fi
CLI_DETECT_DONE=""
detect_all_clis
echo "found=$CLI_FOUND_COUNT"
NONINTERACTIVE=0
DOWNLOADER=curl
has_tty() { return 0; }
headless_guard() { return 0; }
read_reply() { eval "$1=\\"$MENU_ANSWER\\""; }
offer_ai_cli_install
echo "REACHED THE STEP AFTER THE MENU"
`;
function driveMenu(answer: string, installCommand?: string) {
const result = spawnSync('bash', ['-c', DRIVER, 'bash', INSTALL_SH], {
encoding: 'utf-8',
timeout: 30_000,
env: { ...process.env, MENU_ANSWER: answer, ...(installCommand ? { MENU_INSTALL_CMD: installCommand } : {}) },
});
// eslint-disable-next-line no-control-regex
const strip = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, '');
return { status: result.status, stdout: strip(result.stdout ?? ''), stderr: strip(result.stderr ?? '') };
}
it('offers the menu only when nothing is installed', () => {
const run = driveMenu('s');
expect(run.stdout).toContain('found=0');
expect(run.stderr).toContain('Choose [1-');
});
it('continues past the menu when the user skips', () => {
const run = driveMenu('s');
expect(run.stderr).toContain('Skipping AI CLI install');
expect(run.stdout, run.stderr).toContain('REACHED THE STEP AFTER THE MENU');
expect(run.stderr).not.toContain('failed to install');
expect(run.status).toBe(0);
});
it('still dies when the chosen install leaves nothing behind', () => {
const run = driveMenu('1', 'false');
expect(run.stderr).toContain('installation failed');
expect(run.stderr).toContain('The selected AI CLI failed to install');
expect(run.stdout).not.toContain('REACHED THE STEP AFTER THE MENU');
expect(run.status).toBe(1);
});
});