From ac3d0657a24b13163e1739e52c2c5df5dfd2d743 Mon Sep 17 00:00:00 2001 From: Parth Sareen Date: Wed, 3 Jun 2026 14:35:32 -0700 Subject: [PATCH] launch: migrate pi (#16213) --- cmd/launch/pi.go | 387 ++++++++++++++++++++++++++-- cmd/launch/pi_test.go | 533 ++++++++++++++++++++++++++++++++++++++- cmd/launch/registry.go | 2 +- docs/integrations/pi.mdx | 24 +- 4 files changed, 903 insertions(+), 43 deletions(-) diff --git a/cmd/launch/pi.go b/cmd/launch/pi.go index 7510d35a..2854d6b0 100644 --- a/cmd/launch/pi.go +++ b/cmd/launch/pi.go @@ -4,11 +4,15 @@ import ( "context" "encoding/json" "fmt" + "net/http" + "net/url" "os" "os/exec" "path/filepath" + "runtime" "slices" "strings" + "time" "github.com/ollama/ollama/api" "github.com/ollama/ollama/cmd/internal/fileutil" @@ -19,13 +23,16 @@ import ( type Pi struct{} const ( - piNpmPackage = "@mariozechner/pi-coding-agent" - piWebSearchSource = "npm:@ollama/pi-web-search" - piWebSearchPkg = "@ollama/pi-web-search" + piNpmPackage = "@earendil-works/pi-coding-agent" + piLegacyNpmPackage = "@mariozechner/pi-coding-agent" + piWebSearchSource = "npm:@ollama/pi-web-search" + piWebSearchPkg = "@ollama/pi-web-search" ) func (p *Pi) String() string { return "Pi" } +var npmRegistryBaseURL = "https://registry.npmjs.org" + func (p *Pi) Run(_ string, _ []LaunchModel, args []string) error { fmt.Fprintf(os.Stderr, "\n%sPreparing Pi...%s\n", ansiGray, ansiReset) if err := ensureNpmInstalled(); err != nil { @@ -58,6 +65,22 @@ func ensureNpmInstalled() error { func ensurePiInstalled() (string, error) { if _, err := exec.LookPath("pi"); err == nil { + install, pkgErr := installedPiPackageInfo() + if pkgErr != nil { + fmt.Fprintf(os.Stderr, "%sCould not verify which Pi package is installed: %v%s\n", ansiYellow, pkgErr, ansiReset) + fmt.Fprintf(os.Stderr, "Pi will still launch. To switch to the official package manually:\n npm uninstall -g %s\n npm install -g %s\n\n", piLegacyNpmPackage, piNpmPackage) + return "pi", nil + } + + if install.packageName == piLegacyNpmPackage { + fmt.Fprintf(os.Stderr, "%sUpdating Pi...%s\n", ansiGray, ansiReset) + if err := migrateLegacyPiPackage(install.npmPrefix); err != nil { + return "", err + } + if err := requirePiOnPath(); err != nil { + return "", err + } + } return "pi", nil } @@ -65,7 +88,29 @@ func ensurePiInstalled() (string, error) { return "", fmt.Errorf("pi is not installed and required dependencies are missing\n\nInstall the following first:\n npm (Node.js): https://nodejs.org/\n\nThen re-run:\n ollama launch pi") } - ok, err := ConfirmPrompt("Pi is not installed. Install with npm?") + install, pkgErr := installedPiPackageInfo() + if pkgErr == nil && install.packageName == piLegacyNpmPackage { + fmt.Fprintf(os.Stderr, "%sUpdating Pi...%s\n", ansiGray, ansiReset) + if err := migrateLegacyPiPackage(install.npmPrefix); err != nil { + return "", err + } + if err := requirePiOnPath(); err != nil { + return "", err + } + return "pi", nil + } + if pkgErr == nil && install.packageName == piNpmPackage { + fmt.Fprintf(os.Stderr, "%sInstalling Pi...%s\n", ansiGray, ansiReset) + if err := installPiPackageWithPrefix(install.npmPrefix); err != nil { + return "", err + } + if err := requirePiOnPath(); err != nil { + return "", err + } + return "pi", nil + } + + ok, err := ConfirmPrompt("Install Pi with npm?") if err != nil { return "", err } @@ -74,21 +119,237 @@ func ensurePiInstalled() (string, error) { } fmt.Fprintf(os.Stderr, "\nInstalling Pi...\n") - cmd := exec.Command("npm", "install", "-g", piNpmPackage+"@latest") - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - return "", fmt.Errorf("failed to install pi: %w", err) + if err := installPiPackage(); err != nil { + return "", err } - if _, err := exec.LookPath("pi"); err != nil { - return "", fmt.Errorf("pi was installed but the binary was not found on PATH\n\nYou may need to restart your shell") + if err := requirePiOnPath(); err != nil { + return "", err } fmt.Fprintf(os.Stderr, "%sPi installed successfully%s\n\n", ansiGreen, ansiReset) return "pi", nil } +func requirePiOnPath() error { + if _, err := exec.LookPath("pi"); err != nil { + return fmt.Errorf("pi was installed but the binary was not found on PATH\n\nYou may need to restart your shell") + } + return nil +} + +func installPiPackage() error { + return installPiPackageWithPrefix("") +} + +func installPiPackageWithPrefix(prefix string) error { + if err := runQuietCommand("npm", npmArgs(prefix, "install", "-g", piNpmPackage+"@latest")...); err != nil { + return fmt.Errorf("failed to install pi: %w", err) + } + return nil +} + +func migrateLegacyPiPackage(prefix string) error { + if err := installPiPackageForced(prefix); err != nil { + return err + } + + installed, err := npmPackageInstalledWithPrefix(piNpmPackage, prefix) + if err != nil { + return fmt.Errorf("failed to verify official pi package: %w", err) + } + if !installed { + return fmt.Errorf("failed to verify official pi package") + } + + if err := uninstallLegacyPiPackageWithPrefix(prefix); err != nil { + return err + } + return installPiPackageWithPrefix(prefix) +} + +func installPiPackageForced(prefix string) error { + if err := runQuietCommand("npm", npmArgs(prefix, "install", "-g", piNpmPackage+"@latest", "--force")...); err != nil { + return fmt.Errorf("failed to install pi: %w", err) + } + return nil +} + +func uninstallLegacyPiPackageWithPrefix(prefix string) error { + if err := runQuietCommand("npm", npmArgs(prefix, "uninstall", "-g", piLegacyNpmPackage)...); err != nil { + return fmt.Errorf("failed to remove legacy pi package: %w", err) + } + return nil +} + +func runQuietCommand(name string, args ...string) error { + cmd := exec.Command(name, args...) + out, err := cmd.CombinedOutput() + if err == nil { + return nil + } + msg := strings.TrimSpace(string(out)) + if msg == "" { + return err + } + return fmt.Errorf("%w: %s", err, msg) +} + +type piPackageInstall struct { + packageName string + npmPrefix string +} + +func installedPiPackageInfo() (piPackageInstall, error) { + if _, err := exec.LookPath("npm"); err != nil { + return piPackageInstall{}, err + } + + if bin, err := exec.LookPath("pi"); err == nil { + install, err := piPackageInstallFromBinary(bin) + if err == nil && install.packageName != "" { + return install, nil + } + } + + installed, err := npmPackageInstalled(piLegacyNpmPackage) + if err != nil { + return piPackageInstall{}, err + } + if installed { + return piPackageInstall{packageName: piLegacyNpmPackage}, nil + } + + installed, err = npmPackageInstalled(piNpmPackage) + if err != nil { + return piPackageInstall{}, err + } + if installed { + return piPackageInstall{packageName: piNpmPackage}, nil + } + + return piPackageInstall{}, nil +} + +func piPackageInstallFromBinary(bin string) (piPackageInstall, error) { + realPath, err := filepath.EvalSymlinks(bin) + if err != nil { + realPath = bin + } + + dir := filepath.Dir(realPath) + for { + packageJSON := filepath.Join(dir, "package.json") + data, err := os.ReadFile(packageJSON) + if err == nil { + var payload struct { + Name string `json:"name"` + } + if json.Unmarshal(data, &payload) == nil && (payload.Name == piLegacyNpmPackage || payload.Name == piNpmPackage) { + return piPackageInstall{packageName: payload.Name, npmPrefix: npmPrefixForPackageRoot(dir)}, nil + } + } + + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + + return piPackageInstall{}, nil +} + +func npmPrefixForPackageRoot(packageRoot string) string { + return npmPrefixForPackageRootForGOOS(filepath.Clean(packageRoot), runtime.GOOS, string(filepath.Separator)) +} + +func npmPrefixForPackageRootForGOOS(packageRoot, goos, separator string) string { + packageRoot = strings.TrimRight(packageRoot, separator) + nodeModules := separator + "node_modules" + separator + idx := strings.LastIndex(packageRoot, nodeModules) + if idx == -1 { + return "" + } + + rootDir := packageRoot[:idx] + if pathBaseForSeparator(rootDir, separator) == "lib" { + // Unix npm global root is /lib/node_modules. + return pathDirForSeparator(rootDir, separator) + } + if goos == "windows" { + // Windows npm global root is usually \node_modules. + return rootDir + } + return "" +} + +func pathBaseForSeparator(path, separator string) string { + path = strings.TrimRight(path, separator) + idx := strings.LastIndex(path, separator) + if idx == -1 { + return path + } + return path[idx+len(separator):] +} + +func pathDirForSeparator(path, separator string) string { + path = strings.TrimRight(path, separator) + idx := strings.LastIndex(path, separator) + if idx == -1 { + return "" + } + if idx == 0 { + return separator + } + return path[:idx] +} + +func npmPackageInstalled(pkg string) (bool, error) { + return npmPackageInstalledWithPrefix(pkg, "") +} + +func npmPackageInstalledWithPrefix(pkg, prefix string) (bool, error) { + cmd := exec.Command("npm", npmArgs(prefix, "ls", "-g", pkg, "--depth=0", "--json")...) + out, err := cmd.Output() + + var payload struct { + Dependencies map[string]json.RawMessage `json:"dependencies"` + } + + if parseErr := json.Unmarshal(out, &payload); parseErr == nil { + _, ok := payload.Dependencies[pkg] + if ok { + return true, nil + } + return false, nil + } + + if err == nil { + return false, nil + } + + if exitErr, ok := err.(*exec.ExitError); ok { + msg := strings.TrimSpace(string(exitErr.Stderr)) + if msg == "" { + msg = strings.TrimSpace(string(out)) + } + if msg == "" { + return false, err + } + return false, fmt.Errorf("%w: %s", err, msg) + } + + return false, err +} + +func npmArgs(prefix string, args ...string) []string { + if prefix == "" { + return args + } + return append([]string{"--prefix", prefix}, args...) +} + func ensurePiWebSearchPackage(bin string) { if !shouldManagePiWebSearch() { fmt.Fprintf(os.Stderr, "%sCloud is disabled; skipping %s setup.%s\n", ansiGray, piWebSearchPkg, ansiReset) @@ -97,13 +358,13 @@ func ensurePiWebSearchPackage(bin string) { fmt.Fprintf(os.Stderr, "%sChecking Pi web search package...%s\n", ansiGray, ansiReset) - installed, err := piPackageInstalled(bin, piWebSearchSource) + pkg, err := piPackageInfo(bin, piWebSearchSource) if err != nil { fmt.Fprintf(os.Stderr, "%s Warning: could not check %s installation: %v%s\n", ansiYellow, piWebSearchPkg, err, ansiReset) return } - if !installed { + if !pkg.installed { fmt.Fprintf(os.Stderr, "%sInstalling %s...%s\n", ansiGray, piWebSearchPkg, ansiReset) cmd := exec.Command(bin, "install", piWebSearchSource) cmd.Stdout = os.Stdout @@ -117,6 +378,11 @@ func ensurePiWebSearchPackage(bin string) { return } + updateAvailable, err := piWebSearchUpdateAvailable(pkg.installedPath) + if err != nil || !updateAvailable { + return + } + fmt.Fprintf(os.Stderr, "%sUpdating %s...%s\n", ansiGray, piWebSearchPkg, ansiReset) cmd := exec.Command(bin, "update", piWebSearchSource) cmd.Stdout = os.Stdout @@ -142,25 +408,108 @@ func shouldManagePiWebSearch() bool { return true } -func piPackageInstalled(bin, source string) (bool, error) { +type piPackageListEntry struct { + installed bool + installedPath string +} + +func piPackageInfo(bin, source string) (piPackageListEntry, error) { cmd := exec.Command(bin, "list") out, err := cmd.CombinedOutput() if err != nil { msg := strings.TrimSpace(string(out)) if msg == "" { - return false, err + return piPackageListEntry{}, err } - return false, fmt.Errorf("%w: %s", err, msg) + return piPackageListEntry{}, fmt.Errorf("%w: %s", err, msg) } - for _, line := range strings.Split(string(out), "\n") { + lines := strings.Split(string(out), "\n") + for i, line := range lines { trimmed := strings.TrimSpace(line) if strings.HasPrefix(trimmed, source) { - return true, nil + return piPackageListEntry{installed: true, installedPath: piPackageListInstalledPath(lines[i+1:])}, nil } } - return false, nil + return piPackageListEntry{}, nil +} + +func piPackageListInstalledPath(lines []string) string { + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + if strings.HasPrefix(trimmed, "npm:") || strings.HasPrefix(trimmed, "git:") || strings.HasSuffix(trimmed, ":") { + return "" + } + if filepath.IsAbs(trimmed) { + return trimmed + } + return "" + } + return "" +} + +func piWebSearchUpdateAvailable(installedPath string) (bool, error) { + if piOfflineModeEnabled() || installedPath == "" { + return false, nil + } + + installedVersion, err := npmInstalledPackageVersion(installedPath) + if err != nil || installedVersion == "" { + return false, err + } + + latestVersion, err := npmLatestPackageVersion(piWebSearchPkg) + if err != nil || latestVersion == "" { + return false, err + } + + return latestVersion != installedVersion, nil +} + +func piOfflineModeEnabled() bool { + value := os.Getenv("PI_OFFLINE") + return value == "1" || strings.EqualFold(value, "true") || strings.EqualFold(value, "yes") +} + +func npmInstalledPackageVersion(installedPath string) (string, error) { + data, err := os.ReadFile(filepath.Join(installedPath, "package.json")) + if err != nil { + return "", err + } + + var payload struct { + Version string `json:"version"` + } + if err := json.Unmarshal(data, &payload); err != nil { + return "", err + } + return payload.Version, nil +} + +func npmLatestPackageVersion(pkg string) (string, error) { + client := http.Client{Timeout: 10 * time.Second} + requestURL := strings.TrimRight(npmRegistryBaseURL, "/") + "/" + url.PathEscape(pkg) + "/latest" + resp, err := client.Get(requestURL) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("npm registry returned %s", resp.Status) + } + + var payload struct { + Version string `json:"version"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return "", err + } + return payload.Version, nil } func (p *Pi) Paths() []string { diff --git a/cmd/launch/pi_test.go b/cmd/launch/pi_test.go index 08f879aa..788d6ea4 100644 --- a/cmd/launch/pi_test.go +++ b/cmd/launch/pi_test.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "runtime" + "slices" "strings" "testing" @@ -33,6 +34,89 @@ func TestPiIntegration(t *testing.T) { }) } +func TestPiInstallSpec_UsesOfficialPackage(t *testing.T) { + spec, err := LookupIntegrationSpec("pi") + if err != nil { + t.Fatalf("LookupIntegrationSpec(pi) error = %v", err) + } + + want := []string{"npm", "install", "-g", piNpmPackage + "@latest"} + if got := spec.Install.Command; !slices.Equal(got, want) { + t.Fatalf("pi install command = %v, want %v", got, want) + } +} + +func TestPiNpmPrefixForPackageRoot(t *testing.T) { + prefix := filepath.Join(t.TempDir(), "npm-global") + t.Run("unix npm global layout", func(t *testing.T) { + packageRoot := filepath.Join(prefix, "lib", "node_modules", "@mariozechner", "pi-coding-agent") + if got := npmPrefixForPackageRoot(packageRoot); got != prefix { + t.Fatalf("npmPrefixForPackageRoot() = %q, want %q", got, prefix) + } + }) + tests := []struct { + name string + goos string + separator string + packageRoot string + want string + }{ + { + name: "macos npm global layout", + goos: "darwin", + separator: "/", + packageRoot: "/Users/parth/.npm-global/lib/node_modules/@mariozechner/pi-coding-agent", + want: "/Users/parth/.npm-global", + }, + { + name: "linux npm global layout", + goos: "linux", + separator: "/", + packageRoot: "/home/parth/.npm-global/lib/node_modules/@mariozechner/pi-coding-agent", + want: "/home/parth/.npm-global", + }, + { + name: "windows npm global layout", + goos: "windows", + separator: `\`, + packageRoot: `C:\Users\parth\AppData\Roaming\npm\node_modules\@mariozechner\pi-coding-agent`, + want: `C:\Users\parth\AppData\Roaming\npm`, + }, + { + name: "windows lib npm global layout", + goos: "windows", + separator: `\`, + packageRoot: `C:\Users\parth\.npm-global\lib\node_modules\@mariozechner\pi-coding-agent`, + want: `C:\Users\parth\.npm-global`, + }, + { + name: "non-windows direct node_modules layout", + goos: "linux", + separator: "/", + packageRoot: "/home/parth/.npm-global/node_modules/@mariozechner/pi-coding-agent", + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := npmPrefixForPackageRootForGOOS(tt.packageRoot, tt.goos, tt.separator) + if got != tt.want { + t.Fatalf("npmPrefixForPackageRootForGOOS() = %q, want %q", got, tt.want) + } + }) + } + t.Run("host windows npm global layout", func(t *testing.T) { + packageRoot := filepath.Join(prefix, "node_modules", "@mariozechner", "pi-coding-agent") + want := "" + if runtime.GOOS == "windows" { + want = prefix + } + if got := npmPrefixForPackageRoot(packageRoot); got != want { + t.Fatalf("npmPrefixForPackageRoot() = %q, want %q", got, want) + } + }) +} + func TestPiRun_InstallAndWebSearchLifecycle(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("uses POSIX shell test binaries") @@ -75,6 +159,99 @@ exit 0 writeScript(t, filepath.Join(dir, "npm"), "#!/bin/sh\nexit 0\n") } + seedLegacyPiNpm := func(t *testing.T, dir string) { + t.Helper() + npmPath := filepath.Join(dir, "npm") + npmScript := fmt.Sprintf(`#!/bin/sh +echo "$@" >> %q +if [ "$1" = "install" ] && [ "$2" = "-g" ] && [ "$3" = %q ]; then + if [ "$PI_FAIL_OFFICIAL_INSTALL" = "1" ]; then + echo "install failed" >&2 + exit 1 + fi + : > %q + exit 0 +fi +if [ "$1" = "uninstall" ] && [ "$2" = "-g" ] && [ "$3" = %q ]; then + : > %q + exit 0 +fi +if [ "$1" = "ls" ] && [ "$2" = "-g" ] && [ "$4" = "--depth=0" ] && [ "$5" = "--json" ]; then + if [ "$3" = %q ]; then + if [ -f %q ]; then + printf '{"name":"lib","dependencies":{"%s":{"version":"0.58.0","overridden":false}}}\n' + exit 0 + fi + printf '{"name":"lib"}\n' + exit 1 + fi + if [ "$3" = %q ]; then + if [ ! -f %q ]; then + printf '{"name":"lib","dependencies":{"%s":{"version":"0.57.1","overridden":false}}}\n' + exit 0 + fi + printf '{"name":"lib"}\n' + exit 1 + fi +fi +exit 0 +`, filepath.Join(dir, "npm.log"), piNpmPackage+"@latest", filepath.Join(dir, "official-installed"), piLegacyNpmPackage, filepath.Join(dir, "legacy-removed"), piNpmPackage, filepath.Join(dir, "official-installed"), piNpmPackage, piLegacyNpmPackage, filepath.Join(dir, "legacy-removed"), piLegacyNpmPackage) + writeScript(t, npmPath, npmScript) + } + + seedBothPiPackagesNpm := func(t *testing.T, dir string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, "official-installed"), nil, 0o644); err != nil { + t.Fatal(err) + } + npmPath := filepath.Join(dir, "npm") + npmScript := fmt.Sprintf(`#!/bin/sh +echo "$@" >> %q +if [ "$1" = "install" ] && [ "$2" = "-g" ] && [ "$3" = %q ]; then + : > %q + exit 0 +fi +if [ "$1" = "uninstall" ] && [ "$2" = "-g" ] && [ "$3" = %q ]; then + : > %q + exit 0 +fi +if [ "$1" = "ls" ] && [ "$2" = "-g" ] && [ "$4" = "--depth=0" ] && [ "$5" = "--json" ]; then + if [ "$3" = %q ]; then + if [ ! -f %q ]; then + printf '{"name":"lib","dependencies":{"%s":{"version":"0.57.1","overridden":false}}}\n' + exit 0 + fi + printf '{"name":"lib"}\n' + exit 1 + fi + if [ "$3" = %q ]; then + if [ -f %q ]; then + printf '{"name":"lib","dependencies":{"%s":{"version":"0.58.0","overridden":false}}}\n' + exit 0 + fi + printf '{"name":"lib"}\n' + exit 1 + fi +fi +exit 0 +`, filepath.Join(dir, "npm.log"), piNpmPackage+"@latest", filepath.Join(dir, "official-installed"), piLegacyNpmPackage, filepath.Join(dir, "legacy-removed"), piLegacyNpmPackage, filepath.Join(dir, "legacy-removed"), piLegacyNpmPackage, piNpmPackage, filepath.Join(dir, "official-installed"), piNpmPackage) + writeScript(t, npmPath, npmScript) + } + + seedBrokenPiProbeNpm := func(t *testing.T, dir string) { + t.Helper() + npmPath := filepath.Join(dir, "npm") + npmScript := fmt.Sprintf(`#!/bin/sh +echo "$@" >> %q +if [ "$1" = "ls" ] && [ "$2" = "-g" ] && [ "$4" = "--depth=0" ] && [ "$5" = "--json" ]; then + echo "npm probe failed" >&2 + exit 1 +fi +exit 0 +`, filepath.Join(dir, "npm.log")) + writeScript(t, npmPath, npmScript) + } + withConfirm := func(t *testing.T, fn func(prompt string) (bool, error)) { t.Helper() oldConfirm := DefaultConfirmPrompt @@ -97,13 +274,46 @@ exit 0 t.Setenv("OLLAMA_HOST", srv.URL) } + setNpmRegistryVersion := func(t *testing.T, version string) { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/latest") { + fmt.Fprintf(w, `{"version":%q}`, version) + return + } + http.NotFound(w, r) + })) + oldRegistry := npmRegistryBaseURL + npmRegistryBaseURL = srv.URL + t.Cleanup(func() { + npmRegistryBaseURL = oldRegistry + srv.Close() + }) + } + + seedPiWebSearchPackage := func(t *testing.T, dir, version string) { + t.Helper() + packagePath := filepath.Join(dir, ".npm-global", "lib", "node_modules", "@ollama", "pi-web-search") + if err := os.MkdirAll(packagePath, 0o755); err != nil { + t.Fatal(err) + } + packageJSON := fmt.Sprintf(`{"name":%q,"version":%q}`, piWebSearchPkg, version) + if err := os.WriteFile(filepath.Join(packagePath, "package.json"), []byte(packageJSON), 0o644); err != nil { + t.Fatal(err) + } + list := fmt.Sprintf("User packages:\n %s\n %s\n", piWebSearchSource, packagePath) + if err := os.WriteFile(filepath.Join(dir, "pi-list.txt"), []byte(list), 0o644); err != nil { + t.Fatal(err) + } + } + t.Run("pi missing + user accepts install", func(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) t.Setenv("PATH", tmpDir) setCloudStatus(t, false) - if err := os.WriteFile(filepath.Join(tmpDir, "pi-list.txt"), []byte("User packages:\n npm:@ollama/pi-web-search\n"), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(tmpDir, "pi-list.txt"), []byte("User packages:\n"), 0o644); err != nil { t.Fatal(err) } @@ -128,7 +338,7 @@ exit 0 writeScript(t, filepath.Join(tmpDir, "npm"), npmScript) withConfirm(t, func(prompt string) (bool, error) { - if strings.Contains(prompt, "Pi is not installed.") { + if strings.Contains(prompt, "Install Pi with npm?") { return true, nil } return true, nil @@ -136,7 +346,8 @@ exit 0 p := &Pi{} if err := p.Run("ignored", nil, []string{"--version"}); err != nil { - t.Fatalf("Run() error = %v", err) + npmCalls, _ := os.ReadFile(filepath.Join(tmpDir, "npm.log")) + t.Fatalf("Run() error = %v\nnpm calls:\n%s", err, npmCalls) } npmCalls, err := os.ReadFile(filepath.Join(tmpDir, "npm.log")) @@ -155,8 +366,8 @@ exit 0 if !strings.Contains(got, "list\n") { t.Fatalf("expected pi list call, got:\n%s", got) } - if !strings.Contains(got, "update "+piWebSearchSource+"\n") { - t.Fatalf("expected pi update call, got:\n%s", got) + if !strings.Contains(got, "install "+piWebSearchSource+"\n") { + t.Fatalf("expected pi web search install call, got:\n%s", got) } if !strings.Contains(got, "--version\n") { t.Fatalf("expected final pi launch call, got:\n%s", got) @@ -171,7 +382,7 @@ exit 0 writeScript(t, filepath.Join(tmpDir, "npm"), "#!/bin/sh\nexit 0\n") withConfirm(t, func(prompt string) (bool, error) { - if strings.Contains(prompt, "Pi is not installed.") { + if strings.Contains(prompt, "Install Pi with npm?") { return false, nil } return true, nil @@ -184,6 +395,277 @@ exit 0 } }) + t.Run("legacy pi package migrates automatically to official package", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + t.Setenv("PATH", tmpDir) + setCloudStatus(t, false) + seedPiWebSearchPackage(t, tmpDir, "1.0.0") + setNpmRegistryVersion(t, "1.0.0") + seedPiScript(t, tmpDir) + seedLegacyPiNpm(t, tmpDir) + + withConfirm(t, func(prompt string) (bool, error) { + t.Fatalf("did not expect confirmation prompt, got %q", prompt) + return false, nil + }) + + p := &Pi{} + if err := p.Run("ignored", nil, []string{"--version"}); err != nil { + npmCalls, _ := os.ReadFile(filepath.Join(tmpDir, "npm.log")) + t.Fatalf("Run() error = %v\nnpm calls:\n%s", err, npmCalls) + } + + npmCalls, err := os.ReadFile(filepath.Join(tmpDir, "npm.log")) + if err != nil { + t.Fatal(err) + } + gotNPM := string(npmCalls) + if !strings.Contains(gotNPM, "ls -g "+piLegacyNpmPackage+" --depth=0 --json\n") { + t.Fatalf("expected legacy npm probe, got:\n%s", gotNPM) + } + if !strings.Contains(gotNPM, "install -g "+piNpmPackage+"@latest --force\n") { + t.Fatalf("expected forced official npm install call, got:\n%s", gotNPM) + } + if !strings.Contains(gotNPM, "ls -g "+piNpmPackage+" --depth=0 --json\n") { + t.Fatalf("expected official npm verification probe, got:\n%s", gotNPM) + } + if !strings.Contains(gotNPM, "uninstall -g "+piLegacyNpmPackage+"\n") { + t.Fatalf("expected legacy npm uninstall call, got:\n%s", gotNPM) + } + if !strings.Contains(gotNPM, "install -g "+piNpmPackage+"@latest\n") { + t.Fatalf("expected official npm install call, got:\n%s", gotNPM) + } + if strings.Index(gotNPM, "install -g "+piNpmPackage+"@latest --force\n") > strings.Index(gotNPM, "uninstall -g "+piLegacyNpmPackage+"\n") { + t.Fatalf("expected official install before legacy uninstall, got:\n%s", gotNPM) + } + if strings.Index(gotNPM, "uninstall -g "+piLegacyNpmPackage+"\n") > strings.LastIndex(gotNPM, "install -g "+piNpmPackage+"@latest\n") { + t.Fatalf("expected official repair install after legacy uninstall, got:\n%s", gotNPM) + } + + piCalls, err := os.ReadFile(filepath.Join(tmpDir, "pi.log")) + if err != nil { + t.Fatal(err) + } + gotPi := string(piCalls) + if strings.Contains(gotPi, "update "+piWebSearchSource+"\n") { + t.Fatalf("did not expect pi update call when web search is current, got:\n%s", gotPi) + } + if !strings.Contains(gotPi, "--version\n") { + t.Fatalf("expected final pi launch call, got:\n%s", gotPi) + } + }) + + t.Run("legacy pi package migrates even when official package is also installed", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + t.Setenv("PATH", tmpDir) + setCloudStatus(t, false) + seedPiWebSearchPackage(t, tmpDir, "1.0.0") + setNpmRegistryVersion(t, "1.0.0") + seedPiScript(t, tmpDir) + seedBothPiPackagesNpm(t, tmpDir) + + withConfirm(t, func(prompt string) (bool, error) { + t.Fatalf("did not expect confirmation prompt, got %q", prompt) + return false, nil + }) + + p := &Pi{} + if err := p.Run("ignored", nil, []string{"--version"}); err != nil { + npmCalls, _ := os.ReadFile(filepath.Join(tmpDir, "npm.log")) + t.Fatalf("Run() error = %v\nnpm calls:\n%s", err, npmCalls) + } + + npmCalls, err := os.ReadFile(filepath.Join(tmpDir, "npm.log")) + if err != nil { + t.Fatal(err) + } + gotNPM := string(npmCalls) + if !strings.Contains(gotNPM, "ls -g "+piLegacyNpmPackage+" --depth=0 --json\n") { + t.Fatalf("expected legacy npm probe, got:\n%s", gotNPM) + } + if !strings.Contains(gotNPM, "install -g "+piNpmPackage+"@latest --force\n") { + t.Fatalf("expected forced official npm install call, got:\n%s", gotNPM) + } + if !strings.Contains(gotNPM, "ls -g "+piNpmPackage+" --depth=0 --json\n") { + t.Fatalf("expected official npm verification probe, got:\n%s", gotNPM) + } + if !strings.Contains(gotNPM, "uninstall -g "+piLegacyNpmPackage+"\n") { + t.Fatalf("expected legacy npm uninstall call, got:\n%s", gotNPM) + } + if !strings.Contains(gotNPM, "install -g "+piNpmPackage+"@latest\n") { + t.Fatalf("expected official npm install call, got:\n%s", gotNPM) + } + }) + + t.Run("legacy pi package outside current npm prefix migrates with binary prefix", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + setCloudStatus(t, false) + + commandDir := filepath.Join(tmpDir, "commands") + prefix := filepath.Join(tmpDir, "npm-global") + legacyRoot := filepath.Join(prefix, "lib", "node_modules", "@mariozechner", "pi-coding-agent") + legacyDist := filepath.Join(legacyRoot, "dist") + if err := os.MkdirAll(filepath.Join(prefix, "bin"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(legacyDist, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(legacyRoot, "package.json"), []byte(`{"name":"`+piLegacyNpmPackage+`","version":"0.67.1"}`), 0o644); err != nil { + t.Fatal(err) + } + writeScript(t, filepath.Join(legacyDist, "cli.js"), fmt.Sprintf("#!/bin/sh\necho \"$@\" >> %q\nexit 0\n", filepath.Join(tmpDir, "pi.log"))) + if err := os.Symlink(filepath.Join(legacyDist, "cli.js"), filepath.Join(prefix, "bin", "pi")); err != nil { + t.Fatal(err) + } + + npmScript := fmt.Sprintf(`#!/bin/sh +echo "$@" >> %q +prefix="" +if [ "$1" = "--prefix" ]; then + prefix="$2" + shift 2 +fi +if [ "$1" = "install" ] && [ "$2" = "-g" ] && [ "$3" = %q ]; then + : > %q + mkdir -p "$prefix/lib/node_modules/@earendil-works/pi-coding-agent/dist" "$prefix/bin" + printf '{"name":"%s","version":"0.75.3"}\n' > "$prefix/lib/node_modules/@earendil-works/pi-coding-agent/package.json" + printf '#!/bin/sh\necho "$@" >> %s\nexit 0\n' > "$prefix/lib/node_modules/@earendil-works/pi-coding-agent/dist/cli.js" + chmod +x "$prefix/lib/node_modules/@earendil-works/pi-coding-agent/dist/cli.js" + ln -sf "$prefix/lib/node_modules/@earendil-works/pi-coding-agent/dist/cli.js" "$prefix/bin/pi" + exit 0 +fi +if [ "$1" = "uninstall" ] && [ "$2" = "-g" ] && [ "$3" = %q ]; then + : > %q + exit 0 +fi +if [ "$1" = "ls" ] && [ "$2" = "-g" ] && [ "$4" = "--depth=0" ] && [ "$5" = "--json" ]; then + if [ "$3" = %q ] && [ -f %q ]; then + printf '{"name":"lib","dependencies":{"%s":{"version":"0.75.3","overridden":false}}}\n' + exit 0 + fi + if [ "$3" = %q ] && [ ! -f %q ]; then + printf '{"name":"lib","dependencies":{"%s":{"version":"0.67.1","overridden":false}}}\n' + exit 0 + fi + printf '{"name":"lib"}\n' + exit 1 +fi +exit 0 +`, filepath.Join(tmpDir, "npm.log"), piNpmPackage+"@latest", filepath.Join(tmpDir, "official-installed"), piNpmPackage, filepath.Join(tmpDir, "pi.log"), piLegacyNpmPackage, filepath.Join(tmpDir, "legacy-removed"), piNpmPackage, filepath.Join(tmpDir, "official-installed"), piNpmPackage, piLegacyNpmPackage, filepath.Join(tmpDir, "legacy-removed"), piLegacyNpmPackage) + if err := os.MkdirAll(commandDir, 0o755); err != nil { + t.Fatal(err) + } + writeScript(t, filepath.Join(commandDir, "npm"), npmScript) + t.Setenv("PATH", commandDir+string(os.PathListSeparator)+filepath.Join(prefix, "bin")) + + withConfirm(t, func(prompt string) (bool, error) { + t.Fatalf("did not expect confirmation prompt, got %q", prompt) + return false, nil + }) + + p := &Pi{} + if err := p.Run("ignored", nil, []string{"--version"}); err != nil { + npmCalls, _ := os.ReadFile(filepath.Join(tmpDir, "npm.log")) + t.Fatalf("Run() error = %v\nnpm calls:\n%s", err, npmCalls) + } + + npmCalls, err := os.ReadFile(filepath.Join(tmpDir, "npm.log")) + if err != nil { + t.Fatal(err) + } + gotNPM := string(npmCalls) + resolvedPrefix, err := filepath.EvalSymlinks(prefix) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(gotNPM, "--prefix "+resolvedPrefix+" install -g "+piNpmPackage+"@latest --force\n") { + t.Fatalf("expected forced official install in pi binary prefix, got:\n%s", gotNPM) + } + if !strings.Contains(gotNPM, "--prefix "+resolvedPrefix+" uninstall -g "+piLegacyNpmPackage+"\n") { + t.Fatalf("expected legacy uninstall in pi binary prefix, got:\n%s", gotNPM) + } + if !strings.Contains(gotNPM, "--prefix "+resolvedPrefix+" install -g "+piNpmPackage+"@latest\n") { + t.Fatalf("expected official repair install in pi binary prefix, got:\n%s", gotNPM) + } + }) + + t.Run("legacy pi migration install failure does not remove legacy package", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + t.Setenv("PATH", tmpDir) + setCloudStatus(t, false) + t.Setenv("PI_FAIL_OFFICIAL_INSTALL", "1") + seedPiScript(t, tmpDir) + seedLegacyPiNpm(t, tmpDir) + + withConfirm(t, func(prompt string) (bool, error) { + t.Fatalf("did not expect confirmation prompt, got %q", prompt) + return false, nil + }) + + p := &Pi{} + err := p.Run("ignored", nil, nil) + if err == nil || !strings.Contains(err.Error(), "failed to install pi") { + t.Fatalf("expected install failure error, got %v", err) + } + + npmCalls, readErr := os.ReadFile(filepath.Join(tmpDir, "npm.log")) + if readErr != nil { + t.Fatal(readErr) + } + gotNPM := string(npmCalls) + if !strings.Contains(gotNPM, "install -g "+piNpmPackage+"@latest --force\n") { + t.Fatalf("expected forced official npm install call, got:\n%s", gotNPM) + } + if strings.Contains(gotNPM, "uninstall -g "+piLegacyNpmPackage+"\n") { + t.Fatalf("did not expect legacy uninstall after official install failure, got:\n%s", gotNPM) + } + }) + + t.Run("pi installed + package probe failure warns and still launches", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + t.Setenv("PATH", tmpDir) + setCloudStatus(t, false) + seedPiWebSearchPackage(t, tmpDir, "1.0.0") + setNpmRegistryVersion(t, "1.0.0") + seedPiScript(t, tmpDir) + seedBrokenPiProbeNpm(t, tmpDir) + withConfirm(t, func(prompt string) (bool, error) { + t.Fatalf("did not expect confirmation prompt, got %q", prompt) + return false, nil + }) + + p := &Pi{} + stderr := captureStderr(t, func() { + if err := p.Run("ignored", nil, []string{"--version"}); err != nil { + t.Fatalf("Run() error = %v", err) + } + }) + if !strings.Contains(stderr, "Could not verify which Pi package is installed") { + t.Fatalf("expected package probe warning, got:\n%s", stderr) + } + if !strings.Contains(stderr, "npm uninstall -g "+piLegacyNpmPackage) { + t.Fatalf("expected manual migration steps in warning, got:\n%s", stderr) + } + + piCalls, err := os.ReadFile(filepath.Join(tmpDir, "pi.log")) + if err != nil { + t.Fatal(err) + } + gotPi := string(piCalls) + if strings.Contains(gotPi, "update "+piWebSearchSource+"\n") { + t.Fatalf("did not expect pi update call when web search is current, got:\n%s", gotPi) + } + if !strings.Contains(gotPi, "--version\n") { + t.Fatalf("expected final pi launch call, got:\n%s", gotPi) + } + }) + t.Run("pi installed + web search missing auto-installs", func(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) @@ -223,14 +705,41 @@ exit 0 } }) - t.Run("pi installed + web search present updates every launch", func(t *testing.T) { + t.Run("pi installed + web search present skips update when current", func(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) t.Setenv("PATH", tmpDir) setCloudStatus(t, false) - if err := os.WriteFile(filepath.Join(tmpDir, "pi-list.txt"), []byte("User packages:\n "+piWebSearchSource+"\n"), 0o644); err != nil { + seedPiWebSearchPackage(t, tmpDir, "1.0.0") + setNpmRegistryVersion(t, "1.0.0") + seedPiScript(t, tmpDir) + seedNpmNoop(t, tmpDir) + + p := &Pi{} + if err := p.Run("ignored", nil, []string{"doctor"}); err != nil { + t.Fatalf("Run() error = %v", err) + } + + piCalls, err := os.ReadFile(filepath.Join(tmpDir, "pi.log")) + if err != nil { t.Fatal(err) } + got := string(piCalls) + if strings.Contains(got, "update "+piWebSearchSource+"\n") { + t.Fatalf("did not expect pi update call, got:\n%s", got) + } + if !strings.Contains(got, "doctor\n") { + t.Fatalf("expected final pi launch call, got:\n%s", got) + } + }) + + t.Run("pi installed + web search present updates when newer package exists", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + t.Setenv("PATH", tmpDir) + setCloudStatus(t, false) + seedPiWebSearchPackage(t, tmpDir, "1.0.0") + setNpmRegistryVersion(t, "1.0.1") seedPiScript(t, tmpDir) seedNpmNoop(t, tmpDir) @@ -247,6 +756,9 @@ exit 0 if !strings.Contains(got, "update "+piWebSearchSource+"\n") { t.Fatalf("expected pi update call, got:\n%s", got) } + if !strings.Contains(got, "doctor\n") { + t.Fatalf("expected final pi launch call, got:\n%s", got) + } }) t.Run("web search update failure warns and continues", func(t *testing.T) { @@ -255,9 +767,8 @@ exit 0 t.Setenv("PATH", tmpDir) setCloudStatus(t, false) t.Setenv("PI_FAIL_UPDATE", "1") - if err := os.WriteFile(filepath.Join(tmpDir, "pi-list.txt"), []byte("User packages:\n "+piWebSearchSource+"\n"), 0o644); err != nil { - t.Fatal(err) - } + seedPiWebSearchPackage(t, tmpDir, "1.0.0") + setNpmRegistryVersion(t, "1.0.1") seedPiScript(t, tmpDir) seedNpmNoop(t, tmpDir) diff --git a/cmd/launch/registry.go b/cmd/launch/registry.go index 90c9cace..bf1a6523 100644 --- a/cmd/launch/registry.go +++ b/cmd/launch/registry.go @@ -191,7 +191,7 @@ var integrationSpecs = []*IntegrationSpec{ _, err := ensurePiInstalled() return err }, - Command: []string{"npm", "install", "-g", "@mariozechner/pi-coding-agent@latest"}, + Command: []string{"npm", "install", "-g", "@earendil-works/pi-coding-agent@latest"}, }, }, { diff --git a/docs/integrations/pi.mdx b/docs/integrations/pi.mdx index e7e79b88..d4ebc821 100644 --- a/docs/integrations/pi.mdx +++ b/docs/integrations/pi.mdx @@ -4,23 +4,13 @@ title: Pi Pi is a minimal and extensible coding agent. -## Install - -Install [Pi](https://github.com/badlogic/pi-mono): - -```bash -npm install -g @mariozechner/pi-coding-agent -``` - -## Usage with Ollama - -### Quick setup +## Quick setup ```bash ollama launch pi ``` -This installs Pi, configures Ollama as a provider including web tools, and drops you into an interactive session. +This installs Pi if needed, configures Ollama as a provider including web tools, and drops you into an interactive session. To configure without launching: @@ -80,6 +70,16 @@ Each kept experiment is automatically committed. Each failed one is reverted. Wh ## Manual setup +### Install + +Install [Pi](https://github.com/earendil-works/pi): + +```bash +npm install -g @earendil-works/pi-coding-agent +``` + + + Add a configuration block to `~/.pi/agent/models.json`: ```json