Skip to content

Commit 42140ec

Browse files
wenfengwangclaude
andcommitted
feat(install): add conflict detection with override/link/abort options
When a tool already exists in PATH: - override: download new binary, replace existing - link: wrap existing binary with anycli middleware (no download) - abort: cancel installation Supports -y flag for non-interactive mode (defaults to link). Supports -m flag to specify mode directly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f6252eb commit 42140ec

1 file changed

Lines changed: 83 additions & 3 deletions

File tree

cmd/install.go

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
package cmd
22

33
import (
4+
"bufio"
45
"fmt"
56
"os"
7+
"os/exec"
68
"path/filepath"
9+
"strings"
710

811
"github.com/shipbase/anycli/definitions"
912
"github.com/shipbase/anycli/internal/config"
@@ -19,8 +22,10 @@ var installCmd = &cobra.Command{
1922
Args: cobra.ExactArgs(1),
2023
RunE: func(cmd *cobra.Command, args []string) error {
2124
name := args[0]
25+
yes, _ := cmd.Flags().GetBool("yes")
26+
mode, _ := cmd.Flags().GetString("mode")
2227

23-
// Check if already installed
28+
// Check if already installed in anycli
2429
if _, err := registry.Load(name); err == nil {
2530
fmt.Printf("%s is already installed\n", name)
2631
return nil
@@ -43,13 +48,46 @@ var installCmd = &cobra.Command{
4348
def = d
4449
}
4550

46-
// Download the real binary if source is configured
51+
// Check if the tool already exists in PATH
52+
if mode == "" {
53+
existingPath, err := findExistingBinary(name)
54+
if err == nil && existingPath != "" {
55+
if yes {
56+
// Non-interactive: default to link
57+
mode = "link"
58+
fmt.Printf("found existing %s at %s, linking\n", name, existingPath)
59+
} else {
60+
m, err := promptConflict(name, existingPath)
61+
if err != nil {
62+
return err
63+
}
64+
mode = m
65+
}
66+
}
67+
}
68+
69+
if mode == "abort" {
70+
fmt.Println("installation aborted")
71+
return nil
72+
}
73+
74+
if mode == "link" {
75+
// Link mode: wrap existing binary, skip download
76+
existingPath, err := findExistingBinary(name)
77+
if err != nil {
78+
return fmt.Errorf("cannot find existing %s: %w", name, err)
79+
}
80+
def.Resolve = existingPath
81+
def.Source = nil // don't download
82+
fmt.Printf("linking to existing %s at %s\n", name, existingPath)
83+
}
84+
85+
// Download the real binary if source is configured (override mode or no conflict)
4786
if def.Source != nil {
4887
result, err := installer.Install(def)
4988
if err != nil {
5089
return fmt.Errorf("failed to install %s: %w", name, err)
5190
}
52-
// Set resolve to the installed binary path
5391
def.Resolve = result.BinaryPath
5492
fmt.Printf("downloaded %s v%s\n", name, result.Version)
5593
}
@@ -69,6 +107,46 @@ var installCmd = &cobra.Command{
69107
},
70108
}
71109

110+
// findExistingBinary searches PATH for an existing binary, skipping the anycli shim dir.
111+
func findExistingBinary(name string) (string, error) {
112+
shimDir := config.BinDir()
113+
pathEnv := os.Getenv("PATH")
114+
for _, dir := range filepath.SplitList(pathEnv) {
115+
if dir == shimDir {
116+
continue
117+
}
118+
candidate := filepath.Join(dir, name)
119+
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
120+
return candidate, nil
121+
}
122+
}
123+
return "", exec.ErrNotFound
124+
}
125+
126+
// promptConflict asks the user how to handle an existing binary.
127+
func promptConflict(name, existingPath string) (string, error) {
128+
fmt.Printf("found existing %s at %s\n", name, existingPath)
129+
fmt.Println(" [o]verride - download new binary, replace existing")
130+
fmt.Println(" [l]ink - wrap existing binary with anycli middleware")
131+
fmt.Println(" [a]bort - cancel installation")
132+
fmt.Print("choose [o/l/a]: ")
133+
134+
reader := bufio.NewReader(os.Stdin)
135+
input, _ := reader.ReadString('\n')
136+
input = strings.TrimSpace(strings.ToLower(input))
137+
138+
switch input {
139+
case "o", "override":
140+
return "override", nil
141+
case "l", "link":
142+
return "link", nil
143+
case "a", "abort", "":
144+
return "abort", nil
145+
default:
146+
return "abort", nil
147+
}
148+
}
149+
72150
func loadFromFile(path string) (*registry.Definition, error) {
73151
data, err := os.ReadFile(path)
74152
if err != nil {
@@ -108,5 +186,7 @@ func createShim(name string) error {
108186

109187
func init() {
110188
installCmd.Flags().String("from", "", "install from a local JSON definition file")
189+
installCmd.Flags().StringP("mode", "m", "", "conflict resolution: override, link, or abort")
190+
installCmd.Flags().BoolP("yes", "y", false, "non-interactive mode (defaults to link on conflict)")
111191
rootCmd.AddCommand(installCmd)
112192
}

0 commit comments

Comments
 (0)