mirror of
https://github.com/ollama/ollama.git
synced 2026-07-23 09:10:53 -05:00
launch: claude app (#15937)
This commit is contained in:
@@ -1201,15 +1201,16 @@ func (db *database) getSettings() (Settings, error) {
|
|||||||
func (db *database) setSettings(s Settings) error {
|
func (db *database) setSettings(s Settings) error {
|
||||||
lastHomeView := strings.ToLower(strings.TrimSpace(s.LastHomeView))
|
lastHomeView := strings.ToLower(strings.TrimSpace(s.LastHomeView))
|
||||||
validLaunchView := map[string]struct{}{
|
validLaunchView := map[string]struct{}{
|
||||||
"launch": {},
|
"launch": {},
|
||||||
"openclaw": {},
|
"openclaw": {},
|
||||||
"claude": {},
|
"claude": {},
|
||||||
"hermes": {},
|
"claude-desktop": {},
|
||||||
"codex": {},
|
"hermes": {},
|
||||||
"copilot": {},
|
"codex": {},
|
||||||
"opencode": {},
|
"copilot": {},
|
||||||
"droid": {},
|
"opencode": {},
|
||||||
"pi": {},
|
"droid": {},
|
||||||
|
"pi": {},
|
||||||
}
|
}
|
||||||
if lastHomeView != "chat" {
|
if lastHomeView != "chat" {
|
||||||
if _, ok := validLaunchView[lastHomeView]; !ok {
|
if _, ok := validLaunchView[lastHomeView]; !ok {
|
||||||
|
|||||||
1
app/ui/app/public/launch-icons/claude-code.svg
Normal file
1
app/ui/app/public/launch-icons/claude-code.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude Code</title><path clip-rule="evenodd" d="M20.998 10.949H24v3.102h-3v3.028h-1.487V20H18v-2.921h-1.487V20H15v-2.921H9V20H7.488v-2.921H6V20H4.487v-2.921H3V14.05H0V10.95h3V5h17.998v5.949zM6 10.949h1.488V8.102H6v2.847zm10.51 0H18V8.102h-1.49v2.847z" fill="#D97757" fill-rule="evenodd"></path></svg>
|
||||||
|
After Width: | Height: | Size: 424 B |
@@ -13,6 +13,22 @@ interface LaunchCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const LAUNCH_COMMANDS: LaunchCommand[] = [
|
const LAUNCH_COMMANDS: LaunchCommand[] = [
|
||||||
|
{
|
||||||
|
id: "claude-desktop",
|
||||||
|
name: "Claude Desktop",
|
||||||
|
command: "ollama launch claude-desktop",
|
||||||
|
description: "Claude Desktop with Ollama Cloud",
|
||||||
|
icon: "/launch-icons/claude.svg",
|
||||||
|
iconClassName: "h-7 w-7",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "claude",
|
||||||
|
name: "Claude Code",
|
||||||
|
command: "ollama launch claude",
|
||||||
|
description: "Anthropic's coding tool with subagents",
|
||||||
|
icon: "/launch-icons/claude-code.svg",
|
||||||
|
iconClassName: "h-7 w-7",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "openclaw",
|
id: "openclaw",
|
||||||
name: "OpenClaw",
|
name: "OpenClaw",
|
||||||
@@ -21,20 +37,11 @@ const LAUNCH_COMMANDS: LaunchCommand[] = [
|
|||||||
icon: "/launch-icons/openclaw.svg",
|
icon: "/launch-icons/openclaw.svg",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "claude",
|
id: "hermes",
|
||||||
name: "Claude",
|
name: "Hermes Agent",
|
||||||
command: "ollama launch claude",
|
command: "ollama launch hermes",
|
||||||
description: "Anthropic's coding tool with subagents",
|
description: "Self-improving AI agent built by Nous Research",
|
||||||
icon: "/launch-icons/claude.svg",
|
icon: "/launch-icons/hermes-agent.svg",
|
||||||
iconClassName: "h-7 w-7",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "codex",
|
|
||||||
name: "Codex",
|
|
||||||
command: "ollama launch codex",
|
|
||||||
description: "OpenAI's open-source coding agent",
|
|
||||||
icon: "/launch-icons/codex.svg",
|
|
||||||
darkIcon: "/launch-icons/codex-dark.svg",
|
|
||||||
iconClassName: "h-7 w-7",
|
iconClassName: "h-7 w-7",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -46,11 +53,12 @@ const LAUNCH_COMMANDS: LaunchCommand[] = [
|
|||||||
iconClassName: "h-7 w-7 rounded",
|
iconClassName: "h-7 w-7 rounded",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "hermes",
|
id: "codex",
|
||||||
name: "Hermes Agent",
|
name: "Codex",
|
||||||
command: "ollama launch hermes",
|
command: "ollama launch codex",
|
||||||
description: "Self-improving AI agent built by Nous Research",
|
description: "OpenAI's open-source coding agent",
|
||||||
icon: "/launch-icons/hermes-agent.svg",
|
icon: "/launch-icons/codex.svg",
|
||||||
|
darkIcon: "/launch-icons/codex-dark.svg",
|
||||||
iconClassName: "h-7 w-7",
|
iconClassName: "h-7 w-7",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
12
cmd/cmd.go
12
cmd/cmd.go
@@ -2119,8 +2119,7 @@ func runLauncherAction(cmd *cobra.Command, action tui.TUIAction, deps launcherDe
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return true, fmt.Errorf("launching %s: %w", action.Integration, err)
|
return true, fmt.Errorf("launching %s: %w", action.Integration, err)
|
||||||
}
|
}
|
||||||
// VS Code is a GUI app — exit the TUI loop after launching
|
if launcherActionExitsLoop(action.Integration) {
|
||||||
if action.Integration == "vscode" {
|
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
@@ -2129,6 +2128,15 @@ func runLauncherAction(cmd *cobra.Command, action tui.TUIAction, deps launcherDe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func launcherActionExitsLoop(integration string) bool {
|
||||||
|
switch integration {
|
||||||
|
case "claude-desktop", "vscode":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func NewCLI() *cobra.Command {
|
func NewCLI() *cobra.Command {
|
||||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||||
cobra.EnableCommandSorting = false
|
cobra.EnableCommandSorting = false
|
||||||
|
|||||||
@@ -209,29 +209,30 @@ func TestRunLauncherAction_RunModelContinuesAfterCancellation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunLauncherAction_VSCodeExitsTUILoop(t *testing.T) {
|
func TestRunLauncherAction_GUIAppsExitTUILoop(t *testing.T) {
|
||||||
setCmdTestHome(t, t.TempDir())
|
setCmdTestHome(t, t.TempDir())
|
||||||
|
|
||||||
cmd := &cobra.Command{}
|
cmd := &cobra.Command{}
|
||||||
cmd.SetContext(context.Background())
|
cmd.SetContext(context.Background())
|
||||||
|
|
||||||
// VS Code should exit the TUI loop (return false) after a successful launch.
|
for _, integration := range []string{"claude-desktop", "vscode"} {
|
||||||
continueLoop, err := runLauncherAction(cmd, tui.TUIAction{Kind: tui.TUIActionLaunchIntegration, Integration: "vscode"}, launcherDeps{
|
continueLoop, err := runLauncherAction(cmd, tui.TUIAction{Kind: tui.TUIActionLaunchIntegration, Integration: integration}, launcherDeps{
|
||||||
resolveRunModel: unexpectedRunModelResolution(t),
|
resolveRunModel: unexpectedRunModelResolution(t),
|
||||||
launchIntegration: func(ctx context.Context, req launch.IntegrationLaunchRequest) error {
|
launchIntegration: func(ctx context.Context, req launch.IntegrationLaunchRequest) error {
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
runModel: unexpectedModelLaunch(t),
|
runModel: unexpectedModelLaunch(t),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected nil error, got %v", err)
|
t.Fatalf("expected nil error for %s, got %v", integration, err)
|
||||||
}
|
}
|
||||||
if continueLoop {
|
if continueLoop {
|
||||||
t.Fatal("expected vscode launch to exit the TUI loop (return false)")
|
t.Fatalf("expected %s launch to exit the TUI loop (return false)", integration)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Other integrations should continue the TUI loop (return true).
|
// Other integrations should continue the TUI loop (return true).
|
||||||
continueLoop, err = runLauncherAction(cmd, tui.TUIAction{Kind: tui.TUIActionLaunchIntegration, Integration: "claude"}, launcherDeps{
|
continueLoop, err := runLauncherAction(cmd, tui.TUIAction{Kind: tui.TUIActionLaunchIntegration, Integration: "claude"}, launcherDeps{
|
||||||
resolveRunModel: unexpectedRunModelResolution(t),
|
resolveRunModel: unexpectedRunModelResolution(t),
|
||||||
launchIntegration: func(ctx context.Context, req launch.IntegrationLaunchRequest) error {
|
launchIntegration: func(ctx context.Context, req launch.IntegrationLaunchRequest) error {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
889
cmd/launch/claude_desktop.go
Normal file
889
cmd/launch/claude_desktop.go
Normal file
@@ -0,0 +1,889 @@
|
|||||||
|
package launch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ollama/ollama/cmd/config"
|
||||||
|
"github.com/ollama/ollama/cmd/internal/fileutil"
|
||||||
|
"golang.org/x/term"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
claudeDesktopIntegrationName = "claude-desktop"
|
||||||
|
claudeDesktopProfileName = "Ollama"
|
||||||
|
claudeDesktopProfileID = "00000000-0000-4000-8000-000000000114"
|
||||||
|
claudeDesktopGatewayBaseURL = "https://ollama.com"
|
||||||
|
claudeDesktopAPIKeyURL = "https://ollama.com/settings/keys"
|
||||||
|
claudeDesktopModelLabel = "Ollama Cloud"
|
||||||
|
claudeDesktopSuccessMessage = "Claude Desktop profile changed to Ollama Cloud."
|
||||||
|
claudeDesktopRestoreMessage = "To restore the usual Claude profile, run: ollama launch claude-desktop --restore"
|
||||||
|
claudeDesktopRestoredMessage = "Claude Desktop restored to the usual Claude profile."
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
claudeDesktopGOOS = runtime.GOOS
|
||||||
|
claudeDesktopUserHome = os.UserHomeDir
|
||||||
|
claudeDesktopStat = os.Stat
|
||||||
|
claudeDesktopOpenApp = defaultClaudeDesktopOpenApp
|
||||||
|
claudeDesktopOpenAppPath = defaultClaudeDesktopOpenAppPath
|
||||||
|
claudeDesktopQuitApp = defaultClaudeDesktopQuitApp
|
||||||
|
claudeDesktopIsRunning = defaultClaudeDesktopIsRunning
|
||||||
|
claudeDesktopRunningAppPath = defaultClaudeDesktopRunningAppPath
|
||||||
|
claudeDesktopGlob = filepath.Glob
|
||||||
|
claudeDesktopSleep = time.Sleep
|
||||||
|
claudeDesktopHTTPClient = http.DefaultClient
|
||||||
|
claudeDesktopPromptAPIKey = promptClaudeDesktopAPIKey
|
||||||
|
claudeDesktopValidateAPIKey = validateClaudeDesktopAPIKey
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClaudeDesktop configures and launches Claude Desktop in third-party
|
||||||
|
// inference mode using Ollama Cloud as the gateway.
|
||||||
|
type ClaudeDesktop struct{}
|
||||||
|
|
||||||
|
func (c *ClaudeDesktop) String() string { return "Claude Desktop" }
|
||||||
|
|
||||||
|
func (c *ClaudeDesktop) Supported() error { return claudeDesktopSupported() }
|
||||||
|
|
||||||
|
func (c *ClaudeDesktop) Paths() []string {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ClaudeDesktop) AutodiscoveredModel() string {
|
||||||
|
return claudeDesktopModelLabel
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ClaudeDesktop) ConfigureAutodiscovery() error {
|
||||||
|
if err := claudeDesktopSupported(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
targets, err := claudeDesktopTargetPaths()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := claudeDesktopValidatedAPIKey(context.Background(), claudeDesktopTargetProfilePaths(targets))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, path := range targets.normalConfigs {
|
||||||
|
if err := writeClaudeDesktopDeploymentMode(path, "3p"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, target := range targets.thirdPartyProfiles {
|
||||||
|
if err := writeClaudeDesktopDeploymentMode(target.desktopConfig, "3p"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := writeClaudeDesktopMeta(target.meta, claudeDesktopProfileID, claudeDesktopProfileName); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := writeClaudeDesktopGatewayProfile(target.profile, key, true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ClaudeDesktop) RestoreHint() string {
|
||||||
|
return claudeDesktopRestoreMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ClaudeDesktop) ConfigurationSuccessMessage() string {
|
||||||
|
return claudeDesktopSuccessMessage + "\n" + claudeDesktopRestoreMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ClaudeDesktop) RestoreSuccessMessage() string {
|
||||||
|
return claudeDesktopRestoredMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ClaudeDesktop) AutodiscoveryConfigured() bool {
|
||||||
|
targets, err := claudeDesktopTargetPaths()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return claudeDesktopTargetsConfigured(targets)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ClaudeDesktop) Onboard() error {
|
||||||
|
return config.MarkIntegrationOnboarded(claudeDesktopIntegrationName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ClaudeDesktop) RequiresInteractiveOnboarding() bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ClaudeDesktop) SkipModelReadiness() bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ClaudeDesktop) Run(_ string, args []string) error {
|
||||||
|
if err := claudeDesktopSupported(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(args) > 0 {
|
||||||
|
return fmt.Errorf("claude-desktop does not accept extra arguments")
|
||||||
|
}
|
||||||
|
return claudeDesktopLaunchOrRestart("Restart Claude Desktop to use Ollama?")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ClaudeDesktop) Restore() error {
|
||||||
|
if err := claudeDesktopSupported(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
targets, err := claudeDesktopTargetPaths()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, path := range targets.normalConfigs {
|
||||||
|
if err := writeClaudeDesktopDeploymentMode(path, "1p"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, target := range targets.thirdPartyProfiles {
|
||||||
|
if err := writeClaudeDesktopDeploymentMode(target.desktopConfig, "1p"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := restoreClaudeDesktopMeta(target.meta); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := restoreClaudeDesktopOllamaProfile(target.profile); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return claudeDesktopLaunchOrRestart("Restart Claude Desktop to use the usual Claude profile?")
|
||||||
|
}
|
||||||
|
|
||||||
|
func claudeDesktopSupported() error {
|
||||||
|
switch claudeDesktopGOOS {
|
||||||
|
case "darwin", "windows":
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("Claude Desktop launch is only supported on macOS and Windows")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func claudeDesktopInstalled() bool {
|
||||||
|
if claudeDesktopAppPath() != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if claudeDesktopGOOS == "windows" && claudeDesktopIsRunning() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, dir := range claudeDesktopProfileDirCandidates(false) {
|
||||||
|
if _, err := claudeDesktopStat(dir); err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func claudeDesktopAppPath() string {
|
||||||
|
if claudeDesktopGOOS != "darwin" && claudeDesktopGOOS != "windows" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, path := range claudeDesktopAppCandidates() {
|
||||||
|
if _, err := claudeDesktopStat(path); err == nil {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func claudeDesktopAppCandidates() []string {
|
||||||
|
switch claudeDesktopGOOS {
|
||||||
|
case "darwin":
|
||||||
|
return claudeDesktopDarwinAppCandidates()
|
||||||
|
case "windows":
|
||||||
|
return claudeDesktopWindowsAppCandidates()
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func claudeDesktopDarwinAppCandidates() []string {
|
||||||
|
candidates := []string{"/Applications/Claude.app"}
|
||||||
|
if home, err := claudeDesktopUserHome(); err == nil {
|
||||||
|
candidates = append(candidates, filepath.Join(home, "Applications", "Claude.app"))
|
||||||
|
}
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
func claudeDesktopWindowsAppCandidates() []string {
|
||||||
|
local, err := claudeDesktopLocalAppData()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates := []string{
|
||||||
|
filepath.Join(local, "Programs", "Claude", "Claude.exe"),
|
||||||
|
filepath.Join(local, "Programs", "Claude Desktop", "Claude.exe"),
|
||||||
|
filepath.Join(local, "Claude", "Claude.exe"),
|
||||||
|
filepath.Join(local, "Claude Nest", "Claude.exe"),
|
||||||
|
filepath.Join(local, "Claude Desktop", "Claude.exe"),
|
||||||
|
filepath.Join(local, "AnthropicClaude", "Claude.exe"),
|
||||||
|
}
|
||||||
|
for _, pattern := range []string{
|
||||||
|
filepath.Join(local, "AnthropicClaude", "app-*", "Claude.exe"),
|
||||||
|
filepath.Join(local, "Programs", "Claude", "app-*", "Claude.exe"),
|
||||||
|
filepath.Join(local, "Programs", "Claude Desktop", "app-*", "Claude.exe"),
|
||||||
|
} {
|
||||||
|
matches, _ := claudeDesktopGlob(pattern)
|
||||||
|
candidates = append(candidates, matches...)
|
||||||
|
}
|
||||||
|
return claudeDesktopDedupePaths(candidates)
|
||||||
|
}
|
||||||
|
|
||||||
|
func claudeDesktopDedupePaths(paths []string) []string {
|
||||||
|
out := make([]string, 0, len(paths))
|
||||||
|
seen := make(map[string]bool, len(paths))
|
||||||
|
for _, path := range paths {
|
||||||
|
if strings.TrimSpace(path) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.ToLower(path)
|
||||||
|
if seen[key] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
out = append(out, path)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
type claudeDesktopPaths struct {
|
||||||
|
normalConfig string
|
||||||
|
desktopConfig string
|
||||||
|
meta string
|
||||||
|
profile string
|
||||||
|
}
|
||||||
|
|
||||||
|
type claudeDesktopThirdPartyPaths struct {
|
||||||
|
desktopConfig string
|
||||||
|
meta string
|
||||||
|
profile string
|
||||||
|
}
|
||||||
|
|
||||||
|
type claudeDesktopTargets struct {
|
||||||
|
normalConfigs []string
|
||||||
|
thirdPartyProfiles []claudeDesktopThirdPartyPaths
|
||||||
|
}
|
||||||
|
|
||||||
|
func claudeDesktopConfigPaths() (claudeDesktopPaths, error) {
|
||||||
|
switch claudeDesktopGOOS {
|
||||||
|
case "darwin":
|
||||||
|
return claudeDesktopDarwinConfigPaths()
|
||||||
|
case "windows":
|
||||||
|
return claudeDesktopWindowsConfigPaths()
|
||||||
|
default:
|
||||||
|
return claudeDesktopPaths{}, claudeDesktopSupported()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func claudeDesktopDarwinConfigPaths() (claudeDesktopPaths, error) {
|
||||||
|
normalRoots, thirdPartyRoots, err := claudeDesktopDarwinProfileRoots()
|
||||||
|
if err != nil {
|
||||||
|
return claudeDesktopPaths{}, err
|
||||||
|
}
|
||||||
|
normalBase := normalRoots[0]
|
||||||
|
thirdPartyBase := thirdPartyRoots[0]
|
||||||
|
return claudeDesktopPaths{
|
||||||
|
normalConfig: filepath.Join(normalBase, "claude_desktop_config.json"),
|
||||||
|
desktopConfig: filepath.Join(thirdPartyBase, "claude_desktop_config.json"),
|
||||||
|
meta: filepath.Join(thirdPartyBase, "configLibrary", "_meta.json"),
|
||||||
|
profile: filepath.Join(thirdPartyBase, "configLibrary", claudeDesktopProfileID+".json"),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func claudeDesktopWindowsConfigPaths() (claudeDesktopPaths, error) {
|
||||||
|
normalBase, err := claudeDesktopProfileDir(true)
|
||||||
|
if err != nil {
|
||||||
|
return claudeDesktopPaths{}, err
|
||||||
|
}
|
||||||
|
thirdPartyBase, err := claudeDesktopProfileDir(false)
|
||||||
|
if err != nil {
|
||||||
|
return claudeDesktopPaths{}, err
|
||||||
|
}
|
||||||
|
return claudeDesktopPaths{
|
||||||
|
normalConfig: filepath.Join(normalBase, "claude_desktop_config.json"),
|
||||||
|
desktopConfig: filepath.Join(thirdPartyBase, "claude_desktop_config.json"),
|
||||||
|
meta: filepath.Join(thirdPartyBase, "configLibrary", "_meta.json"),
|
||||||
|
profile: filepath.Join(thirdPartyBase, "configLibrary", claudeDesktopProfileID+".json"),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func claudeDesktopProfileDir(normal bool) (string, error) {
|
||||||
|
candidates := claudeDesktopProfileDirCandidates(normal)
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return "", fmt.Errorf("Claude Desktop profile directory could not be resolved")
|
||||||
|
}
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
if _, err := claudeDesktopStat(candidate); err == nil {
|
||||||
|
return candidate, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return candidates[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func claudeDesktopProfileDirCandidates(normal bool) []string {
|
||||||
|
if claudeDesktopGOOS != "windows" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
normalRoots, thirdPartyRoots, err := claudeDesktopWindowsProfileRoots()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if normal {
|
||||||
|
return normalRoots
|
||||||
|
}
|
||||||
|
return thirdPartyRoots
|
||||||
|
}
|
||||||
|
|
||||||
|
func claudeDesktopDarwinProfileRoots() ([]string, []string, error) {
|
||||||
|
home, err := claudeDesktopUserHome()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
base := filepath.Join(home, "Library", "Application Support")
|
||||||
|
return []string{filepath.Join(base, "Claude")}, []string{filepath.Join(base, "Claude-3p")}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func claudeDesktopWindowsProfileRoots() ([]string, []string, error) {
|
||||||
|
local, err := claudeDesktopLocalAppData()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
normalRoots := []string{
|
||||||
|
filepath.Join(local, "Claude"),
|
||||||
|
filepath.Join(local, "Claude Nest"),
|
||||||
|
}
|
||||||
|
thirdPartyRoots := []string{
|
||||||
|
filepath.Join(local, "Claude-3p"),
|
||||||
|
filepath.Join(local, "Claude Nest-3p"),
|
||||||
|
}
|
||||||
|
return normalRoots, thirdPartyRoots, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func claudeDesktopTargetPaths() (claudeDesktopTargets, error) {
|
||||||
|
var (
|
||||||
|
normalRoots []string
|
||||||
|
thirdPartyRoots []string
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
|
switch claudeDesktopGOOS {
|
||||||
|
case "darwin":
|
||||||
|
normalRoots, thirdPartyRoots, err = claudeDesktopDarwinProfileRoots()
|
||||||
|
case "windows":
|
||||||
|
normalRoots, thirdPartyRoots, err = claudeDesktopWindowsProfileRoots()
|
||||||
|
default:
|
||||||
|
err = claudeDesktopSupported()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return claudeDesktopTargets{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return newClaudeDesktopTargets(normalRoots, thirdPartyRoots), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newClaudeDesktopTargets(normalRoots, thirdPartyRoots []string) claudeDesktopTargets {
|
||||||
|
targets := claudeDesktopTargets{}
|
||||||
|
for _, root := range claudeDesktopDedupePaths(normalRoots) {
|
||||||
|
targets.normalConfigs = append(targets.normalConfigs, filepath.Join(root, "claude_desktop_config.json"))
|
||||||
|
}
|
||||||
|
for _, root := range claudeDesktopDedupePaths(thirdPartyRoots) {
|
||||||
|
targets.thirdPartyProfiles = append(targets.thirdPartyProfiles, claudeDesktopThirdPartyPaths{
|
||||||
|
desktopConfig: filepath.Join(root, "claude_desktop_config.json"),
|
||||||
|
meta: filepath.Join(root, "configLibrary", "_meta.json"),
|
||||||
|
profile: filepath.Join(root, "configLibrary", claudeDesktopProfileID+".json"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return targets
|
||||||
|
}
|
||||||
|
|
||||||
|
func claudeDesktopTargetProfilePaths(targets claudeDesktopTargets) []string {
|
||||||
|
paths := make([]string, 0, len(targets.thirdPartyProfiles))
|
||||||
|
for _, target := range targets.thirdPartyProfiles {
|
||||||
|
paths = append(paths, target.profile)
|
||||||
|
}
|
||||||
|
return paths
|
||||||
|
}
|
||||||
|
|
||||||
|
func claudeDesktopLocalAppData() (string, error) {
|
||||||
|
if local := strings.TrimSpace(os.Getenv("LOCALAPPDATA")); local != "" {
|
||||||
|
return local, nil
|
||||||
|
}
|
||||||
|
if home := strings.TrimSpace(os.Getenv("USERPROFILE")); home != "" {
|
||||||
|
return filepath.Join(home, "AppData", "Local"), nil
|
||||||
|
}
|
||||||
|
home, err := claudeDesktopUserHome()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(home, "AppData", "Local"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type claudeDesktopAPIKeySource int
|
||||||
|
|
||||||
|
const (
|
||||||
|
claudeDesktopAPIKeySourceNone claudeDesktopAPIKeySource = iota
|
||||||
|
claudeDesktopAPIKeySourceEnv
|
||||||
|
claudeDesktopAPIKeySourceProfile
|
||||||
|
)
|
||||||
|
|
||||||
|
func claudeDesktopValidatedAPIKey(ctx context.Context, profilePaths []string) (string, error) {
|
||||||
|
key, source, err := claudeDesktopAPIKey(profilePaths)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := claudeDesktopValidateAPIKey(ctx, key); err == nil {
|
||||||
|
return key, nil
|
||||||
|
} else if source != claudeDesktopAPIKeySourceProfile || !canPromptClaudeDesktopAPIKey() {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return promptValidClaudeDesktopAPIKey(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func claudeDesktopAPIKey(profilePaths []string) (string, claudeDesktopAPIKeySource, error) {
|
||||||
|
if key := strings.TrimSpace(os.Getenv("OLLAMA_API_KEY")); key != "" {
|
||||||
|
return key, claudeDesktopAPIKeySourceEnv, nil
|
||||||
|
}
|
||||||
|
for _, profilePath := range profilePaths {
|
||||||
|
if key := readClaudeDesktopGatewayAPIKey(profilePath); key != "" {
|
||||||
|
return key, claudeDesktopAPIKeySourceProfile, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
key, err := promptClaudeDesktopAPIKeyValue()
|
||||||
|
return key, claudeDesktopAPIKeySourceNone, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func canPromptClaudeDesktopAPIKey() bool {
|
||||||
|
return isInteractiveSession() && !currentLaunchConfirmPolicy.requireYesMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
func promptValidClaudeDesktopAPIKey(ctx context.Context) (string, error) {
|
||||||
|
key, err := promptClaudeDesktopAPIKeyValue()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := claudeDesktopValidateAPIKey(ctx, key); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func promptClaudeDesktopAPIKeyValue() (string, error) {
|
||||||
|
if !canPromptClaudeDesktopAPIKey() {
|
||||||
|
return "", missingClaudeDesktopAPIKeyError()
|
||||||
|
}
|
||||||
|
key, err := claudeDesktopPromptAPIKey()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
if key == "" {
|
||||||
|
return "", missingClaudeDesktopAPIKeyError()
|
||||||
|
}
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func missingClaudeDesktopAPIKeyError() error {
|
||||||
|
return fmt.Errorf("OLLAMA_API_KEY is required for Claude Desktop. Create an API key at %s, then re-run with OLLAMA_API_KEY set", claudeDesktopAPIKeyURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func promptClaudeDesktopAPIKey() (string, error) {
|
||||||
|
fmt.Fprint(os.Stderr, claudeDesktopAPIKeyPrompt())
|
||||||
|
key, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||||
|
fmt.Fprintln(os.Stderr)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(key), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func claudeDesktopAPIKeyPrompt() string {
|
||||||
|
return fmt.Sprintf("Create an Ollama API key at %s\nEnter Ollama API key (input hidden): ", claudeDesktopAPIKeyURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readClaudeDesktopGatewayAPIKey(path string) string {
|
||||||
|
cfg, err := readClaudeDesktopJSON(path)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
key, _ := cfg["inferenceGatewayApiKey"].(string)
|
||||||
|
return strings.TrimSpace(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateClaudeDesktopAPIKey(ctx context.Context, key string) error {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if claudeDesktopAPIKeyHasInvalidHeaderChars(key) {
|
||||||
|
return claudeDesktopAPIKeyVerificationError()
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, claudeDesktopGatewayBaseURL+"/v1/models", nil)
|
||||||
|
if err != nil {
|
||||||
|
return claudeDesktopAPIKeyVerificationError()
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+key)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := claudeDesktopHTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return claudeDesktopAPIKeyVerificationError()
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10))
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden:
|
||||||
|
return fmt.Errorf("Ollama API key was rejected; create a valid key at %s", claudeDesktopAPIKeyURL)
|
||||||
|
case resp.StatusCode >= 200 && resp.StatusCode < 300:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("could not verify Ollama API key; ollama.com returned status %d, try again later", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func claudeDesktopAPIKeyHasInvalidHeaderChars(key string) bool {
|
||||||
|
return strings.ContainsFunc(key, func(r rune) bool {
|
||||||
|
return r < ' ' || r == 0x7f
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func claudeDesktopAPIKeyVerificationError() error {
|
||||||
|
return fmt.Errorf("could not verify Ollama API key; copy a key from %s and try again", claudeDesktopAPIKeyURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeClaudeDesktopDeploymentMode(path, mode string) error {
|
||||||
|
cfg, err := readClaudeDesktopJSONAllowMissing(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parse Claude Desktop config: %w", err)
|
||||||
|
}
|
||||||
|
cfg["deploymentMode"] = mode
|
||||||
|
return writeClaudeDesktopJSON(path, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeClaudeDesktopMeta(path, id, name string) error {
|
||||||
|
meta, err := readClaudeDesktopJSONAllowMissing(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parse Claude Desktop config metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
meta["appliedId"] = id
|
||||||
|
entries := make([]any, 0)
|
||||||
|
for _, entry := range claudeDesktopAnySlice(meta["entries"]) {
|
||||||
|
entryMap, _ := entry.(map[string]any)
|
||||||
|
if entryMap == nil {
|
||||||
|
entries = append(entries, entry)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if entryID, _ := entryMap["id"].(string); entryID == id {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entries = append(entries, entryMap)
|
||||||
|
}
|
||||||
|
entries = append(entries, map[string]any{
|
||||||
|
"id": id,
|
||||||
|
"name": name,
|
||||||
|
})
|
||||||
|
meta["entries"] = entries
|
||||||
|
return writeClaudeDesktopJSON(path, meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeClaudeDesktopGatewayProfile(path string, apiKey string, forceChooser bool) error {
|
||||||
|
cfg, err := readClaudeDesktopJSONAllowMissing(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parse Claude Desktop Ollama profile: %w", err)
|
||||||
|
}
|
||||||
|
cfg["inferenceProvider"] = "gateway"
|
||||||
|
cfg["inferenceGatewayBaseUrl"] = claudeDesktopGatewayBaseURL
|
||||||
|
cfg["inferenceGatewayApiKey"] = apiKey
|
||||||
|
cfg["inferenceGatewayAuthScheme"] = "bearer"
|
||||||
|
delete(cfg, "inferenceModels")
|
||||||
|
cfg["disableDeploymentModeChooser"] = forceChooser
|
||||||
|
return writeClaudeDesktopJSON(path, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func restoreClaudeDesktopMeta(path string) error {
|
||||||
|
meta, err := readClaudeDesktopJSONAllowMissing(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parse Claude Desktop config metadata: %w", err)
|
||||||
|
}
|
||||||
|
if len(meta) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
changed := false
|
||||||
|
if appliedID, _ := meta["appliedId"].(string); appliedID == claudeDesktopProfileID {
|
||||||
|
delete(meta, "appliedId")
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
entries := claudeDesktopAnySlice(meta["entries"])
|
||||||
|
if entries != nil {
|
||||||
|
filtered := make([]any, 0, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
entryMap, _ := entry.(map[string]any)
|
||||||
|
if entryID, _ := entryMap["id"].(string); entryID == claudeDesktopProfileID {
|
||||||
|
changed = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, entry)
|
||||||
|
}
|
||||||
|
meta["entries"] = filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
if !changed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return writeClaudeDesktopJSON(path, meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
func restoreClaudeDesktopOllamaProfile(path string) error {
|
||||||
|
cfg, err := readClaudeDesktopJSONAllowMissing(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parse Claude Desktop Ollama profile: %w", err)
|
||||||
|
}
|
||||||
|
if len(cfg) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cfg["disableDeploymentModeChooser"] = false
|
||||||
|
delete(cfg, "inferenceProvider")
|
||||||
|
delete(cfg, "inferenceGatewayBaseUrl")
|
||||||
|
delete(cfg, "inferenceGatewayAuthScheme")
|
||||||
|
delete(cfg, "inferenceModels")
|
||||||
|
return writeClaudeDesktopJSON(path, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readClaudeDesktopAppliedID(path string) string {
|
||||||
|
meta, err := readClaudeDesktopJSON(path)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
applied, _ := meta["appliedId"].(string)
|
||||||
|
return applied
|
||||||
|
}
|
||||||
|
|
||||||
|
func readClaudeDesktopDeploymentMode(path string) string {
|
||||||
|
cfg, err := readClaudeDesktopJSON(path)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
mode, _ := cfg["deploymentMode"].(string)
|
||||||
|
return mode
|
||||||
|
}
|
||||||
|
|
||||||
|
func claudeDesktopTargetsConfigured(targets claudeDesktopTargets) bool {
|
||||||
|
if len(targets.normalConfigs) == 0 || len(targets.thirdPartyProfiles) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, path := range targets.normalConfigs {
|
||||||
|
if readClaudeDesktopDeploymentMode(path) != "3p" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, target := range targets.thirdPartyProfiles {
|
||||||
|
if readClaudeDesktopDeploymentMode(target.desktopConfig) != "3p" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !claudeDesktopThirdPartyProfileConfigured(target) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func claudeDesktopThirdPartyProfileConfigured(target claudeDesktopThirdPartyPaths) bool {
|
||||||
|
if readClaudeDesktopAppliedID(target.meta) != claudeDesktopProfileID {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := readClaudeDesktopJSON(target.profile)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if s, _ := cfg["inferenceProvider"].(string); s != "gateway" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if s, _ := cfg["inferenceGatewayBaseUrl"].(string); strings.TrimRight(s, "/") != claudeDesktopGatewayBaseURL {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if s, _ := cfg["inferenceGatewayApiKey"].(string); strings.TrimSpace(s) == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func readClaudeDesktopJSONAllowMissing(path string) (map[string]any, error) {
|
||||||
|
cfg, err := readClaudeDesktopJSON(path)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return map[string]any{}, nil
|
||||||
|
}
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func readClaudeDesktopJSON(path string) (map[string]any, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var cfg map[string]any
|
||||||
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if cfg == nil {
|
||||||
|
cfg = map[string]any{}
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeClaudeDesktopJSON(path string, cfg any) error {
|
||||||
|
data, err := json.MarshalIndent(cfg, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data = append(data, '\n')
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return fileutil.WriteWithBackup(path, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func claudeDesktopAnySlice(value any) []any {
|
||||||
|
switch v := value.(type) {
|
||||||
|
case []any:
|
||||||
|
return v
|
||||||
|
case nil:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func claudeDesktopLaunchOrRestart(prompt string) error {
|
||||||
|
if !claudeDesktopIsRunning() {
|
||||||
|
return claudeDesktopOpenApp()
|
||||||
|
}
|
||||||
|
restartAppPath := ""
|
||||||
|
if claudeDesktopGOOS == "windows" {
|
||||||
|
restartAppPath = claudeDesktopRunningAppPath()
|
||||||
|
}
|
||||||
|
|
||||||
|
restart, err := ConfirmPrompt(prompt)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !restart {
|
||||||
|
fmt.Fprintln(os.Stderr, "\nQuit and reopen Claude Desktop when you're ready for the profile change to take effect.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := claudeDesktopQuitApp(); err != nil {
|
||||||
|
return fmt.Errorf("quit Claude Desktop: %w", err)
|
||||||
|
}
|
||||||
|
if err := waitForClaudeDesktopExit(30 * time.Second); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if restartAppPath != "" {
|
||||||
|
return claudeDesktopOpenAppPath(restartAppPath)
|
||||||
|
}
|
||||||
|
return claudeDesktopOpenApp()
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForClaudeDesktopExit(timeout time.Duration) error {
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if !claudeDesktopIsRunning() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
claudeDesktopSleep(200 * time.Millisecond)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("Claude Desktop did not quit; quit it manually and re-run the command")
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultClaudeDesktopIsRunning() bool {
|
||||||
|
switch claudeDesktopGOOS {
|
||||||
|
case "darwin":
|
||||||
|
out, err := exec.Command("pgrep", "-f", "Claude.app/Contents/MacOS/Claude").Output()
|
||||||
|
return err == nil && strings.TrimSpace(string(out)) != ""
|
||||||
|
case "windows":
|
||||||
|
out, err := exec.Command("powershell.exe", "-NoProfile", "-Command", `(Get-Process claude -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 } | Select-Object -First 1).Id`).Output()
|
||||||
|
return err == nil && strings.TrimSpace(string(out)) != ""
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultClaudeDesktopOpenApp() error {
|
||||||
|
switch claudeDesktopGOOS {
|
||||||
|
case "windows":
|
||||||
|
if path := claudeDesktopAppPath(); path != "" {
|
||||||
|
return claudeDesktopOpenAppPath(path)
|
||||||
|
}
|
||||||
|
if path := claudeDesktopRunningAppPath(); path != "" {
|
||||||
|
return claudeDesktopOpenAppPath(path)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("Claude Desktop executable was not found; open Claude Desktop manually once and re-run 'ollama launch claude-desktop'")
|
||||||
|
case "darwin":
|
||||||
|
return openClaudeDesktopDarwin()
|
||||||
|
default:
|
||||||
|
return claudeDesktopSupported()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultClaudeDesktopOpenAppPath(path string) error {
|
||||||
|
switch claudeDesktopGOOS {
|
||||||
|
case "windows":
|
||||||
|
return exec.Command("powershell.exe", "-NoProfile", "-Command", "Start-Process -FilePath "+quotePowerShellString(path)).Run()
|
||||||
|
case "darwin":
|
||||||
|
return openClaudeDesktopDarwin()
|
||||||
|
default:
|
||||||
|
return claudeDesktopSupported()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func openClaudeDesktopDarwin() error {
|
||||||
|
cmd := exec.Command("open", "-a", "Claude")
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultClaudeDesktopRunningAppPath() string {
|
||||||
|
if claudeDesktopGOOS != "windows" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
script := `(Get-Process claude -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 -and $_.Path } | Select-Object -First 1 -ExpandProperty Path)`
|
||||||
|
out, err := exec.Command("powershell.exe", "-NoProfile", "-Command", script).Output()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultClaudeDesktopQuitApp() error {
|
||||||
|
if claudeDesktopGOOS == "windows" {
|
||||||
|
script := `Get-Process claude -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 } | ForEach-Object { [void]$_.CloseMainWindow() }`
|
||||||
|
return exec.Command("powershell.exe", "-NoProfile", "-Command", script).Run()
|
||||||
|
}
|
||||||
|
return exec.Command("osascript", "-e", `tell application "Claude" to quit`).Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func quotePowerShellString(s string) string {
|
||||||
|
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
|
||||||
|
}
|
||||||
938
cmd/launch/claude_desktop_test.go
Normal file
938
cmd/launch/claude_desktop_test.go
Normal file
@@ -0,0 +1,938 @@
|
|||||||
|
package launch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||||
|
|
||||||
|
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
return f(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func withClaudeDesktopPlatform(t *testing.T, goos string) {
|
||||||
|
t.Helper()
|
||||||
|
old := claudeDesktopGOOS
|
||||||
|
claudeDesktopGOOS = goos
|
||||||
|
t.Cleanup(func() {
|
||||||
|
claudeDesktopGOOS = old
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func withClaudeDesktopValidation(t *testing.T, fn func(context.Context, string) error) {
|
||||||
|
t.Helper()
|
||||||
|
old := claudeDesktopValidateAPIKey
|
||||||
|
claudeDesktopValidateAPIKey = fn
|
||||||
|
t.Cleanup(func() {
|
||||||
|
claudeDesktopValidateAPIKey = old
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func withClaudeDesktopPrompt(t *testing.T, fn func() (string, error)) {
|
||||||
|
t.Helper()
|
||||||
|
old := claudeDesktopPromptAPIKey
|
||||||
|
claudeDesktopPromptAPIKey = fn
|
||||||
|
t.Cleanup(func() {
|
||||||
|
claudeDesktopPromptAPIKey = old
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func withClaudeDesktopProcessHooks(t *testing.T, running func() bool, quit func() error, open func() error) {
|
||||||
|
t.Helper()
|
||||||
|
oldRunning := claudeDesktopIsRunning
|
||||||
|
oldQuit := claudeDesktopQuitApp
|
||||||
|
oldOpen := claudeDesktopOpenApp
|
||||||
|
oldOpenPath := claudeDesktopOpenAppPath
|
||||||
|
oldRunningPath := claudeDesktopRunningAppPath
|
||||||
|
oldSleep := claudeDesktopSleep
|
||||||
|
claudeDesktopIsRunning = running
|
||||||
|
claudeDesktopQuitApp = quit
|
||||||
|
claudeDesktopOpenApp = open
|
||||||
|
claudeDesktopOpenAppPath = oldOpenPath
|
||||||
|
claudeDesktopRunningAppPath = oldRunningPath
|
||||||
|
claudeDesktopSleep = func(time.Duration) {}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
claudeDesktopIsRunning = oldRunning
|
||||||
|
claudeDesktopQuitApp = oldQuit
|
||||||
|
claudeDesktopOpenApp = oldOpen
|
||||||
|
claudeDesktopOpenAppPath = oldOpenPath
|
||||||
|
claudeDesktopRunningAppPath = oldRunningPath
|
||||||
|
claudeDesktopSleep = oldSleep
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func claudeDesktopReadJSON(t *testing.T, path string) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read %s: %v", path, err)
|
||||||
|
}
|
||||||
|
var cfg map[string]any
|
||||||
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||||
|
t.Fatalf("parse %s: %v", path, err)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeDesktopIntegration(t *testing.T) {
|
||||||
|
c := &ClaudeDesktop{}
|
||||||
|
|
||||||
|
t.Run("implements Runner", func(t *testing.T) {
|
||||||
|
var _ Runner = c
|
||||||
|
})
|
||||||
|
t.Run("implements managed autodiscovery integration", func(t *testing.T) {
|
||||||
|
var _ ManagedAutodiscoveryIntegration = c
|
||||||
|
})
|
||||||
|
t.Run("does not use local Ollama Cloud auth gate", func(t *testing.T) {
|
||||||
|
if _, ok := any(c).(ManagedAutodiscoveryCloudIntegration); ok {
|
||||||
|
t.Fatal("Claude Desktop should validate OLLAMA_API_KEY directly instead of requiring local Ollama Cloud sign-in")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("implements restore", func(t *testing.T) {
|
||||||
|
var _ RestorableIntegration = c
|
||||||
|
})
|
||||||
|
t.Run("has restore hint", func(t *testing.T) {
|
||||||
|
var _ RestoreHintIntegration = c
|
||||||
|
if !strings.Contains(c.RestoreHint(), "--restore") {
|
||||||
|
t.Fatalf("expected restore hint to mention --restore, got %q", c.RestoreHint())
|
||||||
|
}
|
||||||
|
if strings.Contains(c.RestoreHint(), "Tip:") {
|
||||||
|
t.Fatalf("restore hint should not use Tip wording, got %q", c.RestoreHint())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("has success messages", func(t *testing.T) {
|
||||||
|
var _ ConfigurationSuccessIntegration = c
|
||||||
|
var _ RestoreSuccessIntegration = c
|
||||||
|
if got := c.ConfigurationSuccessMessage(); got != "Claude Desktop profile changed to Ollama Cloud.\nTo restore the usual Claude profile, run: ollama launch claude-desktop --restore" {
|
||||||
|
t.Fatalf("configuration success message = %q", got)
|
||||||
|
}
|
||||||
|
if got := c.RestoreSuccessMessage(); got != "Claude Desktop restored to the usual Claude profile." {
|
||||||
|
t.Fatalf("restore success message = %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("skips local model readiness", func(t *testing.T) {
|
||||||
|
var _ ManagedModelReadinessSkipper = c
|
||||||
|
if !c.SkipModelReadiness() {
|
||||||
|
t.Fatal("expected Claude Desktop to skip local model readiness")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLaunchIntegration_ClaudeDesktopDoesNotRequireLocalCloudSignIn(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
withClaudeDesktopPlatform(t, "darwin")
|
||||||
|
withInteractiveSession(t, true)
|
||||||
|
withLauncherHooks(t)
|
||||||
|
t.Setenv("OLLAMA_API_KEY", "test-api-key")
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Join(tmpDir, "Applications", "Claude.app"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/status":
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
fmt.Fprint(w, `{"error":"not found"}`)
|
||||||
|
case "/api/me":
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
fmt.Fprint(w, `{"error":"unauthorized","signin_url":"https://example.com/signin"}`)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
t.Setenv("OLLAMA_HOST", srv.URL)
|
||||||
|
|
||||||
|
var validatedKey string
|
||||||
|
withClaudeDesktopValidation(t, func(_ context.Context, key string) error {
|
||||||
|
validatedKey = key
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
DefaultSignIn = func(modelName, signInURL string) (string, error) {
|
||||||
|
t.Fatalf("Claude Desktop launch should not require local Ollama Cloud sign-in, got %s at %s", modelName, signInURL)
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var openCalls int
|
||||||
|
withClaudeDesktopProcessHooks(t,
|
||||||
|
func() bool { return false },
|
||||||
|
func() error { return nil },
|
||||||
|
func() error {
|
||||||
|
openCalls++
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "claude-desktop"}); err != nil {
|
||||||
|
t.Fatalf("LaunchIntegration returned error: %v", err)
|
||||||
|
}
|
||||||
|
if validatedKey != "test-api-key" {
|
||||||
|
t.Fatalf("validated key = %q, want test API key", validatedKey)
|
||||||
|
}
|
||||||
|
if openCalls != 1 {
|
||||||
|
t.Fatalf("open calls = %d, want 1", openCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeDesktopConfigureWritesOllamaCloudProfile(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
withClaudeDesktopPlatform(t, "darwin")
|
||||||
|
t.Setenv("OLLAMA_API_KEY", "test-api-key")
|
||||||
|
|
||||||
|
var validatedKey string
|
||||||
|
withClaudeDesktopValidation(t, func(_ context.Context, key string) error {
|
||||||
|
validatedKey = key
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
paths, err := claudeDesktopConfigPaths()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(paths.desktopConfig), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(paths.meta), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(paths.desktopConfig, []byte(`{"existing":true}`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(paths.meta, []byte(`{"entries":[{"id":"custom","name":"Custom"}]}`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := (&ClaudeDesktop{}).ConfigureAutodiscovery(); err != nil {
|
||||||
|
t.Fatalf("Configure returned error: %v", err)
|
||||||
|
}
|
||||||
|
if validatedKey != "test-api-key" {
|
||||||
|
t.Fatalf("validated key = %q, want test API key", validatedKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
desktopConfig := claudeDesktopReadJSON(t, paths.desktopConfig)
|
||||||
|
if desktopConfig["existing"] != true {
|
||||||
|
t.Fatalf("existing desktop config key was not preserved: %v", desktopConfig)
|
||||||
|
}
|
||||||
|
if desktopConfig["deploymentMode"] != "3p" {
|
||||||
|
t.Fatalf("deploymentMode = %v, want 3p", desktopConfig["deploymentMode"])
|
||||||
|
}
|
||||||
|
normalConfig := claudeDesktopReadJSON(t, paths.normalConfig)
|
||||||
|
if normalConfig["deploymentMode"] != "3p" {
|
||||||
|
t.Fatalf("normal deploymentMode = %v, want 3p", normalConfig["deploymentMode"])
|
||||||
|
}
|
||||||
|
|
||||||
|
meta := claudeDesktopReadJSON(t, paths.meta)
|
||||||
|
if meta["appliedId"] != claudeDesktopProfileID {
|
||||||
|
t.Fatalf("appliedId = %v, want %s", meta["appliedId"], claudeDesktopProfileID)
|
||||||
|
}
|
||||||
|
entries, _ := meta["entries"].([]any)
|
||||||
|
if len(entries) != 2 {
|
||||||
|
t.Fatalf("entries len = %d, want 2: %v", len(entries), entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
profile := claudeDesktopReadJSON(t, paths.profile)
|
||||||
|
if profile["inferenceProvider"] != "gateway" {
|
||||||
|
t.Fatalf("inferenceProvider = %v, want gateway", profile["inferenceProvider"])
|
||||||
|
}
|
||||||
|
if profile["inferenceGatewayBaseUrl"] != claudeDesktopGatewayBaseURL {
|
||||||
|
t.Fatalf("base URL = %v, want %s", profile["inferenceGatewayBaseUrl"], claudeDesktopGatewayBaseURL)
|
||||||
|
}
|
||||||
|
if profile["inferenceGatewayApiKey"] != "test-api-key" {
|
||||||
|
t.Fatal("expected configured API key to be written")
|
||||||
|
}
|
||||||
|
if profile["inferenceGatewayAuthScheme"] != "bearer" {
|
||||||
|
t.Fatalf("auth scheme = %v, want bearer", profile["inferenceGatewayAuthScheme"])
|
||||||
|
}
|
||||||
|
if profile["disableDeploymentModeChooser"] != true {
|
||||||
|
t.Fatalf("disableDeploymentModeChooser = %v, want true", profile["disableDeploymentModeChooser"])
|
||||||
|
}
|
||||||
|
if _, ok := profile["inferenceModels"]; ok {
|
||||||
|
t.Fatalf("inferenceModels should be omitted so Claude can discover models, got %v", profile["inferenceModels"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeDesktopConfigureAutodiscoveryRemovesExistingModelCatalog(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
withClaudeDesktopPlatform(t, "darwin")
|
||||||
|
t.Setenv("OLLAMA_API_KEY", "test-api-key")
|
||||||
|
withClaudeDesktopValidation(t, func(context.Context, string) error { return nil })
|
||||||
|
|
||||||
|
paths, err := claudeDesktopConfigPaths()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(paths.profile), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(paths.profile, []byte(`{"inferenceModels":["qwen3.5"],"inferenceGatewayApiKey":"old"}`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := (&ClaudeDesktop{}).ConfigureAutodiscovery(); err != nil {
|
||||||
|
t.Fatalf("ConfigureAutodiscovery returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
profile := claudeDesktopReadJSON(t, paths.profile)
|
||||||
|
if _, ok := profile["inferenceModels"]; ok {
|
||||||
|
t.Fatalf("inferenceModels should be removed, got %v", profile["inferenceModels"])
|
||||||
|
}
|
||||||
|
if profile["inferenceGatewayApiKey"] != "test-api-key" {
|
||||||
|
t.Fatal("expected env API key to replace the old key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeDesktopWindowsConfigPathsUseLocalAppData(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
withClaudeDesktopPlatform(t, "windows")
|
||||||
|
t.Setenv("LOCALAPPDATA", filepath.Join(tmpDir, "LocalAppData"))
|
||||||
|
|
||||||
|
paths, err := claudeDesktopConfigPaths()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if want := filepath.Join(tmpDir, "LocalAppData", "Claude-3p", "claude_desktop_config.json"); paths.desktopConfig != want {
|
||||||
|
t.Fatalf("desktop config = %q, want %q", paths.desktopConfig, want)
|
||||||
|
}
|
||||||
|
if want := filepath.Join(tmpDir, "LocalAppData", "Claude", "claude_desktop_config.json"); paths.normalConfig != want {
|
||||||
|
t.Fatalf("normal config = %q, want %q", paths.normalConfig, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeDesktopWindowsConfigPathsFallbackToNestProfile(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
withClaudeDesktopPlatform(t, "windows")
|
||||||
|
local := filepath.Join(tmpDir, "LocalAppData")
|
||||||
|
t.Setenv("LOCALAPPDATA", local)
|
||||||
|
if err := os.MkdirAll(filepath.Join(local, "Claude Nest-3p"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
paths, err := claudeDesktopConfigPaths()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if want := filepath.Join(local, "Claude Nest-3p", "claude_desktop_config.json"); paths.desktopConfig != want {
|
||||||
|
t.Fatalf("desktop config = %q, want %q", paths.desktopConfig, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeDesktopAutodiscoveryConfiguredOnWindows(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
withClaudeDesktopPlatform(t, "windows")
|
||||||
|
t.Setenv("LOCALAPPDATA", filepath.Join(tmpDir, "LocalAppData"))
|
||||||
|
t.Setenv("OLLAMA_API_KEY", "test-api-key")
|
||||||
|
withClaudeDesktopValidation(t, func(context.Context, string) error { return nil })
|
||||||
|
|
||||||
|
c := &ClaudeDesktop{}
|
||||||
|
if err := c.ConfigureAutodiscovery(); err != nil {
|
||||||
|
t.Fatalf("Configure returned error: %v", err)
|
||||||
|
}
|
||||||
|
if !c.AutodiscoveryConfigured() {
|
||||||
|
t.Fatal("expected Claude Desktop autodiscovery config to be detected on Windows")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeDesktopConfigureAutodiscoveryTouchesAllWindowsProfileCandidates(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
withClaudeDesktopPlatform(t, "windows")
|
||||||
|
local := filepath.Join(tmpDir, "LocalAppData")
|
||||||
|
t.Setenv("LOCALAPPDATA", local)
|
||||||
|
t.Setenv("OLLAMA_API_KEY", "test-api-key")
|
||||||
|
withClaudeDesktopValidation(t, func(context.Context, string) error { return nil })
|
||||||
|
|
||||||
|
targets, err := claudeDesktopTargetPaths()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(targets.normalConfigs) != 2 {
|
||||||
|
t.Fatalf("normal config target count = %d, want 2", len(targets.normalConfigs))
|
||||||
|
}
|
||||||
|
if len(targets.thirdPartyProfiles) != 2 {
|
||||||
|
t.Fatalf("third-party target count = %d, want 2", len(targets.thirdPartyProfiles))
|
||||||
|
}
|
||||||
|
|
||||||
|
c := &ClaudeDesktop{}
|
||||||
|
if err := c.ConfigureAutodiscovery(); err != nil {
|
||||||
|
t.Fatalf("ConfigureAutodiscovery returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, path := range targets.normalConfigs {
|
||||||
|
cfg := claudeDesktopReadJSON(t, path)
|
||||||
|
if cfg["deploymentMode"] != "3p" {
|
||||||
|
t.Fatalf("%s deploymentMode = %v, want 3p", path, cfg["deploymentMode"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, target := range targets.thirdPartyProfiles {
|
||||||
|
cfg := claudeDesktopReadJSON(t, target.desktopConfig)
|
||||||
|
if cfg["deploymentMode"] != "3p" {
|
||||||
|
t.Fatalf("%s deploymentMode = %v, want 3p", target.desktopConfig, cfg["deploymentMode"])
|
||||||
|
}
|
||||||
|
meta := claudeDesktopReadJSON(t, target.meta)
|
||||||
|
if meta["appliedId"] != claudeDesktopProfileID {
|
||||||
|
t.Fatalf("%s appliedId = %v, want %s", target.meta, meta["appliedId"], claudeDesktopProfileID)
|
||||||
|
}
|
||||||
|
profile := claudeDesktopReadJSON(t, target.profile)
|
||||||
|
if profile["inferenceProvider"] != "gateway" {
|
||||||
|
t.Fatalf("%s inferenceProvider = %v, want gateway", target.profile, profile["inferenceProvider"])
|
||||||
|
}
|
||||||
|
if profile["inferenceGatewayBaseUrl"] != claudeDesktopGatewayBaseURL {
|
||||||
|
t.Fatalf("%s base URL = %v, want %s", target.profile, profile["inferenceGatewayBaseUrl"], claudeDesktopGatewayBaseURL)
|
||||||
|
}
|
||||||
|
if profile["inferenceGatewayApiKey"] != "test-api-key" {
|
||||||
|
t.Fatalf("%s should contain the configured API key", target.profile)
|
||||||
|
}
|
||||||
|
if _, ok := profile["inferenceModels"]; ok {
|
||||||
|
t.Fatalf("%s inferenceModels should be omitted, got %v", target.profile, profile["inferenceModels"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !c.AutodiscoveryConfigured() {
|
||||||
|
t.Fatal("expected all Windows profile candidates to be considered configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writeClaudeDesktopDeploymentMode(targets.thirdPartyProfiles[1].desktopConfig, "1p"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if c.AutodiscoveryConfigured() {
|
||||||
|
t.Fatal("expected a stale Windows candidate to force reconfiguration")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeDesktopInstalledOnWindowsRecognizesLocalProfileDir(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
withClaudeDesktopPlatform(t, "windows")
|
||||||
|
local := filepath.Join(tmpDir, "LocalAppData")
|
||||||
|
t.Setenv("LOCALAPPDATA", local)
|
||||||
|
withClaudeDesktopProcessHooks(t, func() bool { return false }, func() error { return nil }, func() error { return nil })
|
||||||
|
if err := os.MkdirAll(filepath.Join(local, "Claude-3p"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !claudeDesktopInstalled() {
|
||||||
|
t.Fatal("expected Claude Desktop to be installed when the Windows profile directory exists")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeDesktopWindowsAppPathFindsAnthropicClaudeInstall(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
withClaudeDesktopPlatform(t, "windows")
|
||||||
|
local := filepath.Join(tmpDir, "LocalAppData")
|
||||||
|
t.Setenv("LOCALAPPDATA", local)
|
||||||
|
want := filepath.Join(local, "AnthropicClaude", "app-1.2.3", "Claude.exe")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(want), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(want, []byte(""), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := claudeDesktopAppPath(); got != want {
|
||||||
|
t.Fatalf("claudeDesktopAppPath() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWaitForClaudeDesktopExitUsesRunningHook(t *testing.T) {
|
||||||
|
withClaudeDesktopPlatform(t, "windows")
|
||||||
|
runningChecks := 0
|
||||||
|
withClaudeDesktopProcessHooks(t,
|
||||||
|
func() bool {
|
||||||
|
runningChecks++
|
||||||
|
return runningChecks == 1
|
||||||
|
},
|
||||||
|
func() error { return nil },
|
||||||
|
func() error { return nil },
|
||||||
|
)
|
||||||
|
|
||||||
|
if err := waitForClaudeDesktopExit(time.Second); err != nil {
|
||||||
|
t.Fatalf("waitForClaudeDesktopExit returned error: %v", err)
|
||||||
|
}
|
||||||
|
if runningChecks < 2 {
|
||||||
|
t.Fatalf("expected running hook to be checked until the visible window exits, got %d checks", runningChecks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeDesktopWindowsRestartUsesCapturedDesktopPath(t *testing.T) {
|
||||||
|
withClaudeDesktopPlatform(t, "windows")
|
||||||
|
restoreConfirm := withLaunchConfirmPolicy(launchConfirmPolicy{yes: true})
|
||||||
|
defer restoreConfirm()
|
||||||
|
|
||||||
|
desktopPath := `C:\Users\parth\AppData\Local\AnthropicClaude\app-1.2.3\Claude.exe`
|
||||||
|
running := true
|
||||||
|
var openedPath string
|
||||||
|
withClaudeDesktopProcessHooks(t,
|
||||||
|
func() bool { return running },
|
||||||
|
func() error {
|
||||||
|
running = false
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
func() error {
|
||||||
|
t.Fatal("expected restart to open the captured Desktop executable path, not the generic launcher")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
claudeDesktopRunningAppPath = func() string { return desktopPath }
|
||||||
|
claudeDesktopOpenAppPath = func(path string) error {
|
||||||
|
openedPath = path
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := (&ClaudeDesktop{}).Run("qwen3.5", nil); err != nil {
|
||||||
|
t.Fatalf("Run returned error: %v", err)
|
||||||
|
}
|
||||||
|
if openedPath != desktopPath {
|
||||||
|
t.Fatalf("opened path = %q, want %q", openedPath, desktopPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeDesktopWindowsOpenDoesNotFallBackToClaudeCommand(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
withClaudeDesktopPlatform(t, "windows")
|
||||||
|
t.Setenv("LOCALAPPDATA", filepath.Join(tmpDir, "LocalAppData"))
|
||||||
|
|
||||||
|
oldRunningPath := claudeDesktopRunningAppPath
|
||||||
|
claudeDesktopRunningAppPath = func() string { return "" }
|
||||||
|
t.Cleanup(func() { claudeDesktopRunningAppPath = oldRunningPath })
|
||||||
|
|
||||||
|
err := defaultClaudeDesktopOpenApp()
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "Claude Desktop executable was not found") {
|
||||||
|
t.Fatalf("defaultClaudeDesktopOpenApp error = %v, want executable-not-found error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeDesktopConfigureStopsBeforeWriteWhenKeyValidationFails(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
withClaudeDesktopPlatform(t, "darwin")
|
||||||
|
t.Setenv("OLLAMA_API_KEY", "bad-key")
|
||||||
|
withClaudeDesktopValidation(t, func(context.Context, string) error {
|
||||||
|
return errors.New("invalid key")
|
||||||
|
})
|
||||||
|
|
||||||
|
err := (&ClaudeDesktop{}).ConfigureAutodiscovery()
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "invalid key") {
|
||||||
|
t.Fatalf("Configure error = %v, want invalid key", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
paths, err := claudeDesktopConfigPaths()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(paths.desktopConfig); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("desktop config should not be written after validation failure, stat err = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateClaudeDesktopAPIKeyUsesClaudeModelsRoute(t *testing.T) {
|
||||||
|
oldClient := claudeDesktopHTTPClient
|
||||||
|
var gotPath, gotAuth string
|
||||||
|
claudeDesktopHTTPClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
gotPath = req.URL.Path
|
||||||
|
gotAuth = req.Header.Get("Authorization")
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Body: io.NopCloser(strings.NewReader(`{"data":[]}`)),
|
||||||
|
Header: make(http.Header),
|
||||||
|
}, nil
|
||||||
|
})}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
claudeDesktopHTTPClient = oldClient
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := validateClaudeDesktopAPIKey(context.Background(), "test-key"); err != nil {
|
||||||
|
t.Fatalf("validateClaudeDesktopAPIKey returned error: %v", err)
|
||||||
|
}
|
||||||
|
if gotPath != "/v1/models" {
|
||||||
|
t.Fatalf("validation path = %q, want /v1/models", gotPath)
|
||||||
|
}
|
||||||
|
if gotAuth != "Bearer test-key" {
|
||||||
|
t.Fatalf("Authorization header = %q, want bearer key", gotAuth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateClaudeDesktopAPIKeyHidesInvalidHeaderDetails(t *testing.T) {
|
||||||
|
err := validateClaudeDesktopAPIKey(context.Background(), "bad\nkey")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected validation error for key with newline")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "could not verify Ollama API key") {
|
||||||
|
t.Fatalf("validation error = %v, want friendly verification message", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "invalid header") || strings.Contains(err.Error(), "net/http") {
|
||||||
|
t.Fatalf("validation error should not expose transport internals: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "https://ollama.com/settings/keys") {
|
||||||
|
t.Fatalf("validation error should include settings link: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeDesktopConfigureRequiresAPIKey(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
withClaudeDesktopPlatform(t, "darwin")
|
||||||
|
t.Setenv("OLLAMA_API_KEY", "")
|
||||||
|
withClaudeDesktopValidation(t, func(context.Context, string) error {
|
||||||
|
t.Fatal("validation should not run without an API key")
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
err := (&ClaudeDesktop{}).ConfigureAutodiscovery()
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "OLLAMA_API_KEY is required") {
|
||||||
|
t.Fatalf("Configure error = %v, want missing key guidance", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeDesktopAPIKeyPromptIncludesSettingsLink(t *testing.T) {
|
||||||
|
prompt := claudeDesktopAPIKeyPrompt()
|
||||||
|
if !strings.Contains(prompt, "Enter Ollama API key") {
|
||||||
|
t.Fatalf("prompt should ask for the API key, got %q", prompt)
|
||||||
|
}
|
||||||
|
if !strings.Contains(prompt, "https://ollama.com/settings/keys") {
|
||||||
|
t.Fatalf("prompt should include API key settings link, got %q", prompt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeDesktopConfigureReusesExistingAPIKey(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
withClaudeDesktopPlatform(t, "darwin")
|
||||||
|
t.Setenv("OLLAMA_API_KEY", "")
|
||||||
|
|
||||||
|
paths, err := claudeDesktopConfigPaths()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(paths.profile), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(paths.profile, []byte(`{"inferenceGatewayApiKey":"existing-key"}`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var validatedKey string
|
||||||
|
withClaudeDesktopValidation(t, func(_ context.Context, key string) error {
|
||||||
|
validatedKey = key
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := (&ClaudeDesktop{}).ConfigureAutodiscovery(); err != nil {
|
||||||
|
t.Fatalf("ConfigureAutodiscovery returned error: %v", err)
|
||||||
|
}
|
||||||
|
if validatedKey != "existing-key" {
|
||||||
|
t.Fatalf("validated key = %q, want existing-key", validatedKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeDesktopConfigureReplacesInvalidExistingAPIKey(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
withClaudeDesktopPlatform(t, "darwin")
|
||||||
|
withInteractiveSession(t, true)
|
||||||
|
t.Setenv("OLLAMA_API_KEY", "")
|
||||||
|
|
||||||
|
paths, err := claudeDesktopConfigPaths()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(paths.profile), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(paths.profile, []byte(`{"inferenceGatewayApiKey":"stale-key"}`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var validated []string
|
||||||
|
withClaudeDesktopValidation(t, func(_ context.Context, key string) error {
|
||||||
|
validated = append(validated, key)
|
||||||
|
if key == "stale-key" {
|
||||||
|
return errors.New("invalid key")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
withClaudeDesktopPrompt(t, func() (string, error) {
|
||||||
|
return "replacement-key", nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := (&ClaudeDesktop{}).ConfigureAutodiscovery(); err != nil {
|
||||||
|
t.Fatalf("ConfigureAutodiscovery returned error: %v", err)
|
||||||
|
}
|
||||||
|
if diff := compareStrings(validated, []string{"stale-key", "replacement-key"}); diff != "" {
|
||||||
|
t.Fatalf("validated keys mismatch: %s", diff)
|
||||||
|
}
|
||||||
|
profile := claudeDesktopReadJSON(t, paths.profile)
|
||||||
|
if profile["inferenceGatewayApiKey"] != "replacement-key" {
|
||||||
|
t.Fatalf("configured key = %v, want replacement-key", profile["inferenceGatewayApiKey"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeDesktopConfigureReusesExistingAPIKeyFromAnyWindowsProfile(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
withClaudeDesktopPlatform(t, "windows")
|
||||||
|
local := filepath.Join(tmpDir, "LocalAppData")
|
||||||
|
t.Setenv("LOCALAPPDATA", local)
|
||||||
|
t.Setenv("OLLAMA_API_KEY", "")
|
||||||
|
|
||||||
|
targets, err := claudeDesktopTargetPaths()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fallbackProfile := targets.thirdPartyProfiles[1].profile
|
||||||
|
if err := os.MkdirAll(filepath.Dir(fallbackProfile), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(fallbackProfile, []byte(`{"inferenceGatewayApiKey":"fallback-key"}`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var validatedKey string
|
||||||
|
withClaudeDesktopValidation(t, func(_ context.Context, key string) error {
|
||||||
|
validatedKey = key
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := (&ClaudeDesktop{}).ConfigureAutodiscovery(); err != nil {
|
||||||
|
t.Fatalf("ConfigureAutodiscovery returned error: %v", err)
|
||||||
|
}
|
||||||
|
if validatedKey != "fallback-key" {
|
||||||
|
t.Fatalf("validated key = %q, want fallback-key", validatedKey)
|
||||||
|
}
|
||||||
|
for _, target := range targets.thirdPartyProfiles {
|
||||||
|
profile := claudeDesktopReadJSON(t, target.profile)
|
||||||
|
if profile["inferenceGatewayApiKey"] != "fallback-key" {
|
||||||
|
t.Fatalf("%s should reuse fallback key, got %v", target.profile, profile["inferenceGatewayApiKey"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeDesktopAutodiscoveryConfiguredRequiresAppliedOllamaProfile(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
withClaudeDesktopPlatform(t, "darwin")
|
||||||
|
t.Setenv("OLLAMA_API_KEY", "test-api-key")
|
||||||
|
withClaudeDesktopValidation(t, func(context.Context, string) error { return nil })
|
||||||
|
|
||||||
|
c := &ClaudeDesktop{}
|
||||||
|
if err := c.ConfigureAutodiscovery(); err != nil {
|
||||||
|
t.Fatalf("Configure returned error: %v", err)
|
||||||
|
}
|
||||||
|
if !c.AutodiscoveryConfigured() {
|
||||||
|
t.Fatal("expected Claude Desktop autodiscovery config to be detected")
|
||||||
|
}
|
||||||
|
|
||||||
|
paths, err := claudeDesktopConfigPaths()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(paths.meta, []byte(`{"appliedId":"custom"}`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if c.AutodiscoveryConfigured() {
|
||||||
|
t.Fatal("expected another applied profile to hide Claude Desktop autodiscovery config")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeDesktopAutodiscoveryConfiguredRequiresAPIKey(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
withClaudeDesktopPlatform(t, "darwin")
|
||||||
|
t.Setenv("OLLAMA_API_KEY", "test-api-key")
|
||||||
|
withClaudeDesktopValidation(t, func(context.Context, string) error { return nil })
|
||||||
|
|
||||||
|
c := &ClaudeDesktop{}
|
||||||
|
if err := c.ConfigureAutodiscovery(); err != nil {
|
||||||
|
t.Fatalf("Configure returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
paths, err := claudeDesktopConfigPaths()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
profile := claudeDesktopReadJSON(t, paths.profile)
|
||||||
|
delete(profile, "inferenceGatewayApiKey")
|
||||||
|
data, err := json.Marshal(profile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(paths.profile, data, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.AutodiscoveryConfigured() {
|
||||||
|
t.Fatal("expected missing gateway API key to force Claude Desktop reconfiguration")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeDesktopRestoreSwitchesBackToFirstPartyMode(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
withClaudeDesktopPlatform(t, "darwin")
|
||||||
|
withClaudeDesktopProcessHooks(t, func() bool { return false }, func() error { return nil }, func() error { return nil })
|
||||||
|
|
||||||
|
paths, err := claudeDesktopConfigPaths()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(paths.profile), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(paths.meta, []byte(`{"appliedId":"`+claudeDesktopProfileID+`","entries":[{"id":"`+claudeDesktopProfileID+`","name":"Ollama"}]}`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(paths.profile, []byte(`{"disableDeploymentModeChooser":true,"inferenceGatewayApiKey":"keep","inferenceProvider":"gateway","inferenceGatewayBaseUrl":"https://ollama.com","inferenceGatewayAuthScheme":"bearer","inferenceModels":["legacy"]}`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := (&ClaudeDesktop{}).Restore(); err != nil {
|
||||||
|
t.Fatalf("Restore returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
desktopConfig := claudeDesktopReadJSON(t, paths.desktopConfig)
|
||||||
|
if desktopConfig["deploymentMode"] != "1p" {
|
||||||
|
t.Fatalf("deploymentMode = %v, want 1p", desktopConfig["deploymentMode"])
|
||||||
|
}
|
||||||
|
normalConfig := claudeDesktopReadJSON(t, paths.normalConfig)
|
||||||
|
if normalConfig["deploymentMode"] != "1p" {
|
||||||
|
t.Fatalf("normal deploymentMode = %v, want 1p", normalConfig["deploymentMode"])
|
||||||
|
}
|
||||||
|
profile := claudeDesktopReadJSON(t, paths.profile)
|
||||||
|
if profile["disableDeploymentModeChooser"] != false {
|
||||||
|
t.Fatalf("disableDeploymentModeChooser = %v, want false", profile["disableDeploymentModeChooser"])
|
||||||
|
}
|
||||||
|
if profile["inferenceGatewayApiKey"] != "keep" {
|
||||||
|
t.Fatal("restore should leave existing Ollama profile credentials in place")
|
||||||
|
}
|
||||||
|
for _, key := range []string{"inferenceProvider", "inferenceGatewayBaseUrl", "inferenceGatewayAuthScheme", "inferenceModels"} {
|
||||||
|
if _, ok := profile[key]; ok {
|
||||||
|
t.Fatalf("restore should clear stale %s from the Ollama profile: %v", key, profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
meta := claudeDesktopReadJSON(t, paths.meta)
|
||||||
|
if _, ok := meta["appliedId"]; ok {
|
||||||
|
t.Fatalf("restore should clear the applied Ollama third-party profile: %v", meta)
|
||||||
|
}
|
||||||
|
if (&ClaudeDesktop{}).AutodiscoveryConfigured() {
|
||||||
|
t.Fatal("restore should leave Claude Desktop autodiscovery unconfigured")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeDesktopRestoreTouchesAllWindowsProfileCandidates(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setTestHome(t, tmpDir)
|
||||||
|
withClaudeDesktopPlatform(t, "windows")
|
||||||
|
local := filepath.Join(tmpDir, "LocalAppData")
|
||||||
|
t.Setenv("LOCALAPPDATA", local)
|
||||||
|
withClaudeDesktopProcessHooks(t, func() bool { return false }, func() error { return nil }, func() error { return nil })
|
||||||
|
|
||||||
|
targets, err := claudeDesktopTargetPaths()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(targets.normalConfigs) != 2 {
|
||||||
|
t.Fatalf("normal config target count = %d, want 2", len(targets.normalConfigs))
|
||||||
|
}
|
||||||
|
if len(targets.thirdPartyProfiles) != 2 {
|
||||||
|
t.Fatalf("third-party target count = %d, want 2", len(targets.thirdPartyProfiles))
|
||||||
|
}
|
||||||
|
for _, target := range targets.thirdPartyProfiles {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(target.profile), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(target.meta, []byte(`{"appliedId":"`+claudeDesktopProfileID+`","entries":[{"id":"`+claudeDesktopProfileID+`","name":"Ollama"}]}`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(target.profile, []byte(`{"disableDeploymentModeChooser":true,"inferenceGatewayApiKey":"keep","inferenceProvider":"gateway","inferenceGatewayBaseUrl":"https://ollama.com","inferenceGatewayAuthScheme":"bearer","inferenceModels":["legacy"]}`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := (&ClaudeDesktop{}).Restore(); err != nil {
|
||||||
|
t.Fatalf("Restore returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, path := range targets.normalConfigs {
|
||||||
|
cfg := claudeDesktopReadJSON(t, path)
|
||||||
|
if cfg["deploymentMode"] != "1p" {
|
||||||
|
t.Fatalf("%s deploymentMode = %v, want 1p", path, cfg["deploymentMode"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, target := range targets.thirdPartyProfiles {
|
||||||
|
cfg := claudeDesktopReadJSON(t, target.desktopConfig)
|
||||||
|
if cfg["deploymentMode"] != "1p" {
|
||||||
|
t.Fatalf("%s deploymentMode = %v, want 1p", target.desktopConfig, cfg["deploymentMode"])
|
||||||
|
}
|
||||||
|
meta := claudeDesktopReadJSON(t, target.meta)
|
||||||
|
if _, ok := meta["appliedId"]; ok {
|
||||||
|
t.Fatalf("%s should not keep the Ollama applied profile: %v", target.meta, meta)
|
||||||
|
}
|
||||||
|
profile := claudeDesktopReadJSON(t, target.profile)
|
||||||
|
if profile["disableDeploymentModeChooser"] != false {
|
||||||
|
t.Fatalf("%s disableDeploymentModeChooser = %v, want false", target.profile, profile["disableDeploymentModeChooser"])
|
||||||
|
}
|
||||||
|
if profile["inferenceGatewayApiKey"] != "keep" {
|
||||||
|
t.Fatalf("%s should preserve gateway API key", target.profile)
|
||||||
|
}
|
||||||
|
for _, key := range []string{"inferenceProvider", "inferenceGatewayBaseUrl", "inferenceGatewayAuthScheme", "inferenceModels"} {
|
||||||
|
if _, ok := profile[key]; ok {
|
||||||
|
t.Fatalf("%s should clear stale %s: %v", target.profile, key, profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeDesktopRunRestartsRunningAppWhenConfirmed(t *testing.T) {
|
||||||
|
withClaudeDesktopPlatform(t, "darwin")
|
||||||
|
restoreConfirm := withLaunchConfirmPolicy(launchConfirmPolicy{yes: true})
|
||||||
|
defer restoreConfirm()
|
||||||
|
|
||||||
|
running := true
|
||||||
|
var quitCalls, openCalls int
|
||||||
|
withClaudeDesktopProcessHooks(t,
|
||||||
|
func() bool { return running },
|
||||||
|
func() error {
|
||||||
|
quitCalls++
|
||||||
|
running = false
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
func() error {
|
||||||
|
openCalls++
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if err := (&ClaudeDesktop{}).Run("qwen3.5", nil); err != nil {
|
||||||
|
t.Fatalf("Run returned error: %v", err)
|
||||||
|
}
|
||||||
|
if quitCalls != 1 || openCalls != 1 {
|
||||||
|
t.Fatalf("quit/open calls = %d/%d, want 1/1", quitCalls, openCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeDesktopRunRejectsExtraArgs(t *testing.T) {
|
||||||
|
withClaudeDesktopPlatform(t, "darwin")
|
||||||
|
err := (&ClaudeDesktop{}).Run("qwen3.5", []string{"--foo"})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "does not accept extra arguments") {
|
||||||
|
t.Fatalf("Run error = %v, want extra args rejection", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,6 +73,9 @@ func TestLaunchCmd(t *testing.T) {
|
|||||||
if cmd.Flags().Lookup("config") == nil {
|
if cmd.Flags().Lookup("config") == nil {
|
||||||
t.Error("--config flag should exist")
|
t.Error("--config flag should exist")
|
||||||
}
|
}
|
||||||
|
if cmd.Flags().Lookup("restore") == nil {
|
||||||
|
t.Error("--restore flag should exist")
|
||||||
|
}
|
||||||
if cmd.Flags().Lookup("yes") == nil {
|
if cmd.Flags().Lookup("yes") == nil {
|
||||||
t.Error("--yes flag should exist")
|
t.Error("--yes flag should exist")
|
||||||
}
|
}
|
||||||
@@ -207,6 +210,27 @@ func TestLaunchCmdTUICallback(t *testing.T) {
|
|||||||
t.Error("TUI callback should NOT be called when flags or extra args are provided without an integration")
|
t.Error("TUI callback should NOT be called when flags or extra args are provided without an integration")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("--restore flag without integration returns error", func(t *testing.T) {
|
||||||
|
tuiCalled := false
|
||||||
|
mockTUI := func(cmd *cobra.Command) {
|
||||||
|
tuiCalled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := LaunchCmd(mockCheck, mockTUI)
|
||||||
|
cmd.SetArgs([]string{"--restore"})
|
||||||
|
err := cmd.Execute()
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected --restore without an integration to fail")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "require an integration name") {
|
||||||
|
t.Fatalf("expected integration-name guidance, got %v", err)
|
||||||
|
}
|
||||||
|
if tuiCalled {
|
||||||
|
t.Error("TUI callback should NOT be called when --restore is provided without an integration")
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLaunchCmdNilHeartbeat(t *testing.T) {
|
func TestLaunchCmdNilHeartbeat(t *testing.T) {
|
||||||
@@ -331,6 +355,41 @@ func TestLaunchCmdModelFlagClearsDisabledCloudOverride(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLaunchCmdAutodiscoveryDefaultLaunchDoesNotForceConfigure(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setLaunchTestHome(t, tmpDir)
|
||||||
|
withInteractiveSession(t, true)
|
||||||
|
withLauncherHooks(t)
|
||||||
|
|
||||||
|
runner := &launcherManagedAutodiscoveryRunner{
|
||||||
|
autodiscoveryConfigured: true,
|
||||||
|
}
|
||||||
|
restore := OverrideIntegration("stubauto", runner)
|
||||||
|
defer restore()
|
||||||
|
|
||||||
|
if err := config.SaveIntegration("stubauto", []string{"Ollama Cloud"}); err != nil {
|
||||||
|
t.Fatalf("failed to save managed integration config: %v", err)
|
||||||
|
}
|
||||||
|
if err := config.MarkIntegrationOnboarded("stubauto"); err != nil {
|
||||||
|
t.Fatalf("failed to mark integration onboarded: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := LaunchCmd(func(cmd *cobra.Command, args []string) error { return nil }, func(cmd *cobra.Command) {
|
||||||
|
t.Fatal("TUI callback should not run for direct integration launch")
|
||||||
|
})
|
||||||
|
cmd.SetArgs([]string{"stubauto"})
|
||||||
|
if err := cmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("launch command failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if runner.autodiscoveryConfigures != 0 {
|
||||||
|
t.Fatalf("expected default autodiscovery launch to reuse existing config, got %d configures", runner.autodiscoveryConfigures)
|
||||||
|
}
|
||||||
|
if runner.ranModel != "Ollama Cloud" {
|
||||||
|
t.Fatalf("expected launch to run autodiscovery label, got %q", runner.ranModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLaunchCmdYes_AutoConfirmsLaunchPromptPath(t *testing.T) {
|
func TestLaunchCmdYes_AutoConfirmsLaunchPromptPath(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
setLaunchTestHome(t, tmpDir)
|
setLaunchTestHome(t, tmpDir)
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ func TestIntegrationLookup(t *testing.T) {
|
|||||||
{"claude lowercase", "claude", true, "Claude Code"},
|
{"claude lowercase", "claude", true, "Claude Code"},
|
||||||
{"claude uppercase", "CLAUDE", true, "Claude Code"},
|
{"claude uppercase", "CLAUDE", true, "Claude Code"},
|
||||||
{"claude mixed case", "Claude", true, "Claude Code"},
|
{"claude mixed case", "Claude", true, "Claude Code"},
|
||||||
|
{"claude desktop", "claude-desktop", true, "Claude Desktop"},
|
||||||
|
{"claude desktop alias", "claude-app", true, "Claude Desktop"},
|
||||||
{"codex", "codex", true, "Codex"},
|
{"codex", "codex", true, "Codex"},
|
||||||
{"kimi", "kimi", true, "Kimi Code CLI"},
|
{"kimi", "kimi", true, "Kimi Code CLI"},
|
||||||
{"droid", "droid", true, "Droid"},
|
{"droid", "droid", true, "Droid"},
|
||||||
@@ -76,7 +78,7 @@ func TestIntegrationLookup(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestIntegrationRegistry(t *testing.T) {
|
func TestIntegrationRegistry(t *testing.T) {
|
||||||
expectedIntegrations := []string{"claude", "codex", "kimi", "droid", "opencode", "hermes", "pool"}
|
expectedIntegrations := []string{"claude", "claude-desktop", "codex", "kimi", "droid", "opencode", "hermes", "pool"}
|
||||||
for _, name := range expectedIntegrations {
|
for _, name := range expectedIntegrations {
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
r, ok := integrations[name]
|
r, ok := integrations[name]
|
||||||
@@ -1364,6 +1366,30 @@ func TestEnsureAuth_SkipsWhenNoCloudSelected(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEnsureAuth_EmptyWhoamiRequiresSignIn(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/status":
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
fmt.Fprintf(w, `{"error":"not found"}`)
|
||||||
|
case "/api/me":
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
fmt.Fprintf(w, `{}`)
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
u, _ := url.Parse(srv.URL)
|
||||||
|
client := api.NewClient(u, srv.Client())
|
||||||
|
|
||||||
|
err := ensureAuth(context.Background(), client, map[string]bool{"cloud-model:cloud": true}, []string{"cloud-model:cloud"})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "cloud-model:cloud requires sign in") {
|
||||||
|
t.Fatalf("ensureAuth error = %v, want sign-in required", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEnsureAuth_PreservesCancelledSignInHook(t *testing.T) {
|
func TestEnsureAuth_PreservesCancelledSignInHook(t *testing.T) {
|
||||||
oldSignIn := DefaultSignIn
|
oldSignIn := DefaultSignIn
|
||||||
DefaultSignIn = func(modelName, signInURL string) (string, error) {
|
DefaultSignIn = func(modelName, signInURL string) (string, error) {
|
||||||
@@ -1484,6 +1510,11 @@ func TestIntegration_InstallHint(t *testing.T) {
|
|||||||
input: "claude",
|
input: "claude",
|
||||||
wantURL: "https://code.claude.com/docs/en/quickstart",
|
wantURL: "https://code.claude.com/docs/en/quickstart",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "claude desktop has hint",
|
||||||
|
input: "claude-desktop",
|
||||||
|
wantURL: "https://claude.com/download",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "codex has hint",
|
name: "codex has hint",
|
||||||
input: "codex",
|
input: "codex",
|
||||||
@@ -1565,6 +1596,15 @@ func TestListIntegrationInfos(t *testing.T) {
|
|||||||
}
|
}
|
||||||
want = filtered
|
want = filtered
|
||||||
}
|
}
|
||||||
|
if claudeDesktopSupported() != nil {
|
||||||
|
filtered := make([]string, 0, len(want))
|
||||||
|
for _, name := range want {
|
||||||
|
if name != "claude-desktop" {
|
||||||
|
filtered = append(filtered, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
want = filtered
|
||||||
|
}
|
||||||
|
|
||||||
if diff := compareStrings(got, want); diff != "" {
|
if diff := compareStrings(got, want); diff != "" {
|
||||||
t.Fatalf("launcher integration order mismatch: %s", diff)
|
t.Fatalf("launcher integration order mismatch: %s", diff)
|
||||||
@@ -1584,6 +1624,9 @@ func TestListIntegrationInfos(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("includes known integrations", func(t *testing.T) {
|
t.Run("includes known integrations", func(t *testing.T) {
|
||||||
known := map[string]bool{"claude": false, "codex": false, "opencode": false}
|
known := map[string]bool{"claude": false, "codex": false, "opencode": false}
|
||||||
|
if claudeDesktopSupported() == nil {
|
||||||
|
known["claude-desktop"] = false
|
||||||
|
}
|
||||||
if poolsideGOOS != "windows" {
|
if poolsideGOOS != "windows" {
|
||||||
known["pool"] = false
|
known["pool"] = false
|
||||||
}
|
}
|
||||||
@@ -1621,6 +1664,7 @@ func TestListIntegrationInfos(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestListIntegrationInfos_HidesPoolsideOnWindows(t *testing.T) {
|
func TestListIntegrationInfos_HidesPoolsideOnWindows(t *testing.T) {
|
||||||
prev := poolsideGOOS
|
prev := poolsideGOOS
|
||||||
poolsideGOOS = "windows"
|
poolsideGOOS = "windows"
|
||||||
@@ -1633,6 +1677,18 @@ func TestListIntegrationInfos_HidesPoolsideOnWindows(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestListIntegrationInfos_HidesClaudeDesktopOnUnsupportedPlatform(t *testing.T) {
|
||||||
|
prev := claudeDesktopGOOS
|
||||||
|
claudeDesktopGOOS = "linux"
|
||||||
|
t.Cleanup(func() { claudeDesktopGOOS = prev })
|
||||||
|
|
||||||
|
for _, info := range ListIntegrationInfos() {
|
||||||
|
if info.Name == "claude-desktop" {
|
||||||
|
t.Fatal("expected claude-desktop to be hidden on unsupported platforms")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildModelList_Descriptions(t *testing.T) {
|
func TestBuildModelList_Descriptions(t *testing.T) {
|
||||||
t.Run("installed recommended has base description", func(t *testing.T) {
|
t.Run("installed recommended has base description", func(t *testing.T) {
|
||||||
existing := []modelInfo{
|
existing := []modelInfo{
|
||||||
@@ -1695,6 +1751,7 @@ func TestIntegration_Editor(t *testing.T) {
|
|||||||
{"opencode", true},
|
{"opencode", true},
|
||||||
{"openclaw", true},
|
{"openclaw", true},
|
||||||
{"claude", false},
|
{"claude", false},
|
||||||
|
{"claude-desktop", false},
|
||||||
{"codex", false},
|
{"codex", false},
|
||||||
{"nonexistent", false},
|
{"nonexistent", false},
|
||||||
}
|
}
|
||||||
@@ -1721,6 +1778,7 @@ func TestIntegration_AutoInstallable(t *testing.T) {
|
|||||||
{"pi", true},
|
{"pi", true},
|
||||||
{"hermes", true},
|
{"hermes", true},
|
||||||
{"claude", false},
|
{"claude", false},
|
||||||
|
{"claude-desktop", false},
|
||||||
{"codex", false},
|
{"codex", false},
|
||||||
{"opencode", false},
|
{"opencode", false},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ type IntegrationLaunchRequest struct {
|
|||||||
ModelOverride string
|
ModelOverride string
|
||||||
ForceConfigure bool
|
ForceConfigure bool
|
||||||
ConfigureOnly bool
|
ConfigureOnly bool
|
||||||
|
Restore bool
|
||||||
ExtraArgs []string
|
ExtraArgs []string
|
||||||
Policy *LaunchPolicy
|
Policy *LaunchPolicy
|
||||||
}
|
}
|
||||||
@@ -154,6 +155,46 @@ type ManagedSingleModel interface {
|
|||||||
Onboard() error
|
Onboard() error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ManagedModelListConfigurer lets managed single-model integrations receive
|
||||||
|
// the launcher's model list while still preserving one primary selected model.
|
||||||
|
type ManagedModelListConfigurer interface {
|
||||||
|
ConfigureWithModels(primary string, models []string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ManagedAutodiscoveryIntegration is for managed integrations that do not need
|
||||||
|
// a launcher-selected model because the app discovers available models itself.
|
||||||
|
type ManagedAutodiscoveryIntegration interface {
|
||||||
|
Paths() []string
|
||||||
|
AutodiscoveredModel() string
|
||||||
|
AutodiscoveryConfigured() bool
|
||||||
|
ConfigureAutodiscovery() error
|
||||||
|
Onboard() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ManagedAutodiscoveryCloudIntegration marks an autodiscovery integration whose
|
||||||
|
// discovered model catalog depends on the user's local Ollama Cloud auth state.
|
||||||
|
type ManagedAutodiscoveryCloudIntegration interface {
|
||||||
|
UsesOllamaCloud() bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestoreHintIntegration can provide a short restore command after launch
|
||||||
|
// switches an app into a launch-managed mode.
|
||||||
|
type RestoreHintIntegration interface {
|
||||||
|
RestoreHint() string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigurationSuccessIntegration can print a short message after launcher
|
||||||
|
// successfully switches an app into a launch-managed mode.
|
||||||
|
type ConfigurationSuccessIntegration interface {
|
||||||
|
ConfigurationSuccessMessage() string
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestoreSuccessIntegration can print a short message after launcher restores
|
||||||
|
// an app back to its default mode.
|
||||||
|
type RestoreSuccessIntegration interface {
|
||||||
|
RestoreSuccessMessage() string
|
||||||
|
}
|
||||||
|
|
||||||
// ManagedRuntimeRefresher lets managed integrations refresh any long-lived
|
// ManagedRuntimeRefresher lets managed integrations refresh any long-lived
|
||||||
// background runtime after launch rewrites their config.
|
// background runtime after launch rewrites their config.
|
||||||
type ManagedRuntimeRefresher interface {
|
type ManagedRuntimeRefresher interface {
|
||||||
@@ -172,6 +213,25 @@ type ManagedInteractiveOnboarding interface {
|
|||||||
RequiresInteractiveOnboarding() bool
|
RequiresInteractiveOnboarding() bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ManagedModelReadinessSkipper lets managed integrations opt out of local
|
||||||
|
// Ollama model readiness checks when the configured runtime is not the local
|
||||||
|
// daemon.
|
||||||
|
type ManagedModelReadinessSkipper interface {
|
||||||
|
SkipModelReadiness() bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestorableIntegration lets integrations switch back from a launch-managed
|
||||||
|
// mode to the application's normal/default mode.
|
||||||
|
type RestorableIntegration interface {
|
||||||
|
Restore() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// SupportedIntegration lets an integration report platform support separately
|
||||||
|
// from whether the underlying app binary is installed.
|
||||||
|
type SupportedIntegration interface {
|
||||||
|
Supported() error
|
||||||
|
}
|
||||||
|
|
||||||
type modelInfo struct {
|
type modelInfo struct {
|
||||||
Name string
|
Name string
|
||||||
Remote bool
|
Remote bool
|
||||||
@@ -197,6 +257,7 @@ func LaunchCmd(checkServerHeartbeat func(cmd *cobra.Command, args []string) erro
|
|||||||
var modelFlag string
|
var modelFlag string
|
||||||
var configFlag bool
|
var configFlag bool
|
||||||
var yesFlag bool
|
var yesFlag bool
|
||||||
|
var restoreFlag bool
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "launch [INTEGRATION] [-- [EXTRA_ARGS...]]",
|
Use: "launch [INTEGRATION] [-- [EXTRA_ARGS...]]",
|
||||||
@@ -207,29 +268,37 @@ Without arguments, this is equivalent to running 'ollama' directly.
|
|||||||
Flags and extra arguments require an integration name.
|
Flags and extra arguments require an integration name.
|
||||||
|
|
||||||
Supported integrations:
|
Supported integrations:
|
||||||
claude Claude Code
|
claude Claude Code
|
||||||
cline Cline
|
claude-desktop Claude Desktop (aliases: claude-app)
|
||||||
codex Codex
|
cline Cline
|
||||||
copilot Copilot CLI (aliases: copilot-cli)
|
codex Codex
|
||||||
droid Droid
|
copilot Copilot CLI (aliases: copilot-cli)
|
||||||
hermes Hermes Agent
|
droid Droid
|
||||||
kimi Kimi Code CLI
|
hermes Hermes Agent
|
||||||
opencode OpenCode
|
kimi Kimi Code CLI
|
||||||
openclaw OpenClaw (aliases: clawdbot, moltbot)
|
opencode OpenCode
|
||||||
pi Pi
|
openclaw OpenClaw (aliases: clawdbot, moltbot)
|
||||||
pool Pool
|
pi Pi
|
||||||
vscode VS Code (aliases: code)
|
pool Pool
|
||||||
|
vscode VS Code (aliases: code)
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
ollama launch
|
ollama launch
|
||||||
ollama launch claude
|
ollama launch claude
|
||||||
ollama launch claude --model <model>
|
ollama launch claude --model <model>
|
||||||
|
ollama launch claude-desktop
|
||||||
|
ollama launch claude-desktop --restore
|
||||||
ollama launch hermes
|
ollama launch hermes
|
||||||
ollama launch droid --config (does not auto-launch)
|
ollama launch droid --config (does not auto-launch)
|
||||||
ollama launch codex -- -p myprofile (pass extra args to integration)
|
ollama launch codex -- -p myprofile (pass extra args to integration)
|
||||||
ollama launch codex -- --sandbox workspace-write`,
|
ollama launch codex -- --sandbox workspace-write`,
|
||||||
Args: cobra.ArbitraryArgs,
|
Args: cobra.ArbitraryArgs,
|
||||||
PreRunE: checkServerHeartbeat,
|
PreRunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if restoreFlag || launchCommandCanSkipHeartbeat(args) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return checkServerHeartbeat(cmd, args)
|
||||||
|
},
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
policy := defaultLaunchPolicy(isInteractiveSession(), yesFlag)
|
policy := defaultLaunchPolicy(isInteractiveSession(), yesFlag)
|
||||||
// reset when done to make sure state doens't leak between launches
|
// reset when done to make sure state doens't leak between launches
|
||||||
@@ -258,7 +327,7 @@ Examples:
|
|||||||
}
|
}
|
||||||
|
|
||||||
if name == "" {
|
if name == "" {
|
||||||
if cmd.Flags().Changed("model") || cmd.Flags().Changed("config") || cmd.Flags().Changed("yes") || len(passArgs) > 0 {
|
if cmd.Flags().Changed("model") || cmd.Flags().Changed("config") || cmd.Flags().Changed("yes") || cmd.Flags().Changed("restore") || len(passArgs) > 0 {
|
||||||
return fmt.Errorf("flags and extra args require an integration name, for example: 'ollama launch claude --model qwen3.5'")
|
return fmt.Errorf("flags and extra args require an integration name, for example: 'ollama launch claude --model qwen3.5'")
|
||||||
}
|
}
|
||||||
runTUI(cmd)
|
runTUI(cmd)
|
||||||
@@ -275,11 +344,20 @@ Examples:
|
|||||||
}
|
}
|
||||||
|
|
||||||
headlessYes := yesFlag && !isInteractiveSession()
|
headlessYes := yesFlag && !isInteractiveSession()
|
||||||
|
forceConfigure := configFlag || (modelFlag == "" && !headlessYes)
|
||||||
|
if forceConfigure && !configFlag && modelFlag == "" {
|
||||||
|
if _, runner, err := LookupIntegration(name); err == nil {
|
||||||
|
if _, ok := runner.(ManagedAutodiscoveryIntegration); ok {
|
||||||
|
forceConfigure = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
err := LaunchIntegration(cmd.Context(), IntegrationLaunchRequest{
|
err := LaunchIntegration(cmd.Context(), IntegrationLaunchRequest{
|
||||||
Name: name,
|
Name: name,
|
||||||
ModelOverride: modelFlag,
|
ModelOverride: modelFlag,
|
||||||
ForceConfigure: configFlag || (modelFlag == "" && !headlessYes),
|
ForceConfigure: forceConfigure,
|
||||||
ConfigureOnly: configFlag,
|
ConfigureOnly: configFlag,
|
||||||
|
Restore: restoreFlag,
|
||||||
ExtraArgs: passArgs,
|
ExtraArgs: passArgs,
|
||||||
Policy: &policy,
|
Policy: &policy,
|
||||||
})
|
})
|
||||||
@@ -292,10 +370,19 @@ Examples:
|
|||||||
|
|
||||||
cmd.Flags().StringVar(&modelFlag, "model", "", "Model to use")
|
cmd.Flags().StringVar(&modelFlag, "model", "", "Model to use")
|
||||||
cmd.Flags().BoolVar(&configFlag, "config", false, "Configure without launching")
|
cmd.Flags().BoolVar(&configFlag, "config", false, "Configure without launching")
|
||||||
|
cmd.Flags().BoolVar(&restoreFlag, "restore", false, "Restore an integration to its default profile")
|
||||||
cmd.Flags().BoolVarP(&yesFlag, "yes", "y", false, "Automatically answer yes to confirmation prompts")
|
cmd.Flags().BoolVarP(&yesFlag, "yes", "y", false, "Automatically answer yes to confirmation prompts")
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func launchCommandCanSkipHeartbeat(args []string) bool {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
name, _, err := LookupIntegration(args[0])
|
||||||
|
return err == nil && name == "claude-desktop"
|
||||||
|
}
|
||||||
|
|
||||||
type launcherClient struct {
|
type launcherClient struct {
|
||||||
apiClient *api.Client
|
apiClient *api.Client
|
||||||
modelInventory []ModelInfo
|
modelInventory []ModelInfo
|
||||||
@@ -349,8 +436,13 @@ func LaunchIntegration(ctx context.Context, req IntegrationLaunchRequest) error
|
|||||||
}
|
}
|
||||||
|
|
||||||
policy := launchIntegrationPolicy(req)
|
policy := launchIntegrationPolicy(req)
|
||||||
|
if req.Restore {
|
||||||
|
return restoreIntegration(name, runner, req)
|
||||||
|
}
|
||||||
if policy.Confirm == LaunchConfirmAutoApprove && !isInteractiveSession() && req.ModelOverride == "" {
|
if policy.Confirm == LaunchConfirmAutoApprove && !isInteractiveSession() && req.ModelOverride == "" {
|
||||||
return fmt.Errorf("headless --yes launch for %s requires --model <model>", name)
|
if _, ok := runner.(ManagedAutodiscoveryIntegration); !ok {
|
||||||
|
return fmt.Errorf("headless --yes launch for %s requires --model <model>", name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
launchClient, saved, err := prepareIntegrationLaunch(name, policy)
|
launchClient, saved, err := prepareIntegrationLaunch(name, policy)
|
||||||
@@ -358,6 +450,13 @@ func LaunchIntegration(ctx context.Context, req IntegrationLaunchRequest) error
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if autodiscovery, ok := runner.(ManagedAutodiscoveryIntegration); ok {
|
||||||
|
if err := EnsureIntegrationInstalled(name, runner); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return launchClient.launchManagedAutodiscoveryIntegration(ctx, name, runner, autodiscovery, saved, req)
|
||||||
|
}
|
||||||
|
|
||||||
if managed, ok := runner.(ManagedSingleModel); ok {
|
if managed, ok := runner.(ManagedSingleModel); ok {
|
||||||
if err := EnsureIntegrationInstalled(name, runner); err != nil {
|
if err := EnsureIntegrationInstalled(name, runner); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -377,6 +476,24 @@ func LaunchIntegration(ctx context.Context, req IntegrationLaunchRequest) error
|
|||||||
return launchClient.launchSingleIntegration(ctx, name, runner, saved, req)
|
return launchClient.launchSingleIntegration(ctx, name, runner, saved, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func restoreIntegration(name string, runner Runner, req IntegrationLaunchRequest) error {
|
||||||
|
if req.ModelOverride != "" || req.ConfigureOnly || len(req.ExtraArgs) > 0 {
|
||||||
|
return fmt.Errorf("--restore cannot be combined with --model, --config, or extra args")
|
||||||
|
}
|
||||||
|
restorable, ok := runner.(RestorableIntegration)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("%s does not support --restore", name)
|
||||||
|
}
|
||||||
|
if err := EnsureIntegrationInstalled(name, runner); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := restorable.Restore(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
printRestoreSuccess(restorable)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func launchIntegrationPolicy(req IntegrationLaunchRequest) LaunchPolicy {
|
func launchIntegrationPolicy(req IntegrationLaunchRequest) LaunchPolicy {
|
||||||
// TUI does not set a policy, whereas ollama launch <app> does as it can
|
// TUI does not set a policy, whereas ollama launch <app> does as it can
|
||||||
// have flags which change the behavior.
|
// have flags which change the behavior.
|
||||||
@@ -427,7 +544,12 @@ func (c *launcherClient) buildLauncherIntegrationState(ctx context.Context, info
|
|||||||
}
|
}
|
||||||
var currentModel string
|
var currentModel string
|
||||||
var usable bool
|
var usable bool
|
||||||
if managed, ok := integration.spec.Runner.(ManagedSingleModel); ok {
|
if autodiscovery, ok := integration.spec.Runner.(ManagedAutodiscoveryIntegration); ok {
|
||||||
|
currentModel, usable, err = c.launcherManagedAutodiscoveryState(ctx, info.Name, autodiscovery)
|
||||||
|
if err != nil {
|
||||||
|
return LauncherIntegrationState{}, err
|
||||||
|
}
|
||||||
|
} else if managed, ok := integration.spec.Runner.(ManagedSingleModel); ok {
|
||||||
currentModel, usable, err = c.launcherManagedModelState(ctx, info.Name, managed)
|
currentModel, usable, err = c.launcherManagedModelState(ctx, info.Name, managed)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return LauncherIntegrationState{}, err
|
return LauncherIntegrationState{}, err
|
||||||
@@ -489,6 +611,10 @@ func (c *launcherClient) launcherManagedModelState(ctx context.Context, name str
|
|||||||
return "", false, nil
|
return "", false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if skips, ok := managed.(ManagedModelReadinessSkipper); ok && skips.SkipModelReadiness() {
|
||||||
|
return current, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
usable, err := c.savedModelUsable(ctx, current)
|
usable, err := c.savedModelUsable(ctx, current)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return current, false, err
|
return current, false, err
|
||||||
@@ -496,6 +622,20 @@ func (c *launcherClient) launcherManagedModelState(ctx context.Context, name str
|
|||||||
return current, usable, nil
|
return current, usable, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *launcherClient) launcherManagedAutodiscoveryState(ctx context.Context, name string, autodiscovery ManagedAutodiscoveryIntegration) (string, bool, error) {
|
||||||
|
if autodiscovery.AutodiscoveryConfigured() {
|
||||||
|
return autodiscovery.AutodiscoveredModel(), c.managedAutodiscoveryUsable(ctx, autodiscovery), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, loadErr := loadStoredIntegrationConfig(name)
|
||||||
|
if loadErr == nil {
|
||||||
|
if current := primaryModelFromConfig(cfg); current != "" {
|
||||||
|
return current, false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *launcherClient) resolveRunModel(ctx context.Context, req RunModelRequest) (string, error) {
|
func (c *launcherClient) resolveRunModel(ctx context.Context, req RunModelRequest) (string, error) {
|
||||||
current := config.LastModel()
|
current := config.LastModel()
|
||||||
if !req.ForcePicker && current != "" && c.policy.Confirm == LaunchConfirmAutoApprove && !isInteractiveSession() {
|
if !req.ForcePicker && current != "" && c.policy.Confirm == LaunchConfirmAutoApprove && !isInteractiveSession() {
|
||||||
@@ -594,7 +734,11 @@ func (c *launcherClient) launchManagedSingleIntegration(ctx context.Context, nam
|
|||||||
}
|
}
|
||||||
|
|
||||||
if needsConfigure || req.ModelOverride != "" || (current != "" && target != current) || !savedMatchesModels(saved, []string{target}) {
|
if needsConfigure || req.ModelOverride != "" || (current != "" && target != current) || !savedMatchesModels(saved, []string{target}) {
|
||||||
if err := prepareManagedSingleIntegration(name, runner, managed, target); err != nil {
|
configureModels, err := c.managedSingleConfigureModels(ctx, managed, target)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := prepareManagedSingleIntegration(name, runner, managed, target, configureModels); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if refresher, ok := managed.(ManagedRuntimeRefresher); ok {
|
if refresher, ok := managed.(ManagedRuntimeRefresher); ok {
|
||||||
@@ -620,15 +764,146 @@ func (c *launcherClient) launchManagedSingleIntegration(ctx context.Context, nam
|
|||||||
return runIntegration(runner, target, req.ExtraArgs)
|
return runIntegration(runner, target, req.ExtraArgs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *launcherClient) launchManagedAutodiscoveryIntegration(ctx context.Context, name string, runner Runner, autodiscovery ManagedAutodiscoveryIntegration, saved *config.IntegrationConfig, req IntegrationLaunchRequest) error {
|
||||||
|
if req.ModelOverride != "" {
|
||||||
|
return fmt.Errorf("%s discovers models automatically; omit --model", runner)
|
||||||
|
}
|
||||||
|
|
||||||
|
target := autodiscovery.AutodiscoveredModel()
|
||||||
|
if err := c.ensureManagedAutodiscoveryUsable(ctx, autodiscovery, target); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
needsConfigure := req.ForceConfigure || req.ConfigureOnly || !autodiscovery.AutodiscoveryConfigured() || !savedMatchesModels(saved, []string{target})
|
||||||
|
|
||||||
|
if needsConfigure {
|
||||||
|
if err := prepareManagedAutodiscoveryIntegration(name, runner, autodiscovery, target); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if refresher, ok := autodiscovery.(ManagedRuntimeRefresher); ok {
|
||||||
|
if err := refresher.RefreshRuntimeAfterConfigure(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !managedIntegrationOnboarded(saved, autodiscovery) {
|
||||||
|
if !isInteractiveSession() && managedRequiresInteractiveOnboarding(autodiscovery) {
|
||||||
|
return fmt.Errorf("%s still needs interactive gateway setup; run 'ollama launch %s' in a terminal to finish onboarding", runner, name)
|
||||||
|
}
|
||||||
|
if err := autodiscovery.Onboard(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !printConfigurationSuccess(autodiscovery) {
|
||||||
|
printRestoreHint(autodiscovery)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.ConfigureOnly {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return runIntegration(runner, target, req.ExtraArgs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *launcherClient) managedAutodiscoveryUsable(ctx context.Context, autodiscovery ManagedAutodiscoveryIntegration) bool {
|
||||||
|
if !managedAutodiscoveryUsesOllamaCloud(autodiscovery) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return c.ollamaCloudSignedIn(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *launcherClient) ensureManagedAutodiscoveryUsable(ctx context.Context, autodiscovery ManagedAutodiscoveryIntegration, label string) error {
|
||||||
|
if !managedAutodiscoveryUsesOllamaCloud(autodiscovery) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return ensureCloudAuth(ctx, c.apiClient, label)
|
||||||
|
}
|
||||||
|
|
||||||
|
func managedAutodiscoveryUsesOllamaCloud(autodiscovery ManagedAutodiscoveryIntegration) bool {
|
||||||
|
cloud, ok := autodiscovery.(ManagedAutodiscoveryCloudIntegration)
|
||||||
|
return ok && cloud.UsesOllamaCloud()
|
||||||
|
}
|
||||||
|
|
||||||
|
func printRestoreHint(integration any) {
|
||||||
|
hint, ok := integration.(RestoreHintIntegration)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if msg := strings.TrimSpace(hint.RestoreHint()); msg != "" {
|
||||||
|
fmt.Fprintln(os.Stderr, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func printConfigurationSuccess(integration any) bool {
|
||||||
|
success, ok := integration.(ConfigurationSuccessIntegration)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if msg := strings.TrimSpace(success.ConfigurationSuccessMessage()); msg != "" {
|
||||||
|
fmt.Fprintln(os.Stderr, msg)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func printRestoreSuccess(integration any) {
|
||||||
|
success, ok := integration.(RestoreSuccessIntegration)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if msg := strings.TrimSpace(success.RestoreSuccessMessage()); msg != "" {
|
||||||
|
fmt.Fprintln(os.Stderr, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *launcherClient) ollamaCloudSignedIn(ctx context.Context) bool {
|
||||||
|
if disabled, known := cloudStatusDisabled(ctx, c.apiClient); known && disabled {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
user, err := c.apiClient.Whoami(ctx)
|
||||||
|
return err == nil && user != nil && user.Name != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *launcherClient) managedSingleConfigureModels(ctx context.Context, managed ManagedSingleModel, target string) ([]string, error) {
|
||||||
|
models := []string{target}
|
||||||
|
if _, ok := managed.(ManagedModelListConfigurer); !ok {
|
||||||
|
return models, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
items, _, err := c.loadSelectableModels(ctx, []string{target}, target, "no models available")
|
||||||
|
if err != nil {
|
||||||
|
// Managed integrations that can use a model catalog should still be
|
||||||
|
// configurable with an explicit target even if the broader inventory
|
||||||
|
// cannot be loaded in the moment.
|
||||||
|
//nolint:nilerr
|
||||||
|
return models, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range items {
|
||||||
|
models = append(models, item.Name)
|
||||||
|
}
|
||||||
|
return dedupeModelList(models), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *launcherClient) resolveSingleIntegrationTarget(ctx context.Context, runner Runner, current string, req IntegrationLaunchRequest) (string, bool, error) {
|
func (c *launcherClient) resolveSingleIntegrationTarget(ctx context.Context, runner Runner, current string, req IntegrationLaunchRequest) (string, bool, error) {
|
||||||
target := req.ModelOverride
|
target := req.ModelOverride
|
||||||
needsConfigure := req.ForceConfigure
|
needsConfigure := req.ForceConfigure
|
||||||
|
skipReadiness := false
|
||||||
|
if skipper, ok := runner.(ManagedModelReadinessSkipper); ok {
|
||||||
|
skipReadiness = skipper.SkipModelReadiness()
|
||||||
|
}
|
||||||
|
|
||||||
if target == "" {
|
if target == "" {
|
||||||
target = current
|
target = current
|
||||||
usable, err := c.savedModelUsable(ctx, target)
|
usable := skipReadiness && target != ""
|
||||||
if err != nil {
|
if !skipReadiness {
|
||||||
return "", false, err
|
var err error
|
||||||
|
usable, err = c.savedModelUsable(ctx, target)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if !usable {
|
if !usable {
|
||||||
needsConfigure = true
|
needsConfigure = true
|
||||||
@@ -636,13 +911,19 @@ func (c *launcherClient) resolveSingleIntegrationTarget(ctx context.Context, run
|
|||||||
}
|
}
|
||||||
|
|
||||||
if needsConfigure {
|
if needsConfigure {
|
||||||
selected, err := c.selectSingleModelWithSelector(ctx, fmt.Sprintf("Select model for %s:", runner), target, DefaultSingleSelector)
|
selected, err := c.selectSingleModelWithSelectorReady(ctx, fmt.Sprintf("Select model for %s:", runner), target, DefaultSingleSelector, !skipReadiness)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", false, err
|
return "", false, err
|
||||||
}
|
}
|
||||||
target = selected
|
target = selected
|
||||||
} else if err := c.ensureModelsReady(ctx, []string{target}); err != nil {
|
} else if !skipReadiness {
|
||||||
return "", false, err
|
if err := c.ensureModelsReady(ctx, []string{target}); err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if target == "" {
|
||||||
|
return "", false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return target, needsConfigure, nil
|
return target, needsConfigure, nil
|
||||||
@@ -652,7 +933,7 @@ func savedIntegrationOnboarded(saved *config.IntegrationConfig) bool {
|
|||||||
return saved != nil && saved.Onboarded
|
return saved != nil && saved.Onboarded
|
||||||
}
|
}
|
||||||
|
|
||||||
func managedIntegrationOnboarded(saved *config.IntegrationConfig, managed ManagedSingleModel) bool {
|
func managedIntegrationOnboarded(saved *config.IntegrationConfig, managed any) bool {
|
||||||
if !savedIntegrationOnboarded(saved) {
|
if !savedIntegrationOnboarded(saved) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -666,7 +947,7 @@ func managedIntegrationOnboarded(saved *config.IntegrationConfig, managed Manage
|
|||||||
// Most managed integrations treat onboarding as an interactive terminal step.
|
// Most managed integrations treat onboarding as an interactive terminal step.
|
||||||
// Hermes opts out because its launch-owned onboarding is just bookkeeping, so
|
// Hermes opts out because its launch-owned onboarding is just bookkeeping, so
|
||||||
// headless launches should not be blocked once config is already prepared.
|
// headless launches should not be blocked once config is already prepared.
|
||||||
func managedRequiresInteractiveOnboarding(managed ManagedSingleModel) bool {
|
func managedRequiresInteractiveOnboarding(managed any) bool {
|
||||||
onboarding, ok := managed.(ManagedInteractiveOnboarding)
|
onboarding, ok := managed.(ManagedInteractiveOnboarding)
|
||||||
if !ok {
|
if !ok {
|
||||||
return true
|
return true
|
||||||
@@ -675,6 +956,10 @@ func managedRequiresInteractiveOnboarding(managed ManagedSingleModel) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *launcherClient) selectSingleModelWithSelector(ctx context.Context, title, current string, selector SingleSelector) (string, error) {
|
func (c *launcherClient) selectSingleModelWithSelector(ctx context.Context, title, current string, selector SingleSelector) (string, error) {
|
||||||
|
return c.selectSingleModelWithSelectorReady(ctx, title, current, selector, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *launcherClient) selectSingleModelWithSelectorReady(ctx context.Context, title, current string, selector SingleSelector, ensureReady bool) (string, error) {
|
||||||
if selector == nil {
|
if selector == nil {
|
||||||
return "", fmt.Errorf("no selector configured")
|
return "", fmt.Errorf("no selector configured")
|
||||||
}
|
}
|
||||||
@@ -688,8 +973,13 @@ func (c *launcherClient) selectSingleModelWithSelector(ctx context.Context, titl
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if err := c.ensureModelsReady(ctx, []string{selected}); err != nil {
|
if selected == "" {
|
||||||
return "", err
|
return "", ErrCancelled
|
||||||
|
}
|
||||||
|
if ensureReady {
|
||||||
|
if err := c.ensureModelsReady(ctx, []string{selected}); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return selected, nil
|
return selected, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,22 @@ func (r *launcherSingleRunner) Run(model string, args []string) error {
|
|||||||
|
|
||||||
func (r *launcherSingleRunner) String() string { return "StubSingle" }
|
func (r *launcherSingleRunner) String() string { return "StubSingle" }
|
||||||
|
|
||||||
|
type launcherRestorableRunner struct {
|
||||||
|
launcherSingleRunner
|
||||||
|
restored bool
|
||||||
|
restoreErr error
|
||||||
|
restoreSuccessMessage string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *launcherRestorableRunner) Restore() error {
|
||||||
|
r.restored = true
|
||||||
|
return r.restoreErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *launcherRestorableRunner) RestoreSuccessMessage() string {
|
||||||
|
return r.restoreSuccessMessage
|
||||||
|
}
|
||||||
|
|
||||||
type launcherManagedRunner struct {
|
type launcherManagedRunner struct {
|
||||||
paths []string
|
paths []string
|
||||||
currentModel string
|
currentModel string
|
||||||
@@ -99,6 +115,45 @@ type launcherHeadlessManagedRunner struct {
|
|||||||
|
|
||||||
func (r *launcherHeadlessManagedRunner) RequiresInteractiveOnboarding() bool { return false }
|
func (r *launcherHeadlessManagedRunner) RequiresInteractiveOnboarding() bool { return false }
|
||||||
|
|
||||||
|
type launcherManagedListRunner struct {
|
||||||
|
launcherManagedRunner
|
||||||
|
configuredModelLists [][]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *launcherManagedListRunner) ConfigureWithModels(primary string, models []string) error {
|
||||||
|
r.configuredModelLists = append(r.configuredModelLists, append([]string(nil), models...))
|
||||||
|
return r.Configure(primary)
|
||||||
|
}
|
||||||
|
|
||||||
|
type launcherManagedAutodiscoveryRunner struct {
|
||||||
|
launcherManagedRunner
|
||||||
|
autodiscoveryConfigures int
|
||||||
|
autodiscoveryConfigured bool
|
||||||
|
usesCloud bool
|
||||||
|
restoreHint string
|
||||||
|
configSuccessMessage string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *launcherManagedAutodiscoveryRunner) AutodiscoveredModel() string { return "Ollama Cloud" }
|
||||||
|
|
||||||
|
func (r *launcherManagedAutodiscoveryRunner) UsesOllamaCloud() bool { return r.usesCloud }
|
||||||
|
|
||||||
|
func (r *launcherManagedAutodiscoveryRunner) RestoreHint() string { return r.restoreHint }
|
||||||
|
|
||||||
|
func (r *launcherManagedAutodiscoveryRunner) ConfigurationSuccessMessage() string {
|
||||||
|
return r.configSuccessMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *launcherManagedAutodiscoveryRunner) AutodiscoveryConfigured() bool {
|
||||||
|
return r.autodiscoveryConfigured
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *launcherManagedAutodiscoveryRunner) ConfigureAutodiscovery() error {
|
||||||
|
r.autodiscoveryConfigures++
|
||||||
|
r.autodiscoveryConfigured = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func setLaunchTestHome(t *testing.T, dir string) {
|
func setLaunchTestHome(t *testing.T, dir string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
t.Setenv("HOME", dir)
|
t.Setenv("HOME", dir)
|
||||||
@@ -627,6 +682,378 @@ func TestLaunchIntegration_ManagedSingleIntegrationStopsWhenRuntimeRefreshFails(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLaunchIntegration_ManagedSingleIntegrationCanConfigureWithModelList(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setLaunchTestHome(t, tmpDir)
|
||||||
|
withInteractiveSession(t, true)
|
||||||
|
withLauncherHooks(t)
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/experimental/model-recommendations":
|
||||||
|
fmt.Fprint(w, `{"recommendations":[]}`)
|
||||||
|
case "/api/tags":
|
||||||
|
fmt.Fprint(w, `{"models":[{"name":"gemma4"},{"name":"qwen3:8b"}]}`)
|
||||||
|
case "/api/show":
|
||||||
|
fmt.Fprint(w, `{"model_info":{"general.context_length":131072}}`)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
t.Setenv("OLLAMA_HOST", srv.URL)
|
||||||
|
|
||||||
|
runner := &launcherManagedListRunner{}
|
||||||
|
withIntegrationOverride(t, "stubmanaged", runner)
|
||||||
|
|
||||||
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{
|
||||||
|
Name: "stubmanaged",
|
||||||
|
ModelOverride: "gemma4",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("LaunchIntegration returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if diff := compareStringSlices(runner.configuredModelLists, [][]string{{"gemma4", "kimi-k2.6:cloud", "qwen3.5:cloud", "glm-5.1:cloud", "minimax-m2.7:cloud", "qwen3.5", "qwen3:8b"}}); diff != "" {
|
||||||
|
t.Fatalf("configured model list mismatch (-want +got):\n%s", diff)
|
||||||
|
}
|
||||||
|
if diff := compareStrings(runner.configured, []string{"gemma4"}); diff != "" {
|
||||||
|
t.Fatalf("configured primary mismatch: %s", diff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLaunchIntegration_ManagedAutodiscoverySkipsModelPicker(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setLaunchTestHome(t, tmpDir)
|
||||||
|
withInteractiveSession(t, true)
|
||||||
|
withLauncherHooks(t)
|
||||||
|
|
||||||
|
runner := &launcherManagedAutodiscoveryRunner{}
|
||||||
|
withIntegrationOverride(t, "stubmanaged", runner)
|
||||||
|
|
||||||
|
DefaultSingleSelector = func(title string, items []ModelItem, current string) (string, error) {
|
||||||
|
t.Fatal("model selector should not run for autodiscovery integrations")
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "stubmanaged"}); err != nil {
|
||||||
|
t.Fatalf("LaunchIntegration returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if runner.autodiscoveryConfigures != 1 {
|
||||||
|
t.Fatalf("expected one autodiscovery configure, got %d", runner.autodiscoveryConfigures)
|
||||||
|
}
|
||||||
|
if runner.ranModel != "Ollama Cloud" {
|
||||||
|
t.Fatalf("expected launch to run autodiscovery label, got %q", runner.ranModel)
|
||||||
|
}
|
||||||
|
saved, err := config.LoadIntegration("stubmanaged")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to reload managed integration config: %v", err)
|
||||||
|
}
|
||||||
|
if diff := compareStrings(saved.Models, []string{"Ollama Cloud"}); diff != "" {
|
||||||
|
t.Fatalf("saved models mismatch: %s", diff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLaunchIntegration_ManagedAutodiscoveryPrintsConfigurationSuccessAfterConfigure(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setLaunchTestHome(t, tmpDir)
|
||||||
|
withInteractiveSession(t, true)
|
||||||
|
withLauncherHooks(t)
|
||||||
|
|
||||||
|
runner := &launcherManagedAutodiscoveryRunner{
|
||||||
|
restoreHint: "run restore command",
|
||||||
|
configSuccessMessage: "configured successfully\nrestore via success message",
|
||||||
|
}
|
||||||
|
withIntegrationOverride(t, "stubmanaged", runner)
|
||||||
|
|
||||||
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
stderr := captureStderr(t, func() {
|
||||||
|
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "stubmanaged"}); err != nil {
|
||||||
|
t.Fatalf("LaunchIntegration returned error: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if runner.autodiscoveryConfigures != 1 {
|
||||||
|
t.Fatalf("expected one autodiscovery configure, got %d", runner.autodiscoveryConfigures)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr, "configured successfully") {
|
||||||
|
t.Fatalf("expected configuration success in stderr, got %q", stderr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr, "restore via success message") {
|
||||||
|
t.Fatalf("expected restore guidance in configuration success, got %q", stderr)
|
||||||
|
}
|
||||||
|
if strings.Contains(stderr, "run restore command") {
|
||||||
|
t.Fatalf("restore hint should not print separately after configure, got %q", stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLaunchIntegration_ManagedAutodiscoveryPrintsRestoreHintWhenAlreadyConfigured(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setLaunchTestHome(t, tmpDir)
|
||||||
|
withInteractiveSession(t, true)
|
||||||
|
withLauncherHooks(t)
|
||||||
|
|
||||||
|
runner := &launcherManagedAutodiscoveryRunner{
|
||||||
|
autodiscoveryConfigured: true,
|
||||||
|
restoreHint: "run restore command",
|
||||||
|
}
|
||||||
|
withIntegrationOverride(t, "stubmanaged", runner)
|
||||||
|
|
||||||
|
if err := config.SaveIntegration("stubmanaged", []string{"Ollama Cloud"}); err != nil {
|
||||||
|
t.Fatalf("failed to save managed integration config: %v", err)
|
||||||
|
}
|
||||||
|
if err := config.MarkIntegrationOnboarded("stubmanaged"); err != nil {
|
||||||
|
t.Fatalf("failed to mark integration onboarded: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stderr := captureStderr(t, func() {
|
||||||
|
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "stubmanaged"}); err != nil {
|
||||||
|
t.Fatalf("LaunchIntegration returned error: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if runner.autodiscoveryConfigures != 0 {
|
||||||
|
t.Fatalf("expected configured autodiscovery integration not to reconfigure, got %d configures", runner.autodiscoveryConfigures)
|
||||||
|
}
|
||||||
|
if runner.ranModel != "Ollama Cloud" {
|
||||||
|
t.Fatalf("expected launch to run autodiscovery label, got %q", runner.ranModel)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr, "run restore command") {
|
||||||
|
t.Fatalf("expected restore hint in stderr, got %q", stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLaunchIntegration_ManagedAutodiscoveryPrintsConfigurationSuccessWhenAlreadyConfigured(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setLaunchTestHome(t, tmpDir)
|
||||||
|
withInteractiveSession(t, true)
|
||||||
|
withLauncherHooks(t)
|
||||||
|
|
||||||
|
runner := &launcherManagedAutodiscoveryRunner{
|
||||||
|
autodiscoveryConfigured: true,
|
||||||
|
restoreHint: "run restore command",
|
||||||
|
configSuccessMessage: "configured successfully\nrestore via success message",
|
||||||
|
}
|
||||||
|
withIntegrationOverride(t, "stubmanaged", runner)
|
||||||
|
|
||||||
|
if err := config.SaveIntegration("stubmanaged", []string{"Ollama Cloud"}); err != nil {
|
||||||
|
t.Fatalf("failed to save managed integration config: %v", err)
|
||||||
|
}
|
||||||
|
if err := config.MarkIntegrationOnboarded("stubmanaged"); err != nil {
|
||||||
|
t.Fatalf("failed to mark integration onboarded: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stderr := captureStderr(t, func() {
|
||||||
|
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "stubmanaged"}); err != nil {
|
||||||
|
t.Fatalf("LaunchIntegration returned error: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if runner.autodiscoveryConfigures != 0 {
|
||||||
|
t.Fatalf("expected configured autodiscovery integration not to reconfigure, got %d configures", runner.autodiscoveryConfigures)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr, "configured successfully") {
|
||||||
|
t.Fatalf("expected configuration success in stderr, got %q", stderr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr, "restore via success message") {
|
||||||
|
t.Fatalf("expected restore guidance in configuration success, got %q", stderr)
|
||||||
|
}
|
||||||
|
if strings.Contains(stderr, "run restore command") {
|
||||||
|
t.Fatalf("restore hint should not print separately when success message exists, got %q", stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLaunchIntegration_RestorePrintsSuccessMessage(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setLaunchTestHome(t, tmpDir)
|
||||||
|
withLauncherHooks(t)
|
||||||
|
|
||||||
|
runner := &launcherRestorableRunner{
|
||||||
|
restoreSuccessMessage: "restored successfully",
|
||||||
|
}
|
||||||
|
withIntegrationOverride(t, "stubrestore", runner)
|
||||||
|
|
||||||
|
stderr := captureStderr(t, func() {
|
||||||
|
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{
|
||||||
|
Name: "stubrestore",
|
||||||
|
Restore: true,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("LaunchIntegration returned error: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if !runner.restored {
|
||||||
|
t.Fatal("expected restore to run")
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr, "restored successfully") {
|
||||||
|
t.Fatalf("expected restore success in stderr, got %q", stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLaunchIntegration_ManagedAutodiscoveryForceConfigureRerunsSetup(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setLaunchTestHome(t, tmpDir)
|
||||||
|
withInteractiveSession(t, true)
|
||||||
|
withLauncherHooks(t)
|
||||||
|
|
||||||
|
runner := &launcherManagedAutodiscoveryRunner{
|
||||||
|
autodiscoveryConfigured: true,
|
||||||
|
}
|
||||||
|
withIntegrationOverride(t, "stubmanaged", runner)
|
||||||
|
|
||||||
|
if err := config.SaveIntegration("stubmanaged", []string{"Ollama Cloud"}); err != nil {
|
||||||
|
t.Fatalf("failed to save managed integration config: %v", err)
|
||||||
|
}
|
||||||
|
if err := config.MarkIntegrationOnboarded("stubmanaged"); err != nil {
|
||||||
|
t.Fatalf("failed to mark integration onboarded: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{
|
||||||
|
Name: "stubmanaged",
|
||||||
|
ForceConfigure: true,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("LaunchIntegration returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if runner.autodiscoveryConfigures != 1 {
|
||||||
|
t.Fatalf("expected forced autodiscovery configure to rerun setup, got %d configures", runner.autodiscoveryConfigures)
|
||||||
|
}
|
||||||
|
if runner.ranModel != "Ollama Cloud" {
|
||||||
|
t.Fatalf("expected launch to run autodiscovery label, got %q", runner.ranModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLaunchIntegration_CloudAutodiscoveryUsesSignInHook(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setLaunchTestHome(t, tmpDir)
|
||||||
|
withInteractiveSession(t, true)
|
||||||
|
withLauncherHooks(t)
|
||||||
|
|
||||||
|
runner := &launcherManagedAutodiscoveryRunner{usesCloud: true}
|
||||||
|
withIntegrationOverride(t, "stubmanaged", runner)
|
||||||
|
|
||||||
|
signInCalled := false
|
||||||
|
DefaultSignIn = func(modelName, signInURL string) (string, error) {
|
||||||
|
signInCalled = true
|
||||||
|
if modelName != "Ollama Cloud" {
|
||||||
|
t.Fatalf("sign-in model = %q, want Ollama Cloud", modelName)
|
||||||
|
}
|
||||||
|
if signInURL != "https://example.com/signin" {
|
||||||
|
t.Fatalf("sign-in URL = %q, want test URL", signInURL)
|
||||||
|
}
|
||||||
|
return "test-user", nil
|
||||||
|
}
|
||||||
|
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/status":
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
fmt.Fprint(w, `{"error":"not found"}`)
|
||||||
|
case "/api/me":
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
fmt.Fprint(w, `{"error":"unauthorized","signin_url":"https://example.com/signin"}`)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
t.Setenv("OLLAMA_HOST", srv.URL)
|
||||||
|
|
||||||
|
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "stubmanaged"}); err != nil {
|
||||||
|
t.Fatalf("LaunchIntegration returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !signInCalled {
|
||||||
|
t.Fatal("expected cloud autodiscovery launch to use the sign-in hook")
|
||||||
|
}
|
||||||
|
if runner.autodiscoveryConfigures != 1 {
|
||||||
|
t.Fatalf("expected one autodiscovery configure, got %d", runner.autodiscoveryConfigures)
|
||||||
|
}
|
||||||
|
if runner.ranModel != "Ollama Cloud" {
|
||||||
|
t.Fatalf("expected launch to run autodiscovery label, got %q", runner.ranModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildLauncherIntegrationState_CloudAutodiscoveryRequiresSignedIn(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setLaunchTestHome(t, tmpDir)
|
||||||
|
withLauncherHooks(t)
|
||||||
|
|
||||||
|
runner := &launcherManagedAutodiscoveryRunner{
|
||||||
|
autodiscoveryConfigured: true,
|
||||||
|
usesCloud: true,
|
||||||
|
}
|
||||||
|
withIntegrationOverride(t, "stubmanaged", runner)
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/status":
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
fmt.Fprint(w, `{"error":"not found"}`)
|
||||||
|
case "/api/me":
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
fmt.Fprint(w, `{"error":"unauthorized","signin_url":"https://example.com/signin"}`)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
t.Setenv("OLLAMA_HOST", srv.URL)
|
||||||
|
|
||||||
|
launchClient, err := newLauncherClient(defaultLaunchPolicy(true, false))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
state, err := launchClient.buildLauncherIntegrationState(context.Background(), IntegrationInfo{
|
||||||
|
Name: "stubmanaged",
|
||||||
|
DisplayName: "Stub Managed",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildLauncherIntegrationState returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.CurrentModel != "Ollama Cloud" {
|
||||||
|
t.Fatalf("current model = %q, want Ollama Cloud", state.CurrentModel)
|
||||||
|
}
|
||||||
|
if state.ModelUsable {
|
||||||
|
t.Fatal("expected cloud autodiscovery config to be unusable while signed out")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLaunchIntegration_ManagedAutodiscoveryRejectsModelOverride(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setLaunchTestHome(t, tmpDir)
|
||||||
|
withInteractiveSession(t, true)
|
||||||
|
withLauncherHooks(t)
|
||||||
|
|
||||||
|
runner := &launcherManagedAutodiscoveryRunner{}
|
||||||
|
withIntegrationOverride(t, "stubmanaged", runner)
|
||||||
|
|
||||||
|
err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{
|
||||||
|
Name: "stubmanaged",
|
||||||
|
ModelOverride: "qwen3.5:cloud",
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "discovers models automatically") {
|
||||||
|
t.Fatalf("LaunchIntegration error = %v, want automatic discovery guidance", err)
|
||||||
|
}
|
||||||
|
if runner.autodiscoveryConfigures != 0 {
|
||||||
|
t.Fatalf("expected no configure after model override rejection, got %d", runner.autodiscoveryConfigures)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLaunchIntegration_ManagedSingleIntegrationHeadlessNeedsInteractiveOnboarding(t *testing.T) {
|
func TestLaunchIntegration_ManagedSingleIntegrationHeadlessNeedsInteractiveOnboarding(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
setLaunchTestHome(t, tmpDir)
|
setLaunchTestHome(t, tmpDir)
|
||||||
|
|||||||
@@ -181,6 +181,10 @@ func ensureAuth(ctx context.Context, client *api.Client, cloudModels map[string]
|
|||||||
if len(selectedCloudModels) == 0 {
|
if len(selectedCloudModels) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
return ensureCloudAuth(ctx, client, strings.Join(selectedCloudModels, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureCloudAuth(ctx context.Context, client *api.Client, modelList string) error {
|
||||||
if disabled, known := cloudStatusDisabled(ctx, client); known && disabled {
|
if disabled, known := cloudStatusDisabled(ctx, client); known && disabled {
|
||||||
return errors.New(internalcloud.DisabledError("remote inference is unavailable"))
|
return errors.New(internalcloud.DisabledError("remote inference is unavailable"))
|
||||||
}
|
}
|
||||||
@@ -192,11 +196,12 @@ func ensureAuth(ctx context.Context, client *api.Client, cloudModels map[string]
|
|||||||
|
|
||||||
var aErr api.AuthorizationError
|
var aErr api.AuthorizationError
|
||||||
if !errors.As(err, &aErr) || aErr.SigninURL == "" {
|
if !errors.As(err, &aErr) || aErr.SigninURL == "" {
|
||||||
return err
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s requires sign in", modelList)
|
||||||
}
|
}
|
||||||
|
|
||||||
modelList := strings.Join(selectedCloudModels, ", ")
|
|
||||||
|
|
||||||
if DefaultSignIn != nil {
|
if DefaultSignIn != nil {
|
||||||
_, err := DefaultSignIn(modelList, aErr.SigninURL)
|
_, err := DefaultSignIn(modelList, aErr.SigninURL)
|
||||||
if errors.Is(err, ErrCancelled) {
|
if errors.Is(err, ErrCancelled) {
|
||||||
@@ -310,13 +315,35 @@ func prepareEditorIntegration(name string, runner Runner, editor Editor, models
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func prepareManagedSingleIntegration(name string, runner Runner, managed ManagedSingleModel, model string) error {
|
func prepareManagedSingleIntegration(name string, runner Runner, managed ManagedSingleModel, model string, models []string) error {
|
||||||
if ok, err := confirmConfigEdit(runner, managed.Paths()); err != nil {
|
if ok, err := confirmConfigEdit(runner, managed.Paths()); err != nil {
|
||||||
return err
|
return err
|
||||||
} else if !ok {
|
} else if !ok {
|
||||||
return errCancelled
|
return errCancelled
|
||||||
}
|
}
|
||||||
if err := managed.Configure(model); err != nil {
|
models = dedupeModelList(append([]string{model}, models...))
|
||||||
|
var err error
|
||||||
|
if withModels, ok := managed.(ManagedModelListConfigurer); ok {
|
||||||
|
err = withModels.ConfigureWithModels(model, models)
|
||||||
|
} else {
|
||||||
|
err = managed.Configure(model)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("setup failed: %w", err)
|
||||||
|
}
|
||||||
|
if err := config.SaveIntegration(name, []string{model}); err != nil {
|
||||||
|
return fmt.Errorf("failed to save: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareManagedAutodiscoveryIntegration(name string, runner Runner, autodiscovery ManagedAutodiscoveryIntegration, model string) error {
|
||||||
|
if ok, err := confirmConfigEdit(runner, autodiscovery.Paths()); err != nil {
|
||||||
|
return err
|
||||||
|
} else if !ok {
|
||||||
|
return errCancelled
|
||||||
|
}
|
||||||
|
if err := autodiscovery.ConfigureAutodiscovery(); err != nil {
|
||||||
return fmt.Errorf("setup failed: %w", err)
|
return fmt.Errorf("setup failed: %w", err)
|
||||||
}
|
}
|
||||||
if err := config.SaveIntegration(name, []string{model}); err != nil {
|
if err := config.SaveIntegration(name, []string{model}); err != nil {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ type IntegrationInfo struct {
|
|||||||
Description string
|
Description string
|
||||||
}
|
}
|
||||||
|
|
||||||
var launcherIntegrationOrder = []string{"openclaw", "claude", "opencode", "hermes", "codex", "copilot", "droid", "pi", "pool"}
|
var launcherIntegrationOrder = []string{"claude-desktop", "claude", "openclaw", "hermes", "opencode", "codex", "copilot", "droid", "pi", "pool"}
|
||||||
|
|
||||||
var integrationSpecs = []*IntegrationSpec{
|
var integrationSpecs = []*IntegrationSpec{
|
||||||
{
|
{
|
||||||
@@ -48,6 +48,18 @@ var integrationSpecs = []*IntegrationSpec{
|
|||||||
URL: "https://code.claude.com/docs/en/quickstart",
|
URL: "https://code.claude.com/docs/en/quickstart",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "claude-desktop",
|
||||||
|
Runner: &ClaudeDesktop{},
|
||||||
|
Aliases: []string{"claude-app"},
|
||||||
|
Description: "Claude Desktop with Ollama Cloud",
|
||||||
|
Install: IntegrationInstallSpec{
|
||||||
|
CheckInstalled: func() bool {
|
||||||
|
return claudeDesktopInstalled()
|
||||||
|
},
|
||||||
|
URL: "https://claude.com/download",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: "cline",
|
Name: "cline",
|
||||||
Runner: &Cline{},
|
Runner: &Cline{},
|
||||||
@@ -297,6 +309,9 @@ func ListVisibleIntegrationSpecs() []IntegrationSpec {
|
|||||||
if spec.Hidden {
|
if spec.Hidden {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if supported, ok := spec.Runner.(SupportedIntegration); ok && supported.Supported() != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if spec.Name == "pool" && poolsideGOOS == "windows" {
|
if spec.Name == "pool" && poolsideGOOS == "windows" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -414,6 +429,12 @@ func EnsureIntegrationInstalled(name string, runner Runner) error {
|
|||||||
return fmt.Errorf("%s is not installed", runner)
|
return fmt.Errorf("%s is not installed", runner)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if supported, ok := runner.(SupportedIntegration); ok {
|
||||||
|
if err := supported.Supported(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if integration.spec.Name == "pool" && poolsideGOOS == "windows" {
|
if integration.spec.Name == "pool" && poolsideGOOS == "windows" {
|
||||||
return poolsideUnsupportedError()
|
return poolsideUnsupportedError()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ func TestRenderSignIn_ContainsURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func TestRenderSignIn_ContainsSpinner(t *testing.T) {
|
func TestRenderSignIn_ContainsSpinner(t *testing.T) {
|
||||||
got := renderSignIn("test:cloud", "https://example.com", 0, 80)
|
got := renderSignIn("test:cloud", "https://example.com", 0, 80)
|
||||||
if !strings.Contains(got, "Waiting for sign in to complete") {
|
if !strings.Contains(got, "Waiting for sign in to complete") {
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ type menuItem struct {
|
|||||||
isOthers bool
|
isOthers bool
|
||||||
}
|
}
|
||||||
|
|
||||||
const pinnedIntegrationCount = 3
|
const pinnedIntegrationCount = 4
|
||||||
|
|
||||||
var runModelMenuItem = menuItem{
|
var runModelMenuItem = menuItem{
|
||||||
title: "Chat with a model",
|
title: "Chat with a model",
|
||||||
|
|||||||
@@ -44,6 +44,13 @@ func launcherTestState() *launch.LauncherState {
|
|||||||
Selectable: true,
|
Selectable: true,
|
||||||
Changeable: true,
|
Changeable: true,
|
||||||
},
|
},
|
||||||
|
"claude-desktop": {
|
||||||
|
Name: "claude-desktop",
|
||||||
|
DisplayName: "Claude Desktop",
|
||||||
|
Description: "Claude Desktop with Ollama Cloud",
|
||||||
|
Selectable: true,
|
||||||
|
Changeable: true,
|
||||||
|
},
|
||||||
"hermes": {
|
"hermes": {
|
||||||
Name: "hermes",
|
Name: "hermes",
|
||||||
DisplayName: "Hermes Agent",
|
DisplayName: "Hermes Agent",
|
||||||
@@ -97,18 +104,45 @@ func compareStrings(got, want []string) string {
|
|||||||
return cmp.Diff(want, got)
|
return cmp.Diff(want, got)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func expectedCollapsedSequence(state *launch.LauncherState) []string {
|
||||||
|
sequence := []string{"run"}
|
||||||
|
for _, item := range pinnedIntegrationItems(state) {
|
||||||
|
sequence = append(sequence, item.integration)
|
||||||
|
}
|
||||||
|
if len(otherIntegrationItems(state)) > 0 {
|
||||||
|
sequence = append(sequence, "more")
|
||||||
|
}
|
||||||
|
return sequence
|
||||||
|
}
|
||||||
|
|
||||||
|
func expectedExpandedSequence(state *launch.LauncherState) []string {
|
||||||
|
sequence := []string{"run"}
|
||||||
|
for _, item := range pinnedIntegrationItems(state) {
|
||||||
|
sequence = append(sequence, item.integration)
|
||||||
|
}
|
||||||
|
for _, item := range otherIntegrationItems(state) {
|
||||||
|
sequence = append(sequence, item.integration)
|
||||||
|
}
|
||||||
|
return sequence
|
||||||
|
}
|
||||||
|
|
||||||
func TestMenuRendersPinnedItemsAndMore(t *testing.T) {
|
func TestMenuRendersPinnedItemsAndMore(t *testing.T) {
|
||||||
menu := newModel(launcherTestState())
|
state := launcherTestState()
|
||||||
|
menu := newModel(state)
|
||||||
view := menu.View()
|
view := menu.View()
|
||||||
for _, want := range []string{"Chat with a model", "Launch OpenClaw", "Launch Claude Code", "Launch OpenCode", "More..."} {
|
for _, want := range []string{"Chat with a model", "Launch Claude Code", "Launch OpenClaw", "Launch Hermes Agent", "More..."} {
|
||||||
if !strings.Contains(view, want) {
|
if !strings.Contains(view, want) {
|
||||||
t.Fatalf("expected menu view to contain %q\n%s", want, view)
|
t.Fatalf("expected menu view to contain %q\n%s", want, view)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if strings.Contains(view, "Launch Codex") {
|
if findMenuCursorByIntegration(menu.items, "claude-desktop") == -1 {
|
||||||
t.Fatalf("expected Codex to be under More, not pinned\n%s", view)
|
if strings.Contains(view, "Launch Claude Desktop") {
|
||||||
|
t.Fatalf("expected Claude Desktop to be hidden on unsupported platforms\n%s", view)
|
||||||
|
}
|
||||||
|
} else if !strings.Contains(view, "Launch Claude Desktop") {
|
||||||
|
t.Fatalf("expected menu view to contain Claude Desktop\n%s", view)
|
||||||
}
|
}
|
||||||
wantOrder := []string{"run", "openclaw", "claude", "opencode", "more"}
|
wantOrder := expectedCollapsedSequence(state)
|
||||||
if diff := compareStrings(integrationSequence(menu.items), wantOrder); diff != "" {
|
if diff := compareStrings(integrationSequence(menu.items), wantOrder); diff != "" {
|
||||||
t.Fatalf("unexpected pinned order: %s", diff)
|
t.Fatalf("unexpected pinned order: %s", diff)
|
||||||
}
|
}
|
||||||
@@ -116,20 +150,24 @@ func TestMenuRendersPinnedItemsAndMore(t *testing.T) {
|
|||||||
|
|
||||||
func TestMenuExpandsOthersFromLastSelection(t *testing.T) {
|
func TestMenuExpandsOthersFromLastSelection(t *testing.T) {
|
||||||
state := launcherTestState()
|
state := launcherTestState()
|
||||||
state.LastSelection = "codex"
|
overflow := otherIntegrationItems(state)
|
||||||
|
if len(overflow) == 0 {
|
||||||
|
t.Fatal("expected at least one overflow integration")
|
||||||
|
}
|
||||||
|
state.LastSelection = overflow[0].integration
|
||||||
|
|
||||||
menu := newModel(state)
|
menu := newModel(state)
|
||||||
if !menu.showOthers {
|
if !menu.showOthers {
|
||||||
t.Fatal("expected others section to expand when last selection is in the overflow list")
|
t.Fatal("expected others section to expand when last selection is in the overflow list")
|
||||||
}
|
}
|
||||||
view := menu.View()
|
view := menu.View()
|
||||||
if !strings.Contains(view, "Launch Codex") {
|
if !strings.Contains(view, overflow[0].title) {
|
||||||
t.Fatalf("expected expanded view to contain overflow integration\n%s", view)
|
t.Fatalf("expected expanded view to contain overflow integration\n%s", view)
|
||||||
}
|
}
|
||||||
if strings.Contains(view, "More...") {
|
if strings.Contains(view, "More...") {
|
||||||
t.Fatalf("expected expanded view to replace More... item\n%s", view)
|
t.Fatalf("expected expanded view to replace More... item\n%s", view)
|
||||||
}
|
}
|
||||||
wantOrder := []string{"run", "openclaw", "claude", "opencode", "hermes", "codex", "droid", "pi"}
|
wantOrder := expectedExpandedSequence(state)
|
||||||
if diff := compareStrings(integrationSequence(menu.items), wantOrder); diff != "" {
|
if diff := compareStrings(integrationSequence(menu.items), wantOrder); diff != "" {
|
||||||
t.Fatalf("unexpected expanded order: %s", diff)
|
t.Fatalf("unexpected expanded order: %s", diff)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,7 +115,8 @@
|
|||||||
"expanded": true,
|
"expanded": true,
|
||||||
"pages": [
|
"pages": [
|
||||||
"/integrations/openclaw",
|
"/integrations/openclaw",
|
||||||
"/integrations/hermes"
|
"/integrations/hermes",
|
||||||
|
"/integrations/claude-desktop"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
BIN
docs/images/claude-code-kimi-k2-6.png
Normal file
BIN
docs/images/claude-code-kimi-k2-6.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 307 KiB |
BIN
docs/images/claude-cowork-kimi-k2-6.png
Normal file
BIN
docs/images/claude-cowork-kimi-k2-6.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 297 KiB |
78
docs/integrations/claude-desktop.mdx
Normal file
78
docs/integrations/claude-desktop.mdx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
---
|
||||||
|
title: Claude Desktop
|
||||||
|
---
|
||||||
|
|
||||||
|
Claude Desktop can use Ollama Cloud, including Claude Cowork and Claude Code inside the app.
|
||||||
|
|
||||||
|
<img
|
||||||
|
src="/images/claude-cowork-kimi-k2-6.png"
|
||||||
|
alt="Claude Cowork using kimi-k2.6 through Ollama Cloud"
|
||||||
|
className="rounded-xl"
|
||||||
|
/>
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Claude Desktop for macOS or Windows
|
||||||
|
- An [Ollama API key](https://ollama.com/settings/keys)
|
||||||
|
|
||||||
|
Set the key in your shell before launching:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
export OLLAMA_API_KEY=your_api_key
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick setup
|
||||||
|
|
||||||
|
```shell
|
||||||
|
ollama launch claude-desktop
|
||||||
|
```
|
||||||
|
|
||||||
|
To bring back the usual Anthropic Claude profile later, run:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
ollama launch claude-desktop --restore
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using Ollama Cloud models
|
||||||
|
|
||||||
|
After setup, Claude Desktop discovers your available Ollama Cloud models automatically. For example, `kimi-k2.6` appears inside Claude Cowork.
|
||||||
|
|
||||||
|
The same models are also available to Claude Code inside Claude Desktop:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Configure without launching
|
||||||
|
|
||||||
|
```shell
|
||||||
|
ollama launch claude-desktop --config
|
||||||
|
```
|
||||||
|
|
||||||
|
## Restore normal Claude
|
||||||
|
|
||||||
|
Switch Claude Desktop back to its usual profile:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
ollama launch claude-desktop --restore
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
If Claude Desktop is running, use `--yes` to approve the restart automatically:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
ollama launch claude-desktop --restore --yes
|
||||||
|
```
|
||||||
|
|
||||||
|
## Supported with Ollama
|
||||||
|
|
||||||
|
Claude Desktop with Ollama currently supports:
|
||||||
|
|
||||||
|
- Ollama Cloud as the third-party inference gateway
|
||||||
|
- Automatic model discovery from Ollama Cloud
|
||||||
|
- Claude Cowork with Ollama Cloud models
|
||||||
|
- Claude Code inside Claude Desktop with the same cloud models
|
||||||
|
- subagents (tell Claude to have subagents inherit the current model)
|
||||||
|
|
||||||
|
Claude Desktop with Ollama does not support yet:
|
||||||
|
|
||||||
|
- Web search
|
||||||
|
- Extensions
|
||||||
@@ -23,6 +23,7 @@ AI assistants that help with everyday tasks.
|
|||||||
|
|
||||||
- [OpenClaw](/integrations/openclaw)
|
- [OpenClaw](/integrations/openclaw)
|
||||||
- [Hermes Agent](/integrations/hermes)
|
- [Hermes Agent](/integrations/hermes)
|
||||||
|
- [Claude Desktop](/integrations/claude-desktop)
|
||||||
|
|
||||||
## IDEs & Editors
|
## IDEs & Editors
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user