diff --git a/scripts/install.sh b/scripts/install.sh index dfc430da..508f9afd 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -283,6 +283,20 @@ install_file() { } # resolve_dest — --path > $ENV_VAR > default. +# path_collision_group — tools in the same group write identical +# filenames into a shared --path and would overwrite each other; empty means +# the tool's output is distinct and may share a path with anything. Derived by +# installing one agent with every tool into a sandbox and comparing what +# landed; re-measure if a converter's output naming changes. +path_collision_group() { + case "$1" in + claude-code|copilot) printf 'raw-source-md' ;; # -.md + gemini-cli|opencode|qwen|zcode) printf 'slug-md' ;; # .md + antigravity|osaurus) printf 'agency-skill' ;; # agency-/SKILL.md + *) printf '' ;; + esac +} + resolve_dest() { local tool="$1" def="$2" var="" [[ -n "$OVERRIDE_PATH" ]] && { printf '%s' "$OVERRIDE_PATH"; return; } @@ -1258,11 +1272,22 @@ main() { $duplicate || _cleaned+=("$_t") done _tool_list=("${_cleaned[@]}") - # --path is a single-destination override; with several tools every one of - # them would land in the same directory and clobber each other. + # --path is one shared directory. Tools that write the same filenames into + # it silently overwrite each other; tools with distinct outputs coexist. + # Refuse only the colliding combinations (see path_collision_group). if [[ -n "$OVERRIDE_PATH" && ${#_tool_list[@]} -gt 1 ]]; then - err "--path sets ONE destination; use it with exactly one --tool (got ${#_tool_list[@]}: ${_tool_list[*]})." - exit 1 + local _ta _tb _ga _gb + for _ta in "${_tool_list[@]}"; do + _ga="$(path_collision_group "$_ta")"; [[ -z "$_ga" ]] && continue + for _tb in "${_tool_list[@]}"; do + [[ "$_tb" == "$_ta" ]] && continue + _gb="$(path_collision_group "$_tb")" + if [[ "$_ga" == "$_gb" ]]; then + err "--path is one shared directory, and $_ta and $_tb write the same filenames into it — they would overwrite each other. Use one of them per --path (tools with distinct outputs may share one)." + exit 1 + fi + done + done fi fi diff --git a/scripts/test-install.sh b/scripts/test-install.sh index 2be8448e..01a14611 100755 --- a/scripts/test-install.sh +++ b/scripts/test-install.sh @@ -224,19 +224,38 @@ assert_eq 0 "$(find "$home" -maxdepth 1 -name 'My' -o -maxdepth 1 -name 'Agents' echo "" echo "parallel workers" +# Two tools that write the same filenames into one shared --path would silently +# overwrite each other (claude-code and copilot both copy the raw source as +# -.md). The installer must refuse, not clobber. Both tools work +# under --no-convert, which keeps this case cheap in CI. +home="$(sandbox path-collision)" +dest="$home/My [Agents]/dest dir" +run_install "$home" --tool claude-code,copilot --no-convert --agent "$FIRST_ENG_SLUG" --path "$dest" +assert_eq 1 "$RUN_STATUS" "two tools that write the same filenames into one --path are refused" +assert_eq 1 "$(printf '%s' "$RUN_OUT" | grep -c 'overwrite')" "the refusal explains the collision" +assert_eq 0 "$(count_md "$dest")" "a refused install writes nothing" + +# The propagation cases below therefore use a NON-colliding pair: claude-code +# writes -.md and codex writes .toml, so BOTH outputs must +# survive in the shared --path — which is a stronger check than one tool's count +# alone (a count of 1 cannot tell "two wrote, one clobbered" from "one wrote"). +# codex has no committed output (integrations/ is generated and gitignored), so +# these cases let the installer convert. home="$(sandbox parallel-serial-control)" dest="$home/My [Agents]/dest dir" list="$home/my agents list.txt" { echo "# same selection as the parallel case below"; echo "$FIRST_ENG_SLUG"; } > "$list" -run_install "$home" --tool claude-code,copilot --no-convert --agents-file "$list" --path "$dest" -assert_eq 0 "$RUN_STATUS" "serial control: two tools, spaced/globbed --path, exits 0" -assert_eq 1 "$(count_md "$dest")" "serial control: installs exactly the one selected agent" +run_install "$home" --tool claude-code,codex --agents-file "$list" --path "$dest" +assert_eq 0 "$RUN_STATUS" "serial control: two non-colliding tools, spaced/globbed --path, exits 0" +assert_eq 1 "$(count_md "$dest")" "serial control: claude-code installs exactly the one selected agent" +assert_eq 1 "$(find "$dest" -maxdepth 1 -name '*.toml' -type f 2>/dev/null | wc -l | tr -d ' ')" \ + "serial control: codex's output survives alongside claude-code's" home="$(sandbox parallel)" dest="$home/My [Agents]/dest dir" list="$home/my agents list.txt" { echo "# one agent, listed in a file whose own path has spaces"; echo "$FIRST_ENG_SLUG"; } > "$list" -run_install "$home" --tool claude-code,copilot --parallel --jobs 1 --no-convert --agents-file "$list" --path "$dest" +run_install "$home" --tool claude-code,codex --parallel --jobs 1 --agents-file "$list" --path "$dest" assert_eq 0 "$RUN_STATUS" "--parallel with a spaced/globbed --path exits 0" xfail_eq 1 "$(count_md "$dest")" \ "--parallel installs exactly the one selected agent (spaced --path + --agents-file)" "PR #755"