mirror of
https://github.com/ollama/ollama.git
synced 2026-01-19 04:51:17 -05:00
Compare commits
4 Commits
parth/decr
...
parth-x-cm
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbb34f654a | ||
|
|
8cda63560c | ||
|
|
53a9790328 | ||
|
|
dea8a56e11 |
558
cmd/apps.go
Normal file
558
cmd/apps.go
Normal file
@@ -0,0 +1,558 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ollama/ollama/api"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EnvVar struct {
|
||||||
|
Name string
|
||||||
|
Value string
|
||||||
|
}
|
||||||
|
|
||||||
|
type AppConfig struct {
|
||||||
|
Name string
|
||||||
|
DisplayName string
|
||||||
|
Command string
|
||||||
|
EnvVars func(model string) []EnvVar
|
||||||
|
Args func(model string) []string
|
||||||
|
Setup func(model string) error
|
||||||
|
CheckInstall func() error
|
||||||
|
}
|
||||||
|
|
||||||
|
var ClaudeConfig = &AppConfig{
|
||||||
|
Name: "Claude",
|
||||||
|
DisplayName: "Claude Code",
|
||||||
|
Command: "claude",
|
||||||
|
EnvVars: func(model string) []EnvVar {
|
||||||
|
return []EnvVar{
|
||||||
|
{Name: "ANTHROPIC_BASE_URL", Value: "http://localhost:11434"},
|
||||||
|
{Name: "ANTHROPIC_API_KEY", Value: "ollama"},
|
||||||
|
{Name: "ANTHROPIC_AUTH_TOKEN", Value: "ollama"},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Args: func(model string) []string {
|
||||||
|
if model == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []string{"--model", model}
|
||||||
|
},
|
||||||
|
CheckInstall: func() error {
|
||||||
|
if _, err := exec.LookPath("claude"); err != nil {
|
||||||
|
return fmt.Errorf("claude is not installed. Install with: npm install -g @anthropic-ai/claude-code")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var DroidConfig = &AppConfig{
|
||||||
|
Name: "Droid",
|
||||||
|
DisplayName: "Droid",
|
||||||
|
Command: "droid",
|
||||||
|
EnvVars: func(model string) []EnvVar { return nil },
|
||||||
|
Args: func(model string) []string { return nil },
|
||||||
|
Setup: setupDroidSettings,
|
||||||
|
CheckInstall: func() error {
|
||||||
|
if _, err := exec.LookPath("droid"); err != nil {
|
||||||
|
return fmt.Errorf("droid is not installed. Install from: https://docs.factory.ai/cli/install")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var AppRegistry = map[string]*AppConfig{
|
||||||
|
"claude": ClaudeConfig,
|
||||||
|
"claude-code": ClaudeConfig,
|
||||||
|
"droid": DroidConfig,
|
||||||
|
"opencode": OpenCodeConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetApp(name string) (*AppConfig, bool) {
|
||||||
|
app, ok := AppRegistry[strings.ToLower(name)]
|
||||||
|
return app, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func getModelContextLength(model string) int {
|
||||||
|
client, err := api.ClientFromEnvironment()
|
||||||
|
if err != nil {
|
||||||
|
return 8192
|
||||||
|
}
|
||||||
|
resp, err := client.Show(context.Background(), &api.ShowRequest{Model: model})
|
||||||
|
if err != nil || resp.ModelInfo == nil {
|
||||||
|
return 8192
|
||||||
|
}
|
||||||
|
arch, ok := resp.ModelInfo["general.architecture"].(string)
|
||||||
|
if !ok {
|
||||||
|
return 8192
|
||||||
|
}
|
||||||
|
if v, ok := resp.ModelInfo[fmt.Sprintf("%s.context_length", arch)].(float64); ok {
|
||||||
|
return min(int(v), 128000)
|
||||||
|
}
|
||||||
|
return 8192
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupDroidSettings(model string) error {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsPath := filepath.Join(home, ".factory", "settings.json")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(settingsPath), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
settings := make(map[string]any)
|
||||||
|
if data, err := os.ReadFile(settingsPath); err == nil {
|
||||||
|
json.Unmarshal(data, &settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
var customModels []any
|
||||||
|
if existing, ok := settings["customModels"].([]any); ok {
|
||||||
|
customModels = existing
|
||||||
|
}
|
||||||
|
|
||||||
|
maxIndex := 0
|
||||||
|
existingIdx := -1
|
||||||
|
var existingID string
|
||||||
|
for i, m := range customModels {
|
||||||
|
if entry, ok := m.(map[string]any); ok {
|
||||||
|
if entry["model"] == model {
|
||||||
|
existingIdx = i
|
||||||
|
if id, ok := entry["id"].(string); ok {
|
||||||
|
existingID = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if idx, ok := entry["index"].(float64); ok && int(idx) > maxIndex {
|
||||||
|
maxIndex = int(idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var modelID string
|
||||||
|
newEntry := map[string]any{
|
||||||
|
"model": model,
|
||||||
|
"displayName": fmt.Sprintf("%s [Ollama]", model),
|
||||||
|
"baseUrl": "http://localhost:11434/v1",
|
||||||
|
"apiKey": "ollama",
|
||||||
|
"provider": "generic-chat-completion-api",
|
||||||
|
"maxOutputTokens": getModelContextLength(model),
|
||||||
|
"noImageSupport": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if existingIdx >= 0 {
|
||||||
|
modelID = existingID
|
||||||
|
newEntry["id"] = existingID
|
||||||
|
customModels[existingIdx] = newEntry
|
||||||
|
} else {
|
||||||
|
newIndex := maxIndex + 1
|
||||||
|
modelID = fmt.Sprintf("custom:%s-[Ollama]-%d", model, newIndex)
|
||||||
|
newEntry["id"] = modelID
|
||||||
|
newEntry["index"] = newIndex
|
||||||
|
customModels = append(customModels, newEntry)
|
||||||
|
}
|
||||||
|
settings["customModels"] = customModels
|
||||||
|
|
||||||
|
sessionSettings, ok := settings["sessionDefaultSettings"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
sessionSettings = make(map[string]any)
|
||||||
|
}
|
||||||
|
sessionSettings["model"] = modelID
|
||||||
|
settings["sessionDefaultSettings"] = sessionSettings
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(settings, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(settingsPath, data, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupOpenCodeSettings(model string) error {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
configPath := filepath.Join(home, ".config", "opencode", "opencode.json")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
config := make(map[string]any)
|
||||||
|
if data, err := os.ReadFile(configPath); err == nil {
|
||||||
|
json.Unmarshal(data, &config)
|
||||||
|
}
|
||||||
|
|
||||||
|
config["$schema"] = "https://opencode.ai/config.json"
|
||||||
|
|
||||||
|
provider, ok := config["provider"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
provider = make(map[string]any)
|
||||||
|
}
|
||||||
|
|
||||||
|
ollama, ok := provider["ollama"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
ollama = map[string]any{
|
||||||
|
"npm": "@ai-sdk/openai-compatible",
|
||||||
|
"name": "Ollama (local)",
|
||||||
|
"options": map[string]any{
|
||||||
|
"baseURL": "http://localhost:11434/v1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
models, ok := ollama["models"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
models = make(map[string]any)
|
||||||
|
}
|
||||||
|
|
||||||
|
models[model] = map[string]any{
|
||||||
|
"name": fmt.Sprintf("%s [Ollama]", model),
|
||||||
|
}
|
||||||
|
|
||||||
|
ollama["models"] = models
|
||||||
|
provider["ollama"] = ollama
|
||||||
|
config["provider"] = provider
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(config, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(configPath, data, 0644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
statePath := filepath.Join(home, ".local", "state", "opencode", "model.json")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(statePath), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
state := map[string]any{
|
||||||
|
"recent": []any{},
|
||||||
|
"favorite": []any{},
|
||||||
|
"variant": map[string]any{},
|
||||||
|
}
|
||||||
|
if data, err := os.ReadFile(statePath); err == nil {
|
||||||
|
json.Unmarshal(data, &state)
|
||||||
|
}
|
||||||
|
|
||||||
|
recent, _ := state["recent"].([]any)
|
||||||
|
|
||||||
|
newRecent := []any{}
|
||||||
|
for _, entry := range recent {
|
||||||
|
if e, ok := entry.(map[string]any); ok {
|
||||||
|
if e["providerID"] == "ollama" && e["modelID"] == model {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newRecent = append(newRecent, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
newRecent = append([]any{map[string]any{
|
||||||
|
"providerID": "ollama",
|
||||||
|
"modelID": model,
|
||||||
|
}}, newRecent...)
|
||||||
|
|
||||||
|
if len(newRecent) > 10 {
|
||||||
|
newRecent = newRecent[:10]
|
||||||
|
}
|
||||||
|
|
||||||
|
state["recent"] = newRecent
|
||||||
|
|
||||||
|
data, err = json.MarshalIndent(state, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(statePath, data, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
var OpenCodeConfig = &AppConfig{
|
||||||
|
Name: "OpenCode",
|
||||||
|
DisplayName: "OpenCode",
|
||||||
|
Command: "opencode",
|
||||||
|
EnvVars: func(model string) []EnvVar { return nil },
|
||||||
|
Args: func(model string) []string { return nil },
|
||||||
|
Setup: setupOpenCodeSettings,
|
||||||
|
CheckInstall: func() error {
|
||||||
|
if _, err := exec.LookPath("opencode"); err != nil {
|
||||||
|
return fmt.Errorf("opencode is not installed. Install from: https://opencode.ai")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func getOpenCodeConfiguredModel() string {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
statePath := filepath.Join(home, ".local", "state", "opencode", "model.json")
|
||||||
|
data, err := os.ReadFile(statePath)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var state map[string]any
|
||||||
|
if err := json.Unmarshal(data, &state); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
recent, ok := state["recent"].([]any)
|
||||||
|
if !ok || len(recent) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
first, ok := recent[0].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if first["providerID"] == "ollama" {
|
||||||
|
if modelID, ok := first["modelID"].(string); ok {
|
||||||
|
return modelID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func getDroidConfiguredModel() string {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsPath := filepath.Join(home, ".factory", "settings.json")
|
||||||
|
data, err := os.ReadFile(settingsPath)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var settings map[string]any
|
||||||
|
if err := json.Unmarshal(data, &settings); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionSettings, ok := settings["sessionDefaultSettings"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
modelID, ok := sessionSettings["model"].(string)
|
||||||
|
if !ok || modelID == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
customModels, ok := settings["customModels"].([]any)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range customModels {
|
||||||
|
entry, ok := m.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if entry["id"] == modelID {
|
||||||
|
if model, ok := entry["model"].(string); ok {
|
||||||
|
return model
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func runInApp(appName, modelName string) error {
|
||||||
|
app, ok := GetApp(appName)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("unknown app: %s", appName)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := app.CheckInstall(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if app.Setup != nil {
|
||||||
|
if err := app.Setup(modelName); err != nil {
|
||||||
|
return fmt.Errorf("setup failed: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
proc := exec.Command(app.Command, app.Args(modelName)...)
|
||||||
|
proc.Stdin = os.Stdin
|
||||||
|
proc.Stdout = os.Stdout
|
||||||
|
proc.Stderr = os.Stderr
|
||||||
|
proc.Env = os.Environ()
|
||||||
|
for _, env := range app.EnvVars(modelName) {
|
||||||
|
proc.Env = append(proc.Env, fmt.Sprintf("%s=%s", env.Name, env.Value))
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stderr, "Launching %s with %s...\n", app.DisplayName, modelName)
|
||||||
|
return proc.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func ConnectCmd() *cobra.Command {
|
||||||
|
var modelName string
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "connect [APP]",
|
||||||
|
Short: "Configure an external app to use Ollama",
|
||||||
|
Long: `Configure an external application to use Ollama as its backend.
|
||||||
|
|
||||||
|
Supported apps:
|
||||||
|
claude Claude Code
|
||||||
|
droid Droid
|
||||||
|
opencode OpenCode
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
ollama connect
|
||||||
|
ollama connect claude
|
||||||
|
ollama connect claude --model llama3.2`,
|
||||||
|
Args: cobra.MaximumNArgs(1),
|
||||||
|
PreRunE: checkServerHeartbeat,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
var appName string
|
||||||
|
if len(args) > 0 {
|
||||||
|
appName = args[0]
|
||||||
|
} else {
|
||||||
|
var err error
|
||||||
|
appName, err = selectApp()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := GetApp(appName); !ok {
|
||||||
|
return fmt.Errorf("unknown app: %s", appName)
|
||||||
|
}
|
||||||
|
|
||||||
|
if modelName == "" {
|
||||||
|
var err error
|
||||||
|
modelName, err = selectModelForConnect(cmd.Context(), "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := SaveConnection(appName, modelName); err != nil {
|
||||||
|
return fmt.Errorf("failed to save: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stderr, "Added %s to %s\n", modelName, appName)
|
||||||
|
|
||||||
|
if launch, _ := confirmLaunch(appName); launch {
|
||||||
|
return runInApp(appName, modelName)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stderr, "Run 'ollama launch %s' to start later\n", strings.ToLower(appName))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVar(&modelName, "model", "", "Model to use")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAppConfiguredModel(appName string) string {
|
||||||
|
switch strings.ToLower(appName) {
|
||||||
|
case "opencode":
|
||||||
|
return getOpenCodeConfiguredModel()
|
||||||
|
case "droid":
|
||||||
|
return getDroidConfiguredModel()
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func LaunchCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "launch [APP]",
|
||||||
|
Short: "Launch a configured app",
|
||||||
|
Long: `Launch a configured application with Ollama as its backend.
|
||||||
|
|
||||||
|
If no app is specified, shows a list of configured apps to choose from.
|
||||||
|
If no apps have been configured, starts the connect flow.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
ollama launch
|
||||||
|
ollama launch claude`,
|
||||||
|
Args: cobra.MaximumNArgs(1),
|
||||||
|
PreRunE: checkServerHeartbeat,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
var appName string
|
||||||
|
if len(args) > 0 {
|
||||||
|
appName = args[0]
|
||||||
|
} else {
|
||||||
|
selected, err := selectConnectedApp()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if selected == "" {
|
||||||
|
// No connected apps, start connect flow
|
||||||
|
fmt.Fprintf(os.Stderr, "No apps configured. Let's set one up.\n\n")
|
||||||
|
appName, err = selectApp()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
modelName, err := selectModelForConnect(cmd.Context(), "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := SaveConnection(appName, modelName); err != nil {
|
||||||
|
return fmt.Errorf("failed to save: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stderr, "Added %s to %s\n", modelName, appName)
|
||||||
|
return runInApp(appName, modelName)
|
||||||
|
}
|
||||||
|
appName = selected
|
||||||
|
}
|
||||||
|
|
||||||
|
app, ok := GetApp(appName)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("unknown app: %s", appName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check app's own config first
|
||||||
|
modelName := getAppConfiguredModel(appName)
|
||||||
|
|
||||||
|
// Fall back to our saved connection config
|
||||||
|
if modelName == "" {
|
||||||
|
config, err := LoadConnection(appName)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
// No config, drop into connect flow
|
||||||
|
modelName, err = selectModelForConnect(cmd.Context(), "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := SaveConnection(appName, modelName); err != nil {
|
||||||
|
return fmt.Errorf("failed to save: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stderr, "Added %s to %s\n", modelName, appName)
|
||||||
|
} else {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
modelName = config.Model
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return runInApp(app.Name, modelName)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
189
cmd/apps_test.go
Normal file
189
cmd/apps_test.go
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSetupOpenCodeSettings(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
origHome := os.Getenv("HOME")
|
||||||
|
os.Setenv("HOME", tmpDir)
|
||||||
|
defer os.Setenv("HOME", origHome)
|
||||||
|
|
||||||
|
configDir := filepath.Join(tmpDir, ".config", "opencode")
|
||||||
|
configPath := filepath.Join(configDir, "opencode.json")
|
||||||
|
stateDir := filepath.Join(tmpDir, ".local", "state", "opencode")
|
||||||
|
statePath := filepath.Join(stateDir, "model.json")
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
os.RemoveAll(configDir)
|
||||||
|
os.RemoveAll(stateDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("fresh install", func(t *testing.T) {
|
||||||
|
cleanup()
|
||||||
|
if err := setupOpenCodeSettings("llama3.2"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assertOpenCodeModelExists(t, configPath, "llama3.2")
|
||||||
|
assertOpenCodeRecentModel(t, statePath, 0, "ollama", "llama3.2")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("preserve other providers", func(t *testing.T) {
|
||||||
|
cleanup()
|
||||||
|
os.MkdirAll(configDir, 0755)
|
||||||
|
os.WriteFile(configPath, []byte(`{"provider":{"anthropic":{"apiKey":"xxx"}}}`), 0644)
|
||||||
|
if err := setupOpenCodeSettings("llama3.2"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data, _ := os.ReadFile(configPath)
|
||||||
|
var cfg map[string]any
|
||||||
|
json.Unmarshal(data, &cfg)
|
||||||
|
provider := cfg["provider"].(map[string]any)
|
||||||
|
if provider["anthropic"] == nil {
|
||||||
|
t.Error("anthropic provider was removed")
|
||||||
|
}
|
||||||
|
assertOpenCodeModelExists(t, configPath, "llama3.2")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("preserve other models", func(t *testing.T) {
|
||||||
|
cleanup()
|
||||||
|
os.MkdirAll(configDir, 0755)
|
||||||
|
os.WriteFile(configPath, []byte(`{"provider":{"ollama":{"models":{"mistral":{"name":"Mistral"}}}}}`), 0644)
|
||||||
|
if err := setupOpenCodeSettings("llama3.2"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assertOpenCodeModelExists(t, configPath, "mistral")
|
||||||
|
assertOpenCodeModelExists(t, configPath, "llama3.2")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("update existing model", func(t *testing.T) {
|
||||||
|
cleanup()
|
||||||
|
setupOpenCodeSettings("llama3.2")
|
||||||
|
setupOpenCodeSettings("llama3.2")
|
||||||
|
assertOpenCodeModelExists(t, configPath, "llama3.2")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("preserve top-level keys", func(t *testing.T) {
|
||||||
|
cleanup()
|
||||||
|
os.MkdirAll(configDir, 0755)
|
||||||
|
os.WriteFile(configPath, []byte(`{"theme":"dark","keybindings":{}}`), 0644)
|
||||||
|
if err := setupOpenCodeSettings("llama3.2"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data, _ := os.ReadFile(configPath)
|
||||||
|
var cfg map[string]any
|
||||||
|
json.Unmarshal(data, &cfg)
|
||||||
|
if cfg["theme"] != "dark" {
|
||||||
|
t.Error("theme was removed")
|
||||||
|
}
|
||||||
|
if cfg["keybindings"] == nil {
|
||||||
|
t.Error("keybindings was removed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("model state - insert at index 0", func(t *testing.T) {
|
||||||
|
cleanup()
|
||||||
|
os.MkdirAll(stateDir, 0755)
|
||||||
|
os.WriteFile(statePath, []byte(`{"recent":[{"providerID":"anthropic","modelID":"claude"}],"favorite":[],"variant":{}}`), 0644)
|
||||||
|
if err := setupOpenCodeSettings("llama3.2"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assertOpenCodeRecentModel(t, statePath, 0, "ollama", "llama3.2")
|
||||||
|
assertOpenCodeRecentModel(t, statePath, 1, "anthropic", "claude")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("model state - preserve favorites and variants", func(t *testing.T) {
|
||||||
|
cleanup()
|
||||||
|
os.MkdirAll(stateDir, 0755)
|
||||||
|
os.WriteFile(statePath, []byte(`{"recent":[],"favorite":[{"providerID":"x","modelID":"y"}],"variant":{"a":"b"}}`), 0644)
|
||||||
|
if err := setupOpenCodeSettings("llama3.2"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data, _ := os.ReadFile(statePath)
|
||||||
|
var state map[string]any
|
||||||
|
json.Unmarshal(data, &state)
|
||||||
|
if len(state["favorite"].([]any)) != 1 {
|
||||||
|
t.Error("favorite was modified")
|
||||||
|
}
|
||||||
|
if state["variant"].(map[string]any)["a"] != "b" {
|
||||||
|
t.Error("variant was modified")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("model state - deduplicate on re-add", func(t *testing.T) {
|
||||||
|
cleanup()
|
||||||
|
os.MkdirAll(stateDir, 0755)
|
||||||
|
os.WriteFile(statePath, []byte(`{"recent":[{"providerID":"ollama","modelID":"llama3.2"},{"providerID":"anthropic","modelID":"claude"}],"favorite":[],"variant":{}}`), 0644)
|
||||||
|
if err := setupOpenCodeSettings("llama3.2"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data, _ := os.ReadFile(statePath)
|
||||||
|
var state map[string]any
|
||||||
|
json.Unmarshal(data, &state)
|
||||||
|
recent := state["recent"].([]any)
|
||||||
|
if len(recent) != 2 {
|
||||||
|
t.Errorf("expected 2 recent entries, got %d", len(recent))
|
||||||
|
}
|
||||||
|
assertOpenCodeRecentModel(t, statePath, 0, "ollama", "llama3.2")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertOpenCodeModelExists(t *testing.T, path, model string) {
|
||||||
|
t.Helper()
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var cfg map[string]any
|
||||||
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
provider, ok := cfg["provider"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("provider not found")
|
||||||
|
}
|
||||||
|
ollama, ok := provider["ollama"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("ollama provider not found")
|
||||||
|
}
|
||||||
|
models, ok := ollama["models"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("models not found")
|
||||||
|
}
|
||||||
|
if models[model] == nil {
|
||||||
|
t.Errorf("model %s not found", model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertOpenCodeRecentModel(t *testing.T, path string, index int, providerID, modelID string) {
|
||||||
|
t.Helper()
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var state map[string]any
|
||||||
|
if err := json.Unmarshal(data, &state); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
recent, ok := state["recent"].([]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("recent not found")
|
||||||
|
}
|
||||||
|
if index >= len(recent) {
|
||||||
|
t.Fatalf("index %d out of range (len=%d)", index, len(recent))
|
||||||
|
}
|
||||||
|
entry, ok := recent[index].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("entry is not a map")
|
||||||
|
}
|
||||||
|
if entry["providerID"] != providerID {
|
||||||
|
t.Errorf("expected providerID %s, got %s", providerID, entry["providerID"])
|
||||||
|
}
|
||||||
|
if entry["modelID"] != modelID {
|
||||||
|
t.Errorf("expected modelID %s, got %s", modelID, entry["modelID"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1945,6 +1945,8 @@ func NewCLI() *cobra.Command {
|
|||||||
copyCmd,
|
copyCmd,
|
||||||
deleteCmd,
|
deleteCmd,
|
||||||
runnerCmd,
|
runnerCmd,
|
||||||
|
ConnectCmd(),
|
||||||
|
LaunchCmd(),
|
||||||
)
|
)
|
||||||
|
|
||||||
return rootCmd
|
return rootCmd
|
||||||
|
|||||||
99
cmd/config.go
Normal file
99
cmd/config.go
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ConnectionConfig struct {
|
||||||
|
App string `json:"app"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
ConfiguredAt time.Time `json:"configured_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func configPath(appName string) (string, error) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(home, ".ollama", "connections", appName+".json"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SaveConnection(appName, model string) error {
|
||||||
|
path, err := configPath(appName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(ConnectionConfig{
|
||||||
|
App: appName,
|
||||||
|
Model: model,
|
||||||
|
ConfiguredAt: time.Now(),
|
||||||
|
}, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.WriteFile(path, data, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadConnection(appName string) (*ConnectionConfig, error) {
|
||||||
|
path, err := configPath(appName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var config ConnectionConfig
|
||||||
|
if err := json.Unmarshal(data, &config); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListConnections() ([]ConnectionConfig, error) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := filepath.Join(home, ".ollama", "connections")
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var configs []ConnectionConfig
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(filepath.Join(dir, entry.Name()))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var config ConnectionConfig
|
||||||
|
if err := json.Unmarshal(data, &config); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
configs = append(configs, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
return configs, nil
|
||||||
|
}
|
||||||
@@ -37,6 +37,8 @@ func generateInteractive(cmd *cobra.Command, opts runOptions) error {
|
|||||||
fmt.Fprintln(os.Stderr, " /load <model> Load a session or model")
|
fmt.Fprintln(os.Stderr, " /load <model> Load a session or model")
|
||||||
fmt.Fprintln(os.Stderr, " /save <model> Save your current session")
|
fmt.Fprintln(os.Stderr, " /save <model> Save your current session")
|
||||||
fmt.Fprintln(os.Stderr, " /clear Clear session context")
|
fmt.Fprintln(os.Stderr, " /clear Clear session context")
|
||||||
|
fmt.Fprintln(os.Stderr, " /connect Configure an external app to use Ollama")
|
||||||
|
fmt.Fprintln(os.Stderr, " /launch [app] Launch a configured app")
|
||||||
fmt.Fprintln(os.Stderr, " /bye Exit")
|
fmt.Fprintln(os.Stderr, " /bye Exit")
|
||||||
fmt.Fprintln(os.Stderr, " /?, /help Help for a command")
|
fmt.Fprintln(os.Stderr, " /?, /help Help for a command")
|
||||||
fmt.Fprintln(os.Stderr, " /? shortcuts Help for keyboard shortcuts")
|
fmt.Fprintln(os.Stderr, " /? shortcuts Help for keyboard shortcuts")
|
||||||
@@ -460,6 +462,136 @@ func generateInteractive(cmd *cobra.Command, opts runOptions) error {
|
|||||||
}
|
}
|
||||||
case strings.HasPrefix(line, "/exit"), strings.HasPrefix(line, "/bye"):
|
case strings.HasPrefix(line, "/exit"), strings.HasPrefix(line, "/bye"):
|
||||||
return nil
|
return nil
|
||||||
|
case strings.HasPrefix(line, "/connect"):
|
||||||
|
args := strings.Fields(line)
|
||||||
|
var appName string
|
||||||
|
if len(args) > 1 {
|
||||||
|
appName = args[1]
|
||||||
|
} else {
|
||||||
|
var err error
|
||||||
|
appName, err = selectApp()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("error: %v\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := GetApp(appName); !ok {
|
||||||
|
fmt.Printf("Unknown app: %s\n", appName)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
modelName, err := selectModelForConnect(cmd.Context(), opts.Model)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("error: %v\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := SaveConnection(appName, modelName); err != nil {
|
||||||
|
fmt.Printf("error: %v\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stderr, "Added %s to %s\n", modelName, appName)
|
||||||
|
|
||||||
|
if launch, _ := confirmLaunch(appName); launch {
|
||||||
|
if err := runInApp(appName, modelName); err != nil {
|
||||||
|
fmt.Printf("error: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
case strings.HasPrefix(line, "/launch"):
|
||||||
|
args := strings.Fields(line)
|
||||||
|
var appName string
|
||||||
|
if len(args) >= 2 {
|
||||||
|
appName = args[1]
|
||||||
|
} else {
|
||||||
|
selected, err := selectConnectedApp()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("error: %v\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if selected == "" {
|
||||||
|
// No connected apps, start connect flow
|
||||||
|
fmt.Fprintf(os.Stderr, "No apps configured. Let's set one up.\n\n")
|
||||||
|
appName, err = selectApp()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("error: %v\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
modelName, err := selectModelForConnect(cmd.Context(), opts.Model)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("error: %v\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := SaveConnection(appName, modelName); err != nil {
|
||||||
|
fmt.Printf("error: %v\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stderr, "Added %s to %s\n", modelName, appName)
|
||||||
|
if err := runInApp(appName, modelName); err != nil {
|
||||||
|
fmt.Printf("error: %v\n", err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
appName = selected
|
||||||
|
}
|
||||||
|
|
||||||
|
app, ok := GetApp(appName)
|
||||||
|
if !ok {
|
||||||
|
fmt.Printf("Unknown app: %s\n", appName)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check app's own config first
|
||||||
|
modelName := getAppConfiguredModel(appName)
|
||||||
|
|
||||||
|
// Fall back to our saved connection config
|
||||||
|
if modelName == "" {
|
||||||
|
config, err := LoadConnection(appName)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
// No config, drop into connect flow
|
||||||
|
modelName, err = selectModelForConnect(cmd.Context(), opts.Model)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("error: %v\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := SaveConnection(appName, modelName); err != nil {
|
||||||
|
fmt.Printf("error: %v\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stderr, "Added %s to %s\n", modelName, appName)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("error: %v\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
modelName = config.Model
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If running model differs from configured, offer to switch
|
||||||
|
if opts.Model != "" && modelName != opts.Model {
|
||||||
|
if switchModel, _ := confirmPrompt(fmt.Sprintf("Switch %s to use %s?", app.DisplayName, opts.Model)); switchModel {
|
||||||
|
modelName = opts.Model
|
||||||
|
if err := SaveConnection(appName, modelName); err != nil {
|
||||||
|
fmt.Printf("error: %v\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "Updated %s to %s\n", appName, modelName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := runInApp(app.Name, modelName); err != nil {
|
||||||
|
fmt.Printf("error: %v\n", err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
case strings.HasPrefix(line, "/"):
|
case strings.HasPrefix(line, "/"):
|
||||||
args := strings.Fields(line)
|
args := strings.Fields(line)
|
||||||
isFile := false
|
isFile := false
|
||||||
|
|||||||
328
cmd/selector.go
Normal file
328
cmd/selector.go
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/term"
|
||||||
|
|
||||||
|
"github.com/ollama/ollama/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxDisplayedItems = 10
|
||||||
|
|
||||||
|
var AppOrder = []string{"claude", "opencode", "droid"}
|
||||||
|
|
||||||
|
type SelectItem struct {
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Select(prompt string, items []SelectItem) (string, error) {
|
||||||
|
if len(items) == 0 {
|
||||||
|
return "", fmt.Errorf("no items to select from")
|
||||||
|
}
|
||||||
|
|
||||||
|
fd := int(os.Stdin.Fd())
|
||||||
|
oldState, err := term.MakeRaw(fd)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer term.Restore(fd, oldState)
|
||||||
|
|
||||||
|
fmt.Fprint(os.Stderr, "\033[?25l")
|
||||||
|
defer fmt.Fprint(os.Stderr, "\033[?25h")
|
||||||
|
|
||||||
|
var filter string
|
||||||
|
selected := 0
|
||||||
|
scrollOffset := 0
|
||||||
|
var lastLineCount int
|
||||||
|
|
||||||
|
render := func() {
|
||||||
|
filtered := filterItems(items, filter)
|
||||||
|
|
||||||
|
if lastLineCount > 0 {
|
||||||
|
fmt.Fprintf(os.Stderr, "\033[%dA", lastLineCount)
|
||||||
|
}
|
||||||
|
fmt.Fprint(os.Stderr, "\033[J")
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stderr, "%s %s\r\n", prompt, filter)
|
||||||
|
lineCount := 1
|
||||||
|
|
||||||
|
if len(filtered) == 0 {
|
||||||
|
fmt.Fprintf(os.Stderr, " \033[37m(no matches)\033[0m\r\n")
|
||||||
|
lineCount++
|
||||||
|
} else {
|
||||||
|
displayCount := min(len(filtered), maxDisplayedItems)
|
||||||
|
|
||||||
|
for i := 0; i < displayCount; i++ {
|
||||||
|
idx := scrollOffset + i
|
||||||
|
if idx >= len(filtered) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
item := filtered[idx]
|
||||||
|
if idx == selected {
|
||||||
|
if item.Description != "" {
|
||||||
|
fmt.Fprintf(os.Stderr, " \033[1m> %s\033[0m \033[37m- %s\033[0m\r\n", item.Name, item.Description)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(os.Stderr, " \033[1m> %s\033[0m\r\n", item.Name)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if item.Description != "" {
|
||||||
|
fmt.Fprintf(os.Stderr, " %s \033[37m- %s\033[0m\r\n", item.Name, item.Description)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(os.Stderr, " %s\r\n", item.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lineCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
if remaining := len(filtered) - scrollOffset - displayCount; remaining > 0 {
|
||||||
|
fmt.Fprintf(os.Stderr, " \033[37m... and %d more\033[0m\r\n", remaining)
|
||||||
|
lineCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lastLineCount = lineCount
|
||||||
|
}
|
||||||
|
|
||||||
|
clearUI := func() {
|
||||||
|
if lastLineCount > 0 {
|
||||||
|
fmt.Fprintf(os.Stderr, "\033[%dA", lastLineCount)
|
||||||
|
fmt.Fprint(os.Stderr, "\033[J")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render()
|
||||||
|
|
||||||
|
buf := make([]byte, 3)
|
||||||
|
for {
|
||||||
|
n, err := os.Stdin.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := filterItems(items, filter)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case n == 1 && buf[0] == 13: // Enter
|
||||||
|
if len(filtered) > 0 && selected < len(filtered) {
|
||||||
|
clearUI()
|
||||||
|
return filtered[selected].Name, nil
|
||||||
|
}
|
||||||
|
case n == 1 && (buf[0] == 3 || buf[0] == 27): // Ctrl+C or Escape
|
||||||
|
clearUI()
|
||||||
|
return "", fmt.Errorf("cancelled")
|
||||||
|
case n == 1 && buf[0] == 127: // Backspace
|
||||||
|
if len(filter) > 0 {
|
||||||
|
filter = filter[:len(filter)-1]
|
||||||
|
selected = 0
|
||||||
|
scrollOffset = 0
|
||||||
|
}
|
||||||
|
case n == 3 && buf[0] == 27 && buf[1] == 91: // Arrow keys
|
||||||
|
if buf[2] == 65 && selected > 0 { // Up
|
||||||
|
selected--
|
||||||
|
if selected < scrollOffset {
|
||||||
|
scrollOffset = selected
|
||||||
|
}
|
||||||
|
} else if buf[2] == 66 && selected < len(filtered)-1 { // Down
|
||||||
|
selected++
|
||||||
|
if selected >= scrollOffset+maxDisplayedItems {
|
||||||
|
scrollOffset = selected - maxDisplayedItems + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case n == 1 && buf[0] >= 32 && buf[0] < 127: // Printable chars
|
||||||
|
filter += string(buf[0])
|
||||||
|
selected = 0
|
||||||
|
scrollOffset = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterItems(items []SelectItem, filter string) []SelectItem {
|
||||||
|
if filter == "" {
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
var result []SelectItem
|
||||||
|
filterLower := strings.ToLower(filter)
|
||||||
|
for _, item := range items {
|
||||||
|
if strings.Contains(strings.ToLower(item.Name), filterLower) {
|
||||||
|
result = append(result, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectApp() (string, error) {
|
||||||
|
var items []SelectItem
|
||||||
|
|
||||||
|
for _, name := range AppOrder {
|
||||||
|
app, ok := AppRegistry[name]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, SelectItem{Name: app.Name, Description: app.DisplayName})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(items) == 0 {
|
||||||
|
return "", fmt.Errorf("no apps available")
|
||||||
|
}
|
||||||
|
|
||||||
|
return Select("Select app:", items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectConnectedApp() (string, error) {
|
||||||
|
connections, err := ListConnections()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(connections) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var items []SelectItem
|
||||||
|
for _, conn := range connections {
|
||||||
|
app, ok := GetApp(conn.App)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, SelectItem{
|
||||||
|
Name: app.Name,
|
||||||
|
Description: fmt.Sprintf("%s (%s)", app.DisplayName, conn.Model),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(items) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return Select("Select app to launch:", items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func confirmLaunch(appName string) (bool, error) {
|
||||||
|
return confirmPrompt(fmt.Sprintf("Launch %s now?", appName))
|
||||||
|
}
|
||||||
|
|
||||||
|
func confirmPrompt(prompt string) (bool, error) {
|
||||||
|
fd := int(os.Stdin.Fd())
|
||||||
|
oldState, err := term.MakeRaw(fd)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer term.Restore(fd, oldState)
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stderr, "%s [y/n] ", prompt)
|
||||||
|
|
||||||
|
buf := make([]byte, 1)
|
||||||
|
for {
|
||||||
|
if _, err := os.Stdin.Read(buf); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch buf[0] {
|
||||||
|
case 'Y', 'y', 13:
|
||||||
|
fmt.Fprintf(os.Stderr, "yes\r\n")
|
||||||
|
return true, nil
|
||||||
|
case 'N', 'n', 27, 3:
|
||||||
|
fmt.Fprintf(os.Stderr, "no\r\n")
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectModelForConnect(ctx context.Context, currentModel string) (string, error) {
|
||||||
|
client, err := api.ClientFromEnvironment()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
models, err := client.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(models.Models) == 0 {
|
||||||
|
return "", fmt.Errorf("no models available. Run 'ollama pull <model>' first")
|
||||||
|
}
|
||||||
|
|
||||||
|
var items []SelectItem
|
||||||
|
cloudModels := make(map[string]bool)
|
||||||
|
for _, m := range models.Models {
|
||||||
|
items = append(items, SelectItem{Name: m.Name})
|
||||||
|
if m.RemoteModel != "" {
|
||||||
|
cloudModels[m.Name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(items, func(i, j int) bool {
|
||||||
|
return strings.ToLower(items[i].Name) < strings.ToLower(items[j].Name)
|
||||||
|
})
|
||||||
|
|
||||||
|
if currentModel != "" {
|
||||||
|
for i, item := range items {
|
||||||
|
if item.Name == currentModel {
|
||||||
|
items = append([]SelectItem{item}, append(items[:i], items[i+1:]...)...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
selected, err := Select("Select model:", items)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if cloudModels[selected] {
|
||||||
|
if err := ensureSignedIn(ctx, client); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return selected, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureSignedIn(ctx context.Context, client *api.Client) error {
|
||||||
|
user, err := client.Whoami(ctx)
|
||||||
|
if err == nil && user != nil && user.Name != "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var aErr api.AuthorizationError
|
||||||
|
if !errors.As(err, &aErr) || aErr.SigninURL == "" {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
yes, err := confirmPrompt("Sign in to ollama.com?")
|
||||||
|
if err != nil || !yes {
|
||||||
|
return fmt.Errorf("sign in required for cloud models")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stderr, "\nTo sign in, navigate to:\n %s\n\n", aErr.SigninURL)
|
||||||
|
fmt.Fprintf(os.Stderr, "\033[90mwaiting for sign in to complete...\033[0m")
|
||||||
|
|
||||||
|
ticker := time.NewTicker(2 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
fmt.Fprintf(os.Stderr, "\n")
|
||||||
|
return ctx.Err()
|
||||||
|
case <-ticker.C:
|
||||||
|
user, err := client.Whoami(ctx)
|
||||||
|
if err == nil && user != nil && user.Name != "" {
|
||||||
|
fmt.Fprintf(os.Stderr, "\r\033[K\033[A\r\033[K\033[1msigned in:\033[0m %s\n", user.Name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, ".")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user