diff --git a/README.md b/README.md
index 1e5a52d24..e511fbe3f 100644
--- a/README.md
+++ b/README.md
@@ -65,7 +65,7 @@ To launch a specific integration:
ollama launch claude
```
-Supported integrations include [Claude Code](https://docs.ollama.com/integrations/claude-code), [Codex](https://docs.ollama.com/integrations/codex), [Copilot CLI](https://docs.ollama.com/integrations/copilot-cli), [Droid](https://docs.ollama.com/integrations/droid), and [OpenCode](https://docs.ollama.com/integrations/opencode).
+Supported integrations include [Claude Code](https://docs.ollama.com/integrations/claude-code), [Codex](https://docs.ollama.com/integrations/codex), [Copilot CLI](https://docs.ollama.com/integrations/copilot-cli), [DeepSeek Harness](https://docs.ollama.com/integrations/deepseek-harness), [Droid](https://docs.ollama.com/integrations/droid), and [OpenCode](https://docs.ollama.com/integrations/opencode).
### AI assistant
diff --git a/app/ui/app/public/launch-icons/deepseek-harness.svg b/app/ui/app/public/launch-icons/deepseek-harness.svg
new file mode 100644
index 000000000..4f770e51f
--- /dev/null
+++ b/app/ui/app/public/launch-icons/deepseek-harness.svg
@@ -0,0 +1,4 @@
+
diff --git a/app/ui/app/src/components/LaunchCommands.tsx b/app/ui/app/src/components/LaunchCommands.tsx
index dcf2ab937..e050681dc 100644
--- a/app/ui/app/src/components/LaunchCommands.tsx
+++ b/app/ui/app/src/components/LaunchCommands.tsx
@@ -77,6 +77,14 @@ const LAUNCH_COMMANDS: LaunchCommand[] = [
description: "Factory's coding agent across terminal and IDEs",
icon: "/launch-icons/droid.svg",
},
+ {
+ id: "dsh",
+ name: "DeepSeek Harness",
+ command: "ollama launch dsh",
+ description: "DeepSeek's open-source agent harness",
+ icon: "/launch-icons/deepseek-harness.svg",
+ iconClassName: "h-7 w-7",
+ },
{
id: "pi",
name: "Pi",
diff --git a/cmd/launch/deepseek_harness.go b/cmd/launch/deepseek_harness.go
new file mode 100644
index 000000000..07778dbc6
--- /dev/null
+++ b/cmd/launch/deepseek_harness.go
@@ -0,0 +1,520 @@
+package launch
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "slices"
+ "strings"
+
+ "github.com/ollama/ollama/cmd/config"
+ "github.com/ollama/ollama/cmd/internal/fileutil"
+ "github.com/ollama/ollama/envconfig"
+ "github.com/ollama/ollama/types/model"
+ "gopkg.in/yaml.v3"
+)
+
+const (
+ deepSeekHarnessIntegrationName = "dsh"
+ deepSeekHarnessNpmPackage = "@deepseek-ai/dsh@latest"
+ deepSeekHarnessProvider = "ollama"
+ deepSeekHarnessAPIKeyEnv = "OLLAMA_LAUNCH_DSH_API_KEY"
+ deepSeekHarnessWebSettings = "web-search-deepseek"
+)
+
+var (
+ deepSeekHarnessLookPath = exec.LookPath
+ deepSeekHarnessCommand = exec.Command
+ deepSeekHarnessGOOS = runtime.GOOS
+)
+
+// DeepSeekHarness is the Ollama-managed DeepSeek Harness integration.
+// It redirects only the settings provider for this invocation to an
+// Ollama-owned document. The user's normal DSH_HOME, profiles, sessions,
+// credentials, and patch layers remain available and untouched.
+type DeepSeekHarness struct{}
+
+func (d *DeepSeekHarness) String() string { return "DeepSeek Harness" }
+
+func (d *DeepSeekHarness) Run(_ string, _ []LaunchModel, args []string) error {
+ if err := validateDeepSeekHarnessArgs(args); err != nil {
+ return err
+ }
+
+ bin, err := deepSeekHarnessLookPath("dsh")
+ if err != nil {
+ return fmt.Errorf("dsh is not installed: %w", err)
+ }
+ patchPath, err := deepSeekHarnessPatchPath()
+ if err != nil {
+ return err
+ }
+
+ cmd, err := deepSeekHarnessExecutableCommand(bin, deepSeekHarnessLaunchArgs(patchPath, args))
+ if err != nil {
+ return err
+ }
+ cmd.Stdin = os.Stdin
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ cmd.Env = deepSeekHarnessLaunchEnv(os.Environ())
+ return cmd.Run()
+}
+
+func deepSeekHarnessLaunchArgs(patchPath string, args []string) []string {
+ launchArgs := []string{"web", "--patch", patchPath}
+ return append(launchArgs, args...)
+}
+
+func validateDeepSeekHarnessArgs(args []string) error {
+ for _, arg := range args {
+ if arg == "--patch" || strings.HasPrefix(arg, "--patch=") {
+ return fmt.Errorf("conflicting extra argument %q: ollama launch dsh manages --patch", arg)
+ }
+ }
+ return nil
+}
+
+func deepSeekHarnessLaunchEnv(env []string) []string {
+ return deepSeekHarnessUpsertEnv(env, deepSeekHarnessAPIKeyEnv, "ollama")
+}
+
+func deepSeekHarnessUpsertEnv(env []string, key, value string) []string {
+ prefix := key + "="
+ out := make([]string, 0, len(env)+1)
+ for _, entry := range env {
+ if strings.HasPrefix(entry, prefix) {
+ continue
+ }
+ out = append(out, entry)
+ }
+ return append(out, prefix+value)
+}
+
+func ensureDeepSeekHarnessInstalled() (string, error) {
+ if path, err := deepSeekHarnessLookPath("dsh"); err == nil {
+ return path, nil
+ }
+ npm, err := deepSeekHarnessLookPath("npm")
+ if err != nil {
+ return "", fmt.Errorf("dsh is not installed and npm (Node.js) is required\n\nInstall Node.js first:\n https://nodejs.org/\n\nThen re-run:\n ollama launch dsh")
+ }
+
+ ok, err := ConfirmPrompt("DeepSeek Harness is not installed. Install with npm?")
+ if err != nil {
+ return "", err
+ }
+ if !ok {
+ return "", fmt.Errorf("deepseek harness installation cancelled")
+ }
+
+ fmt.Fprintln(os.Stderr, "\nInstalling DeepSeek Harness...")
+ cmd, err := deepSeekHarnessNpmCommand(npm, []string{"install", "-g", deepSeekHarnessNpmPackage})
+ if err != nil {
+ return "", err
+ }
+ cmd.Stdin = os.Stdin
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ if err := cmd.Run(); err != nil {
+ return "", fmt.Errorf("failed to install deepseek harness: %w", err)
+ }
+
+ path, err := deepSeekHarnessLookPath("dsh")
+ if err != nil {
+ return "", fmt.Errorf("deepseek harness was installed but dsh was not found on PATH\n\nYou may need to restart your shell")
+ }
+ fmt.Fprintf(os.Stderr, "%sDeepSeek Harness installed successfully%s\n\n", ansiGreen, ansiReset)
+ return path, nil
+}
+
+func deepSeekHarnessExecutableCommand(bin string, args []string) (*exec.Cmd, error) {
+ return deepSeekHarnessNodeShimCommand(bin, []string{"node_modules", "@deepseek-ai", "dsh", "lib", "bin.js"}, args)
+}
+
+func deepSeekHarnessNpmCommand(bin string, args []string) (*exec.Cmd, error) {
+ return deepSeekHarnessNodeShimCommand(bin, []string{"node_modules", "npm", "bin", "npm-cli.js"}, args)
+}
+
+// Windows npm binaries are .cmd shims, which cannot be passed safely to
+// CreateProcess with an argv. Invoke their JavaScript entrypoints with Node so
+// passthrough arguments remain data rather than cmd.exe syntax.
+func deepSeekHarnessNodeShimCommand(shim string, entrypointParts, args []string) (*exec.Cmd, error) {
+ if deepSeekHarnessGOOS != "windows" || !deepSeekHarnessIsCommandShim(shim) {
+ return deepSeekHarnessCommand(shim, args...), nil
+ }
+
+ node, err := deepSeekHarnessLookPath("node")
+ if err != nil {
+ return nil, fmt.Errorf("node is required to run %s on Windows: %w", filepath.Base(shim), err)
+ }
+ entrypoint := filepath.Join(append([]string{filepath.Dir(shim)}, entrypointParts...)...)
+ if _, err := os.Stat(entrypoint); err != nil {
+ return nil, fmt.Errorf("resolve Windows entrypoint for %s: %w", filepath.Base(shim), err)
+ }
+ return deepSeekHarnessCommand(node, append([]string{entrypoint}, args...)...), nil
+}
+
+func deepSeekHarnessIsCommandShim(path string) bool {
+ ext := strings.ToLower(filepath.Ext(path))
+ return ext == ".cmd" || ext == ".bat"
+}
+
+func (d *DeepSeekHarness) Paths() []string {
+ settingsPath, settingsErr := deepSeekHarnessSettingsPath()
+ patchPath, patchErr := deepSeekHarnessPatchPath()
+ if settingsErr != nil || patchErr != nil {
+ return nil
+ }
+ return []string{settingsPath, patchPath}
+}
+
+func (d *DeepSeekHarness) Configure(modelName string) error {
+ return d.ConfigureWithModels(modelName, []LaunchModel{fallbackLaunchModel(modelName)})
+}
+
+func (d *DeepSeekHarness) ConfigureWithModels(primary string, models []LaunchModel) error {
+ if strings.TrimSpace(primary) == "" {
+ return nil
+ }
+ if len(models) == 0 {
+ models = []LaunchModel{fallbackLaunchModel(primary)}
+ }
+ if selected, ok := findLaunchModel(models, primary); ok {
+ primary = selected.Name
+ }
+
+ settingsPath, err := deepSeekHarnessSettingsPath()
+ if err != nil {
+ return err
+ }
+ settings, err := readDeepSeekHarnessYAMLDocument(settingsPath)
+ if err != nil {
+ return fmt.Errorf("parse deepseek harness launch settings: %w", err)
+ }
+ if err := applyDeepSeekHarnessSettings(settings, primary, models, shouldManageOllamaWebSearch()); err != nil {
+ return err
+ }
+
+ settingsData, err := yaml.Marshal(settings)
+ if err != nil {
+ return err
+ }
+ if err := writeDeepSeekHarnessFile(settingsPath, settingsData); err != nil {
+ return err
+ }
+
+ patchPath, err := deepSeekHarnessPatchPath()
+ if err != nil {
+ return err
+ }
+ patchData, err := yaml.Marshal([]map[string]any{
+ {
+ "id": "settings",
+ "config": map[string]any{
+ "path": settingsPath,
+ },
+ },
+ })
+ if err != nil {
+ return err
+ }
+ return writeDeepSeekHarnessFile(patchPath, patchData)
+}
+
+func readDeepSeekHarnessYAML(path string) (map[string]any, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return make(map[string]any), nil
+ }
+ return nil, err
+ }
+ settings := make(map[string]any)
+ if err := yaml.Unmarshal(data, &settings); err != nil {
+ return nil, err
+ }
+ if settings == nil {
+ settings = make(map[string]any)
+ }
+ return settings, nil
+}
+
+func readDeepSeekHarnessYAMLDocument(path string) (*yaml.Node, error) {
+ document := &yaml.Node{Kind: yaml.DocumentNode}
+ data, err := os.ReadFile(path)
+ if err != nil {
+ if !os.IsNotExist(err) {
+ return nil, err
+ }
+ document.Content = []*yaml.Node{{Kind: yaml.MappingNode, Tag: "!!map"}}
+ return document, nil
+ }
+ if err := yaml.Unmarshal(data, document); err != nil {
+ return nil, err
+ }
+ if len(document.Content) == 0 || document.Content[0].Kind == yaml.ScalarNode && document.Content[0].Tag == "!!null" {
+ document.Content = []*yaml.Node{{Kind: yaml.MappingNode, Tag: "!!map"}}
+ }
+ if document.Content[0].Kind != yaml.MappingNode {
+ return nil, fmt.Errorf("settings root must be a mapping")
+ }
+ return document, nil
+}
+
+func applyDeepSeekHarnessSettings(document *yaml.Node, primary string, models []LaunchModel, manageWebSearch bool) error {
+ settings := document.Content[0]
+ selected := deepSeekHarnessEnsureYAMLMapping(settings, "agent-default-model")
+ for key, value := range map[string]string{
+ "provider": deepSeekHarnessProvider,
+ "model": primary,
+ } {
+ if err := deepSeekHarnessSetYAMLValue(selected, key, value); err != nil {
+ return err
+ }
+ }
+
+ llm := deepSeekHarnessEnsureYAMLMapping(settings, "llm-pi-ai")
+ providers := deepSeekHarnessEnsureYAMLMapping(llm, "providers")
+ provider := deepSeekHarnessEnsureYAMLMapping(providers, deepSeekHarnessProvider)
+ for key, value := range map[string]any{
+ "displayName": "Ollama",
+ "apiKeyEnv": deepSeekHarnessAPIKeyEnv,
+ "api": "openai-completions",
+ "baseURL": deepSeekHarnessBaseURL(),
+ "models": deepSeekHarnessModelConfigs(primary, models),
+ } {
+ if err := deepSeekHarnessSetYAMLValue(provider, key, value); err != nil {
+ return err
+ }
+ }
+
+ if !manageWebSearch {
+ return nil
+ }
+
+ // Harness's bundled search provider appends /messages to this /v1 base and
+ // sends the Anthropic web_search server tool. This is separate from the main
+ // model provider above; Harness does not expose a configured way to send the
+ // native OpenAI Responses web_search tool.
+ web := deepSeekHarnessEnsureYAMLMapping(settings, deepSeekHarnessWebSettings)
+ for key, value := range map[string]string{
+ "apiKeyEnv": deepSeekHarnessAPIKeyEnv,
+ "baseURL": deepSeekHarnessBaseURL(),
+ "model": primary,
+ } {
+ if err := deepSeekHarnessSetYAMLValue(web, key, value); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func deepSeekHarnessEnsureYAMLMapping(mapping *yaml.Node, key string) *yaml.Node {
+ if value := deepSeekHarnessYAMLValue(mapping, key); value != nil && value.Kind == yaml.MappingNode {
+ return value
+ }
+ value := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
+ deepSeekHarnessSetYAMLNode(mapping, key, value)
+ return value
+}
+
+func deepSeekHarnessSetYAMLValue(mapping *yaml.Node, key string, value any) error {
+ node := &yaml.Node{}
+ if err := node.Encode(value); err != nil {
+ return err
+ }
+ deepSeekHarnessSetYAMLNode(mapping, key, node)
+ return nil
+}
+
+func deepSeekHarnessSetYAMLNode(mapping *yaml.Node, key string, value *yaml.Node) {
+ for i := 0; i+1 < len(mapping.Content); i += 2 {
+ if mapping.Content[i].Value == key {
+ mapping.Content[i+1] = value
+ return
+ }
+ }
+ mapping.Content = append(mapping.Content,
+ &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key},
+ value,
+ )
+}
+
+func deepSeekHarnessYAMLValue(mapping *yaml.Node, key string) *yaml.Node {
+ for i := 0; i+1 < len(mapping.Content); i += 2 {
+ if mapping.Content[i].Value == key {
+ return mapping.Content[i+1]
+ }
+ }
+ return nil
+}
+
+func deepSeekHarnessModelConfigs(primary string, models []LaunchModel) []any {
+ ordered := append([]LaunchModel(nil), models...)
+ if selected, ok := findLaunchModel(ordered, primary); ok {
+ ordered = append([]LaunchModel{selected}, removeLaunchModel(ordered, primary)...)
+ } else {
+ ordered = append([]LaunchModel{fallbackLaunchModel(primary)}, ordered...)
+ }
+
+ configs := make([]any, 0, len(ordered))
+ seen := make(map[string]bool, len(ordered))
+ for _, item := range ordered {
+ if item.Name == "" || seen[item.Name] {
+ continue
+ }
+ seen[item.Name] = true
+ entry := map[string]any{
+ "id": item.Name,
+ "name": item.Name,
+ "input": []string{"text"},
+ }
+ if slices.Contains(item.Capabilities, model.CapabilityVision) {
+ entry["input"] = []string{"text", "image"}
+ }
+ if item.ContextLength > 0 {
+ entry["contextWindow"] = item.ContextLength
+ }
+ if item.MaxOutputTokens > 0 {
+ entry["maxTokens"] = item.MaxOutputTokens
+ }
+ configs = append(configs, entry)
+ }
+ return configs
+}
+
+func (d *DeepSeekHarness) CurrentModel() string {
+ settingsPath, err := deepSeekHarnessSettingsPath()
+ if err != nil {
+ return ""
+ }
+ if !deepSeekHarnessPatchHealthy(settingsPath) {
+ return ""
+ }
+ settings, err := readDeepSeekHarnessYAML(settingsPath)
+ if err != nil {
+ return ""
+ }
+
+ selected, _ := settings["agent-default-model"].(map[string]any)
+ if selected == nil || selected["provider"] != deepSeekHarnessProvider {
+ return ""
+ }
+ modelName, _ := selected["model"].(string)
+ if modelName == "" {
+ return ""
+ }
+
+ llm, _ := settings["llm-pi-ai"].(map[string]any)
+ providers, _ := llm["providers"].(map[string]any)
+ provider, _ := providers[deepSeekHarnessProvider].(map[string]any)
+ if !deepSeekHarnessProviderHealthy(provider, modelName) {
+ return ""
+ }
+ if !shouldManageOllamaWebSearch() {
+ return modelName
+ }
+ web, _ := settings[deepSeekHarnessWebSettings].(map[string]any)
+ if !deepSeekHarnessWebProviderHealthy(web, modelName) {
+ return ""
+ }
+ return modelName
+}
+
+func deepSeekHarnessPatchHealthy(settingsPath string) bool {
+ patchPath, err := deepSeekHarnessPatchPath()
+ if err != nil {
+ return false
+ }
+ data, err := os.ReadFile(patchPath)
+ if err != nil {
+ return false
+ }
+ var patches []struct {
+ ID string `yaml:"id"`
+ Config struct {
+ Path string `yaml:"path"`
+ } `yaml:"config"`
+ }
+ if err := yaml.Unmarshal(data, &patches); err != nil || len(patches) != 1 {
+ return false
+ }
+ return patches[0].ID == "settings" && patches[0].Config.Path == settingsPath
+}
+
+func deepSeekHarnessProviderHealthy(provider map[string]any, modelName string) bool {
+ if provider == nil || provider["api"] != "openai-completions" || provider["apiKeyEnv"] != deepSeekHarnessAPIKeyEnv {
+ return false
+ }
+ baseURL, _ := provider["baseURL"].(string)
+ if strings.TrimRight(baseURL, "/") != strings.TrimRight(deepSeekHarnessBaseURL(), "/") {
+ return false
+ }
+ models, _ := provider["models"].([]any)
+ for _, raw := range models {
+ entry, _ := raw.(map[string]any)
+ if entry["id"] == modelName {
+ return true
+ }
+ }
+ return false
+}
+
+func deepSeekHarnessWebProviderHealthy(web map[string]any, modelName string) bool {
+ if web == nil || web["apiKeyEnv"] != deepSeekHarnessAPIKeyEnv || web["model"] != modelName {
+ return false
+ }
+ baseURL, _ := web["baseURL"].(string)
+ return strings.TrimRight(baseURL, "/") == strings.TrimRight(deepSeekHarnessBaseURL(), "/")
+}
+
+func (d *DeepSeekHarness) Onboard() error {
+ return config.MarkIntegrationOnboarded(deepSeekHarnessIntegrationName)
+}
+
+func (d *DeepSeekHarness) RequiresInteractiveOnboarding() bool { return false }
+
+func deepSeekHarnessBaseURL() string {
+ return strings.TrimRight(envconfig.ConnectableHost().String(), "/") + "/v1"
+}
+
+func deepSeekHarnessConfigDir() (string, error) {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(home, ".ollama", "launch", "dsh"), nil
+}
+
+func deepSeekHarnessSettingsPath() (string, error) {
+ dir, err := deepSeekHarnessConfigDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(dir, "settings.yaml"), nil
+}
+
+func deepSeekHarnessPatchPath() (string, error) {
+ dir, err := deepSeekHarnessConfigDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(dir, "ollama.cordis.yml"), nil
+}
+
+func writeDeepSeekHarnessFile(path string, data []byte) error {
+ dir := filepath.Dir(path)
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ return err
+ }
+ if err := os.Chmod(dir, 0o700); err != nil {
+ return err
+ }
+ if err := fileutil.WriteWithBackup(path, data, deepSeekHarnessIntegrationName); err != nil {
+ return err
+ }
+ return os.Chmod(path, 0o600)
+}
diff --git a/cmd/launch/deepseek_harness_test.go b/cmd/launch/deepseek_harness_test.go
new file mode 100644
index 000000000..219fbeda4
--- /dev/null
+++ b/cmd/launch/deepseek_harness_test.go
@@ -0,0 +1,461 @@
+package launch
+
+import (
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "slices"
+ "strings"
+ "testing"
+
+ "github.com/ollama/ollama/types/model"
+ "gopkg.in/yaml.v3"
+)
+
+func TestDeepSeekHarnessRegistry(t *testing.T) {
+ spec, err := LookupIntegrationSpec("deepseek-harness")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if spec.Name != deepSeekHarnessIntegrationName {
+ t.Fatalf("canonical name = %q, want %q", spec.Name, deepSeekHarnessIntegrationName)
+ }
+ if spec.Runner.String() != "DeepSeek Harness" {
+ t.Fatalf("display name = %q", spec.Runner.String())
+ }
+ if got := strings.Join(spec.Install.Command, " "); got != "npm install -g @deepseek-ai/dsh@latest" {
+ t.Fatalf("install command = %q", got)
+ }
+}
+
+func TestDeepSeekHarnessConfigurePreservesSettingsAndIsIdempotent(t *testing.T) {
+ home := t.TempDir()
+ setTestHome(t, home)
+ t.Setenv("OLLAMA_HOST", "http://127.0.0.1:12345")
+
+ settingsPath, err := deepSeekHarnessSettingsPath()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(filepath.Dir(settingsPath), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ existing := []byte("# keep-comment\ndefaults: &defaults\n mode: dark\ntheme: *defaults\nagent-default-model:\n # keep-reasoning-comment\n reasoningEffort: high\nllm-pi-ai:\n providers:\n custom:\n api: openai-completions\n baseURL: https://example.invalid/v1\n models:\n - id: custom-model\n ollama:\n # keep-retry-comment\n retryPolicy:\n maxAttempts: 2\nweb-search-deepseek:\n maxUses: 3\n")
+ if err := os.WriteFile(settingsPath, existing, 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ models := []LaunchModel{
+ {Name: "qwen3.5:latest", ContextLength: 262144, MaxOutputTokens: 32768, Capabilities: []model.Capability{model.CapabilityVision}},
+ {Name: "kimi-k2.6:cloud", ContextLength: 262144, MaxOutputTokens: 262144},
+ }
+ dsh := &DeepSeekHarness{}
+ if err := dsh.ConfigureWithModels("qwen3.5", models); err != nil {
+ t.Fatal(err)
+ }
+ firstSettings, err := os.ReadFile(settingsPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ patchPath, err := deepSeekHarnessPatchPath()
+ if err != nil {
+ t.Fatal(err)
+ }
+ firstPatch, err := os.ReadFile(patchPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if err := dsh.ConfigureWithModels("qwen3.5", models); err != nil {
+ t.Fatal(err)
+ }
+ secondSettings, _ := os.ReadFile(settingsPath)
+ secondPatch, _ := os.ReadFile(patchPath)
+ if string(firstSettings) != string(secondSettings) || string(firstPatch) != string(secondPatch) {
+ t.Fatal("repeated configuration changed Ollama-managed files")
+ }
+ for _, preserved := range []string{"# keep-comment", "&defaults", "*defaults", "# keep-reasoning-comment", "# keep-retry-comment"} {
+ if !strings.Contains(string(firstSettings), preserved) {
+ t.Fatalf("settings did not preserve %q:\n%s", preserved, firstSettings)
+ }
+ }
+
+ var settings map[string]any
+ if err := yaml.Unmarshal(firstSettings, &settings); err != nil {
+ t.Fatal(err)
+ }
+ if theme, _ := settings["theme"].(map[string]any); theme["mode"] != "dark" {
+ t.Fatalf("unrelated settings were not preserved: %#v", settings["theme"])
+ }
+ selected, _ := settings["agent-default-model"].(map[string]any)
+ if selected["provider"] != deepSeekHarnessProvider || selected["model"] != "qwen3.5:latest" {
+ t.Fatalf("default model = %#v", selected)
+ }
+ if selected["reasoningEffort"] != "high" {
+ t.Fatalf("default model settings were not preserved: %#v", selected)
+ }
+ llm, _ := settings["llm-pi-ai"].(map[string]any)
+ providers, _ := llm["providers"].(map[string]any)
+ if providers["custom"] == nil {
+ t.Fatal("custom provider was removed")
+ }
+ provider, _ := providers[deepSeekHarnessProvider].(map[string]any)
+ if provider["baseURL"] != "http://127.0.0.1:12345/v1" || provider["apiKeyEnv"] != deepSeekHarnessAPIKeyEnv {
+ t.Fatalf("Ollama provider = %#v", provider)
+ }
+ retryPolicy, _ := provider["retryPolicy"].(map[string]any)
+ if retryPolicy["maxAttempts"] != 2 {
+ t.Fatalf("Ollama provider settings were not preserved: %#v", provider)
+ }
+ configuredModels, _ := provider["models"].([]any)
+ if len(configuredModels) != 2 {
+ t.Fatalf("configured models = %#v", configuredModels)
+ }
+ local, _ := configuredModels[0].(map[string]any)
+ if local["id"] != "qwen3.5:latest" || local["contextWindow"] != 262144 || local["maxTokens"] != 32768 {
+ t.Fatalf("local model = %#v", local)
+ }
+ if got, _ := local["input"].([]any); !slices.Equal(got, []any{"text", "image"}) {
+ t.Fatalf("local model input = %#v", local["input"])
+ }
+ cloud, _ := configuredModels[1].(map[string]any)
+ if cloud["id"] != "kimi-k2.6:cloud" || cloud["contextWindow"] != 262144 || cloud["maxTokens"] != 262144 {
+ t.Fatalf("cloud model = %#v", cloud)
+ }
+ web, _ := settings[deepSeekHarnessWebSettings].(map[string]any)
+ if web["baseURL"] != "http://127.0.0.1:12345/v1" || web["apiKeyEnv"] != deepSeekHarnessAPIKeyEnv || web["model"] != "qwen3.5:latest" {
+ t.Fatalf("Ollama web search provider = %#v", web)
+ }
+ if web["maxUses"] != 3 {
+ t.Fatalf("existing web search settings were not preserved: %#v", web)
+ }
+
+ var patches []map[string]any
+ if err := yaml.Unmarshal(firstPatch, &patches); err != nil {
+ t.Fatal(err)
+ }
+ if len(patches) != 1 || patches[0]["id"] != "settings" {
+ t.Fatalf("patches = %#v", patches)
+ }
+ patchConfig, _ := patches[0]["config"].(map[string]any)
+ if patchConfig["path"] != settingsPath {
+ t.Fatalf("settings patch path = %#v", patchConfig["path"])
+ }
+ if got := dsh.CurrentModel(); got != "qwen3.5:latest" {
+ t.Fatalf("CurrentModel() = %q", got)
+ }
+}
+
+func TestDeepSeekHarnessConfigureSkipsWebSearchWhenCloudDisabled(t *testing.T) {
+ home := t.TempDir()
+ setTestHome(t, home)
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/api/status" {
+ fmt.Fprint(w, `{"cloud":{"disabled":true,"source":"config"}}`)
+ return
+ }
+ http.NotFound(w, r)
+ }))
+ t.Cleanup(srv.Close)
+ t.Setenv("OLLAMA_HOST", srv.URL)
+
+ settingsPath, err := deepSeekHarnessSettingsPath()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(filepath.Dir(settingsPath), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ existing := []byte("web-search-deepseek:\n maxUses: 3\n")
+ if err := os.WriteFile(settingsPath, existing, 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ dsh := &DeepSeekHarness{}
+ if err := dsh.ConfigureWithModels("qwen3.5", []LaunchModel{{Name: "qwen3.5:latest"}}); err != nil {
+ t.Fatal(err)
+ }
+
+ settings, err := readDeepSeekHarnessYAML(settingsPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ web, _ := settings[deepSeekHarnessWebSettings].(map[string]any)
+ if len(web) != 1 || web["maxUses"] != 3 {
+ t.Fatalf("web search settings = %#v", web)
+ }
+ if got := dsh.CurrentModel(); got != "qwen3.5:latest" {
+ t.Fatalf("CurrentModel() = %q", got)
+ }
+}
+
+func TestDeepSeekHarnessCurrentModelRejectsDrift(t *testing.T) {
+ setTestHome(t, t.TempDir())
+ t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434")
+ dsh := &DeepSeekHarness{}
+ if err := dsh.Configure("qwen3.5"); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("OLLAMA_HOST", "http://127.0.0.1:9999")
+ if got := dsh.CurrentModel(); got != "" {
+ t.Fatalf("CurrentModel() = %q for stale endpoint", got)
+ }
+}
+
+func TestDeepSeekHarnessCurrentModelRejectsPatchDrift(t *testing.T) {
+ setTestHome(t, t.TempDir())
+ t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434")
+ dsh := &DeepSeekHarness{}
+ if err := dsh.Configure("qwen3.5"); err != nil {
+ t.Fatal(err)
+ }
+ patchPath, err := deepSeekHarnessPatchPath()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ for name, data := range map[string][]byte{
+ "missing": nil,
+ "malformed": []byte("["),
+ "wrong path": []byte(`- id: settings
+ config:
+ path: /tmp/not-managed.yaml
+`),
+ } {
+ t.Run(name, func(t *testing.T) {
+ if err := dsh.Configure("qwen3.5"); err != nil {
+ t.Fatal(err)
+ }
+ if data == nil {
+ if err := os.Remove(patchPath); err != nil {
+ t.Fatal(err)
+ }
+ } else if err := os.WriteFile(patchPath, data, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if got := dsh.CurrentModel(); got != "" {
+ t.Fatalf("CurrentModel() = %q", got)
+ }
+ })
+ }
+}
+
+func TestDeepSeekHarnessConfigureRejectsMalformedSettingsWithoutOverwrite(t *testing.T) {
+ setTestHome(t, t.TempDir())
+ settingsPath, err := deepSeekHarnessSettingsPath()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(filepath.Dir(settingsPath), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ malformed := []byte("llm-pi-ai: [")
+ if err := os.WriteFile(settingsPath, malformed, 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ err = (&DeepSeekHarness{}).Configure("qwen3.5")
+ if err == nil || !strings.Contains(err.Error(), "parse deepseek harness launch settings") {
+ t.Fatalf("Configure() error = %v", err)
+ }
+ got, err := os.ReadFile(settingsPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !slices.Equal(got, malformed) {
+ t.Fatalf("malformed settings were overwritten: %q", got)
+ }
+}
+
+func TestDeepSeekHarnessConfigureAcceptsNullSettings(t *testing.T) {
+ setTestHome(t, t.TempDir())
+ settingsPath, err := deepSeekHarnessSettingsPath()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(filepath.Dir(settingsPath), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(settingsPath, []byte("null\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := (&DeepSeekHarness{}).Configure("qwen3.5"); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestDeepSeekHarnessRunUsesManagedPatchAndCredential(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("uses a POSIX shell test binary")
+ }
+
+ home := t.TempDir()
+ setTestHome(t, home)
+ binDir := t.TempDir()
+ logPath := filepath.Join(home, "dsh-invocation")
+ script := "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$DSH_TEST_LOG\"\nprintf '%s\\n' \"$OLLAMA_LAUNCH_DSH_API_KEY\" >> \"$DSH_TEST_LOG\"\n"
+ bin := filepath.Join(binDir, "dsh")
+ if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("PATH", strings.Join([]string{binDir, "/bin", "/usr/bin"}, string(os.PathListSeparator)))
+ t.Setenv("DSH_TEST_LOG", logPath)
+ t.Setenv(deepSeekHarnessAPIKeyEnv, "do-not-keep")
+
+ dsh := &DeepSeekHarness{}
+ if err := dsh.Configure("qwen3.5"); err != nil {
+ t.Fatal(err)
+ }
+ if err := dsh.Run("qwen3.5", nil, []string{"--port", "0"}); err != nil {
+ t.Fatal(err)
+ }
+ data, err := os.ReadFile(logPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ patchPath, _ := deepSeekHarnessPatchPath()
+ want := "web\n--patch\n" + patchPath + "\n--port\n0\nollama\n"
+ if string(data) != want {
+ t.Fatalf("invocation = %q, want %q", data, want)
+ }
+}
+
+func TestDeepSeekHarnessRejectsManagedPatchArgument(t *testing.T) {
+ for _, args := range [][]string{{"--patch", "other.yml"}, {"--patch=other.yml"}} {
+ if err := (&DeepSeekHarness{}).Run("qwen3.5", nil, args); err == nil || !strings.Contains(err.Error(), "manages --patch") {
+ t.Fatalf("Run(%v) error = %v", args, err)
+ }
+ }
+}
+
+func TestEnsureDeepSeekHarnessInstalledUsesPublicNpmPackage(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("uses a POSIX shell test binary")
+ }
+
+ home := t.TempDir()
+ binDir := t.TempDir()
+ logPath := filepath.Join(home, "npm-invocation")
+ npm := filepath.Join(binDir, "npm")
+ script := "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$DSH_NPM_LOG\"\nprintf '#!/bin/sh\\nexit 0\\n' > \"$DSH_INSTALLED_BIN\"\nchmod +x \"$DSH_INSTALLED_BIN\"\n"
+ if err := os.WriteFile(npm, []byte(script), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ dshBin := filepath.Join(binDir, "dsh")
+ t.Setenv("PATH", strings.Join([]string{binDir, "/bin", "/usr/bin"}, string(os.PathListSeparator)))
+ t.Setenv("DSH_NPM_LOG", logPath)
+ t.Setenv("DSH_INSTALLED_BIN", dshBin)
+
+ restore := withLaunchConfirmPolicy(launchConfirmPolicy{yes: true})
+ defer restore()
+ path, err := ensureDeepSeekHarnessInstalled()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if path != dshBin {
+ t.Fatalf("installed path = %q, want %q", path, dshBin)
+ }
+ data, err := os.ReadFile(logPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(data) != "install\n-g\n@deepseek-ai/dsh@latest\n" {
+ t.Fatalf("npm invocation = %q", data)
+ }
+}
+
+func TestDeepSeekHarnessLaunchArgs(t *testing.T) {
+ got := deepSeekHarnessLaunchArgs("/tmp/ollama.cordis.yml", []string{"--port", "0"})
+ want := []string{"web", "--patch", "/tmp/ollama.cordis.yml", "--port", "0"}
+ if !slices.Equal(got, want) {
+ t.Fatalf("launch args = %v, want %v", got, want)
+ }
+}
+
+func TestDeepSeekHarnessWindowsNodeShims(t *testing.T) {
+ root := t.TempDir()
+ node := filepath.Join(root, "node.exe")
+ dsh := filepath.Join(root, "npm", "dsh.cmd")
+ npm := filepath.Join(root, "node", "npm.cmd")
+ dshEntrypoint := filepath.Join(filepath.Dir(dsh), "node_modules", "@deepseek-ai", "dsh", "lib", "bin.js")
+ npmEntrypoint := filepath.Join(filepath.Dir(npm), "node_modules", "npm", "bin", "npm-cli.js")
+ for _, path := range []string{node, dsh, npm, dshEntrypoint, npmEntrypoint} {
+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, nil, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ originalGOOS := deepSeekHarnessGOOS
+ originalLookPath := deepSeekHarnessLookPath
+ deepSeekHarnessGOOS = "windows"
+ deepSeekHarnessLookPath = func(file string) (string, error) {
+ if file == "node" {
+ return node, nil
+ }
+ return "", exec.ErrNotFound
+ }
+ t.Cleanup(func() {
+ deepSeekHarnessGOOS = originalGOOS
+ deepSeekHarnessLookPath = originalLookPath
+ })
+
+ t.Run("dsh", func(t *testing.T) {
+ cmd, err := deepSeekHarnessExecutableCommand(dsh, []string{"web", "--port", "0", "a&b"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := []string{node, dshEntrypoint, "web", "--port", "0", "a&b"}
+ if !slices.Equal(cmd.Args, want) {
+ t.Fatalf("command args = %v, want %v", cmd.Args, want)
+ }
+ })
+
+ t.Run("npm", func(t *testing.T) {
+ cmd, err := deepSeekHarnessNpmCommand(npm, []string{"install", "-g", deepSeekHarnessNpmPackage})
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := []string{node, npmEntrypoint, "install", "-g", deepSeekHarnessNpmPackage}
+ if !slices.Equal(cmd.Args, want) {
+ t.Fatalf("command args = %v, want %v", cmd.Args, want)
+ }
+ })
+}
+
+func TestDeepSeekHarnessWindowsNodeShimRequiresEntrypoint(t *testing.T) {
+ originalGOOS := deepSeekHarnessGOOS
+ originalLookPath := deepSeekHarnessLookPath
+ deepSeekHarnessGOOS = "windows"
+ deepSeekHarnessLookPath = func(file string) (string, error) {
+ if file == "node" {
+ return filepath.Join(t.TempDir(), "node.exe"), nil
+ }
+ return "", exec.ErrNotFound
+ }
+ t.Cleanup(func() {
+ deepSeekHarnessGOOS = originalGOOS
+ deepSeekHarnessLookPath = originalLookPath
+ })
+
+ _, err := deepSeekHarnessExecutableCommand(filepath.Join(t.TempDir(), "dsh.cmd"), nil)
+ if err == nil || !strings.Contains(err.Error(), "resolve Windows entrypoint") {
+ t.Fatalf("error = %v", err)
+ }
+}
+
+func TestDeepSeekHarnessInstallDependencyError(t *testing.T) {
+ originalLookPath := deepSeekHarnessLookPath
+ deepSeekHarnessLookPath = func(file string) (string, error) { return "", exec.ErrNotFound }
+ t.Cleanup(func() { deepSeekHarnessLookPath = originalLookPath })
+ _, err := ensureDeepSeekHarnessInstalled()
+ if err == nil || !strings.Contains(err.Error(), "npm (Node.js) is required") {
+ t.Fatalf("error = %v", err)
+ }
+}
diff --git a/cmd/launch/integrations_test.go b/cmd/launch/integrations_test.go
index 38c77fc4f..cccc84736 100644
--- a/cmd/launch/integrations_test.go
+++ b/cmd/launch/integrations_test.go
@@ -67,6 +67,8 @@ func TestIntegrationLookup(t *testing.T) {
{"muse", "muse", true, "Muse Code"},
{"muse alias", "muse-code", true, "Muse Code"},
{"droid", "droid", true, "Droid"},
+ {"dsh", "dsh", true, "DeepSeek Harness"},
+ {"deepseek harness alias", "deepseek-harness", true, "DeepSeek Harness"},
{"opencode", "opencode", true, "OpenCode"},
{"omp", "omp", true, "OMP"},
{"pool", "pool", true, "Pool"},
@@ -88,7 +90,7 @@ func TestIntegrationLookup(t *testing.T) {
}
func TestIntegrationRegistry(t *testing.T) {
- expectedIntegrations := []string{"claude", "claude-desktop", "cline", "codex", "chatgpt", "kimi", "muse", "droid", "opencode", "omp", "hermes", "hermes-desktop", "pool", "qwen"}
+ expectedIntegrations := []string{"claude", "claude-desktop", "cline", "codex", "chatgpt", "kimi", "muse", "droid", "dsh", "opencode", "omp", "hermes", "hermes-desktop", "pool", "qwen"}
for _, name := range expectedIntegrations {
t.Run(name, func(t *testing.T) {
r, ok := integrations[name]
diff --git a/cmd/launch/launch.go b/cmd/launch/launch.go
index 671efa78d..86c69c984 100644
--- a/cmd/launch/launch.go
+++ b/cmd/launch/launch.go
@@ -296,6 +296,7 @@ Supported integrations:
copilot Copilot CLI (aliases: copilot-cli)
omp OMP
droid Droid
+ dsh DeepSeek Harness (alias: deepseek-harness)
kimi Kimi Code CLI
muse Muse Code (aliases: muse-code)
pi Pi
@@ -312,6 +313,7 @@ Examples:
ollama launch chatgpt --restore
ollama launch hermes
ollama launch hermes-desktop
+ ollama launch dsh
ollama launch droid --config (does not auto-launch)
ollama launch codex --restore
ollama launch codex -- --sandbox workspace-write`,
diff --git a/cmd/launch/launch_test.go b/cmd/launch/launch_test.go
index b1137b9de..2c4fd6fb5 100644
--- a/cmd/launch/launch_test.go
+++ b/cmd/launch/launch_test.go
@@ -151,6 +151,21 @@ func (r *launcherManagedListRunner) ConfigureWithModels(primary string, models [
return r.Configure(primary)
}
+type launcherCanonicalManagedListRunner struct {
+ launcherManagedListRunner
+}
+
+func (r *launcherCanonicalManagedListRunner) ConfigureWithModels(primary string, models []LaunchModel) error {
+ r.configuredModelLists = append(r.configuredModelLists, launchModelNames(models))
+ r.configured = append(r.configured, primary)
+ if selected, ok := findLaunchModel(models, primary); ok {
+ r.currentModel = selected.Name
+ } else {
+ r.currentModel = primary
+ }
+ return nil
+}
+
type launcherManagedAutodiscoveryRunner struct {
launcherManagedRunner
autodiscoveryConfigures int
@@ -1089,6 +1104,51 @@ func TestLaunchIntegration_ManagedSingleIntegrationCanConfigureWithModelList(t *
}
}
+func TestLaunchIntegration_ManagedSingleIntegrationSavesCanonicalModel(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":"qwen3.5:latest"}]}`)
+ 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 := &launcherCanonicalManagedListRunner{}
+ withIntegrationOverride(t, "stubmanaged", runner)
+
+ request := IntegrationLaunchRequest{Name: "stubmanaged", ModelOverride: "qwen3.5"}
+ if err := LaunchIntegration(context.Background(), request); err != nil {
+ t.Fatalf("first LaunchIntegration returned error: %v", err)
+ }
+
+ saved, err := config.LoadIntegration("stubmanaged")
+ if err != nil {
+ t.Fatalf("failed to reload managed integration config: %v", err)
+ }
+ if diff := compareStrings(saved.Models, []string{"qwen3.5:latest"}); diff != "" {
+ t.Fatalf("saved models mismatch: %s", diff)
+ }
+
+ if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "stubmanaged"}); err != nil {
+ t.Fatalf("second LaunchIntegration returned error: %v", err)
+ }
+ if diff := compareStrings(runner.configured, []string{"qwen3.5"}); diff != "" {
+ t.Fatalf("expected second launch to skip configuration: %s", diff)
+ }
+}
+
func TestLaunchIntegration_ManagedAutodiscoverySkipsModelPicker(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
diff --git a/cmd/launch/models.go b/cmd/launch/models.go
index 37afb6c62..abec3a226 100644
--- a/cmd/launch/models.go
+++ b/cmd/launch/models.go
@@ -323,6 +323,9 @@ func prepareManagedSingleIntegration(name string, managed ManagedSingleModel, mo
if err != nil {
return fmt.Errorf("setup failed: %w", err)
}
+ if current := managed.CurrentModel(); current != "" {
+ model = current
+ }
if err := config.SaveIntegration(name, []string{model}); err != nil {
return fmt.Errorf("failed to save: %w", err)
}
diff --git a/cmd/launch/registry.go b/cmd/launch/registry.go
index 0cd186d2e..3e5aa845e 100644
--- a/cmd/launch/registry.go
+++ b/cmd/launch/registry.go
@@ -33,7 +33,7 @@ type IntegrationInfo struct {
Description string
}
-var launcherIntegrationOrder = []string{"claude", "chatgpt", "hermes", "openclaw", "opencode", "hermes-desktop", "codex", "copilot", "omp", "cline", "droid", "pi", "pool", "qwen"}
+var launcherIntegrationOrder = []string{"claude", "chatgpt", "hermes", "openclaw", "opencode", "hermes-desktop", "codex", "copilot", "omp", "cline", "droid", "dsh", "pi", "pool", "qwen"}
var integrationSpecs = []*IntegrationSpec{
{
@@ -166,6 +166,24 @@ var integrationSpecs = []*IntegrationSpec{
URL: "https://docs.factory.ai/cli/getting-started/quickstart",
},
},
+ {
+ Name: deepSeekHarnessIntegrationName,
+ Runner: &DeepSeekHarness{},
+ Aliases: []string{"deepseek-harness"},
+ Description: "DeepSeek's open-source agent harness",
+ Install: IntegrationInstallSpec{
+ CheckInstalled: func() bool {
+ _, err := deepSeekHarnessLookPath("dsh")
+ return err == nil
+ },
+ EnsureInstalled: func() error {
+ _, err := ensureDeepSeekHarnessInstalled()
+ return err
+ },
+ URL: "https://github.com/deepseek-ai/deepseek-harness",
+ Command: []string{"npm", "install", "-g", deepSeekHarnessNpmPackage},
+ },
+ },
{
Name: "opencode",
Runner: &OpenCode{},
diff --git a/docs/images/launch-icons/deepseek-harness.svg b/docs/images/launch-icons/deepseek-harness.svg
new file mode 100644
index 000000000..4f770e51f
--- /dev/null
+++ b/docs/images/launch-icons/deepseek-harness.svg
@@ -0,0 +1,4 @@
+
diff --git a/docs/integrations/deepseek-harness.mdx b/docs/integrations/deepseek-harness.mdx
new file mode 100644
index 000000000..b22719ce5
--- /dev/null
+++ b/docs/integrations/deepseek-harness.mdx
@@ -0,0 +1,50 @@
+---
+title: DeepSeek Harness
+---
+
+[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) is an open-source coding agent.
+
+DeepSeek Harness is currently a developer preview. Its upstream configuration may change between releases.
+
+## Setup
+
+```shell
+ollama launch dsh
+```
+
+Ollama installs `@deepseek-ai/dsh` if needed. To choose a model:
+
+```shell
+ollama launch dsh --model qwen3.5
+ollama launch dsh --model qwen3.5:cloud
+```
+
+To configure without starting:
+
+```shell
+ollama launch dsh --config
+```
+
+## Web search
+
+Web search is enabled automatically. It requires Ollama cloud access and a model that supports tools. Run `ollama signin` if needed.
+
+## Configuration
+
+Ollama stores its settings in `~/.ollama/launch/dsh/settings.yaml`. These settings load last and set the model, provider, and web search connection. Repeated launches preserve other settings in this file. Ollama does not change `~/.dsh/settings.yaml`, profiles, sessions, or credentials.
+
+Launch rejects additional `--patch` arguments. Pass other Harness arguments after `--`:
+
+```shell
+ollama launch dsh -- --port 3081
+```
+
+## Manual install
+
+DeepSeek Harness requires Node.js. To install it manually:
+
+```shell
+npm install -g @deepseek-ai/dsh@latest
+```
+
+Then run `ollama launch dsh`. On Windows, install Node.js for Windows.
diff --git a/docs/integrations/index.mdx b/docs/integrations/index.mdx
index 1e835e542..84c697068 100644
--- a/docs/integrations/index.mdx
+++ b/docs/integrations/index.mdx
@@ -17,6 +17,10 @@ Run `ollama launch` to see the latest integrations you can run from the terminal
Open-source coding agent that edits, runs, and iterates on code.
+
+
+ DeepSeek's open-source agent harness with subagents and web search.
+
## Connect an assistant