@@ -16,6 +16,13 @@ CLI="${PYTHON:-python3}"
1616# Ignore pins: IGNORE_PINS=1 to show all tools regardless of pin status
1717IGNORE_PINS=" ${IGNORE_PINS:- 0} "
1818
19+ # Duplicate-install count for the tool most recently passed to
20+ # check_multi_installs(); consumed by process_tool to offer inline cleanup.
21+ LAST_MULTI_COUNT=0
22+ # Space-separated tool names seen with duplicate installs this run (for the
23+ # summary hint); de-duplicated when counted.
24+ GUIDE_DUP_LIST=" "
25+
1926# Summary counters
2027SUMMARY_UPDATED=0
2128SUMMARY_SKIPPED=0
@@ -33,6 +40,14 @@ print_summary() {
3340 printf " Failed: %d\n" " $SUMMARY_FAILED "
3441 echo
3542 echo " Re-run: make audit"
43+ local dup_count=0
44+ if [ -n " ${GUIDE_DUP_LIST// / } " ]; then
45+ dup_count=" $( printf ' %s\n' $GUIDE_DUP_LIST | sort -u | grep -c . || true) "
46+ fi
47+ if [ " $dup_count " -gt 0 ]; then
48+ printf " → %d tool(s) with duplicate installs: 'make reconcile-all'\n" " $dup_count "
49+ fi
50+ echo " → upgrade the package managers themselves: 'make upgrade-managed'"
3651}
3752
3853# Category filter: CATEGORY=python,go or --category=python
@@ -221,11 +236,50 @@ check_multi_installs() {
221236 local binary_name
222237 binary_name=" $( catalog_get_property " $catalog_tool " binary_name) "
223238 binary_name=" ${binary_name:- $catalog_tool } "
239+ # Cheap shell pre-check: only pay for the richer reconcile query when there
240+ # actually is more than one install (the common case is a single install).
224241 local all_installs
225242 all_installs=" $( detect_all_installations " $catalog_tool " " $binary_name " 2> /dev/null || true) "
226243 local install_count
227244 install_count=" $( echo " $all_installs " | grep -c . || true) "
228- if [ " $install_count " -gt 1 ]; then
245+ LAST_MULTI_COUNT=" $install_count "
246+ [ " $install_count " -le 1 ] && return 0
247+ GUIDE_DUP_LIST=" $GUIDE_DUP_LIST $catalog_tool "
248+
249+ # Duplicates exist — render version + active + preferred markers from the
250+ # reconcile plan (the single source of truth). Fall back to the basic listing
251+ # if reconcile produces nothing.
252+ local recon_json rendered
253+ recon_json=" $( cd " $ROOT " && CLI_AUDIT_JSON=1 " $CLI " audit.py --reconcile " $catalog_tool " 2> /dev/null || true) "
254+ rendered=" $( RECON_JSON=" $recon_json " " $CLI " - << 'PY '
255+ import os, json
256+ try:
257+ doc = json.loads(os.environ.get("RECON_JSON", "").strip())
258+ plan = (doc.get("results") or [None])[0]
259+ installs = plan.get("installations") or []
260+ except Exception:
261+ raise SystemExit(0)
262+ if len(installs) < 2:
263+ raise SystemExit(0)
264+ active_path = (plan.get("active") or {}).get("path")
265+ pref_path = (plan.get("preferred") or {}).get("path")
266+ lines = [f" ⚠️ Multiple installations detected ({len(installs)}):"]
267+ for i in installs:
268+ marks = []
269+ if i.get("active"):
270+ marks.append("→ active")
271+ if i.get("preferred"):
272+ marks.append("✓ keep")
273+ suffix = (" " + " ".join(marks)) if marks else ""
274+ lines.append(f" • {i.get('version') or '?'} {i.get('method')} {i.get('path')}{suffix}")
275+ if active_path and pref_path and active_path != pref_path:
276+ lines.append(f" note: cleanup would change which {plan.get('tool')} runs (active → preferred).")
277+ print("\n".join(lines))
278+ PY
279+ ) "
280+ if [ -n " $rendered " ]; then
281+ printf ' %s\n' " $rendered "
282+ else
229283 printf " ⚠️ Multiple installations detected (%d):\n" " $install_count "
230284 echo " $all_installs " | while IFS=: read -r inst_method inst_path; do
231285 printf " • %s: %s\n" " $inst_method " " $inst_path "
@@ -446,6 +500,9 @@ process_tool() {
446500 printf " p = Pin to %s (don't ask for upgrades)\n" " $installed "
447501 fi
448502 printf " r = Remove/uninstall this tool\n"
503+ if [ " ${LAST_MULTI_COUNT:- 0} " -gt 1 ]; then
504+ printf " c = Clean up duplicates (keep preferred, remove the rest)\n"
505+ fi
449506 if [ -n " $is_multi_version " ]; then
450507 printf " P = Skip ALL outdated %s cycles\n" " $catalog_tool "
451508 fi
@@ -470,6 +527,9 @@ process_tool() {
470527 else
471528 prompt_text=" Upgrade? [Y/a/n/s/p/r] "
472529 fi
530+ if [ " ${LAST_MULTI_COUNT:- 0} " -gt 1 ]; then
531+ prompt_text=" ${prompt_text% ] } /c] "
532+ fi
473533 else
474534 if [ -n " $is_multi_version " ]; then
475535 prompt_text=" Install? [y/a/N/s/p/P] "
@@ -706,6 +766,32 @@ process_tool() {
706766 printf " Tool is not installed, nothing to remove\n"
707767 fi
708768 ;;
769+ [Cc])
770+ # Clean up duplicate installations: keep the preferred, remove the rest.
771+ # The plan (keep/remove, active marker) was already shown by
772+ # check_multi_installs above. Confirm the destructive step, then apply.
773+ if [ " ${LAST_MULTI_COUNT:- 0} " -gt 1 ]; then
774+ local ans_c=" "
775+ if [ -t 0 ]; then
776+ read -r -p " Remove the non-preferred duplicate(s)? [y/N] " ans_c || true
777+ elif [ -r /dev/tty ]; then
778+ read -r -p " Remove the non-preferred duplicate(s)? [y/N] " ans_c < /dev/tty || true
779+ fi
780+ if [ " $ans_c " = " y" ] || [ " $ans_c " = " Y" ]; then
781+ if (cd " $ROOT " && " $CLI " audit.py --reconcile " $catalog_tool " --apply --yes); then
782+ printf " ✓ Duplicates removed (kept preferred install)\n"
783+ else
784+ printf " ⚠️ Cleanup did not complete (tool may be on the protect list)\n"
785+ fi
786+ CLI_AUDIT_JSON=1 CLI_AUDIT_COLLECT=1 CLI_AUDIT_MERGE=1 " $CLI " audit.py " $tool " > /dev/null 2>&1 || true
787+ reload_audit_json
788+ else
789+ printf " Skipped cleanup\n"
790+ fi
791+ else
792+ printf " No duplicate installations to clean up\n"
793+ fi
794+ ;;
709795 [P])
710796 # Skip ALL versions of this runtime (only for multi-version tools)
711797 if [ -n " $is_multi_version " ]; then
0 commit comments