Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
448 lines
13 KiB
Go
448 lines
13 KiB
Go
package runtimecmd
|
|
|
|
import (
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
type Resolver struct {
|
|
Environ func() []string
|
|
HomeDir func() (string, error)
|
|
IsExecutableFile func(string) bool
|
|
LookPath func(string) (string, error)
|
|
// ScutilProxy returns the raw output of `scutil --proxy` (and whether it is
|
|
// available). It is injectable for legacy macOS parser tests; production
|
|
// reads the platform system proxy through the build-tagged adapter.
|
|
ScutilProxy func() (string, bool)
|
|
}
|
|
|
|
// nestingGuardEnvKeys are the environment variables a parent Claude Code
|
|
// session exports to detect (and refuse) nested launches. When tuttid itself
|
|
// runs inside a Claude Code session these leak into spawned agent subprocesses, causing
|
|
// a child `claude` to abort with "cannot be launched inside another Claude Code
|
|
// session". They are stripped from the base environment so each spawned agent
|
|
// starts as a fresh session; they are CLAUDE-specific and harmless to other runtimes.
|
|
var nestingGuardEnvKeys = []string{
|
|
"CLAUDECODE",
|
|
"CLAUDE_CODE_ENTRYPOINT",
|
|
"CLAUDE_CODE_SESSION_ID",
|
|
"CLAUDE_CODE_CHILD_SESSION",
|
|
}
|
|
|
|
func (r Resolver) Env(overrides []string) []string {
|
|
baseEnv := stripEnvKeys(r.environ(), nestingGuardEnvKeys)
|
|
env := append(baseEnv, overrides...)
|
|
pathKey := pathEnvKey(env)
|
|
pathGroups := [][]string{}
|
|
if overridePathGroups := pathGroupsFromEnv(overrides, pathKey, envValue(baseEnv, pathKey)); len(overridePathGroups) > 0 {
|
|
pathGroups = append(pathGroups, overridePathGroups...)
|
|
pathGroups = append(pathGroups, preferredExecutableDirs(env))
|
|
pathGroups = append(pathGroups, r.fallbackExecutableDirs())
|
|
} else {
|
|
pathGroups = append(pathGroups, preferredExecutableDirs(env))
|
|
pathGroups = append(pathGroups, splitPathList(envValue(baseEnv, pathKey)))
|
|
pathGroups = append(pathGroups, r.fallbackExecutableDirs())
|
|
}
|
|
pathDirs := mergePathDirs(pathGroups...)
|
|
if len(pathDirs) > 0 {
|
|
env = setEnvValue(env, pathKey, strings.Join(pathDirs, string(os.PathListSeparator)))
|
|
}
|
|
return r.injectSystemProxyEnv(env)
|
|
}
|
|
|
|
func (r Resolver) Resolve(command string, env []string) string {
|
|
command = strings.TrimSpace(command)
|
|
if command == "" || strings.ContainsAny(command, `/\`) {
|
|
return command
|
|
}
|
|
if candidates := r.ResolveAll(command, env); len(candidates) > 0 {
|
|
return candidates[0]
|
|
}
|
|
return command
|
|
}
|
|
|
|
// ResolveAll returns every executable match for command in the effective PATH,
|
|
// preserving directory order and removing duplicate paths. It deliberately
|
|
// does not call LookPath: callers that need an OS-specific fallback can append
|
|
// that result explicitly, while discovery callers get a complete, stable view
|
|
// of the resolver's own search plan.
|
|
func (r Resolver) ResolveAll(command string, env []string) []string {
|
|
command = strings.TrimSpace(command)
|
|
if command == "" || strings.ContainsAny(command, "/\\") {
|
|
return nil
|
|
}
|
|
return r.ResolveAllNames([]string{command}, env)
|
|
}
|
|
|
|
// ResolveAllNames returns every executable match for a set of command names.
|
|
// Directory order is primary and name order is secondary, which lets callers
|
|
// enumerate platform launcher variants without changing PATH precedence.
|
|
func (r Resolver) ResolveAllNames(commandNames []string, env []string) []string {
|
|
result := []string{}
|
|
seen := map[string]struct{}{}
|
|
for _, dir := range splitPathList(envValue(env, pathEnvKey(env))) {
|
|
for _, command := range commandNames {
|
|
command = strings.TrimSpace(command)
|
|
if command == "" || strings.ContainsAny(command, `/\\`) {
|
|
continue
|
|
}
|
|
for _, executableName := range executableNameCandidates(command, env) {
|
|
candidate := filepath.Join(dir, executableName)
|
|
if !r.isExecutableFile(candidate) {
|
|
continue
|
|
}
|
|
key := executablePathKey(candidate)
|
|
if _, ok := seen[key]; ok {
|
|
continue
|
|
}
|
|
seen[key] = struct{}{}
|
|
result = append(result, candidate)
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (r Resolver) ResolveBinary(binaryNames []string, overrides []string) string {
|
|
env := r.Env(overrides)
|
|
for _, binaryName := range binaryNames {
|
|
binaryName = strings.TrimSpace(binaryName)
|
|
if binaryName == "" {
|
|
continue
|
|
}
|
|
path := r.Resolve(binaryName, env)
|
|
if path != binaryName {
|
|
return path
|
|
}
|
|
if path := r.lookPath(binaryName); path != "" {
|
|
return path
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (r Resolver) UserBinInstallDirs(overrides []string) []string {
|
|
baseEnv := r.environ()
|
|
env := append([]string{}, baseEnv...)
|
|
env = append(env, overrides...)
|
|
pathValue := envValue(baseEnv, pathEnvKey(baseEnv))
|
|
if overridePath, ok := envValueFrom(overrides, pathEnvKey(env)); ok {
|
|
pathValue = overridePath
|
|
}
|
|
candidates := [][]string{
|
|
splitPathList(pathValue),
|
|
}
|
|
home, err := r.homeDir()
|
|
if err == nil && strings.TrimSpace(home) != "" {
|
|
managedNPMDirs := UserManagedNPMExecutableDirs(home)
|
|
candidates = append(candidates, managedNPMDirs, []string{filepath.Join(home, "bin")})
|
|
}
|
|
return mergePathDirs(candidates...)
|
|
}
|
|
|
|
func preferredExecutableDirs(env []string) []string {
|
|
dirs := []string{}
|
|
if nPrefix := strings.TrimSpace(envValue(env, "N_PREFIX")); nPrefix != "" {
|
|
dirs = append([]string{filepath.Join(nPrefix, "bin")}, dirs...)
|
|
}
|
|
if pnpmHome := strings.TrimSpace(envValue(env, "PNPM_HOME")); pnpmHome != "" {
|
|
dirs = append([]string{pnpmHome}, dirs...)
|
|
}
|
|
if voltaHome := strings.TrimSpace(envValue(env, "VOLTA_HOME")); voltaHome != "" {
|
|
dirs = append([]string{filepath.Join(voltaHome, "bin")}, dirs...)
|
|
}
|
|
if asdfDataDir := strings.TrimSpace(envValue(env, "ASDF_DATA_DIR")); asdfDataDir != "" {
|
|
dirs = append([]string{filepath.Join(asdfDataDir, "shims")}, dirs...)
|
|
}
|
|
if miseDataDir := strings.TrimSpace(envValue(env, "MISE_DATA_DIR")); miseDataDir != "" {
|
|
dirs = append([]string{filepath.Join(miseDataDir, "shims")}, dirs...)
|
|
}
|
|
if fnmDir := strings.TrimSpace(envValue(env, "FNM_DIR")); fnmDir != "" {
|
|
dirs = append(fnmNodeBinDirs(fnmDir), dirs...)
|
|
}
|
|
return dirs
|
|
}
|
|
|
|
func (r Resolver) fallbackExecutableDirs() []string {
|
|
dirs := []string{
|
|
"/opt/homebrew/bin",
|
|
"/usr/local/bin",
|
|
"/usr/bin",
|
|
"/bin",
|
|
}
|
|
homeDirs := []string{}
|
|
home, err := r.homeDir()
|
|
if err == nil && strings.TrimSpace(home) != "" {
|
|
homeDirs = []string{
|
|
filepath.Join(home, ".tutti", "bin"),
|
|
filepath.Join(home, ".opencode", "bin"),
|
|
}
|
|
homeDirs = append(homeDirs, UserManagedNPMExecutableDirs(home)...)
|
|
homeDirs = append(homeDirs,
|
|
filepath.Join(home, "bin"),
|
|
filepath.Join(home, ".npm-global", "bin"),
|
|
filepath.Join(home, ".n", "bin"),
|
|
filepath.Join(home, "n", "bin"),
|
|
filepath.Join(home, ".volta", "bin"),
|
|
filepath.Join(home, ".asdf", "shims"),
|
|
filepath.Join(home, ".mise", "shims"),
|
|
filepath.Join(home, ".bun", "bin"),
|
|
filepath.Join(home, "Library", "pnpm"),
|
|
)
|
|
homeDirs = append(homeDirs, nvmNodeBinDirs(home)...)
|
|
homeDirs = append(homeDirs, fnmNodeBinDirs(filepath.Join(home, ".fnm"))...)
|
|
// Cursor's native Windows installer places agent.ps1 and its versioned
|
|
// payload under %LOCALAPPDATA%\\cursor-agent rather than on PATH.
|
|
homeDirs = append(homeDirs, filepath.Join(home, "AppData", "Local", "cursor-agent"))
|
|
}
|
|
return append(homeDirs, dirs...)
|
|
}
|
|
|
|
func (r Resolver) environ() []string {
|
|
if r.Environ != nil {
|
|
return r.Environ()
|
|
}
|
|
return os.Environ()
|
|
}
|
|
|
|
func (r Resolver) homeDir() (string, error) {
|
|
if r.HomeDir != nil {
|
|
return r.HomeDir()
|
|
}
|
|
return os.UserHomeDir()
|
|
}
|
|
|
|
func (r Resolver) isExecutableFile(path string) bool {
|
|
if r.IsExecutableFile != nil {
|
|
return r.IsExecutableFile(path)
|
|
}
|
|
return isExecutableFile(path)
|
|
}
|
|
|
|
func (r Resolver) lookPath(binaryName string) string {
|
|
lookPath := r.LookPath
|
|
if lookPath == nil {
|
|
lookPath = exec.LookPath
|
|
}
|
|
path, err := lookPath(binaryName)
|
|
if err == nil && strings.TrimSpace(path) != "" {
|
|
return strings.TrimSpace(path)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func nvmNodeBinDirs(home string) []string {
|
|
matches, err := filepath.Glob(filepath.Join(home, ".nvm", "versions", "node", "*", "bin"))
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
sort.SliceStable(matches, func(i, j int) bool {
|
|
return compareNVMNodeBinDirs(matches[i], matches[j]) > 0
|
|
})
|
|
return matches
|
|
}
|
|
|
|
func compareNVMNodeBinDirs(left string, right string) int {
|
|
leftVersion := nvmNodeVersion(left)
|
|
rightVersion := nvmNodeVersion(right)
|
|
for i := 0; i < max(len(leftVersion), len(rightVersion)); i++ {
|
|
leftPart := versionPart(leftVersion, i)
|
|
rightPart := versionPart(rightVersion, i)
|
|
if leftPart > rightPart {
|
|
return 1
|
|
}
|
|
if leftPart < rightPart {
|
|
return -1
|
|
}
|
|
}
|
|
return strings.Compare(left, right)
|
|
}
|
|
|
|
func nvmNodeVersion(binDir string) []int {
|
|
versionDir := filepath.Base(filepath.Dir(binDir))
|
|
version := strings.TrimPrefix(versionDir, "v")
|
|
parts := strings.Split(version, ".")
|
|
result := make([]int, 0, len(parts))
|
|
for _, part := range parts {
|
|
value, err := strconv.Atoi(part)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
result = append(result, value)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func versionPart(version []int, index int) int {
|
|
if index < 0 || index >= len(version) {
|
|
return 0
|
|
}
|
|
return version[index]
|
|
}
|
|
|
|
func fnmNodeBinDirs(fnmDir string) []string {
|
|
matches, err := filepath.Glob(filepath.Join(fnmDir, "node-versions", "*", "installation", "bin"))
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return matches
|
|
}
|
|
|
|
func mergePathDirs(groups ...[]string) []string {
|
|
result := []string{}
|
|
seen := map[string]bool{}
|
|
for _, group := range groups {
|
|
for _, dir := range group {
|
|
normalized := strings.TrimSpace(dir)
|
|
if normalized == "" {
|
|
continue
|
|
}
|
|
key := pathDirKey(normalized)
|
|
if seen[key] {
|
|
continue
|
|
}
|
|
seen[key] = true
|
|
result = append(result, normalized)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func pathDirKey(dir string) string {
|
|
key := filepath.Clean(strings.TrimSpace(dir))
|
|
if runtime.GOOS == "windows" {
|
|
key = strings.ToLower(key)
|
|
}
|
|
return key
|
|
}
|
|
|
|
func executablePathKey(path string) string {
|
|
return pathDirKey(path)
|
|
}
|
|
|
|
func pathEnvKey(env []string) string {
|
|
for i := len(env) - 1; i >= 0; i-- {
|
|
key, _, ok := strings.Cut(env[i], "=")
|
|
if ok && strings.EqualFold(key, "PATH") {
|
|
return key
|
|
}
|
|
}
|
|
return "PATH"
|
|
}
|
|
|
|
func envValue(env []string, key string) string {
|
|
value, _ := envValueFrom(env, key)
|
|
return value
|
|
}
|
|
|
|
func envValueFrom(env []string, key string) (string, bool) {
|
|
for i := len(env) - 1; i >= 0; i-- {
|
|
candidateKey, value, ok := strings.Cut(env[i], "=")
|
|
if ok && strings.EqualFold(candidateKey, key) {
|
|
return value, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func pathGroupsFromEnv(env []string, key string, basePath string) [][]string {
|
|
groups := [][]string{}
|
|
baseDirs := splitPathList(basePath)
|
|
inheritedBaseDirs := []string{}
|
|
for i := len(env) - 1; i >= 0; i-- {
|
|
candidateKey, value, ok := strings.Cut(env[i], "=")
|
|
if ok && strings.EqualFold(candidateKey, key) {
|
|
dirs := splitPathList(value)
|
|
if prefix, inherited, ok := splitInheritedPath(dirs, baseDirs); ok {
|
|
groups = append(groups, prefix)
|
|
if len(inheritedBaseDirs) == 0 {
|
|
inheritedBaseDirs = inherited
|
|
}
|
|
continue
|
|
}
|
|
groups = append(groups, dirs)
|
|
}
|
|
}
|
|
if len(inheritedBaseDirs) > 0 {
|
|
groups = append(groups, inheritedBaseDirs)
|
|
}
|
|
return groups
|
|
}
|
|
|
|
// splitPathList follows the host separator but also accepts the colon form
|
|
// emitted by MSYS/Unix-like test and launcher environments on Windows. A
|
|
// native Windows path containing a drive letter or backslashes remains on the
|
|
// normal semicolon path-list path.
|
|
func splitPathList(value string) []string {
|
|
if runtime.GOOS == "windows" && !strings.Contains(value, ";") &&
|
|
strings.Contains(value, ":") && !strings.Contains(value, `\`) {
|
|
return strings.Split(value, ":")
|
|
}
|
|
return filepath.SplitList(value)
|
|
}
|
|
|
|
func splitInheritedPath(dirs []string, baseDirs []string) ([]string, []string, bool) {
|
|
if len(baseDirs) == 0 || len(dirs) < len(baseDirs) {
|
|
return nil, nil, false
|
|
}
|
|
for i := 0; i+len(baseDirs) <= len(dirs); i++ {
|
|
if pathDirSlicesEqual(dirs[i:i+len(baseDirs)], baseDirs) {
|
|
return dirs[:i], dirs[i:], true
|
|
}
|
|
}
|
|
return nil, nil, false
|
|
}
|
|
|
|
func pathDirSlicesEqual(a []string, b []string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if pathDirKey(a[i]) != pathDirKey(b[i]) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func stripEnvKeys(env []string, keys []string) []string {
|
|
if len(keys) == 0 {
|
|
return env
|
|
}
|
|
next := make([]string, 0, len(env))
|
|
for _, item := range env {
|
|
candidateKey, _, ok := strings.Cut(item, "=")
|
|
if ok {
|
|
drop := false
|
|
for _, key := range keys {
|
|
if strings.EqualFold(candidateKey, key) {
|
|
drop = true
|
|
break
|
|
}
|
|
}
|
|
if drop {
|
|
continue
|
|
}
|
|
}
|
|
next = append(next, item)
|
|
}
|
|
return next
|
|
}
|
|
|
|
func setEnvValue(env []string, key string, value string) []string {
|
|
next := make([]string, 0, len(env)+1)
|
|
for _, item := range env {
|
|
candidateKey, _, ok := strings.Cut(item, "=")
|
|
if ok && strings.EqualFold(candidateKey, key) {
|
|
continue
|
|
}
|
|
next = append(next, item)
|
|
}
|
|
return append(next, key+"="+value)
|
|
}
|