diff --git a/cmd/launch/command_test.go b/cmd/launch/command_test.go index cc8d0df49..94c719e6a 100644 --- a/cmd/launch/command_test.go +++ b/cmd/launch/command_test.go @@ -61,6 +61,9 @@ func TestLaunchCmd(t *testing.T) { if !strings.Contains(cmd.Long, "hermes") { t.Error("Long description should mention hermes") } + if !strings.Contains(cmd.Long, "kimi") { + t.Error("Long description should mention kimi") + } }) t.Run("flags exist", func(t *testing.T) { diff --git a/cmd/launch/integrations_test.go b/cmd/launch/integrations_test.go index f1a9bbac0..ed844dc5e 100644 --- a/cmd/launch/integrations_test.go +++ b/cmd/launch/integrations_test.go @@ -54,6 +54,7 @@ func TestIntegrationLookup(t *testing.T) { {"claude uppercase", "CLAUDE", true, "Claude Code"}, {"claude mixed case", "Claude", true, "Claude Code"}, {"codex", "codex", true, "Codex"}, + {"kimi", "kimi", true, "Kimi Code CLI"}, {"droid", "droid", true, "Droid"}, {"opencode", "opencode", true, "OpenCode"}, {"unknown integration", "unknown", false, ""}, @@ -74,7 +75,7 @@ func TestIntegrationLookup(t *testing.T) { } func TestIntegrationRegistry(t *testing.T) { - expectedIntegrations := []string{"claude", "codex", "droid", "opencode", "hermes"} + expectedIntegrations := []string{"claude", "codex", "kimi", "droid", "opencode", "hermes"} for _, name := range expectedIntegrations { t.Run(name, func(t *testing.T) { @@ -89,6 +90,15 @@ func TestIntegrationRegistry(t *testing.T) { } } +func TestHiddenIntegrationsExcludedFromVisibleLists(t *testing.T) { + for _, info := range ListIntegrationInfos() { + switch info.Name { + case "cline", "vscode", "kimi": + t.Fatalf("hidden integration %q should not appear in ListIntegrationInfos", info.Name) + } + } +} + func TestHasLocalModel(t *testing.T) { tests := []struct { name string diff --git a/cmd/launch/kimi.go b/cmd/launch/kimi.go new file mode 100644 index 000000000..0084b4f86 --- /dev/null +++ b/cmd/launch/kimi.go @@ -0,0 +1,230 @@ +package launch + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "runtime" + "strings" + "time" + + "github.com/ollama/ollama/api" + "github.com/ollama/ollama/envconfig" +) + +// Kimi implements Runner for Kimi Code CLI integration. +type Kimi struct{} + +const ( + kimiDefaultModelAlias = "ollama" + kimiDefaultMaxContextSize = 32768 +) + +var ( + kimiGOOS = runtime.GOOS + kimiModelShowTimeout = 5 * time.Second +) + +func (k *Kimi) String() string { return "Kimi Code CLI" } + +func (k *Kimi) args(config string, extra []string) []string { + args := []string{"--config", config} + args = append(args, extra...) + return args +} + +func (k *Kimi) Run(model string, args []string) error { + if strings.TrimSpace(model) == "" { + return fmt.Errorf("model is required") + } + if err := validateKimiPassthroughArgs(args); err != nil { + return err + } + + config, err := buildKimiInlineConfig(model, resolveKimiMaxContextSize(model)) + if err != nil { + return fmt.Errorf("failed to build kimi config: %w", err) + } + + bin, err := ensureKimiInstalled() + if err != nil { + return err + } + + cmd := exec.Command(bin, k.args(config, args)...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func validateKimiPassthroughArgs(args []string) error { + for _, arg := range args { + switch { + case arg == "--config", strings.HasPrefix(arg, "--config="): + return fmt.Errorf("conflicting extra argument %q: ollama launch kimi manages --config", arg) + case arg == "--config-file", strings.HasPrefix(arg, "--config-file="): + return fmt.Errorf("conflicting extra argument %q: ollama launch kimi manages --config-file", arg) + case arg == "--model", strings.HasPrefix(arg, "--model="): + return fmt.Errorf("conflicting extra argument %q: ollama launch kimi manages --model", arg) + case arg == "-m", strings.HasPrefix(arg, "-m="): + return fmt.Errorf("conflicting extra argument %q: ollama launch kimi manages -m/--model", arg) + } + } + return nil +} + +func buildKimiInlineConfig(model string, maxContextSize int) (string, error) { + cfg := map[string]any{ + "default_model": kimiDefaultModelAlias, + "providers": map[string]any{ + kimiDefaultModelAlias: map[string]any{ + "type": "openai_legacy", + "base_url": envconfig.Host().String() + "/v1", + "api_key": "ollama", + }, + }, + "models": map[string]any{ + kimiDefaultModelAlias: map[string]any{ + "provider": kimiDefaultModelAlias, + "model": model, + "max_context_size": maxContextSize, + }, + }, + } + + data, err := json.Marshal(cfg) + if err != nil { + return "", err + } + return string(data), nil +} + +func resolveKimiMaxContextSize(model string) int { + if l, ok := lookupCloudModelLimit(model); ok { + return l.Context + } + + client, err := api.ClientFromEnvironment() + if err != nil { + return kimiDefaultMaxContextSize + } + + ctx, cancel := context.WithTimeout(context.Background(), kimiModelShowTimeout) + defer cancel() + resp, err := client.Show(ctx, &api.ShowRequest{Model: model}) + if err != nil { + return kimiDefaultMaxContextSize + } + + if n, ok := modelInfoContextLength(resp.ModelInfo); ok { + return n + } + + return kimiDefaultMaxContextSize +} + +func modelInfoContextLength(modelInfo map[string]any) (int, bool) { + for key, val := range modelInfo { + if !strings.HasSuffix(key, ".context_length") { + continue + } + switch v := val.(type) { + case float64: + if v > 0 { + return int(v), true + } + case int: + if v > 0 { + return v, true + } + case int64: + if v > 0 { + return int(v), true + } + } + } + return 0, false +} + +func ensureKimiInstalled() (string, error) { + if _, err := exec.LookPath("kimi"); err == nil { + return "kimi", nil + } + + if err := checkKimiInstallerDependencies(); err != nil { + return "", err + } + + ok, err := ConfirmPrompt("Kimi is not installed. Install now?") + if err != nil { + return "", err + } + if !ok { + return "", fmt.Errorf("kimi installation cancelled") + } + + bin, args, err := kimiInstallerCommand(kimiGOOS) + if err != nil { + return "", err + } + + fmt.Fprintf(os.Stderr, "\nInstalling Kimi...\n") + cmd := exec.Command(bin, args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("failed to install kimi: %w", err) + } + + if _, err := exec.LookPath("kimi"); err != nil { + return "", fmt.Errorf("kimi was installed but the binary was not found on PATH\n\nYou may need to restart your shell") + } + + fmt.Fprintf(os.Stderr, "%sKimi installed successfully%s\n\n", ansiGreen, ansiReset) + return "kimi", nil +} + +func checkKimiInstallerDependencies() error { + switch kimiGOOS { + case "windows": + if _, err := exec.LookPath("powershell"); err != nil { + return fmt.Errorf("kimi is not installed and required dependencies are missing\n\nInstall the following first:\n PowerShell: https://learn.microsoft.com/powershell/\n\nThen re-run:\n ollama launch kimi") + } + default: + var missing []string + if _, err := exec.LookPath("curl"); err != nil { + missing = append(missing, "curl: https://curl.se/") + } + if _, err := exec.LookPath("bash"); err != nil { + missing = append(missing, "bash: https://www.gnu.org/software/bash/") + } + if len(missing) > 0 { + return fmt.Errorf("kimi is not installed and required dependencies are missing\n\nInstall the following first:\n %s\n\nThen re-run:\n ollama launch kimi", strings.Join(missing, "\n ")) + } + } + return nil +} + +func kimiInstallerCommand(goos string) (string, []string, error) { + switch goos { + case "windows": + return "powershell", []string{ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + "Invoke-RestMethod https://code.kimi.com/install.ps1 | Invoke-Expression", + }, nil + case "darwin", "linux": + return "bash", []string{ + "-c", + "curl -LsSf https://code.kimi.com/install.sh | bash", + }, nil + default: + return "", nil, fmt.Errorf("unsupported platform for kimi install: %s", goos) + } +} diff --git a/cmd/launch/kimi_test.go b/cmd/launch/kimi_test.go new file mode 100644 index 000000000..f17f5d822 --- /dev/null +++ b/cmd/launch/kimi_test.go @@ -0,0 +1,456 @@ +package launch + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "slices" + "strings" + "testing" +) + +func TestKimiIntegration(t *testing.T) { + k := &Kimi{} + + t.Run("String", func(t *testing.T) { + if got := k.String(); got != "Kimi Code CLI" { + t.Errorf("String() = %q, want %q", got, "Kimi Code CLI") + } + }) + + t.Run("implements Runner", func(t *testing.T) { + var _ Runner = k + }) +} + +func TestKimiArgs(t *testing.T) { + k := &Kimi{} + + got := k.args(`{"foo":"bar"}`, []string{"--quiet", "--print"}) + want := []string{"--config", `{"foo":"bar"}`, "--quiet", "--print"} + if !slices.Equal(got, want) { + t.Fatalf("args() = %v, want %v", got, want) + } +} + +func TestValidateKimiPassthroughArgs_RejectsConflicts(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + {name: "--config", args: []string{"--config", "{}"}, want: "--config"}, + {name: "--config=", args: []string{"--config={}"}, want: "--config={"}, + {name: "--config-file", args: []string{"--config-file", "x.toml"}, want: "--config-file"}, + {name: "--config-file=", args: []string{"--config-file=x.toml"}, want: "--config-file=x.toml"}, + {name: "--model", args: []string{"--model", "foo"}, want: "--model"}, + {name: "--model=", args: []string{"--model=foo"}, want: "--model=foo"}, + {name: "-m", args: []string{"-m", "foo"}, want: "-m"}, + {name: "-m=", args: []string{"-m=foo"}, want: "-m=foo"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateKimiPassthroughArgs(tt.args) + if err == nil { + t.Fatalf("expected error for args %v", tt.args) + } + if !strings.Contains(err.Error(), tt.want) { + t.Fatalf("error %q does not contain %q", err.Error(), tt.want) + } + }) + } +} + +func TestBuildKimiInlineConfig(t *testing.T) { + t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434") + + cfg, err := buildKimiInlineConfig("llama3.2", 65536) + if err != nil { + t.Fatalf("buildKimiInlineConfig() error = %v", err) + } + + var parsed map[string]any + if err := json.Unmarshal([]byte(cfg), &parsed); err != nil { + t.Fatalf("config is not valid JSON: %v", err) + } + + if parsed["default_model"] != "ollama" { + t.Fatalf("default_model = %v, want ollama", parsed["default_model"]) + } + + providers, ok := parsed["providers"].(map[string]any) + if !ok { + t.Fatalf("providers missing or wrong type: %T", parsed["providers"]) + } + ollamaProvider, ok := providers["ollama"].(map[string]any) + if !ok { + t.Fatalf("providers.ollama missing or wrong type: %T", providers["ollama"]) + } + if ollamaProvider["type"] != "openai_legacy" { + t.Fatalf("provider type = %v, want openai_legacy", ollamaProvider["type"]) + } + if ollamaProvider["base_url"] != "http://127.0.0.1:11434/v1" { + t.Fatalf("provider base_url = %v, want http://127.0.0.1:11434/v1", ollamaProvider["base_url"]) + } + if ollamaProvider["api_key"] != "ollama" { + t.Fatalf("provider api_key = %v, want ollama", ollamaProvider["api_key"]) + } + + models, ok := parsed["models"].(map[string]any) + if !ok { + t.Fatalf("models missing or wrong type: %T", parsed["models"]) + } + ollamaModel, ok := models["ollama"].(map[string]any) + if !ok { + t.Fatalf("models.ollama missing or wrong type: %T", models["ollama"]) + } + if ollamaModel["provider"] != "ollama" { + t.Fatalf("model provider = %v, want ollama", ollamaModel["provider"]) + } + if ollamaModel["model"] != "llama3.2" { + t.Fatalf("model model = %v, want llama3.2", ollamaModel["model"]) + } + if ollamaModel["max_context_size"] != float64(65536) { + t.Fatalf("model max_context_size = %v, want 65536", ollamaModel["max_context_size"]) + } +} + +func TestResolveKimiMaxContextSize(t *testing.T) { + t.Run("uses cloud limit when known", func(t *testing.T) { + got := resolveKimiMaxContextSize("kimi-k2.5:cloud") + if got != 262_144 { + t.Fatalf("resolveKimiMaxContextSize() = %d, want 262144", got) + } + }) + + t.Run("uses model show context length for local models", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/show" { + http.NotFound(w, r) + return + } + fmt.Fprint(w, `{"model_info":{"llama.context_length":131072}}`) + })) + defer srv.Close() + t.Setenv("OLLAMA_HOST", srv.URL) + + got := resolveKimiMaxContextSize("llama3.2") + if got != 131_072 { + t.Fatalf("resolveKimiMaxContextSize() = %d, want 131072", got) + } + }) + + t.Run("falls back to default when show fails", func(t *testing.T) { + srv := httptest.NewServer(http.NotFoundHandler()) + defer srv.Close() + t.Setenv("OLLAMA_HOST", srv.URL) + + oldTimeout := kimiModelShowTimeout + kimiModelShowTimeout = 100 * 1000 * 1000 // 100ms + t.Cleanup(func() { kimiModelShowTimeout = oldTimeout }) + + got := resolveKimiMaxContextSize("llama3.2") + if got != kimiDefaultMaxContextSize { + t.Fatalf("resolveKimiMaxContextSize() = %d, want %d", got, kimiDefaultMaxContextSize) + } + }) +} + +func TestKimiRun_RejectsConflictingArgsBeforeInstall(t *testing.T) { + k := &Kimi{} + + oldConfirm := DefaultConfirmPrompt + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { + t.Fatalf("did not expect install prompt, got %q", prompt) + return false, nil + } + t.Cleanup(func() { DefaultConfirmPrompt = oldConfirm }) + + err := k.Run("llama3.2", []string{"--model", "other"}) + if err == nil || !strings.Contains(err.Error(), "--model") { + t.Fatalf("expected conflict error mentioning --model, got %v", err) + } +} + +func TestKimiRun_PassesInlineConfigAndExtraArgs(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses POSIX shell fake binary") + } + + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + logPath := filepath.Join(tmpDir, "kimi-args.log") + script := fmt.Sprintf(`#!/bin/sh +for arg in "$@"; do + printf "%%s\n" "$arg" >> %q +done +exit 0 +`, logPath) + if err := os.WriteFile(filepath.Join(tmpDir, "kimi"), []byte(script), 0o755); err != nil { + t.Fatalf("failed to write fake kimi: %v", err) + } + t.Setenv("PATH", tmpDir) + + srv := httptest.NewServer(http.NotFoundHandler()) + defer srv.Close() + t.Setenv("OLLAMA_HOST", srv.URL) + + k := &Kimi{} + if err := k.Run("llama3.2", []string{"--quiet", "--print"}); err != nil { + t.Fatalf("Run() error = %v", err) + } + + data, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("failed to read args log: %v", err) + } + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) < 4 { + t.Fatalf("expected at least 4 args, got %v", lines) + } + if lines[0] != "--config" { + t.Fatalf("first arg = %q, want --config", lines[0]) + } + + var cfg map[string]any + if err := json.Unmarshal([]byte(lines[1]), &cfg); err != nil { + t.Fatalf("config arg is not valid JSON: %v", err) + } + providers := cfg["providers"].(map[string]any) + ollamaProvider := providers["ollama"].(map[string]any) + if ollamaProvider["type"] != "openai_legacy" { + t.Fatalf("provider type = %v, want openai_legacy", ollamaProvider["type"]) + } + + if lines[2] != "--quiet" || lines[3] != "--print" { + t.Fatalf("extra args = %v, want [--quiet --print]", lines[2:]) + } +} + +func TestEnsureKimiInstalled(t *testing.T) { + oldGOOS := kimiGOOS + t.Cleanup(func() { kimiGOOS = oldGOOS }) + + withConfirm := func(t *testing.T, fn func(prompt string) (bool, error)) { + t.Helper() + oldConfirm := DefaultConfirmPrompt + DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) { + return fn(prompt) + } + t.Cleanup(func() { DefaultConfirmPrompt = oldConfirm }) + } + + t.Run("already installed", func(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("PATH", tmpDir) + writeFakeBinary(t, tmpDir, "kimi") + kimiGOOS = runtime.GOOS + + withConfirm(t, func(prompt string) (bool, error) { + t.Fatalf("did not expect prompt, got %q", prompt) + return false, nil + }) + + bin, err := ensureKimiInstalled() + if err != nil { + t.Fatalf("ensureKimiInstalled() error = %v", err) + } + if bin != "kimi" { + t.Fatalf("bin = %q, want kimi", bin) + } + }) + + t.Run("missing dependencies", func(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("PATH", tmpDir) + kimiGOOS = "linux" + + withConfirm(t, func(prompt string) (bool, error) { + t.Fatalf("did not expect prompt, got %q", prompt) + return false, nil + }) + + _, err := ensureKimiInstalled() + if err == nil || !strings.Contains(err.Error(), "required dependencies are missing") { + t.Fatalf("expected missing dependency error, got %v", err) + } + }) + + t.Run("missing and user declines install", func(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("PATH", tmpDir) + writeFakeBinary(t, tmpDir, "curl") + writeFakeBinary(t, tmpDir, "bash") + kimiGOOS = "linux" + + withConfirm(t, func(prompt string) (bool, error) { + if !strings.Contains(prompt, "Kimi is not installed.") { + t.Fatalf("unexpected prompt: %q", prompt) + } + return false, nil + }) + + _, err := ensureKimiInstalled() + if err == nil || !strings.Contains(err.Error(), "installation cancelled") { + t.Fatalf("expected cancellation error, got %v", err) + } + }) + + t.Run("missing and user confirms install succeeds", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses POSIX shell fake binaries") + } + + tmpDir := t.TempDir() + t.Setenv("PATH", tmpDir) + kimiGOOS = "linux" + + writeFakeBinary(t, tmpDir, "curl") + + installLog := filepath.Join(tmpDir, "bash.log") + kimiPath := filepath.Join(tmpDir, "kimi") + bashScript := fmt.Sprintf(`#!/bin/sh +echo "$@" >> %q +if [ "$1" = "-c" ]; then + /bin/cat > %q <<'EOS' +#!/bin/sh +exit 0 +EOS + /bin/chmod +x %q +fi +exit 0 +`, installLog, kimiPath, kimiPath) + if err := os.WriteFile(filepath.Join(tmpDir, "bash"), []byte(bashScript), 0o755); err != nil { + t.Fatalf("failed to write fake bash: %v", err) + } + + withConfirm(t, func(prompt string) (bool, error) { + return true, nil + }) + + bin, err := ensureKimiInstalled() + if err != nil { + t.Fatalf("ensureKimiInstalled() error = %v", err) + } + if bin != "kimi" { + t.Fatalf("bin = %q, want kimi", bin) + } + + logData, err := os.ReadFile(installLog) + if err != nil { + t.Fatalf("failed to read install log: %v", err) + } + if !strings.Contains(string(logData), "https://code.kimi.com/install.sh") { + t.Fatalf("expected install.sh command in log, got:\n%s", string(logData)) + } + }) + + t.Run("install command fails", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses POSIX shell fake binaries") + } + + tmpDir := t.TempDir() + t.Setenv("PATH", tmpDir) + kimiGOOS = "linux" + writeFakeBinary(t, tmpDir, "curl") + if err := os.WriteFile(filepath.Join(tmpDir, "bash"), []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil { + t.Fatalf("failed to write fake bash: %v", err) + } + + withConfirm(t, func(prompt string) (bool, error) { + return true, nil + }) + + _, err := ensureKimiInstalled() + if err == nil || !strings.Contains(err.Error(), "failed to install kimi") { + t.Fatalf("expected install failure error, got %v", err) + } + }) + + t.Run("install succeeds but binary missing on PATH", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses POSIX shell fake binaries") + } + + tmpDir := t.TempDir() + t.Setenv("PATH", tmpDir) + kimiGOOS = "linux" + writeFakeBinary(t, tmpDir, "curl") + if err := os.WriteFile(filepath.Join(tmpDir, "bash"), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatalf("failed to write fake bash: %v", err) + } + + withConfirm(t, func(prompt string) (bool, error) { + return true, nil + }) + + _, err := ensureKimiInstalled() + if err == nil || !strings.Contains(err.Error(), "binary was not found on PATH") { + t.Fatalf("expected PATH guidance error, got %v", err) + } + }) +} + +func TestKimiInstallerCommand(t *testing.T) { + tests := []struct { + name string + goos string + wantBin string + wantParts []string + wantErr bool + }{ + { + name: "linux", + goos: "linux", + wantBin: "bash", + wantParts: []string{"-c", "install.sh"}, + }, + { + name: "darwin", + goos: "darwin", + wantBin: "bash", + wantParts: []string{"-c", "install.sh"}, + }, + { + name: "windows", + goos: "windows", + wantBin: "powershell", + wantParts: []string{"-Command", "install.ps1"}, + }, + { + name: "unsupported", + goos: "freebsd", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bin, args, err := kimiInstallerCommand(tt.goos) + if tt.wantErr { + if err == nil { + t.Fatal("expected error") + } + return + } + if err != nil { + t.Fatalf("kimiInstallerCommand() error = %v", err) + } + if bin != tt.wantBin { + t.Fatalf("bin = %q, want %q", bin, tt.wantBin) + } + joined := strings.Join(args, " ") + for _, part := range tt.wantParts { + if !strings.Contains(joined, part) { + t.Fatalf("args %q missing %q", joined, part) + } + } + }) + } +} diff --git a/cmd/launch/launch.go b/cmd/launch/launch.go index f4c13092e..41cd84270 100644 --- a/cmd/launch/launch.go +++ b/cmd/launch/launch.go @@ -209,6 +209,7 @@ Supported integrations: copilot Copilot CLI (aliases: copilot-cli) droid Droid hermes Hermes Agent + kimi Kimi Code CLI opencode OpenCode openclaw OpenClaw (aliases: clawdbot, moltbot) pi Pi diff --git a/cmd/launch/registry.go b/cmd/launch/registry.go index fc96a1bab..f390142ca 100644 --- a/cmd/launch/registry.go +++ b/cmd/launch/registry.go @@ -74,6 +74,23 @@ var integrationSpecs = []*IntegrationSpec{ Command: []string{"npm", "install", "-g", "@openai/codex"}, }, }, + { + Name: "kimi", + Runner: &Kimi{}, + Description: "Moonshot's coding agent for terminal and IDEs", + Hidden: true, + Install: IntegrationInstallSpec{ + CheckInstalled: func() bool { + _, err := exec.LookPath("kimi") + return err == nil + }, + EnsureInstalled: func() error { + _, err := ensureKimiInstalled() + return err + }, + URL: "https://moonshotai.github.io/kimi-cli/en/guides/getting-started.html", + }, + }, { Name: "copilot", Runner: &Copilot{}, diff --git a/cmd/launch/runner_exec_only_test.go b/cmd/launch/runner_exec_only_test.go index 0138a9c1a..137ed8192 100644 --- a/cmd/launch/runner_exec_only_test.go +++ b/cmd/launch/runner_exec_only_test.go @@ -45,6 +45,14 @@ func TestEditorRunsDoNotRewriteConfig(t *testing.T) { return filepath.Join(home, ".pi", "agent", "models.json") }, }, + { + name: "kimi", + binary: "kimi", + runner: &Kimi{}, + checkPath: func(home string) string { + return filepath.Join(home, ".kimi", "config.toml") + }, + }, } for _, tt := range tests { @@ -57,6 +65,10 @@ func TestEditorRunsDoNotRewriteConfig(t *testing.T) { if tt.name == "pi" { writeFakeBinary(t, binDir, "npm") } + if tt.name == "kimi" { + writeFakeBinary(t, binDir, "curl") + writeFakeBinary(t, binDir, "bash") + } t.Setenv("PATH", binDir) configPath := tt.checkPath(home)