diff --git a/docs/subsystems/agent-wiring.md b/docs/subsystems/agent-wiring.md index dc0bfdf0..6fd419d4 100644 --- a/docs/subsystems/agent-wiring.md +++ b/docs/subsystems/agent-wiring.md @@ -20,7 +20,7 @@ something else shows up as drift. | What came before | The stash (`stash.json`) remembers what the config said before magpie pointed it at the gateway, so Disconnect restores it rather than guessing. | [`stash.go`](../../internal/agent/stash.go) | | Disconnect and preview | `Disconnect` runs `Unwire`, then returns each field still on magpie to its default. `DisconnectPreview` runs the real Disconnect on a copy under a temporary home, in a subprocess (`DryRunArg`), and diffs the files. The preview is never a second account of what Disconnect does. | [`applied.go`](../../internal/agent/applied.go), [`preview.go`](../../internal/agent/preview.go) | | Keeping configs current | `Reseat` moves agents off a model magpie no longer serves (#200). `OnGateway` and `Rewire` write the new gateway address into every connected agent when the port changes. `Source` and `Stale` say what an unconnected agent runs on, and whether running copies still use the old list. | [`reseat.go`](../../internal/agent/reseat.go), [`rewire.go`](../../internal/agent/rewire.go), [`connectinfo.go`](../../internal/agent/connectinfo.go) | -| WSL twins | On Windows each running distro is probed once, without starting a stopped one. The `wslKinds` agents found there become agents of their own, `@wsl:`, edited through `\\wsl.localhost\`. The gateway address and key follow the distro's networking: mirrored or NAT (`place`, `keyAt`). `ListsFor` keeps a twin's model picks under the Windows twin's id (#927). | [`wsl.go`](../../internal/agent/wsl.go), [`gatewaykey.go`](../../internal/agent/gatewaykey.go) | +| WSL twins | On Windows each running distro is probed once, without starting a stopped one. The `wslKinds` agents found there become agents of their own, `@wsl:`, edited through `\\wsl.localhost\`. A kind is found by its folder under the distro's home or by its command there (not one on a Windows drive). Where another tool shares one of them, the kind leaves it out: Gemini CLI is found by its command alone, because Antigravity keeps `~/.gemini` too. Goose is found by its folder alone, because a `goose` command can be pressly's migration tool. Reasonix also needs the version its command gives (`accept`), because the old npm client is named `reasonix` too. The gateway address and key follow the distro's networking: mirrored or NAT (`place`, `keyAt`). `ListsFor` keeps a twin's model picks under the Windows twin's id (#927). | [`wsl.go`](../../internal/agent/wsl.go), [`gatewaykey.go`](../../internal/agent/gatewaykey.go) | | Environment-only agents | Agents that read the gateway only from the environment get a `Launch` command. Cursor Private Inference instead gets user-level variables (`cursorLocalOn`, `KeepCursorLocalEnv`), and magpie writes none of Cursor's own files. Its effort field is kept in magpie (`provider.AgentEffort`) and asked by the gateway, its levels those of the models it lists (`cursorLocalEfforts`, #1003). | [`cursorlocal.go`](../../internal/agent/cursorlocal.go) | ## Runtime path diff --git a/internal/agent/agents.go b/internal/agent/agents.go index a84a2e2d..b9d336ea 100644 --- a/internal/agent/agents.go +++ b/internal/agent/agents.go @@ -884,9 +884,15 @@ func piLike(at place, id, name, dir string) *Agent { } } -func goose(home, cfg string) *Agent { +func goose(home, cfg string) *Agent { return gooseIn(here(home), cfg) } + +// gooseIn is Goose at a place whose config folder is cfg: this machine's, +// or a WSL distro's ~/.config (see wsl.go), where Goose keeps Linux's +// layout and its custom provider names the gateway as the distro reaches +// it, with the key it takes from there. +func gooseIn(at place, cfg string) *Agent { path := filepath.Join(cfg, "goose", "config.yaml") - if runtime.GOOS == "windows" { + if runtime.GOOS == "windows" && at.spell == nil { if app := appdir.Getenv("APPDATA"); app != "" { path = filepath.Join(app, "Block", "goose", "config", "config.yaml") } @@ -894,6 +900,12 @@ func goose(home, cfg string) *Agent { get := func(k string) (string, bool) { return edit.GetYAMLTop(path, k) } set := func(kvs ...edit.KV) error { return edit.SetYAMLTop(path, kvs...) } provider := gooseProviderPath(path) + // the provider keys goose takes from its environment: this machine's + // for this machine's goose, none of a distro's + keyEnv := os.Getenv + if at.spell != nil { + keyEnv = func(string) string { return "" } + } return &Agent{ ID: "goose", Name: "Goose", Icon: "goose", Bin: "goose", Dir: filepath.Dir(path), Path: path, Spelled: prefixed, UA: []string{"goose"}, @@ -902,9 +914,9 @@ func goose(home, cfg string) *Agent { return "" } return wiringOff("Goose", provider, func(k string) (string, bool) { return edit.GetJSON(provider, k) }, - "base_url", gatewayV1(), "headers.Authorization", "Bearer "+gateway.Token) + "base_url", at.v1(), "headers.Authorization", "Bearer "+at.gwKey()) }, - Sync: func() error { return syncGooseProvider(provider) }, + Sync: func() error { return syncGooseProvider(provider, at) }, // goose loads custom_providers when it starts Notice: func() string { if p, _ := gooseActive(path); p == gooseProviderID && Running(`Goose\.app/`, `(^|/)goose( |$)`) { @@ -936,7 +948,7 @@ func goose(home, cfg string) *Agent { // Goose's, each model with its window and whether Goose // can send it a thinking level (see goose.go) if ref, ok := strings.CutPrefix(v, gooseProviderID+"/"); ok && isMagpie(ref) { - if err := writeGooseProvider(provider); err != nil { + if err := writeGooseProvider(provider, at); err != nil { return err } } @@ -946,7 +958,7 @@ func goose(home, cfg string) *Agent { // only the native providers this goose is set up with (#987: // every one of four was listed, OpenRouter's hundreds of // models on a goose that had only magpie) - return append(ownOptions("", cur["model"], gooseConfigured(path)...), viaMagpie("goose", gooseProviderID+"/")...) + return append(ownOptions("", cur["model"], gooseConfigured(path, keyEnv)...), viaMagpie("goose", gooseProviderID+"/")...) }, }, { // GOOSE_THINKING_EFFORT, the effort goose asks of a model that diff --git a/internal/agent/atomcode.go b/internal/agent/atomcode.go index d40fb267..81844e31 100644 --- a/internal/agent/atomcode.go +++ b/internal/agent/atomcode.go @@ -23,7 +23,6 @@ import ( "strconv" "strings" - "github.com/yetone/magpie/internal/appdir" "github.com/yetone/magpie/internal/edit" "github.com/yetone/magpie/internal/gateway" ) @@ -50,9 +49,14 @@ func atomcodeKey(ref string) string { return magpieID + "/" + ref } // (atomcodeOwnModels), not built ones. func atomcodeTable(key string) string { return "models." + strconv.Quote(key) } -func atomcode(home string) *Agent { - dir := filepath.Join(home, ".atomcode") - if h := appdir.Getenv("ATOMCODE_HOME"); filepath.IsAbs(h) { +func atomcode(home string) *Agent { return atomcodeIn(here(home)) } + +// atomcodeIn is AtomCode at a place: this machine's home, or a WSL distro's +// (see wsl.go), where ATOMCODE_HOME isn't read and its account names the +// gateway as the distro reaches it, with the key it takes from there. +func atomcodeIn(at place) *Agent { + dir := filepath.Join(at.home, ".atomcode") + if h := at.getenv("ATOMCODE_HOME"); filepath.IsAbs(h) { dir = filepath.Clean(h) } path := filepath.Join(dir, "config.toml") @@ -102,7 +106,7 @@ func atomcode(home string) *Agent { return edit.SetTOMLTop(path, edit.KV{Path: "default_model", Value: v}) } writeMagpie := func() error { - return edit.SetTOMLTablesMatching(path, atomcodeAccountNames, atomcodeTablePrefixes, atomcodeTables(path)) + return edit.SetTOMLTablesMatching(path, atomcodeAccountNames, atomcodeTablePrefixes, atomcodeTablesAt(path, at.v1(), agentKeyAt("atomcode", at.gw()))) } dropMagpie := func() error { return edit.SetTOMLTablesMatching(path, atomcodeAccountNames, atomcodeTablePrefixes, nil) @@ -149,7 +153,7 @@ func atomcode(home string) *Agent { return "AtomCode's [" + atomcodeAccount + "] (config.toml) is gone, so it no longer reaches magpie" } return wiringOff("AtomCode", path, func(f string) (string, bool) { v, ok := a[f]; return v, ok }, - "base_url", gatewayV1(), "api_key", gateway.TokenFor("atomcode")) + "base_url", at.v1(), "api_key", agentKeyAt("atomcode", at.gw())) }, Fields: []Field{{ Key: "model", Label: "model", @@ -289,12 +293,18 @@ func atomcode(home string) *Agent { // No reasoning_effort is picked for the user — the vendor's default stands // until they choose one. func atomcodeTables(path string) []edit.Table { + return atomcodeTablesAt(path, gatewayV1(), gateway.TokenFor("atomcode")) +} + +// atomcodeTablesAt is atomcodeTables for an AtomCode reaching the +// gateway's /v1 at v1 with key. +func atomcodeTablesAt(path, v1, key string) []edit.Table { efforts := atomcodeExistingEfforts(path) out := []edit.Table{{Name: atomcodeAccount, KVs: []edit.KV{ // openai-compatible is the documented preset for a custom endpoint {Path: "provider", Value: "openai-compatible"}, - {Path: "base_url", Value: gatewayV1()}, - {Path: "api_key", Value: gateway.TokenFor("atomcode")}, + {Path: "base_url", Value: v1}, + {Path: "api_key", Value: key}, }}} for _, m := range magpieModels("atomcode") { ctx := m.Context diff --git a/internal/agent/cline.go b/internal/agent/cline.go index b694d06b..7b881eb5 100644 --- a/internal/agent/cline.go +++ b/internal/agent/cline.go @@ -46,20 +46,24 @@ import ( "strings" "time" - "github.com/yetone/magpie/internal/appdir" "github.com/yetone/magpie/internal/edit" - "github.com/yetone/magpie/internal/gateway" ) // clineSlot is the provider magpie takes in Cline. const clineSlot = "openai-compatible" -func cline(home string) *Agent { - dir := appdir.Getenv("CLINE_DIR") +func cline(home string) *Agent { return clineIn(here(home)) } + +// clineIn is Cline at a place: this machine's home, or a WSL distro's (see +// wsl.go), where CLINE_DIR and CLINE_DATA_DIR aren't read and its provider +// names the gateway as the distro reaches it, with the key it takes from +// there. +func clineIn(at place) *Agent { + dir := at.getenv("CLINE_DIR") if dir == "" { - dir = filepath.Join(home, ".cline") + dir = filepath.Join(at.home, ".cline") } - data := appdir.Getenv("CLINE_DATA_DIR") + data := at.getenv("CLINE_DATA_DIR") if data == "" { data = filepath.Join(dir, "data") } @@ -71,13 +75,13 @@ func cline(home string) *Agent { get := func(k string) string { v, _ := edit.GetJSON(path, k); return v } inUse := func() string { return get("lastUsedProvider") } onMagpie := func() bool { - return inUse() == clineSlot && get(slot+".settings.apiKey") == gateway.Token + return inUse() == clineSlot && ourKey(get(slot+".settings.apiKey")) } // stateOnMagpie: the extension's state is magpie's stateOnMagpie := func() bool { k, _ := edit.GetJSON(secrets, "openAiApiKey") u, _ := edit.GetJSON(state, "openAiBaseUrl") - return k == gateway.Token && u == gatewayV1() + return ourKey(k) && u == at.v1() } // wireState points the extension's state, when there is one, at model // on magpie's openai-compatible @@ -97,11 +101,11 @@ func cline(home string) *Agent { if err := edit.SetJSON(state, edit.KV{Path: "actModeApiProvider", Value: "openai"}, edit.KV{Path: "planModeApiProvider", Value: "openai"}, edit.KV{Path: "actModeOpenAiModelId", Value: model}, edit.KV{Path: "planModeOpenAiModelId", Value: model}, - edit.KV{Path: "openAiBaseUrl", Value: gatewayV1()}, + edit.KV{Path: "openAiBaseUrl", Value: at.v1()}, edit.KV{Path: "openAiHeaders", Value: map[string]string{"User-Agent": "cline"}}); err != nil { return err } - return clineWrite(secrets, "{}", edit.KV{Path: "openAiApiKey", Value: gateway.Token}) + return clineWrite(secrets, "{}", edit.KV{Path: "openAiApiKey", Value: at.gwKey()}) } // restoreState puts back the extension's state from before magpie; // magpie's keys go when nothing was stashed @@ -165,15 +169,15 @@ func cline(home string) *Agent { if !onMagpie() { return nil } - if err := syncJSON(path, slot+".settings.baseUrl", func() any { return gatewayV1() }); err != nil { + if err := syncJSON(path, slot+".settings.baseUrl", func() any { return at.v1() }); err != nil { return err } - if k, _ := edit.GetJSON(secrets, "openAiApiKey"); k == gateway.Token { - if err := syncJSON(state, "openAiBaseUrl", func() any { return gatewayV1() }); err != nil { + if k, _ := edit.GetJSON(secrets, "openAiApiKey"); ourKey(k) { + if err := syncJSON(state, "openAiBaseUrl", func() any { return at.v1() }); err != nil { return err } } - return syncJSON(models, "providers."+clineSlot, func() any { return clineModels(get(slot + ".settings.model")) }) + return syncJSON(models, "providers."+clineSlot, func() any { return clineModelsAt(get(slot+".settings.model"), at.v1()) }) }, Notice: func() string { if runtime.GOOS == "windows" { @@ -193,7 +197,7 @@ func cline(home string) *Agent { return "" } return wiringOff("Cline", path, func(k string) (string, bool) { return edit.GetJSON(path, slot+".settings."+k) }, - "baseUrl", gatewayV1()) + "baseUrl", at.v1()) }, Fields: []Field{{ Key: "model", Label: "model", @@ -229,14 +233,14 @@ func cline(home string) *Agent { }) } if err := clineWrite(models, `{"version":1,"providers":{}}`, - edit.KV{Path: "providers." + clineSlot, Value: clineModels(ref)}); err != nil { + edit.KV{Path: "providers." + clineSlot, Value: clineModelsAt(ref, at.v1())}); err != nil { return err } if err := wireState(ref); err != nil { return err } return clineWrite(path, providersEmpty, - edit.KV{Path: slot, Value: clineProvider(ref, effort)}, + edit.KV{Path: slot, Value: clineProvider(ref, effort, at)}, edit.KV{Path: "lastUsedProvider", Value: clineSlot}) } if onMagpie() { @@ -284,10 +288,10 @@ func cutMagpie(v string) (string, bool) { } // clineProvider is magpie's openai-compatible entry, asking model with -// effort (none unset). -func clineProvider(model, effort string) map[string]any { +// effort (none unset), for the Cline at a place. +func clineProvider(model, effort string, at place) map[string]any { s := map[string]any{ - "provider": clineSlot, "apiKey": gateway.Token, "model": model, "baseUrl": gatewayV1(), + "provider": clineSlot, "apiKey": at.gwKey(), "model": model, "baseUrl": at.v1(), "headers": map[string]string{"User-Agent": "cline"}, } if effort != "" { @@ -366,7 +370,10 @@ func clineRestore(path, snapshot string) error { // clineModels is magpie's entry in models.json: every magpie model, model // the one the provider starts on. -func clineModels(model string) map[string]any { +func clineModels(model string) map[string]any { return clineModelsAt(model, gatewayV1()) } + +// clineModelsAt is clineModels for a Cline reaching the gateway's /v1 at v1. +func clineModelsAt(model, v1 string) map[string]any { ms := map[string]any{} for _, m := range magpieModels("cline") { caps := []string{"streaming", "tools"} @@ -386,7 +393,7 @@ func clineModels(model string) map[string]any { ms[m.ID] = e } return map[string]any{ - "provider": map[string]any{"name": "magpie", "baseUrl": gatewayV1(), "defaultModelId": model}, + "provider": map[string]any{"name": "magpie", "baseUrl": v1, "defaultModelId": model}, "models": ms, } } diff --git a/internal/agent/codebuddy.go b/internal/agent/codebuddy.go index 5847fe00..0ebdab94 100644 --- a/internal/agent/codebuddy.go +++ b/internal/agent/codebuddy.go @@ -23,16 +23,22 @@ import ( "slices" "strings" - "github.com/yetone/magpie/internal/appdir" "github.com/yetone/magpie/internal/edit" ) -func codebuddy(home string) *Agent { - dir := appdir.Getenv("CODEBUDDY_CONFIG_DIR") +func codebuddy(home string) *Agent { return codebuddyIn(here(home)) } + +// codebuddyIn is CodeBuddy Code at a place: this machine's home, or a WSL +// distro's (see wsl.go), where CODEBUDDY_CONFIG_DIR isn't read and its +// models name the gateway as the distro reaches it, with the key it takes +// from there. +func codebuddyIn(at place) *Agent { + dir := at.getenv("CODEBUDDY_CONFIG_DIR") if dir == "" { - dir = filepath.Join(home, ".codebuddy") + dir = filepath.Join(at.home, ".codebuddy") } path := filepath.Join(dir, "models.json") + write := func(on bool) error { return buddyWriteAt(path, "codebuddy", on, at) } settings := filepath.Join(dir, "settings.json") model := func() string { v, _ := edit.GetJSON(settings, "model"); return v } // ours is whether id is one of magpie's models in models.json @@ -48,7 +54,7 @@ func codebuddy(home string) *Agent { if !workbuddyWired(path) { return nil } - return buddyWrite(path, "codebuddy", true) + return write(true) }, Notice: func() string { if Running(`(^|/)(codebuddy|cbc|codebuddy-code)( |$)`) { @@ -67,13 +73,13 @@ func codebuddy(home string) *Agent { }, Set: func(v string) error { if ref, ok := strings.CutPrefix(v, magpieID+"/"); ok && isMagpie(ref) { - if err := buddyWrite(path, "codebuddy", true); err != nil { + if err := write(true); err != nil { return err } return edit.SetJSON(settings, edit.KV{Path: "model", Value: ref}) } // CodeBuddy's own: magpie's models come out of its list - if err := buddyWrite(path, "codebuddy", false); err != nil { + if err := write(false); err != nil { return err } if v == "" { diff --git a/internal/agent/gemini.go b/internal/agent/gemini.go index 303a0b36..0a4c85c2 100644 --- a/internal/agent/gemini.go +++ b/internal/agent/gemini.go @@ -8,7 +8,6 @@ import ( "github.com/yetone/magpie/internal/catalog" "github.com/yetone/magpie/internal/edit" - "github.com/yetone/magpie/internal/gateway" "github.com/yetone/magpie/internal/provider" ) @@ -21,15 +20,27 @@ import ( // points GOOGLE_GEMINI_BASE_URL at it, uses the gateway token as the API // key and names the catalog model in model.name. -func gemini(home string) *Agent { - dir := filepath.Join(home, ".gemini") +func gemini(home string) *Agent { return geminiIn(here(home)) } + +// geminiIn is Gemini CLI at a place: this machine's home, or a WSL +// distro's (see wsl.go), whose .env names the gateway as the distro +// reaches it, with the key it takes from there. +func geminiIn(at place) *Agent { + dir := filepath.Join(at.home, ".gemini") path := filepath.Join(dir, "settings.json") envPath := filepath.Join(dir, ".env") auth := jsonGet(path, "security.auth.selectedType") model := jsonGet(path, "model.name") base := func() string { v, _ := edit.GetEnvFile(envPath, "GOOGLE_GEMINI_BASE_URL"); return v } envKey := func() string { v, _ := edit.GetEnvFile(envPath, "GEMINI_API_KEY"); return v } - routed := func() bool { return base() == gateway.URL() } + // routed: the .env names the gateway, or names it at an address it + // no longer has (a WSL distro's under NAT changes as WSL restarts) with + // magpie's key: either way what is there is magpie's, never stashed as + // the user's own + routed := func() bool { + b, k := base(), envKey() + return b == at.gw() || b != "" && (ourKey(k) || k == at.gwKey()) + } magpieKey := func() string { for _, id := range []string{"google", "gemini"} { if p, err := provider.Find(id); err == nil && p.Key != "" { @@ -47,10 +58,10 @@ func gemini(home string) *Agent { return err } var env []edit.KV - if u := unstash("gemini.base_url"); u != "" { + if u := unstash(at.key("gemini.base_url")); u != "" { env = append(env, edit.KV{Path: "GOOGLE_GEMINI_BASE_URL", Value: u}) } - if k := unstash("gemini.api_key"); k != "" { + if k := unstash(at.key("gemini.api_key")); k != "" { env = append(env, edit.KV{Path: "GEMINI_API_KEY", Value: k}) } if len(env) > 0 { @@ -58,14 +69,14 @@ func gemini(home string) *Agent { return err } } - if a := unstash("gemini.auth"); a != "" { + if a := unstash(at.key("gemini.auth")); a != "" { if err := edit.SetJSON(path, edit.KV{Path: "security.auth.selectedType", Value: a}); err != nil { return err } } else if err := edit.DelJSON(path, "security.auth.selectedType"); err != nil { return err } - if m := unstash("gemini.model"); m != "" { + if m := unstash(at.key("gemini.model")); m != "" { return edit.SetJSON(path, edit.KV{Path: "model.name", Value: m}) } return delModelName(path) @@ -73,7 +84,7 @@ func gemini(home string) *Agent { setModel := func(v string) error { if v == "" { if routed() { - forget("gemini.base_url", "gemini.api_key", "gemini.auth", "gemini.model") + forget(at.key("gemini.base_url"), at.key("gemini.api_key"), at.key("gemini.auth"), at.key("gemini.model")) if err := edit.DelEnvFile(envPath, "GOOGLE_GEMINI_BASE_URL", "GEMINI_API_KEY"); err != nil { return err } @@ -85,9 +96,9 @@ func gemini(home string) *Agent { } if isMagpie(v) { if !routed() { - stash(map[string]string{"gemini.base_url": base(), "gemini.api_key": envKey(), "gemini.auth": auth(), "gemini.model": model()}) + stash(map[string]string{at.key("gemini.base_url"): base(), at.key("gemini.api_key"): envKey(), at.key("gemini.auth"): auth(), at.key("gemini.model"): model()}) } - if err := edit.SetEnvFile(envPath, edit.KV{Path: "GOOGLE_GEMINI_BASE_URL", Value: gateway.URL()}, edit.KV{Path: "GEMINI_API_KEY", Value: gateway.Token}); err != nil { + if err := edit.SetEnvFile(envPath, edit.KV{Path: "GOOGLE_GEMINI_BASE_URL", Value: at.gw()}, edit.KV{Path: "GEMINI_API_KEY", Value: at.gwKey()}); err != nil { return err } return edit.SetJSON(path, edit.KV{Path: "security.auth.selectedType", Value: "gemini-api-key"}, edit.KV{Path: "model.name", Value: v}) @@ -186,7 +197,7 @@ func gemini(home string) *Agent { return "" } if d := wiringOff("Gemini CLI", envPath, func(k string) (string, bool) { return edit.GetEnvFile(envPath, k) }, - "GOOGLE_GEMINI_BASE_URL", gateway.URL(), "GEMINI_API_KEY", gateway.Token); d != "" { + "GOOGLE_GEMINI_BASE_URL", at.gw(), "GEMINI_API_KEY", at.gwKey()); d != "" { return d } if a := auth(); a != "gemini-api-key" { diff --git a/internal/agent/goose.go b/internal/agent/goose.go index 1f972e86..aac03b7d 100644 --- a/internal/agent/goose.go +++ b/internal/agent/goose.go @@ -14,7 +14,6 @@ import ( "gopkg.in/yaml.v3" "github.com/yetone/magpie/internal/edit" - "github.com/yetone/magpie/internal/gateway" ) // Goose (Block's goose, its CLI and desktop app alike) reaches magpie as a @@ -69,8 +68,9 @@ var gooseKeys = map[string]string{ // (migrate_provider_config, config/migrations.rs), and a provider whose key // is in the environment. A key in goose's keyring can't be seen from here; // such a provider still counts once goose has used it, as each provider it -// is set up with gets an entry. -func gooseConfigured(cfg string) []string { +// is set up with gets an entry. getenv is the environment goose runs in: +// none of a WSL distro's is read. +func gooseConfigured(cfg string, getenv func(string) string) []string { set := map[string]bool{} if b, err := os.ReadFile(cfg); err == nil { var c map[string]any @@ -95,7 +95,7 @@ func gooseConfigured(cfg string) []string { } } for p, k := range gooseKeys { - if os.Getenv(k) != "" { + if getenv(k) != "" { set[p] = true } } @@ -114,9 +114,9 @@ func gooseProviderPath(cfg string) string { return filepath.Join(filepath.Dir(cfg), "custom_providers", gooseProviderID+".json") } -// gooseProviderJSON is magpie as a Goose custom provider, its models the -// catalog as Goose is shown it. -func gooseProviderJSON() map[string]any { +// gooseProviderJSON is magpie as a custom provider of the Goose at a +// place, its models the catalog as Goose is shown it. +func gooseProviderJSON(at place) map[string]any { models := []map[string]any{} for _, m := range magpieModels("goose") { e := map[string]any{"name": m.ID, "reasoning": gooseThinks.MatchString(m.ID)} @@ -131,8 +131,8 @@ func gooseProviderJSON() map[string]any { "display_name": "magpie", "description": "Every model magpie has, through its local gateway", "api_key_env": "", - "base_url": gatewayV1(), - "headers": map[string]any{"Authorization": "Bearer " + gateway.Token}, + "base_url": at.v1(), + "headers": map[string]any{"Authorization": "Bearer " + at.gwKey()}, "requires_auth": false, "skip_canonical_filtering": true, "models": models, @@ -145,8 +145,8 @@ func gooseProviderJSON() map[string]any { } // writeGooseProvider writes magpie's custom provider file for Goose. -func writeGooseProvider(path string) error { - b, err := json.MarshalIndent(gooseProviderJSON(), "", " ") +func writeGooseProvider(path string, at place) error { + b, err := json.MarshalIndent(gooseProviderJSON(at), "", " ") if err != nil { return err } @@ -158,15 +158,15 @@ func writeGooseProvider(path string) error { // syncGooseProvider rewrites magpie's custom provider file for Goose with // the catalog as it is now, where magpie wrote one and it differs. -func syncGooseProvider(path string) error { +func syncGooseProvider(path string, at place) error { raw, err := edit.Read(path) if err != nil || raw == nil { return nil } - if sameJSON(string(raw), gooseProviderJSON()) { + if sameJSON(string(raw), gooseProviderJSON(at)) { return nil } - return writeGooseProvider(path) + return writeGooseProvider(path, at) } // removeGooseProvider takes magpie's custom provider file away once Goose's diff --git a/internal/agent/reasonix.go b/internal/agent/reasonix.go index 0ceb865b..c20e34e7 100644 --- a/internal/agent/reasonix.go +++ b/internal/agent/reasonix.go @@ -21,7 +21,6 @@ import ( "github.com/pelletier/go-toml/v2" "github.com/yetone/magpie/internal/catalog" "github.com/yetone/magpie/internal/edit" - "github.com/yetone/magpie/internal/gateway" "github.com/yetone/magpie/internal/provider" ) @@ -78,8 +77,19 @@ type reasonixState struct { AddedAccess bool `json:"added_access"` } -func reasonix(home string) *Agent { - dir := reasonixHome(runtime.GOOS, home, os.Getenv) +func reasonix(home string) *Agent { return reasonixIn(here(home)) } + +// reasonixIn is Reasonix at a place: this machine's home, or a WSL +// distro's (see wsl.go), where its CLI keeps Linux's ~/.reasonix +// (REASONIX_HOME isn't read) and its provider names the gateway as the +// distro reaches it, with the key it takes from there. +func reasonixIn(at place) *Agent { + goos, getenv := runtime.GOOS, os.Getenv + if at.spell != nil { + goos, getenv = "linux", func(string) string { return "" } + } + dir := reasonixHome(goos, at.home, getenv) + gwKey := func() string { return agentKeyAt("reasonix", at.gw()) } if absolute, err := filepath.Abs(dir); err == nil { dir = absolute } @@ -203,7 +213,7 @@ func reasonix(home string) *Agent { return err } } - if v, exists := edit.GetEnvFile(env, reasonixKey); exists && v == gateway.TokenFor("reasonix") { + if v, exists := edit.GetEnvFile(env, reasonixKey); exists && v == gwKey() { if err := edit.DelEnvFile(env, reasonixKey); err != nil { return err } @@ -240,7 +250,7 @@ func reasonix(home string) *Agent { currentState = &reasonixState{Version: 1, Default: cfg.DefaultModel, NewConfig: os.IsNotExist(cErr), NewEnv: os.IsNotExist(eErr)} currentState.AddedAccess = cfg.Desktop.Access != nil && !slices.Contains(cfg.Desktop.Access, magpieID) } - p := reasonixCatalog(models, selected, cfg.magpie().Effort) + p := reasonixCatalogAt(models, selected, cfg.magpie().Effort, at.v1()) if err := writeProvider(p); err != nil { return err } @@ -251,8 +261,8 @@ func reasonix(home string) *Agent { } currentState.AddedAccess = true } - if token, _ := edit.GetEnvFile(env, reasonixKey); token != gateway.TokenFor("reasonix") { - if err := edit.SetEnvFile(env, edit.KV{Path: reasonixKey, Value: gateway.TokenFor("reasonix")}); err != nil { + if token, _ := edit.GetEnvFile(env, reasonixKey); token != gwKey() { + if err := edit.SetEnvFile(env, edit.KV{Path: reasonixKey, Value: gwKey()}); err != nil { return err } } @@ -289,7 +299,7 @@ func reasonix(home string) *Agent { a := &Agent{ ID: "reasonix", Name: "Reasonix Studio", Icon: "reasonix-color", Aliases: []string{"reasonix-studio"}, Bin: "reasonix", Dir: dir, Path: path, UA: []string{"reasonix"}, - detect: func() bool { return reasonixDetected(home) }, + detect: func() bool { return reasonixDetected(at.home) }, Notice: func() string { return "Reasonix Studio 2.x and the native 1.39.x/2.x CLI read global settings at startup. Start a new process to load changes. Project/session models can override Executor and Plan." }, @@ -318,10 +328,10 @@ func reasonix(home string) *Agent { if err := checkOwner(cfg, state); err != nil { return err.Error() } - if p.Kind != "openai" || p.BaseURL != gatewayV1() { + if p.Kind != "openai" || p.BaseURL != at.v1() { return "Reasonix's magpie base_url or kind changed" } - if token, _ := edit.GetEnvFile(env, reasonixKey); token != gateway.TokenFor("reasonix") { + if token, _ := edit.GetEnvFile(env, reasonixKey); token != gwKey() { return "Reasonix's gateway credential changed" } if !slices.Contains(p.Models, ref) || cfg.Agent.Planner != nil && usesMagpie(*cfg.Agent.Planner) && !slices.Contains(p.Models, strings.TrimPrefix(*cfg.Agent.Planner, magpieID+"/")) { @@ -351,7 +361,7 @@ func reasonix(home string) *Agent { if len(models) == 0 { return fmt.Errorf("magpie has no visible Reasonix models") } - next := reasonixCatalog(models, current.Default, current.Effort) + next := reasonixCatalogAt(models, current.Default, current.Effort, at.v1()) if reflect.DeepEqual(current, next) { return nil } @@ -618,7 +628,13 @@ func (cfg reasonixConfig) hasModel(ref string) bool { } func reasonixCatalog(models []catalog.Model, selected, effort string) reasonixProvider { - p := reasonixProvider{Name: magpieID, Kind: "openai", BaseURL: gatewayV1(), APIKeyEnv: reasonixKey, + return reasonixCatalogAt(models, selected, effort, gatewayV1()) +} + +// reasonixCatalogAt is reasonixCatalog for a Reasonix reaching the +// gateway's /v1 at v1. +func reasonixCatalogAt(models []catalog.Model, selected, effort, v1 string) reasonixProvider { + p := reasonixProvider{Name: magpieID, Kind: "openai", BaseURL: v1, APIKeyEnv: reasonixKey, Default: selected, Protocol: "openai", NoProxy: true, Overrides: map[string]reasonixModel{}} for _, m := range models { p.Models = append(p.Models, m.ID) diff --git a/internal/agent/workbuddy.go b/internal/agent/workbuddy.go index 66661cae..1ae9853b 100644 --- a/internal/agent/workbuddy.go +++ b/internal/agent/workbuddy.go @@ -125,7 +125,15 @@ func workbuddyWrite(path string, on bool) error { return buddyWrite(path, "workb // WorkBuddy, or CodeBuddy Code (codebuddy.go), whose models go under the // agent's own key. A new file is a bare list for WorkBuddy, as it writes // one, and {"models":[…]} for CodeBuddy Code, as its docs give it. -func buddyWrite(path, agent string, on bool) error { +func buddyWrite(path, agent string, on bool) error { return buddyWriteAt(path, agent, on, place{}) } + +// buddyWriteAt is buddyWrite for the agent at a place. One in a WSL distro +// (where.base set) is written the gateway as the distro reaches it, with the +// key it takes from there, at every write: its address is the distro's +// view of Windows, which moves, so it is never kept as a magpie of the +// user's on another machine. +func buddyWriteAt(path, agent string, on bool, where place) error { + away := where.base != nil d, err := workbuddyRead(path) if err != nil { return err @@ -136,6 +144,9 @@ func buddyWrite(path, agent string, on bool) error { // the keys magpie's models are asked with: the gateway's, or that of a // magpie on another machine the user pointed them at keys := map[string]bool{gateway.TokenFor(agent): true} + if away { + keys[agentKeyAt(agent, where.gw())] = true + } for _, raw := range d.models { if e, ok := workbuddyMine(raw); ok && e.APIKey != "" { keys[e.APIKey] = true @@ -159,7 +170,7 @@ func buddyWrite(path, agent string, on bool) error { if at < 0 { at = len(kept) } - if remote == "" && onAnotherMachine(e.URL) { + if !away && remote == "" && onAnotherMachine(e.URL) { remote, remoteKey = e.URL, e.APIKey } ours = append(ours, e.ID) @@ -195,6 +206,9 @@ func buddyWrite(path, agent string, on bool) error { } } } + if away { + e["url"], e["apiKey"] = where.v1()+"/chat/completions", agentKeyAt(agent, where.gw()) + } if remote != "" { e["url"] = remote if remoteKey != "" { diff --git a/internal/agent/wsl.go b/internal/agent/wsl.go index 481b5d99..882d407b 100644 --- a/internal/agent/wsl.go +++ b/internal/agent/wsl.go @@ -204,12 +204,17 @@ func (d distro) place(id string) place { // of it, and how it lives at a place. Adding one here is all it takes. type wslKind struct { id, name string - dir, bin string // under $HOME, and on its PATH: either says it is there + // dir, under $HOME, and bin, on its PATH: either says it is there; "" + // for one that says nothing (a folder other tools keep too) + dir, bin string in func(place) *Agent // the agent at a place, as on this machine restart string // advice after a change, when mirrored; "" none // version: the probe asks the distro's own bin its version (--version), // for an agent whose config depends on it version bool + // accept, when set, is whether what the probe found is the agent: a + // command another tool's release shares the name of, told by its version + accept func(d distro) bool // asleep, when set, is a stopped distro's options for a field, which // must read nothing of its files; nil keeps the live agent's asleep func(key string) func(map[string]string) []Option @@ -371,6 +376,86 @@ var wslKinds = []wslKind{ restart: "reads its settings as a session starts — open sessions keep the model they have; new ones use this."}, {id: "qoder-cn", name: "Qoder CN", dir: ".qoder-cn", bin: "qoderclicn", in: qoderCNIn, restart: "reads its settings as a session starts — open sessions keep the model they have; new ones use this."}, + // lgtm on Discord: magpie didn't find a CodeBuddy Code installed in WSL + {id: "codebuddy", name: "CodeBuddy Code", dir: ".codebuddy", bin: "codebuddy", in: codebuddyIn, + restart: "reads its model when a session starts — restart open codebuddy sessions, or run /clear in them, to use this."}, + // Cline's CLI, and its VS Code extension in a Remote - WSL window, which + // keep the same ~/.cline + {id: "cline", name: "Cline", dir: ".cline", bin: "cline", in: clineIn, + restart: "reads its provider as a session starts — open sessions keep the model they have; new ones use this. Reload VS Code's window for its extension.", + asleep: wslOwnAsleep("cline", "model")}, + {id: "atomcode", name: "AtomCode", dir: ".atomcode", bin: "atomcode", in: atomcodeIn, + restart: "reads its settings at start-up — restart open atomcode sessions to use this.", + asleep: func(key string) func(map[string]string) []Option { + if key != "model" { + return nil + } + // the models of its config.toml are the distro's files + return func(cur map[string]string) []Option { + return append(atomcodeOwnModels("", cur["model"]), viaMagpie("atomcode", magpieID+"/")...) + } + }}, + // no dir: Antigravity keeps its folders in ~/.gemini too, so only the + // command says Gemini CLI is there + {id: "gemini", name: "Gemini CLI", bin: "gemini", in: geminiIn, + restart: "reads its settings at start-up — restart open gemini sessions to see this.", + asleep: func(key string) func(map[string]string) []Option { + if key != "provider" { + return nil + } + // which key its .env has is the distro's file + return func(cur map[string]string) []Option { + out := []Option{ + {Value: "google", Label: "Google", Icon: "gemini-color", Note: "Google account · OAuth sign-in"}, + {Value: "api-key", Label: "API key", Icon: "gemini-color"}, + {Value: "vertex", Label: "Vertex AI", Icon: "googlecloud-color", Note: "Vertex AI · $GOOGLE_CLOUD_PROJECT"}, + } + if v := cur["provider"]; v == magpieID || v == "custom" { + out = append(out, Option{Value: v}) + } + return out + } + }}, + // Block's goose, by its folder alone: a goose command may be pressly's + // database migration tool + {id: "goose", name: "Goose", dir: ".config/goose", in: func(at place) *Agent { return gooseIn(at, filepath.Join(at.home, ".config")) }, + restart: "loads its providers at start-up — restart open goose sessions to use magpie's models.", + asleep: wslOwnAsleep("goose", "model")}, + // Reasonix's native CLI (1.39 on), by its command and the version it + // gives: the historical npm client is reasonix too, and ~/.reasonix is + // both's + {id: "reasonix", name: "Reasonix", bin: "reasonix", in: reasonixIn, version: true, + accept: func(d distro) bool { + v := d.Versions["reasonix"] + return strings.HasPrefix(v, "2.") || strings.HasPrefix(v, "1.39.") + }, + restart: "reads its settings at start-up — start a new reasonix process to use this.", + asleep: func(key string) func(map[string]string) []Option { + switch key { + case "model", "planner": + return func(cur map[string]string) []Option { + out := append(ownOptions("", cur[key]), viaMagpie("reasonix", magpieID+"/")...) + if key == "planner" { + out = append([]Option{{Value: "off", Label: "off", Note: "Disable the separate planner"}}, out...) + } + return out + } + case "effort": + // the levels of magpie's model, as its provider is written them + return func(cur map[string]string) []Option { + ref, ok := strings.CutPrefix(cur["model"], magpieID+"/") + if !ok { + return nil + } + levels := reasonixCatalogAt(magpieModels("reasonix"), ref, "", "").Overrides[ref].Efforts + if len(levels) == 0 { + return nil + } + return static(append([]string{"auto"}, levels...)...) + } + } + return nil + }}, } // wslOwnAsleep is a stopped distro's options for an agent's model fields @@ -389,7 +474,10 @@ func wslOwnAsleep(id string, keys ...string) func(string) func(map[string]string // found reports whether the probe found the agent in d. func (k wslKind) found(d distro) bool { - return d.Has["dir:"+k.dir] || k.bin != "" && d.Has["bin:"+k.bin] + if k.accept != nil && !k.accept(d) { + return false + } + return k.dir != "" && d.Has["dir:"+k.dir] || k.bin != "" && d.Has["bin:"+k.bin] } // memo is the key a field of the agent is kept under in distro.Values: @@ -903,7 +991,9 @@ var wslProbeScript = func() string { s := `wslbin() { for d in "$HOME/.bun/bin" "$HOME/.local/bin" "$HOME/.npm-global/bin" "$HOME/.volta/bin" "$HOME/.local/share/pnpm" "$HOME"/.nvm/versions/node/*/bin "$HOME/.local/share/mise/shims" "$HOME/.asdf/shims"; do [ -x "$d/$1" ] && { echo "$d/$1"; return 0; }; done; return 1; }; ` s += `echo "home:$HOME"; ` for _, k := range wslKinds { - s += `[ -d "$HOME/` + k.dir + `" ] && echo dir:` + k.dir + `; ` + if k.dir != "" { + s += `[ -d "$HOME/` + k.dir + `" ] && echo dir:` + k.dir + `; ` + } if k.bin != "" { s += `p=$(command -v ` + k.bin + ` 2>/dev/null || wslbin ` + k.bin + `) && echo "bin:` + k.bin + ` $p"` if k.version { diff --git a/internal/agent/wsl_more_test.go b/internal/agent/wsl_more_test.go index 6f517564..ce07d9dd 100644 --- a/internal/agent/wsl_more_test.go +++ b/internal/agent/wsl_more_test.go @@ -34,6 +34,29 @@ var wslMore = []struct { {"muse", ".config/muse/settings.json", "/v1"}, {"qoder", ".qoder/settings.json", "/v1"}, {"qoder-cn", ".qoder-cn/settings.json", "/v1"}, + // lgtm on Discord: a CodeBuddy Code in WSL wasn't found, nor were the + // other CLIs magpie set up only on this machine + {"codebuddy", ".codebuddy/models.json", "/v1/chat/completions"}, + {"cline", ".cline/data/settings/providers.json", "/v1"}, + {"atomcode", ".atomcode/config.toml", "/v1"}, + {"goose", ".config/goose/custom_providers/magpie.json", "/v1"}, + {"reasonix", ".reasonix/config.toml", "/v1"}, +} + +// wslFinds is what a probe of a distro with the agent of kind k prints, +// and the distro it makes: its folder, or for one found by its command +// alone, the command and the version it gives. +func wslFinds(k wslKind) (probe string, has map[string]bool, versions map[string]string) { + if k.dir != "" { + return "dir:" + k.dir + "\n", map[string]bool{"dir:" + k.dir: true}, nil + } + probe = "bin:" + k.bin + " /home/me/.local/bin/" + k.bin + "\n" + has = map[string]bool{"bin:" + k.bin: true} + if k.version { + probe += "ver:" + k.id + " " + k.bin + " v2.1.0\n" + versions = map[string]string{k.id: "2.1.0"} + } + return probe, has, versions } // moveHostDirs points the variables that move these agents' folders on @@ -43,7 +66,8 @@ func moveHostDirs(t *testing.T) string { t.Helper() moved := t.TempDir() for _, v := range []string{"OPENCODE_CONFIG_DIR", "MIMOCODE_HOME", "KIMI_CODE_HOME", "KIMI_SHARE_DIR", "PI_CODING_AGENT_DIR", - "HERMES_HOME", "GROK_HOME", "FACTORY_HOME_OVERRIDE", "MINIMAX_DATA_DIR", "QODER_CONFIG_DIR", "QODERCN_CONFIG_DIR", "DSH_HOME", "MISTER_MORPH_CONFIG"} { + "HERMES_HOME", "GROK_HOME", "FACTORY_HOME_OVERRIDE", "MINIMAX_DATA_DIR", "QODER_CONFIG_DIR", "QODERCN_CONFIG_DIR", "DSH_HOME", "MISTER_MORPH_CONFIG", + "CODEBUDDY_CONFIG_DIR", "CLINE_DIR", "CLINE_DATA_DIR", "ATOMCODE_HOME", "REASONIX_HOME", "APPDATA"} { t.Setenv(v, filepath.Join(moved, v)) } return moved @@ -55,25 +79,27 @@ func moveHostDirs(t *testing.T) string { func TestWSLProbeFindsMoreAgents(t *testing.T) { for _, c := range wslMore { k := wslKindOf(c.id) - if !strings.Contains(wslProbeScript, `[ -d "$HOME/`+k.dir+`" ] && echo dir:`+k.dir+`; `) { + if k.dir != "" && !strings.Contains(wslProbeScript, `[ -d "$HOME/`+k.dir+`" ] && echo dir:`+k.dir+`; `) { t.Errorf("%s: probe lacks its folder %s", c.id, k.dir) } - if !strings.HasPrefix(c.file, k.dir+"/") { + if k.dir != "" && !strings.HasPrefix(c.file, k.dir+"/") { t.Errorf("%s: its file %s isn't under %s", c.id, c.file, k.dir) } if k.bin != "" && !strings.Contains(wslProbeScript, `command -v `+k.bin+` `) { t.Errorf("%s: probe lacks its command %s", c.id, k.bin) } - if d := parseProbe("U", "home:/home/me\ndir:"+k.dir+"\n"); d == nil || !k.found(*d) { - t.Errorf("%s: not found by its folder", c.id) + if p, _, _ := wslFinds(k); k.dir != "" || k.version { + if d := parseProbe("U", "home:/home/me\n"+p); d == nil || !k.found(*d) { + t.Errorf("%s: not found by %q", c.id, p) + } } - if k.bin != "" { + if k.bin != "" && !k.version { if d := parseProbe("U", "home:/home/me\nbin:"+k.bin+" /usr/bin/"+k.bin+"\n"); d == nil || !k.found(*d) { t.Errorf("%s: not found by its command", c.id) } } } - for _, bin := range []string{"fx", "grok"} { + for _, bin := range []string{"fx", "grok", "goose"} { if strings.Contains(wslProbeScript, `command -v `+bin+` `) { t.Errorf("probe takes any %s command for the agent", bin) } @@ -92,7 +118,8 @@ func TestWSLMoreAgentsDiscovered(t *testing.T) { probe := "home:/home/me\nroute:default via 172.20.0.1 dev eth0\n" var want []string for _, c := range wslMore { - probe += "dir:" + wslKindOf(c.id).dir + "\n" + p, _, _ := wslFinds(wslKindOf(c.id)) + probe += p want = append(want, c.id+"@wsl:Ubuntu") } _, opened := fakeWSL(t, "Ubuntu\r\n", "Ubuntu\r\n", map[string]string{"Ubuntu": probe}, map[string]string{"Ubuntu": root}) @@ -128,8 +155,11 @@ func TestWSLMoreAgentsPick(t *testing.T) { root := t.TempDir() dhome := filepath.Join(root, "home", "me") k := wslKindOf(c.id) - os.MkdirAll(filepath.Join(dhome, filepath.FromSlash(k.dir)), 0o755) - d := distro{Name: "Ubuntu", Root: root, Home: "/home/me", Has: map[string]bool{"dir:" + k.dir: true}, + if k.dir != "" { + os.MkdirAll(filepath.Join(dhome, filepath.FromSlash(k.dir)), 0o755) + } + _, has, versions := wslFinds(k) + d := distro{Name: "Ubuntu", Root: root, Home: "/home/me", Has: has, Versions: versions, Gateway: "172.20.0.1", Mirrored: mirrored, Running: true} a := wslAgent(k, d) if err := a.Field("model").Set("magpie/relay/glm-4.6"); err != nil { @@ -150,8 +180,10 @@ func TestWSLMoreAgentsPick(t *testing.T) { if v := a.Field("model").Get(); !usesMagpie(v) { t.Fatalf("%s: model %q", c.id, v) } - if msg := a.Check(); msg != "" { - t.Fatalf("%s mirrored %v: %s", c.id, mirrored, msg) + if a.Check != nil { + if msg := a.Check(); msg != "" { + t.Fatalf("%s mirrored %v: %s", c.id, mirrored, msg) + } } if a.Sync != nil { if err := a.Sync(); err != nil || !strings.Contains(readFile(path), gw+c.suffix) { @@ -164,7 +196,11 @@ func TestWSLMoreAgentsPick(t *testing.T) { if mirrored && !strings.HasPrefix(a.Notice(), k.name+" in WSL Ubuntu ") { t.Errorf("%s: %s", c.id, a.Notice()) } - for _, dir := range []string{filepath.Join(home, filepath.FromSlash(k.dir)), moved} { + own := k.dir + if own == "" { + own, _, _ = strings.Cut(c.file, "/") + } + for _, dir := range []string{filepath.Join(home, filepath.FromSlash(own)), moved} { if es, _ := os.ReadDir(dir); len(es) > 0 { t.Fatalf("%s: this machine's %s was touched", c.id, dir) } @@ -181,8 +217,9 @@ func TestWSLMoreAgentsStopped(t *testing.T) { syncHome(t) root := t.TempDir() k := wslKindOf(c.id) + _, has, versions := wslFinds(k) b, _ := json.Marshal(map[string]*distro{ - "Stopped": {Name: "Stopped", Home: "/home/me", Root: root, Has: map[string]bool{"dir:" + k.dir: true}, + "Stopped": {Name: "Stopped", Home: "/home/me", Root: root, Has: has, Versions: versions, Probe: wslProbeVersion, Values: map[string]string{k.memo("model"): "own/last-seen"}}, }) os.MkdirAll(filepath.Dir(wslStatePath()), 0o755) @@ -319,3 +356,130 @@ func TestWSLDshProfiles(t *testing.T) { } } } + +// Gemini CLI in a distro is found by its command alone (Antigravity keeps +// ~/.gemini too), and a magpie model points the distro's ~/.gemini/.env at +// the gateway as the distro reaches it, with the key it takes from there; +// a model of its own puts back what that replaced. +func TestWSLGemini(t *testing.T) { + home := syncHome(t) + k := wslKindOf("gemini") + if strings.Contains(wslProbeScript, `"$HOME/" ]`) { + t.Fatal("the probe asks after $HOME itself") + } + if d := parseProbe("U", "home:/home/me\ndir:.gemini\n"); d != nil && k.found(*d) { + t.Fatal("found by a ~/.gemini Antigravity keeps too") + } + if d := parseProbe("U", "home:/home/me\nbin:gemini /home/me/.npm-global/bin/gemini\n"); d == nil || !k.found(*d) { + t.Fatal("not found by its command") + } + root := t.TempDir() + dhome := filepath.Join(root, "home", "me") + env := filepath.Join(dhome, ".gemini", ".env") + os.MkdirAll(filepath.Dir(env), 0o755) + os.WriteFile(env, []byte("GEMINI_API_KEY=mine\n"), 0o600) + d := distro{Name: "Ubuntu", Root: root, Home: "/home/me", Has: map[string]bool{"bin:gemini": true}, + Gateway: "172.20.0.1", Running: true} + a := wslAgent(k, d) + if !a.Detected() { + t.Fatal("not detected") + } + if err := a.Field("model").Set("relay/glm-4.6"); err != nil { + t.Fatal(err) + } + gw := "http://172.20.0.1:" + gateway.Port() + if v, _ := edit.GetEnvFile(env, "GOOGLE_GEMINI_BASE_URL"); v != gw { + t.Fatalf(".env:\n%s", readFile(env)) + } + if v, _ := edit.GetJSON(filepath.Join(dhome, ".gemini", "settings.json"), "model.name"); v != "relay/glm-4.6" { + t.Fatalf("model %q", v) + } + if msg := a.Check(); msg != "" { + t.Fatal(msg) + } + if v := a.Field("provider").Get(); v != magpieID { + t.Fatalf("auth %q", v) + } + if err := a.Field("model").Set("gemini-2.5-pro"); err != nil { + t.Fatal(err) + } + if v, _ := edit.GetEnvFile(env, "GEMINI_API_KEY"); v != "mine" { + t.Fatalf("the key of its own isn't back:\n%s", readFile(env)) + } + if _, set := edit.GetEnvFile(env, "GOOGLE_GEMINI_BASE_URL"); set { + t.Fatalf(".env:\n%s", readFile(env)) + } + if es, _ := os.ReadDir(filepath.Join(home, ".gemini")); len(es) > 0 { + t.Fatal("this machine's ~/.gemini was touched") + } +} + +// A distro's address under NAT changes when WSL restarts: the next sync +// names the gateway at the new one, and the old is gone from the file. +func TestWSLMoreAgentsFollowTheAddress(t *testing.T) { + for _, c := range wslMore { + syncHome(t) + moveHostDirs(t) + root := t.TempDir() + k := wslKindOf(c.id) + if k.dir != "" { + os.MkdirAll(filepath.Join(root, "home", "me", filepath.FromSlash(k.dir)), 0o755) + } + _, has, versions := wslFinds(k) + d := distro{Name: "Ubuntu", Root: root, Home: "/home/me", Has: has, Versions: versions, Gateway: "172.20.0.1", Running: true} + if err := wslAgent(k, d).Field("model").Set("magpie/relay/glm-4.6"); err != nil { + t.Fatalf("%s: %v", c.id, err) + } + d.Gateway = "172.29.0.1" + a := wslAgent(k, d) + if a.Sync == nil { + continue + } + if err := a.Sync(); err != nil { + t.Fatalf("%s: %v", c.id, err) + } + body := readFile(filepath.Join(root, "home", "me", filepath.FromSlash(c.file))) + if !strings.Contains(body, "http://172.29.0.1:"+gateway.Port()+c.suffix) || strings.Contains(body, "172.20.0.1") { + t.Errorf("%s: %s after the address moved:\n%s", c.id, c.file, body) + } + } +} + +// Gemini CLI in a distro whose address moved is on magpie still, and +// picking its model again names the new address without taking the old +// one for the user's own: a model of its own then puts back their key. +func TestWSLGeminiAddressMoved(t *testing.T) { + syncHome(t) + k := wslKindOf("gemini") + root := t.TempDir() + env := filepath.Join(root, "home", "me", ".gemini", ".env") + writeFile(t, env, "GEMINI_API_KEY=mine\n") + d := distro{Name: "Ubuntu", Root: root, Home: "/home/me", Has: map[string]bool{"bin:gemini": true}, + Gateway: "172.20.0.1", Running: true} + if err := wslAgent(k, d).Field("model").Set("relay/glm-4.6"); err != nil { + t.Fatal(err) + } + d.Gateway = "172.29.0.1" + a := wslAgent(k, d) + if v := a.Field("provider").Get(); v != magpieID { + t.Fatalf("auth %q", v) + } + if a.Check() == "" { + t.Fatal("the old address passes") + } + if err := a.Field("model").Set("relay/glm-4.6"); err != nil { + t.Fatal(err) + } + if v, _ := edit.GetEnvFile(env, "GOOGLE_GEMINI_BASE_URL"); v != "http://172.29.0.1:"+gateway.Port() { + t.Fatalf(".env:\n%s", readFile(env)) + } + if msg := a.Check(); msg != "" { + t.Fatal(msg) + } + if err := a.Field("model").Set("gemini-2.5-pro"); err != nil { + t.Fatal(err) + } + if body := readFile(env); body != "GEMINI_API_KEY=mine\n" { + t.Fatalf(".env:\n%s", body) + } +}