launch: migrate pi (#16213)
This commit is contained in:
387
cmd/launch/pi.go
387
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 <prefix>/lib/node_modules.
|
||||
return pathDirForSeparator(rootDir, separator)
|
||||
}
|
||||
if goos == "windows" {
|
||||
// Windows npm global root is usually <prefix>\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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"},
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user