diff --git a/cmd/launch/integrations_test.go b/cmd/launch/integrations_test.go index 9a9d353fa..4cabd654b 100644 --- a/cmd/launch/integrations_test.go +++ b/cmd/launch/integrations_test.go @@ -1551,6 +1551,31 @@ func TestIntegration_Editor(t *testing.T) { } } +func TestIntegration_AutoInstallable(t *testing.T) { + tests := []struct { + name string + want bool + }{ + {"openclaw", true}, + {"pi", true}, + {"claude", false}, + {"codex", false}, + {"opencode", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := false + integration, err := integrationFor(tt.name) + if err == nil { + got = integration.autoInstallable + } + if got != tt.want { + t.Errorf("integrationFor(%q).autoInstallable = %v, want %v", tt.name, got, tt.want) + } + }) + } +} + func TestIntegrationModels(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) diff --git a/cmd/launch/launch_test.go b/cmd/launch/launch_test.go index 7e1146454..dcea0abf9 100644 --- a/cmd/launch/launch_test.go +++ b/cmd/launch/launch_test.go @@ -967,6 +967,40 @@ func TestLaunchIntegration_OpenclawInstallsBeforeConfigSideEffects(t *testing.T) } } +func TestLaunchIntegration_PiInstallsBeforeConfigSideEffects(t *testing.T) { + tmpDir := t.TempDir() + setLaunchTestHome(t, tmpDir) + withLauncherHooks(t) + + t.Setenv("PATH", t.TempDir()) + + editor := &launcherEditorRunner{} + withIntegrationOverride(t, "pi", editor) + + selectorCalled := false + DefaultMultiSelector = func(title string, items []ModelItem, preChecked []string) ([]string, error) { + selectorCalled = true + return []string{"llama3.2"}, nil + } + + err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "pi"}) + if err == nil { + t.Fatal("expected launch to fail before configuration when Pi is missing") + } + if !strings.Contains(err.Error(), "required dependencies are missing") { + t.Fatalf("expected install prerequisite error, got %v", err) + } + if selectorCalled { + t.Fatal("expected install check to happen before model selection") + } + if len(editor.edited) != 0 { + t.Fatalf("expected no editor writes before install succeeds, got %v", editor.edited) + } + if _, statErr := os.Stat(filepath.Join(tmpDir, ".pi", "agent", "models.json")); !os.IsNotExist(statErr) { + t.Fatalf("expected no Pi config file to be created, stat err = %v", statErr) + } +} + func TestLaunchIntegration_ConfigureOnlyDoesNotRequireInstalledBinary(t *testing.T) { tmpDir := t.TempDir() setLaunchTestHome(t, tmpDir) diff --git a/cmd/launch/pi.go b/cmd/launch/pi.go index e123c955c..d6d6b1dad 100644 --- a/cmd/launch/pi.go +++ b/cmd/launch/pi.go @@ -20,20 +20,151 @@ import ( // Pi implements Runner and Editor for Pi (Pi Coding Agent) integration type Pi struct{} +const ( + piNpmPackage = "@mariozechner/pi-coding-agent" + piWebSearchSource = "npm:@ollama/pi-web-search" + piWebSearchPkg = "@ollama/pi-web-search" +) + func (p *Pi) String() string { return "Pi" } func (p *Pi) Run(model string, args []string) error { - if _, err := exec.LookPath("pi"); err != nil { - return fmt.Errorf("pi is not installed, install with: npm install -g @mariozechner/pi-coding-agent") + fmt.Fprintf(os.Stderr, "\n%sPreparing Pi...%s\n", ansiGray, ansiReset) + if err := ensureNpmInstalled(); err != nil { + return err } - cmd := exec.Command("pi", args...) + fmt.Fprintf(os.Stderr, "%sChecking Pi installation...%s\n", ansiGray, ansiReset) + bin, err := ensurePiInstalled() + if err != nil { + return err + } + + ensurePiWebSearchPackage(bin) + + fmt.Fprintf(os.Stderr, "\n%sLaunching Pi...%s\n\n", ansiGray, ansiReset) + + cmd := exec.Command(bin, args...) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() } +func ensureNpmInstalled() error { + if _, err := exec.LookPath("npm"); err != nil { + return fmt.Errorf("npm (Node.js) is required to launch pi\n\nInstall it first:\n https://nodejs.org/") + } + return nil +} + +func ensurePiInstalled() (string, error) { + if _, err := exec.LookPath("pi"); err == nil { + return "pi", nil + } + + if _, err := exec.LookPath("npm"); err != nil { + return "", fmt.Errorf("pi is not installed and required dependencies are missing\n\nInstall the following first:\n npm (Node.js): https://nodejs.org/") + } + + ok, err := ConfirmPrompt("Pi is not installed. Install with npm?") + if err != nil { + return "", err + } + if !ok { + return "", fmt.Errorf("pi installation cancelled") + } + + 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 := 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") + } + + fmt.Fprintf(os.Stderr, "%sPi installed successfully%s\n\n", ansiGreen, ansiReset) + return "pi", nil +} + +func ensurePiWebSearchPackage(bin string) { + if !shouldManagePiWebSearch() { + fmt.Fprintf(os.Stderr, "%sCloud is disabled; skipping %s setup.%s\n", ansiGray, piWebSearchPkg, ansiReset) + return + } + + fmt.Fprintf(os.Stderr, "%sChecking Pi web search package...%s\n", ansiGray, ansiReset) + + installed, err := piPackageInstalled(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 { + fmt.Fprintf(os.Stderr, "%sInstalling %s...%s\n", ansiGray, piWebSearchPkg, ansiReset) + cmd := exec.Command(bin, "install", piWebSearchSource) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + fmt.Fprintf(os.Stderr, "%s Warning: could not install %s: %v%s\n", ansiYellow, piWebSearchPkg, err, ansiReset) + return + } + + fmt.Fprintf(os.Stderr, "%s ✓ Installed %s%s\n", ansiGreen, piWebSearchPkg, ansiReset) + return + } + + fmt.Fprintf(os.Stderr, "%sUpdating %s...%s\n", ansiGray, piWebSearchPkg, ansiReset) + cmd := exec.Command(bin, "update", piWebSearchSource) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + fmt.Fprintf(os.Stderr, "%s Warning: could not update %s: %v%s\n", ansiYellow, piWebSearchPkg, err, ansiReset) + return + } + + fmt.Fprintf(os.Stderr, "%s ✓ Updated %s%s\n", ansiGreen, piWebSearchPkg, ansiReset) +} + +func shouldManagePiWebSearch() bool { + client, err := api.ClientFromEnvironment() + if err != nil { + return true + } + + disabled, known := cloudStatusDisabled(context.Background(), client) + if known && disabled { + return false + } + return true +} + +func piPackageInstalled(bin, source string) (bool, error) { + cmd := exec.Command(bin, "list") + out, err := cmd.CombinedOutput() + if err != nil { + msg := strings.TrimSpace(string(out)) + if msg == "" { + return false, err + } + return false, fmt.Errorf("%w: %s", err, msg) + } + + for _, line := range strings.Split(string(out), "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, source) { + return true, nil + } + } + + return false, nil +} + func (p *Pi) Paths() []string { home, err := os.UserHomeDir() if err != nil { diff --git a/cmd/launch/pi_test.go b/cmd/launch/pi_test.go index fb869fdb8..4e6241031 100644 --- a/cmd/launch/pi_test.go +++ b/cmd/launch/pi_test.go @@ -9,6 +9,8 @@ import ( "net/url" "os" "path/filepath" + "runtime" + "strings" "testing" "github.com/ollama/ollama/api" @@ -33,6 +35,339 @@ func TestPiIntegration(t *testing.T) { }) } +func TestPiRun_InstallAndWebSearchLifecycle(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses POSIX shell test binaries") + } + + writeScript := func(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o755); err != nil { + t.Fatal(err) + } + } + + seedPiScript := func(t *testing.T, dir string) { + t.Helper() + piPath := filepath.Join(dir, "pi") + listPath := filepath.Join(dir, "pi-list.txt") + piScript := fmt.Sprintf(`#!/bin/sh +echo "$@" >> %q +if [ "$1" = "list" ]; then + if [ -f %q ]; then + /bin/cat %q + fi + exit 0 +fi +if [ "$1" = "update" ] && [ "$PI_FAIL_UPDATE" = "1" ]; then + echo "update failed" >&2 + exit 1 +fi +if [ "$1" = "install" ] && [ "$PI_FAIL_INSTALL" = "1" ]; then + echo "install failed" >&2 + exit 1 +fi +exit 0 +`, filepath.Join(dir, "pi.log"), listPath, listPath) + writeScript(t, piPath, piScript) + } + + seedNpmNoop := func(t *testing.T, dir string) { + t.Helper() + writeScript(t, filepath.Join(dir, "npm"), "#!/bin/sh\nexit 0\n") + } + + withConfirm := func(t *testing.T, fn func(prompt string) (bool, error)) { + t.Helper() + oldConfirm := DefaultConfirmPrompt + DefaultConfirmPrompt = fn + t.Cleanup(func() { DefaultConfirmPrompt = oldConfirm }) + } + + setCloudStatus := func(t *testing.T, disabled bool) { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/status" { + fmt.Fprintf(w, `{"cloud":{"disabled":%t,"source":"config"}}`, disabled) + return + } + http.NotFound(w, r) + })) + t.Cleanup(srv.Close) + t.Setenv("OLLAMA_HOST", srv.URL) + } + + 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 { + t.Fatal(err) + } + + npmScript := fmt.Sprintf(`#!/bin/sh +echo "$@" >> %q +if [ "$1" = "install" ] && [ "$2" = "-g" ] && [ "$3" = %q ]; then + /bin/cat > %q <<'EOS' +#!/bin/sh +echo "$@" >> %q +if [ "$1" = "list" ]; then + if [ -f %q ]; then + /bin/cat %q + fi + exit 0 +fi +exit 0 +EOS + /bin/chmod +x %q +fi +exit 0 +`, filepath.Join(tmpDir, "npm.log"), piNpmPackage+"@latest", filepath.Join(tmpDir, "pi"), filepath.Join(tmpDir, "pi.log"), filepath.Join(tmpDir, "pi-list.txt"), filepath.Join(tmpDir, "pi-list.txt"), filepath.Join(tmpDir, "pi")) + writeScript(t, filepath.Join(tmpDir, "npm"), npmScript) + + withConfirm(t, func(prompt string) (bool, error) { + if strings.Contains(prompt, "Pi is not installed.") { + return true, nil + } + return true, nil + }) + + p := &Pi{} + if err := p.Run("ignored", []string{"--version"}); err != nil { + t.Fatalf("Run() error = %v", err) + } + + npmCalls, err := os.ReadFile(filepath.Join(tmpDir, "npm.log")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(npmCalls), "install -g "+piNpmPackage+"@latest") { + t.Fatalf("expected npm install call, got:\n%s", npmCalls) + } + + piCalls, err := os.ReadFile(filepath.Join(tmpDir, "pi.log")) + if err != nil { + t.Fatal(err) + } + got := string(piCalls) + 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, "--version\n") { + t.Fatalf("expected final pi launch call, got:\n%s", got) + } + }) + + t.Run("pi missing + user declines install", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + t.Setenv("PATH", tmpDir) + setCloudStatus(t, false) + 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.") { + return false, nil + } + return true, nil + }) + + p := &Pi{} + err := p.Run("ignored", nil) + if err == nil || !strings.Contains(err.Error(), "pi installation cancelled") { + t.Fatalf("expected install cancellation error, got %v", err) + } + }) + + t.Run("pi installed + web search missing auto-installs", 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"), 0o644); err != nil { + t.Fatal(err) + } + seedPiScript(t, tmpDir) + seedNpmNoop(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", []string{"session"}); 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, "list\n") { + t.Fatalf("expected pi list call, got:\n%s", got) + } + if !strings.Contains(got, "install "+piWebSearchSource+"\n") { + t.Fatalf("expected pi install call, got:\n%s", got) + } + if strings.Contains(got, "update "+piWebSearchSource+"\n") { + t.Fatalf("did not expect pi update call when package missing, got:\n%s", got) + } + if !strings.Contains(got, "session\n") { + t.Fatalf("expected final pi launch call, got:\n%s", got) + } + }) + + t.Run("pi installed + web search present updates every launch", 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 { + t.Fatal(err) + } + seedPiScript(t, tmpDir) + seedNpmNoop(t, tmpDir) + + p := &Pi{} + if err := p.Run("ignored", []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("expected pi update call, got:\n%s", got) + } + }) + + t.Run("web search update failure warns and continues", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + 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) + } + seedPiScript(t, tmpDir) + seedNpmNoop(t, tmpDir) + + p := &Pi{} + stderr := captureStderr(t, func() { + if err := p.Run("ignored", []string{"session"}); err != nil { + t.Fatalf("Run() should continue after web search update failure, got %v", err) + } + }) + if !strings.Contains(stderr, "Warning: could not update "+piWebSearchPkg) { + t.Fatalf("expected update warning, got:\n%s", stderr) + } + + piCalls, err := os.ReadFile(filepath.Join(tmpDir, "pi.log")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(piCalls), "session\n") { + t.Fatalf("expected final pi launch call, got:\n%s", piCalls) + } + }) + + t.Run("web search install failure warns and continues", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + t.Setenv("PATH", tmpDir) + setCloudStatus(t, false) + t.Setenv("PI_FAIL_INSTALL", "1") + if err := os.WriteFile(filepath.Join(tmpDir, "pi-list.txt"), []byte("User packages:\n"), 0o644); err != nil { + t.Fatal(err) + } + seedPiScript(t, tmpDir) + seedNpmNoop(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", []string{"session"}); err != nil { + t.Fatalf("Run() should continue after web search install failure, got %v", err) + } + }) + if !strings.Contains(stderr, "Warning: could not install "+piWebSearchPkg) { + t.Fatalf("expected install warning, got:\n%s", stderr) + } + + piCalls, err := os.ReadFile(filepath.Join(tmpDir, "pi.log")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(piCalls), "session\n") { + t.Fatalf("expected final pi launch call, got:\n%s", piCalls) + } + }) + + t.Run("cloud disabled skips web search package management", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + t.Setenv("PATH", tmpDir) + setCloudStatus(t, true) + if err := os.WriteFile(filepath.Join(tmpDir, "pi-list.txt"), []byte("User packages:\n"), 0o644); err != nil { + t.Fatal(err) + } + seedPiScript(t, tmpDir) + seedNpmNoop(t, tmpDir) + + p := &Pi{} + stderr := captureStderr(t, func() { + if err := p.Run("ignored", []string{"session"}); err != nil { + t.Fatalf("Run() error = %v", err) + } + }) + if !strings.Contains(stderr, "Cloud is disabled; skipping "+piWebSearchPkg+" setup.") { + t.Fatalf("expected cloud-disabled skip message, got:\n%s", stderr) + } + + piCalls, err := os.ReadFile(filepath.Join(tmpDir, "pi.log")) + if err != nil { + t.Fatal(err) + } + got := string(piCalls) + if strings.Contains(got, "list\n") || strings.Contains(got, "install "+piWebSearchSource+"\n") || strings.Contains(got, "update "+piWebSearchSource+"\n") { + t.Fatalf("did not expect web search package management calls, got:\n%s", got) + } + if !strings.Contains(got, "session\n") { + t.Fatalf("expected final pi launch call, got:\n%s", got) + } + }) + + t.Run("missing npm returns error before pi flow", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + t.Setenv("PATH", tmpDir) + setCloudStatus(t, false) + seedPiScript(t, tmpDir) + + p := &Pi{} + err := p.Run("ignored", []string{"session"}) + if err == nil || !strings.Contains(err.Error(), "npm (Node.js) is required to launch pi") { + t.Fatalf("expected missing npm error, got %v", err) + } + + if _, statErr := os.Stat(filepath.Join(tmpDir, "pi.log")); !os.IsNotExist(statErr) { + t.Fatalf("expected pi not to run when npm is missing, stat err = %v", statErr) + } + }) +} + func TestPiPaths(t *testing.T) { pi := &Pi{} diff --git a/cmd/launch/registry.go b/cmd/launch/registry.go index 9ddebe560..482aac02c 100644 --- a/cmd/launch/registry.go +++ b/cmd/launch/registry.go @@ -129,7 +129,11 @@ var integrationSpecs = []*IntegrationSpec{ _, err := exec.LookPath("pi") return err == nil }, - Command: []string{"npm", "install", "-g", "@mariozechner/pi-coding-agent"}, + EnsureInstalled: func() error { + _, err := ensurePiInstalled() + return err + }, + Command: []string{"npm", "install", "-g", "@mariozechner/pi-coding-agent@latest"}, }, }, { diff --git a/cmd/launch/runner_exec_only_test.go b/cmd/launch/runner_exec_only_test.go index 97dbab9c9..66cee064e 100644 --- a/cmd/launch/runner_exec_only_test.go +++ b/cmd/launch/runner_exec_only_test.go @@ -54,6 +54,9 @@ func TestEditorRunsDoNotRewriteConfig(t *testing.T) { binDir := t.TempDir() writeFakeBinary(t, binDir, tt.binary) + if tt.name == "pi" { + writeFakeBinary(t, binDir, "npm") + } t.Setenv("PATH", binDir) configPath := tt.checkPath(home) diff --git a/docs/integrations/pi.mdx b/docs/integrations/pi.mdx index fd2dadbed..179c04045 100644 --- a/docs/integrations/pi.mdx +++ b/docs/integrations/pi.mdx @@ -26,6 +26,17 @@ To configure without launching: ollama launch pi --config ``` +## Web search + +Pi can use web search and fetch tools via the `@ollama/pi-web-search` package. + +When launching Pi through Ollama, package install/update is managed automatically. +To install manually: + +```bash +pi install npm:@ollama/pi-web-search +``` + ### Manual setup Add a configuration block to `~/.pi/agent/models.json`: