star on Discord: "DeepSeek Harness 就是现在已经有桌面版了,但是他还是非要找CLI". With DeepSeek Harness Desktop in use and no dsh on PATH, the Agents page said 未找到 CLI and Install another agent offered npm's dsh: CLIMissing (#843) took ~/.dsh without a CLI for an uninstall. The desktop app (DeepSeek Harness.app, com.deepseek.dsh, 0.2.0-rc.2 from download.deepseek.com) keeps the same ~/.dsh — its own profile, profiles/desktop/cordis.patch.yml, and .credentials.yaml — and needs no CLI; its terminal command (Manage dsh Command…) is optional. - desktopapp.go: dsh's app is known by its bundle id on macOS and by %LOCALAPPDATA%\Programs\DeepSeek Harness\DeepSeek Harness.exe on Windows (electron-builder per-user NSIS, productName DeepSeek Harness, getWindowsInstallationDirName); HasApp says either is installed. - install.go: CLIMissing is false with the agent's desktop app installed, so the GUI row, Install another agent and /api all follow; the app's version shows through the existing CLI().App path. - dsh.go: the restart notice also sees DeepSeek Harness.app running. The red "已不经过 magpie" line in the report is a separate dshCheck finding, not caused by the missing CLI: a desktop-only ~/.dsh shaped from the app's real new-profile template wires cleanly (Check "", no Drift; TestDshDesktopOnlyWired). The reporter's exact detail is cut off in the screenshot and is not reproduced. Run: TestDshDesktopAppIsNoMissingCLI fails on the old code ("with the desktop app: missing true, offered true"; with the Windows path removed, "with the Windows app: missing true, offered true"), passes with the fix. go test -tags nogui ./internal/agent ./internal/gui under a temp HOME with pinned caches: ok. gofmt -l clean; go vet darwin, GOOS=linux -tags nogui, GOOS=windows on both packages; go build darwin, GOOS=linux -tags nogui, GOOS=windows: ok. Not tried: the real desktop app running live (not installed here; its userData isn't redirected by a sandbox HOME), a real Windows install.
216 lines
8.5 KiB
Go
216 lines
8.5 KiB
Go
package agent
|
|
|
|
// The agents magpie knows that aren't on this machine, with the commands
|
|
// their vendors give to install them (#727): on a new computer the Agents
|
|
// page said only "Install Claude Code, Codex, Gemini CLI, OpenCode…", and
|
|
// which command installs each was left to be looked up. magpie shows the
|
|
// commands to copy and run in a terminal; it doesn't run them itself, as an
|
|
// install needs what a new machine may not have yet (Node.js for npm,
|
|
// Homebrew), and a global npm prefix that may want sudo.
|
|
//
|
|
// Where no Node.js is found, each npm command installs it first (#727,
|
|
// Sun1090: "curl xxx && npm xxxx"): nvm's installer and its LTS Node on a
|
|
// Mac or Linux, as nodejs.org's download page gives, winget's Node.js LTS
|
|
// on Windows; the npm install follows in the same line.
|
|
|
|
import (
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"slices"
|
|
|
|
"github.com/yetone/magpie/internal/proc"
|
|
)
|
|
|
|
// InstallCmd is one way to install an agent's CLI.
|
|
type InstallCmd struct {
|
|
// Via is what runs it: script (the vendor's installer, in a shell),
|
|
// powershell (the vendor's installer, on Windows), brew or npm
|
|
Via string `json:"via"`
|
|
Command string `json:"command"`
|
|
// Node is how the command installs Node.js before npm, where none is
|
|
// here: nvm (Linux), nvm-mac (a Mac, where nvm's installer wants the
|
|
// Xcode Command Line Tools) or winget (Windows); "" when it doesn't
|
|
Node string `json:"node,omitempty"`
|
|
}
|
|
|
|
// Install is an agent not on this machine, and how to install it.
|
|
type Install struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Icon string `json:"icon,omitempty"`
|
|
Commands []InstallCmd `json:"commands"`
|
|
// Missing: the agent's settings are here but its CLI isn't (CLIMissing),
|
|
// so it is listed on the Agents page and offered here again too
|
|
Missing bool `json:"missing,omitempty"`
|
|
}
|
|
|
|
// vendorInstall is the installer each vendor's own docs give first, where
|
|
// it isn't a package manager's — Claude Code's (code.claude.com/docs/en/setup),
|
|
// Codex's (github.com/openai/codex's README) and OpenCode's
|
|
// (opencode.ai/docs), the ones whose own updaters cliSpecs knows by where
|
|
// they put the binary — and the Homebrew cask and formula the READMEs name
|
|
// (Codex's, Gemini CLI's).
|
|
var vendorInstall = map[string]func(goos string) []InstallCmd{
|
|
"claude": func(goos string) []InstallCmd {
|
|
if goos == "windows" {
|
|
return []InstallCmd{{Via: "powershell", Command: "irm https://claude.ai/install.ps1 | iex"}}
|
|
}
|
|
return []InstallCmd{{Via: "script", Command: "curl -fsSL https://claude.ai/install.sh | bash"}}
|
|
},
|
|
"opencode": func(goos string) []InstallCmd {
|
|
if goos == "windows" {
|
|
return nil
|
|
}
|
|
return []InstallCmd{{Via: "script", Command: "curl -fsSL https://opencode.ai/install | bash"}}
|
|
},
|
|
"codex": func(goos string) []InstallCmd {
|
|
switch goos {
|
|
case "windows":
|
|
return []InstallCmd{{Via: "powershell", Command: `powershell -ExecutionPolicy ByPass -c "irm https://chatgpt.com/codex/install.ps1 | iex"`}}
|
|
case "darwin":
|
|
return []InstallCmd{{Via: "script", Command: "curl -fsSL https://chatgpt.com/codex/install.sh | sh"}, {Via: "brew", Command: "brew install --cask codex"}}
|
|
}
|
|
return []InstallCmd{{Via: "script", Command: "curl -fsSL https://chatgpt.com/codex/install.sh | sh"}}
|
|
},
|
|
"gemini": func(goos string) []InstallCmd {
|
|
if goos == "windows" {
|
|
return nil
|
|
}
|
|
return []InstallCmd{{Via: "brew", Command: "brew install gemini-cli"}}
|
|
},
|
|
}
|
|
|
|
// installCommands is how to install the agent with the given id on goos:
|
|
// the vendor's installer first, then npm's package, the one cliSpecs
|
|
// updates, after Node.js's own install when node is false; nil for one
|
|
// magpie knows no command for.
|
|
func installCommands(id, goos string, node bool) []InstallCmd {
|
|
var out []InstallCmd
|
|
if f := vendorInstall[id]; f != nil {
|
|
out = append(out, f(goos)...)
|
|
}
|
|
if spec, ok := cliSpecs[id]; ok && len(spec.npm) > 0 {
|
|
out = append(out, npmInstall(spec.npm[0], goos, node))
|
|
}
|
|
return out
|
|
}
|
|
|
|
// nvmVersion is the nvm release whose installer the commands fetch
|
|
// (github.com/nvm-sh/nvm/releases/latest).
|
|
const nvmVersion = "v0.40.8"
|
|
|
|
// npmInstall is the command installing pkg with npm on goos, with Node.js
|
|
// installed first when node is false.
|
|
//
|
|
// On a Mac or Linux: nvm's installer, then nvm.sh read into the shell the
|
|
// command runs in (the installer adds it to the profile, which only the
|
|
// terminals opened after read), its LTS Node, then npm. NVM_DIR is set
|
|
// and its folder made, as the installer would go by $XDG_CONFIG_HOME/nvm
|
|
// where that is set, and nvm.sh couldn't be found then. nvm's Node is the
|
|
// user's own, so the global install wants no sudo.
|
|
//
|
|
// On Windows, in PowerShell: winget's Node.js LTS, then PATH read again
|
|
// from the registry (the installer adds Node's folder there, not to this
|
|
// terminal), then npm.cmd — npm alone is npm.ps1 there, which PowerShell's
|
|
// default execution policy refuses to run. Each step is parted by ";", as
|
|
// Windows PowerShell 5.1 has no "&&".
|
|
func npmInstall(pkg, goos string, node bool) InstallCmd {
|
|
plain := "npm install -g " + pkg
|
|
switch {
|
|
case node:
|
|
return InstallCmd{Via: "npm", Command: plain}
|
|
case goos == "windows":
|
|
return InstallCmd{Via: "npm", Node: "winget", Command: "winget install -e --id OpenJS.NodeJS.LTS --accept-source-agreements --accept-package-agreements; " +
|
|
"$env:Path = [Environment]::GetEnvironmentVariable('Path','Machine') + ';' + [Environment]::GetEnvironmentVariable('Path','User'); " +
|
|
"npm.cmd install -g " + pkg}
|
|
}
|
|
how := "nvm"
|
|
if goos == "darwin" {
|
|
how = "nvm-mac"
|
|
}
|
|
return InstallCmd{Via: "npm", Node: how, Command: `export NVM_DIR="$HOME/.nvm" && mkdir -p "$NVM_DIR" && ` +
|
|
"t=$(mktemp) && curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/" + nvmVersion + `/install.sh -o "$t" && ` +
|
|
`bash "$t" && . "$NVM_DIR/nvm.sh" && nvm install --lts && ` + plain}
|
|
}
|
|
|
|
// nodeHere says whether this machine has Node.js's npm: on PATH, in one
|
|
// of the folders a user's tools go in (nvm's, fnm's, mise's, volta's,
|
|
// Homebrew's, …) or, on Windows, in the PATH a terminal opened now has
|
|
// and in Node.js's installer's own folder.
|
|
func nodeHere() bool {
|
|
if _, err := exec.LookPath("npm"); err == nil {
|
|
return true
|
|
}
|
|
dirs := proc.UserBinDirs()
|
|
if runtime.GOOS == "windows" {
|
|
dirs = append(dirs, proc.LoginPath()...)
|
|
if pf := os.Getenv("ProgramFiles"); pf != "" {
|
|
dirs = append(dirs, filepath.Join(pf, "nodejs"))
|
|
}
|
|
}
|
|
return npmIn(dirs, runtime.GOOS)
|
|
}
|
|
|
|
// npmIn says whether one of dirs has npm in it.
|
|
func npmIn(dirs []string, goos string) bool {
|
|
name := "npm"
|
|
if goos == "windows" {
|
|
name = "npm.cmd"
|
|
}
|
|
return slices.ContainsFunc(dirs, func(d string) bool {
|
|
return d != "" && isFile(filepath.Join(d, name))
|
|
})
|
|
}
|
|
|
|
// Installs is every agent magpie knows a command for that isn't on this
|
|
// machine, in the Agents page's order.
|
|
func Installs() []Install {
|
|
return installsOf(All(), runtime.GOOS, nodeHere())
|
|
}
|
|
|
|
func installsOf(all []*Agent, goos string, node bool) []Install {
|
|
out := []Install{}
|
|
for _, a := range all {
|
|
missing := a.CLIMissing()
|
|
if a.WSL != "" || a.Detected() && !missing {
|
|
continue
|
|
}
|
|
cmds := installCommands(a.ID, goos, node)
|
|
if len(cmds) == 0 {
|
|
continue
|
|
}
|
|
out = append(out, Install{ID: a.ID, Name: a.Name, Icon: a.Icon, Commands: cmds, Missing: missing})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// cliShared are the agents whose folder an app or an editor's extension
|
|
// of theirs keeps too, with a CLI of its own inside or none: ~/.codex (the
|
|
// Codex app), ~/.claude (Claude Desktop's Code, the editors' extensions),
|
|
// ~/.gemini (Antigravity, Gemini Code Assist), OpenCode's (its desktop
|
|
// app), ~/.cline (Cline's extensions), Goose's (Goose Desktop). Their
|
|
// folder without the CLI is no sign it was uninstalled.
|
|
var cliShared = map[string]bool{"codex": true, "claude": true, "gemini": true, "opencode": true, "cline": true, "goose": true}
|
|
|
|
// CLIMissing reports an agent that is here by its settings alone: its
|
|
// folder or config is, its command-line program isn't — not on PATH nor
|
|
// where users' tools go (proc.FindTool), as after an uninstall that left
|
|
// ~/.dsh behind (#843). Only for an agent that is its CLI and that magpie
|
|
// knows an install command for, so the Agents page can offer it again; one
|
|
// whose desktop app is installed (HasApp) is that app's settings, which need
|
|
// no CLI (DeepSeek Harness Desktop, star on Discord).
|
|
func (a *Agent) CLIMissing() bool {
|
|
if a.WSL != "" || a.detect != nil || a.Bin == "" || cliShared[a.ID] {
|
|
return false
|
|
}
|
|
if a.HasApp() {
|
|
return false
|
|
}
|
|
if len(installCommands(a.ID, runtime.GOOS, true)) == 0 {
|
|
return false
|
|
}
|
|
return a.Detected() && proc.FindTool(a.Bin) == ""
|
|
}
|