Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions internal/clients/claudecode/claudecode.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,17 @@ func (c *Client) Install(_ *config.Global) clients.InstallPlan {
}
}

// Upgrade implements UpgradePlan for Claude Code
func (c *Client) Upgrade() clients.UpgradePlan {
return clients.PlanUpgrade(clients.UpgradeSpec{
BinaryName: binaryName,
ExtraPaths: commonBinaryPaths(),
NPMPackage: "@anthropic-ai/claude-code",
BrewName: "claude-code",
SelfUpdateArgs: []string{"update"},
})
}

// Uninstall implements clients.Client.
func (c *Client) Uninstall() clients.UninstallPlan {
return clients.UninstallPlan{
Expand Down
11 changes: 11 additions & 0 deletions internal/clients/codex/codex.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,17 @@ func (c *Client) Install(_ *config.Global) clients.InstallPlan {
}
}

// Upgrade creates an UpgradePlan for Codex
func (c *Client) Upgrade() clients.UpgradePlan {
return clients.PlanUpgrade(clients.UpgradeSpec{
BinaryName: binaryName,
ExtraPaths: commonBinaryPaths(),
NPMPackage: "@openai/codex",
BrewName: "codex",
NativeUpgradeCommand: "curl -fsSL https://chatgpt.com/codex/install.sh | sh", // no self-update command
})
}

// Uninstall implements clients.Client.
func (c *Client) Uninstall() clients.UninstallPlan {
return clients.UninstallPlan{
Expand Down
10 changes: 10 additions & 0 deletions internal/clients/codex/codex_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,16 @@ func TestInstallUninstall(t *testing.T) {
if uninstall.Hint != "npm uninstall -g @openai/codex" {
t.Errorf("Uninstall.Hint = %q", uninstall.Hint)
}

// The upgrade command depends on how (and whether) codex is installed
// on the host, only assert plan is populated
upgrade := c.Upgrade()
if upgrade.Hint == "" {
t.Error("Upgrade.Hint is empty")
}
if upgrade.Run == nil {
t.Error("Upgrade.Run is nil")
}
}

func TestReplay_StaleProvider(t *testing.T) {
Expand Down
11 changes: 11 additions & 0 deletions internal/clients/copilot/copilot.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,17 @@ func (c *Client) Install(_ *config.Global) clients.InstallPlan {
}
}

// Upgrade creates an UpgradePlan for GitHub Copilot
func (c *Client) Upgrade() clients.UpgradePlan {
return clients.PlanUpgrade(clients.UpgradeSpec{
BinaryName: binaryName,
ExtraPaths: commonBinaryPaths(),
NPMPackage: "@github/copilot",
BrewName: "copilot-cli",
SelfUpdateArgs: []string{"update"},
})
}

// Uninstall implements clients.Client.
func (c *Client) Uninstall() clients.UninstallPlan {
return clients.UninstallPlan{
Expand Down
10 changes: 10 additions & 0 deletions internal/clients/gemini/gemini.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,16 @@ func (c *Client) Install(_ *config.Global) clients.InstallPlan {
}
}

// Upgrade creates an UpgradePlan for Gemini
func (c *Client) Upgrade() clients.UpgradePlan {
return clients.PlanUpgrade(clients.UpgradeSpec{
BinaryName: binaryName,
ExtraPaths: commonBinaryPaths(),
NPMPackage: "@google/gemini-cli",
BrewName: "gemini-cli",
})
}

// Uninstall implements clients.Client.
func (c *Client) Uninstall() clients.UninstallPlan {
return clients.UninstallPlan{
Expand Down
11 changes: 11 additions & 0 deletions internal/clients/opencode/opencode.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,17 @@ func (c *Client) Install(_ *config.Global) clients.InstallPlan {
}
}

// Upgrade creates an UpgradePlan for OpenCode
func (c *Client) Upgrade() clients.UpgradePlan {
return clients.PlanUpgrade(clients.UpgradeSpec{
BinaryName: binaryName,
ExtraPaths: commonBinaryPaths(),
NPMPackage: "opencode-ai",
BrewName: "opencode",
SelfUpdateArgs: []string{"upgrade"},
})
}

// Uninstall implements clients.Client.
func (c *Client) Uninstall() clients.UninstallPlan {
return clients.UninstallPlan{
Expand Down
17 changes: 15 additions & 2 deletions internal/clients/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ import (
// Client is one AI coding agent that the launcher can install and launch.
// Each client lives in its own sub-package and is wholly responsible for
// its own provider/backend/model flow, env generation, config writing,
// install and uninstall — all of which are expressed through the MenuItem
// returned from Menu() plus the InstallPlan / UninstallPlan.
// install, upgrade and uninstall — all of which are expressed through the
// MenuItem returned from Menu() plus the InstallPlan / UpgradePlan / UninstallPlan.
type Client interface {
// Name is the user-visible display name (e.g. "Claude Code").
Name() string
Expand All @@ -34,6 +34,9 @@ type Client interface {
// host-dependent setup (e.g. writing platform config before download).
Install(g *config.Global) InstallPlan

// Upgrade describes how to upgrade the client to its latest version.
Upgrade() UpgradePlan

// Uninstall describes how to uninstall the client.
Uninstall() UninstallPlan

Expand Down Expand Up @@ -62,6 +65,16 @@ type InstallPlan struct {
Run func() (*exec.Cmd, error)
}

// UpgradePlan describes how to upgrade an installed client.
type UpgradePlan struct {
// Hint is shown to the user before confirming; e.g.
// "npm install -g @openai/codex@latest".
Hint string
// Run returns the command to execute on confirmation. If nil, the
// upgrade is manual-only: the TUI shows Hint and does nothing.
Run func() (*exec.Cmd, error)
}

// UninstallPlan describes how to uninstall a client.
type UninstallPlan struct {
// Hint is shown to the user before confirming.
Expand Down
154 changes: 154 additions & 0 deletions internal/clients/upgrade.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
package clients

import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)

// InstallMethod identifies which package manager owns a client binary
type InstallMethod int

const (
MethodNative InstallMethod = iota // client's own installer
MethodNPM //global npm package
MethodHomebrew //Homebrew formula or cask.
)

// UpgradeSpec describes the upgrade paths a client supports
type UpgradeSpec struct {
// locate the installed binary
BinaryName string
ExtraPaths []string
NPMPackage string // global npm package name
BrewName string // Homebrew formula/cask name
SelfUpdateArgs []string // invoke the binary's built-in updater
NativeUpgradeCommand string // re-runs the client's official standalone installer
}

// PlanUpgrade builds an UpgradePlan client whose command matches how the binary was installed
func PlanUpgrade(s UpgradeSpec) UpgradePlan {
bin := FindBinary(s.BinaryName, s.ExtraPaths)
if bin == "" {
return UpgradePlan{
Hint: s.BinaryName + " is not installed",
Run: func() (*exec.Cmd, error) {
return nil, fmt.Errorf("%s binary not found", s.BinaryName)
},
}
}
method := DetectInstallMethod(bin, s.NPMPackage)
if method == MethodHomebrew {
// prefer the installed package name
if name := brewPackageName(resolvePath(bin)); name != "" {
s.BrewName = name
}
}
hint := upgradeCommand(s, s.BinaryName, method)
cmd := upgradeCommand(s, bin, method)
if cmd == "" {
return UpgradePlan{Hint: "No known upgrade path for " + bin}
}
cmd += " && " + shellQuote(bin) + " --version"
return UpgradePlan{
Hint: hint,
Run: func() (*exec.Cmd, error) {
return exec.Command("/bin/sh", "-c", cmd), nil
},
}
}

// upgradeCommand returns the upgrade command for the detected install method
func upgradeCommand(s UpgradeSpec, bin string, m InstallMethod) string {
switch m {
case MethodNPM:
if s.NPMPackage != "" {
return "npm install -g " + s.NPMPackage + "@latest"
}
case MethodHomebrew:
if s.BrewName != "" {
return "brew upgrade " + s.BrewName
}
}
if len(s.SelfUpdateArgs) > 0 {
return shellQuote(bin) + " " + strings.Join(s.SelfUpdateArgs, " ")
}
if s.NativeUpgradeCommand != "" {
return s.NativeUpgradeCommand
}
if s.NPMPackage != "" {
return "npm install -g " + s.NPMPackage + "@latest"
}
return ""
}

// DetectInstallMethod inspects a binary's on-disk location to determine which package manager owns it
func DetectInstallMethod(bin, npmPkg string) InstallMethod {
resolved := resolvePath(bin)
// check npm first; with a Homebrew-installed node, npm's global
// prefix also lives under the Homebrew prefix.
if npmOwns(bin, resolved, npmPkg) {
return MethodNPM
}
if brewOwns(resolved) {
return MethodHomebrew
}
return MethodNative
}

func resolvePath(bin string) string {
if r, err := filepath.EvalSymlinks(bin); err == nil {
return r
}
return bin
}

// brewPackageName extracts the formula/cask name from a resolved Cellar or Caskroom path
func brewPackageName(resolved string) string {
segs := strings.Split(filepath.ToSlash(resolved), "/")
for i, seg := range segs {
if (seg == "Cellar" || seg == "Caskroom") && i+1 < len(segs) {
return segs[i+1]
}
}
return ""
}

func npmOwns(bin, resolved, pkg string) bool {
if strings.Contains(resolved, "node_modules") {
return true
}
if pkg == "" {
return false
}
dir := filepath.Dir(bin)
for _, cand := range []string{
filepath.Join(dir, "node_modules", pkg), // Windows npm prefix
filepath.Join(dir, "..", "lib", "node_modules", pkg), // Unix npm prefix
} {
if _, err := os.Stat(cand); err == nil {
return true
}
}
return false
}

func brewOwns(resolved string) bool {
for _, marker := range []string{"/Cellar/", "/Caskroom/", "/homebrew/", "/linuxbrew/"} {
if strings.Contains(resolved, marker) {
return true
}
}
return false
}

// shellQuote single-quotes s for /bin/sh when it contains characters that
// would be interpreted by the shell.
func shellQuote(s string) string {
if !strings.ContainsAny(s, " \t'\"$`\\&|;<>(){}*?#~") {
return s
}
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
Loading