diff --git a/docs/reference.md b/docs/reference.md index 22bc8444..cf8fe62a 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -109,6 +109,7 @@ line; agents connected to magpie lose it when it quits. | Kimi Code | `~/.kimi/config.toml` (`$KIMI_SHARE_DIR`) | model (a `magpie` provider; magpie's models in Kimi's /model) | | Muse Code | `~/.config/muse/settings.json` (`$XDG_CONFIG_HOME`) | model (endpoint_transport to the gateway, auth none; magpie's models in Muse's list) | | Empryo | `~/.empryo/config.json` | defaultModel (a `magpie` provider at the gateway in `providers`) | +| Ante | `~/.ante/catalog.json` and `settings.json` (`$ANTE_HOME`) | provider, model (a `magpie` provider on OpenAiCompatible, with no auth of magpie's: the gateway lets this machine in with any token and Ante counts a provider with none as authenticated; magpie's models in Ante's model picker, and Ante started on magpie, which is what its settings' `provider` decides) | | MiniMax Code (mcode) | `~/.minimax/config.yaml` (`$MINIMAX_DATA_DIR`) | model (a `magpie` custom provider; magpie's models in its /model) | | Droid (Factory) | `~/.factory/settings.json` (`$FACTORY_HOME_OVERRIDE`) | model (magpie's models as BYOK `customModels`, in Droid's /model) | | Cline (CLI) | `~/.cline/data/settings/providers.json` (`$CLINE_DIR`) | model, effort (magpie takes its openai-compatible provider) | diff --git a/docs/subsystems/agent-wiring.md b/docs/subsystems/agent-wiring.md index d3113713..953f072c 100644 --- a/docs/subsystems/agent-wiring.md +++ b/docs/subsystems/agent-wiring.md @@ -35,6 +35,7 @@ something else shows up as drift. - magpie only removes what it wrote. Anything else in an agent's config stays as it was. - JetBrains Air's `magpie-opencode.json` keeps user-added fields in `provider.magpie`, including nested options, on sync and model picks (`air` in [`air.go`](../../internal/agent/air.go), using `theirsKept` in [`sync.go`](../../internal/agent/sync.go)). The gateway fields and complete model list are still replaced, so removed models leave the list. +- Ante's `catalog.json` keeps user-added fields in `providers.magpie` the same way (`ante` in [`ante.go`](../../internal/agent/ante.go)); its `auth`, `http_headers`, `extra_body` and `stream_idle_timeout_secs` are what a gateway shared on the local network or reached from WSL needs to send a key. Ante starts a session on the `provider` and `model` of `settings.json`, not on what its catalog holds (#1210), so magpie sets that pair and puts back what it found there; a pick the user makes in Ante while magpie is wired is theirs and stands. - JSON/JSONC config edits and their previews reject malformed input rather than editing a partially parsed document. Object edits and VS Code's model-group array edits leave the original file untouched on a parse error; valid comments and trailing commas remain supported (`internal/edit`). - Codex signed in to ChatGPT stays beside its sign-in (`openai_base_url`) unless the user picks magpie API. magpie becomes its provider only while the Codex app holds the active account, which it sends nothing for: `/wham/usage` says it isn't allowed, and it has no credits and isn't a workspace still within its overage (`codexHeld`, read by `provider.CodexUsedUp`). Where the app goes by what usage can't show (an experiment's gate, a reserve, a Team plan's overage under the reserve experiment), it counts as held: a wrong move costs ChatGPT extras, a missed one every turn. A saved reading kept through a failed read stops saying held once its used-up window has started again; a hold with no window used up stays till a fresh read. The app then loses its ChatGPT state (durable threads, remote control), so a window at 100% alone, which credits get past, doesn't move it (`codex.out` marks the move, and Sync undoes it once the account has room). - Moving a field from one of magpie's models to another (the agent's own picker) is not drift. Moving it off magpie is. @@ -55,5 +56,6 @@ something else shows up as drift. go test -tags nogui ./internal/agent -run 'TestConnect|TestDisconnect|Drift|TestKeptAddress|TestWSLAgentAddress|TestReachProbe|TestDryRunKnowsTheAskersProviders|TestReseat|TestPort|TestWSL|TestClaudeKeepsItsClaudeAISignIn|TestClaudeSignIn|TestClaudeOwnEndpointWithAnEmptyKey|TestClaudeAtAnOlderGatewayAddress' go test -tags nogui ./internal/agent go test -tags nogui ./internal/agent -run '^TestAir' +go test -tags nogui ./internal/agent -run '^TestAnte' node --test internal/gui/tests/agent-connect.test.cjs internal/gui/tests/agent-disconnect-preview.test.cjs internal/gui/tests/claude-signin.test.cjs ``` diff --git a/internal/agent/agents.go b/internal/agent/agents.go index a5e9d5a2..c55c0658 100644 --- a/internal/agent/agents.go +++ b/internal/agent/agents.go @@ -98,6 +98,7 @@ func All() []*Agent { kimi(home), muse(cfg), empryo(home), + ante(home), miniMax(home), droid(home), cline(home), diff --git a/internal/agent/ante.go b/internal/agent/ante.go new file mode 100644 index 00000000..58efe052 --- /dev/null +++ b/internal/agent/ante.go @@ -0,0 +1,411 @@ +package agent + +// Ante (antigma.ai) takes a provider of one's own in ~/.ante/catalog.json +// (ANTE_HOME moves the folder; the catalog is always $ANTE_HOME/catalog.json), +// merged on top of the providers it ships (docs.antigma.ai, +// reference/catalog-reference): +// +// {"providers":{"":{"display_name":…,"base_url":…, +// "wire_style":"OpenAiCompatible","auth":{"bearer":{"env_key":…}}, +// "http_headers":{…},"extra_body":{…}, +// "preferred_models":[{"id":…,"display_name":…,"context_limit":…, +// "max_tokens":…,"effort":…,"supported_efforts":[…], +// "support_vision":…}]}}, +// "models":{…}} +// +// The providers map's key is what --provider takes; an id inside an entry is +// ignored. magpie adds the entry "magpie" at the gateway's /v1, on +// OpenAiCompatible — the wire style Ante speaks Chat Completions with, which +// is what the gateway takes every model on — and lists magpie's models in its +// preferred_models, Ante's model picker. The user's own providers and the +// models section are left as they are, and so is everything the user added +// inside magpie's own entry (an auth block, http_headers, extra_body, +// stream_idle_timeout_secs), which is what a gateway shared over the local +// network or reached from WSL needs to send a key (#1103, theirsKept). +// +// No auth of magpie's own: the gateway listens on loopback and lets any token +// in from this machine (gateway.Token's comment, lan.go's identifyCaller), and +// Ante counts a provider with no auth block as authenticated ("Providers with +// no `auth` block are always authenticated", catalog-reference). Writing a +// bearer env_key instead would mean magpie putting a key into Ante's +// environment, which the user would have to do themselves — the same decision +// Empryo's entry makes, which sends no key of the user's either. +// +// Being in the catalog is not enough for Ante to use it. Which provider a +// session starts on is the `provider` and `model` of ~/.ante/settings.json: +// "Explicit --provider and --model flags override the provider and model +// values in ~/.ante/settings.json. When neither source specifies a choice, +// Ante uses the auto-detection rules below… it uses the first authenticated +// provider in catalog order" (catalog-reference). Providers it ships come +// first, so magpie's is one auto-detection never picks while the user has any +// other signed in, and a user who set nothing is on Ante's own provider. So +// magpie sets those two as well and saves the pair it found there, which is +// what steps out puts back — the same pair Empryo's defaultModel and MiniMax +// Code's defaultModel are. A pick the user makes in Ante while magpie is +// wired is theirs and stands; magpie hands them what they moved off, not the +// one it read when it wired in. +// +// Ante reads the catalog once per process (restart to pick an edit up) and +// parses it leniently: an entry that doesn't deserialize is skipped with a +// notice and the rest still load, so a bad file magpie wrote costs the user +// magpie's provider, not their own. + +import ( + "path/filepath" + "slices" + "strings" + + "github.com/tidwall/gjson" + + "github.com/yetone/magpie/internal/catalog" + "github.com/yetone/magpie/internal/edit" + "github.com/yetone/magpie/internal/provider" +) + +const ( + anteProviders = "providers" + anteProvider = anteProviders + "." + magpieID + // anteProviderKey and anteModelKey are the two settings.json keys that + // decide which provider a session starts on (catalog-reference, "How a + // model and provider are chosen"). + anteProviderKey = "provider" + anteModelKey = "model" +) + +// anteDir is the folder Ante keeps catalog.json and settings.json in: +// ANTE_HOME, else ~/.ante (catalog-reference: "~/.ante can be relocated with +// the ANTE_HOME environment variable; the catalog is always +// $ANTE_HOME/catalog.json"). A distro's ANTE_HOME isn't one magpie can read, +// so there it is ~/.ante, as MiniMax Code's mcode (place.getenv). +func anteDir(at place) string { + if d := strings.TrimSpace(at.getenv("ANTE_HOME")); d != "" { + return d + } + return filepath.Join(at.home, ".ante") +} + +func ante(home string) *Agent { return anteAt(here(home)) } + +// anteIn is Ante in a WSL distro (see wsl.go). +func anteIn(at place) *Agent { return anteAt(at) } + +// anteAt is Ante with its home at at.home, reaching the gateway as at does. +func anteAt(at place) *Agent { + gatewayV1 := at.v1 + dir := anteDir(at) + path := filepath.Join(dir, "catalog.json") + settingsPath := filepath.Join(dir, "settings.json") + // keyNew marks a file magpie made, which goes again once magpie's own + // keys are taken out of it and nothing is left + keyNew := "ante:" + path + ":new" + keyNewSettings := "ante:" + settingsPath + ":new" + // the provider and model Ante starts on, stashed for stepping out + keyProvider := at.key("ante.provider") + keyModel := at.key("ante.model") + // wired: magpie's provider is in the catalog + wired := func() bool { _, ok := edit.GetJSON(path, anteProvider); return ok } + get := func(k string) string { v, _ := edit.GetJSON(settingsPath, k); return v } + set := func(k, v string) error { + if v == "" { + return edit.DelJSON(settingsPath, k) + } + return edit.SetJSON(settingsPath, edit.KV{Path: k, Value: v}) + } + // entry is magpie's catalog entry, the user's own keys in it kept and + // preferred_models — magpie's list alone — replaced + entry := theirsKept(path, anteProvider, func() any { return anteProviderJSON(gatewayV1()) }, "preferred_models") + // ours is whether Ante starts on magpie. Its settings name the provider + // and the model apart, so magpie's provider is what makes the pair + // magpie's; with no model named Ante takes the default of + // preferred_models, one of magpie's. + ours := func() bool { return get(anteProviderKey) == magpieID } + // unwire takes Ante back off magpie: the provider and model it found + // when it wired in — or nothing, which leaves Ante to its + // auto-detection — and magpie's catalog entry out. What the user chose + // in Ante since is theirs and stands, and the pair saved for it goes + // rather than being written over their pick. Every step is a no-op when + // there is nothing to undo, which is how this survives Disconnect + // returning every field to its default: the first call takes the whole + // wiring out. + unwire := func() error { + if get(anteProviderKey) == magpieID { + was, mine := unstash(keyProvider), unstash(keyModel) + if err := set(anteProviderKey, was); err != nil { + return err + } + if err := set(anteModelKey, mine); err != nil { + return err + } + } else { + forget(keyProvider, keyModel) + } + // settings.json holding neither of magpie's keys, and magpie having + // made it, is magpie's file to take away + if get(anteProviderKey) == "" && get(anteModelKey) == "" && unstash(keyNewSettings) != "" { + if raw, err := edit.Read(settingsPath); err == nil && len(gjson.ParseBytes(raw).Map()) == 0 { + if err := edit.Remove(settingsPath); err != nil { + return err + } + } + } + if !isFile(path) { + return nil + } + if err := edit.DelJSON(path, anteProvider); err != nil { + return err + } + // a providers map left with nothing goes with it, as Ante reads a + // missing catalog as the providers it ships + if raw, err := edit.Read(path); err == nil { + if m := gjson.GetBytes(raw, anteProviders); m.Exists() && len(m.Map()) == 0 { + if err := edit.DelJSON(path, anteProviders); err != nil { + return err + } + } + } + // a file magpie made goes with its last entry + if unstash(keyNew) != "" { + if raw, err := edit.Read(path); err == nil && len(gjson.ParseBytes(raw).Map()) == 0 { + return edit.Remove(path) + } + } + return nil + } + // put is magpie's whole wiring in one step: magpie's catalog entry, with + // the user's own keys in it kept, and Ante started on magpie. What Ante + // named as its own provider is saved before magpie writes over it, and + // only then — a value the user chose in Ante while magpie was wired is + // theirs and is what comes back. An empty one is no value to restore, + // which leaves Ante to its auto-detection. + put := func(model string) error { + if get(anteProviderKey) != magpieID { + stash(map[string]string{keyProvider: get(anteProviderKey), keyModel: get(anteModelKey)}) + } else if m := get(anteModelKey); model == "" && isMagpie(m) { + // already on magpie: the model it was started on stays + model = m + } + if !wired() && !isFile(path) { + stash(map[string]string{keyNew: "1"}) + } + if !isFile(settingsPath) { + stash(map[string]string{keyNewSettings: "1"}) + } + if err := edit.SetJSON(path, edit.KV{Path: anteProvider, Value: entry()}); err != nil { + return err + } + if err := set(anteProviderKey, magpieID); err != nil { + return err + } + return set(anteModelKey, model) + } + return atomic(&Agent{ + ID: "ante", Name: "Ante", Icon: "generic", + Bin: "ante", Dir: dir, Path: path, + Notice: func() string { + if Running(`(^|/)ante( |$)`) { + return "Ante reads its catalog at start-up — restart open ante sessions to use this." + } + return "" + }, + Check: func() string { + // magpie's entry alone says nothing is off when Ante has been + // moved to another provider in its own /providers: its settings + // decide what a session asks (commandcode's modelProvider is the + // same pair) + if !wired() { + return "" + } + if !ours() { + return "Ante's provider (settings.json) is " + orDefault(get(anteProviderKey)) + ", so it no longer asks magpie" + } + raw, err := edit.Read(path) + if err != nil { + return "" + } + e := gjson.GetBytes(raw, anteProvider) + return wiringOff("Ante", path, func(k string) (string, bool) { + r := e.Get(k) + return r.String(), r.Exists() + }, "base_url", gatewayV1()) + }, + Sync: func() error { + if !wired() { + return nil + } + // the user's own keys in magpie's entry stay, preferred_models + // is magpie's list, and a file that says the same as it does is + // not written again (syncJSON, theirsKept) + return syncJSON(path, anteProvider, entry) + }, + Fields: []Field{{ + // This field is magpie's models in Ante's picker: its entry in + // the catalog, and Ante started on it (settings.json's + // provider). Get reads the entry alone, as Pencil's does, so + // Disconnect takes the entry out even when the user has since + // picked another provider in Ante's own /providers. + Key: "provider", Label: "provider", + Get: func() string { + if wired() { + return magpieID + } + return "" + }, + Set: func(v string) error { + if v == "" { + return unwire() + } + return put("") + }, + Options: func(map[string]string) []Option { + return []Option{{Value: magpieID, Label: "magpie", Icon: "magpie", Note: "every magpie model in Ante's model picker, and Ante starts on magpie"}} + }, + }, { + // Ante names the model on its own, without a provider in front + // of it (its settings keep the two apart, and its picker lists + // the ids of preferred_models), so magpie's catalog refs are + // written as they are — the ids magpie put in preferred_models. + // A bare id says nothing on its own: read beside magpie's + // provider only, so an id the user's own provider happens to + // serve the same way is not taken for one of magpie's. + Key: "model", Label: "model", + Get: func() string { + if ours() { + return get(anteModelKey) + } + return "" + }, + Set: func(v string) error { + if v == "" { + // no model of its own: Ante starts on magpie and takes + // the default of preferred_models, one of magpie's + if ours() { + return set(anteModelKey, "") + } + return unwire() + } + if !isMagpie(v) { + // a model of Ante's own: magpie steps out and Ante is + // left on it — with no provider named, which is how Ante + // reaches the provider whose list holds that id + if err := unwire(); err != nil { + return err + } + return set(anteModelKey, v) + } + return put(v) + }, + Options: func(map[string]string) []Option { + return viaMagpie("ante", "") + }, + }}, + }, path, settingsPath) +} + +// anteProviderJSON is magpie's entry in Ante's catalog: the gateway's /v1 on +// OpenAiCompatible, every magpie model in preferred_models. +// +// A routing group is not told what it reasons at: which member answers, and +// what that member takes, is the group's own to decide per turn, so magpie's +// other agents leave an effort off for one too (magpieModels sets no APIs for +// a group for the same reason). Ante would otherwise ask for an effort the +// member the group picked doesn't have. +// +// support_vision is left alone for every model: Ante takes a model as seeing +// images unless it is told otherwise, and what magpie knows is only as good +// as a provider's own list — a relay's models are often not described at all, +// so writing false would have Ante drop the reader's images before the +// gateway, which describes them to a model that can't see them itself +// (provider.Described). No other agent magpie wires declares this either. +func anteProviderJSON(gw string) map[string]any { + ms := []map[string]any{} + for _, m := range magpieModels("ante") { + ms = append(ms, anteModelJSON(m)) + } + p := map[string]any{ + "display_name": "magpie", + // gw is the gateway's /v1 already (place.v1), which is what Ante + // joins its own path onto + "base_url": gw, + "wire_style": "OpenAiCompatible", + } + if len(ms) > 0 { + p["preferred_models"] = ms + } + return p +} + +// anteModelJSON is one magpie model as Ante's catalog entry lists it: what +// magpie knows of it, and nothing it doesn't. A routing group is not told +// what it reasons at (anteProviderJSON says why), so its levels are left out +// with the rest of its capabilities. +func anteModelJSON(m catalog.Model) map[string]any { + group := strings.HasPrefix(m.ID, provider.GroupPrefix) + e := map[string]any{"id": m.ID} + if m.Name != "" && m.Name != m.ID { + e["display_name"] = m.Name + } + if m.Context > 0 { + e["context_limit"] = m.Context + } + if m.Output > 0 { + e["max_tokens"] = maxTokens(m) + } + // Ante's ladder is per provider and model family; a model magpie knows + // the levels of says which of them Ante may send. No default "effort" is + // written: a model then starts where Ante puts it, and written as the + // top rung it would make every one of them Ante's slowest and dearest + // (anteEfforts). + if !group && len(m.Efforts) > 0 { + e["supported_efforts"] = anteEfforts(m.Efforts) + } + return e +} + +// anteLadder is Ante's effort scale, ascending, and the magpie level each +// of its rungs is. Ante's lowest rung, "min", is not a name magpie lists: +// Ante's reference says what it sends it as (docs.antigma.ai, +// reference/catalog-reference, the supported_efforts row): +// +// "OpenAI-compatible providers send the selected level as the same-named +// reasoning_effort (min is sent as minimal)" +// +// so Ante's "min" is magpie's "minimal", and magpie's "none" — no thinking +// asked for at all — has no rung of Ante's, which sends one whatever the +// model was told it accepts. The PR's first version folded both into "min", +// so a model that takes only "none" was asked for `reasoning_effort: +// minimal` and refused. +// +// The other way Ante says a model takes no effort is an empty list: +// +// "An empty list (supported_efforts: []) declares that the model takes no +// effort setting at all." +// +// which is what a model magpie knows only "none" comes out as: Ante then +// shows no slider and sends none, rather than a level the model refuses. +// The default is left off entirely — Ante's own words are +// +// "effort … Omit for models with no reasoning knob (Ante then sends no +// effort parameter and hides the effort slider)" +// +// — so a written default is a sent effort, and setting it to the top rung +// made every model Ante's slowest and dearest one. +var anteLadder = []struct{ ante, magpie string }{ + {"min", "minimal"}, + {"low", "low"}, + {"medium", "medium"}, + {"high", "high"}, + {"xhigh", "xhigh"}, + {"max", "max"}, +} + +// anteEfforts are the levels Ante is told a model accepts, on Ante's ladder, +// in its order. Empty is the declaration that it takes no effort setting. +func anteEfforts(levels []string) []string { + out := []string{} + for _, rung := range anteLadder { + if slices.Contains(levels, rung.magpie) { + out = append(out, rung.ante) + } + } + return out +} diff --git a/internal/agent/ante_test.go b/internal/agent/ante_test.go new file mode 100644 index 00000000..ab050e32 --- /dev/null +++ b/internal/agent/ante_test.go @@ -0,0 +1,745 @@ +package agent + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "github.com/yetone/magpie/internal/catalog" + "github.com/yetone/magpie/internal/edit" + "github.com/yetone/magpie/internal/provider" +) + +// anteHome is a HOME with an Ante of the user's: a catalog holding a provider +// of their own and a model patch, and a settings.json naming the provider and +// model Ante starts a session on. +func anteHome(t *testing.T) (home, path, settings string) { + t.Helper() + home = t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache")) + if err := provider.Save(provider.Provider{ID: "deepseek", Name: "DeepSeek", Key: "k", + Chat: "https://api.deepseek.com/v1", Models: []string{"pro", "flash"}}); err != nil { + t.Fatal(err) + } + // one model of magpie's with levels of its own, so what Ante is told of + // them — and what it is not — is said by a real catalog entry + if err := catalog.SaveLive("deepseek", "https://api.deepseek.com/v1", []catalog.Model{ + {ID: "pro", Efforts: []string{"none", "minimal", "low", "high", "max"}, Context: 200000, Output: 8000}, + {ID: "flash"}, + }); err != nil { + t.Fatal(err) + } + dir := filepath.Join(home, ".ante") + writeFile(t, filepath.Join(dir, "catalog.json"), `{ + "providers": { + "together": { + "display_name": "Together AI", + "base_url": "https://api.together.xyz/v1", + "wire_style": "OpenAiCompatible", + "auth": { "bearer": { "env_key": "TOGETHER_API_KEY" } } + } + }, + "models": { "claude-opus-5-5": { "effort": "max" } } +} +`) + settings = filepath.Join(dir, "settings.json") + writeFile(t, settings, `{ + "provider": "together", + "model": "moonshotai/Kimi-K2-Instruct", + "permission_mode": "auto", + "theme": "default" +} +`) + return home, filepath.Join(dir, "catalog.json"), settings +} + +func anteRead(t *testing.T, p string) map[string]any { + t.Helper() + var m map[string]any + b, err := os.ReadFile(p) + if err != nil { + return nil + } + if err := json.Unmarshal(b, &m); err != nil { + t.Fatalf("%s is not JSON: %v\n%s", p, err, b) + } + return m +} + +func anteProv(t *testing.T, path string) map[string]any { + t.Helper() + ps, _ := anteRead(t, path)["providers"].(map[string]any) + return ps +} + +func anteEntry(t *testing.T, path string) map[string]any { + t.Helper() + e, _ := anteProv(t, path)[magpieID].(map[string]any) + return e +} + +func anteModelList(t *testing.T, path string) []map[string]any { + t.Helper() + var out []map[string]any + for _, x := range anteEntry(t, path)["preferred_models"].([]any) { + e, _ := x.(map[string]any) + out = append(out, e) + } + return out +} + +// Ante's catalog.json is merged on top of the providers it ships: magpie's +// entry is added to the providers map under its own id, with the user's own +// providers and the models section left as they are, and it goes again when +// magpie steps out. A file magpie made goes with it. Being in the catalog is +// not enough for Ante to use it — its settings say which provider a session +// starts on — so magpie sets those too and puts back what the user had. +func TestAnte(t *testing.T) { + home, path, settings := anteHome(t) + want := anteRead(t, path) + wantSettings := anteRead(t, settings) + + a := ante(home) + if a.Path != path || a.Dir != filepath.Dir(path) || a.Bin != "ante" { + t.Fatalf("path %q dir %q bin %q", a.Path, a.Dir, a.Bin) + } + f, m := a.Field("provider"), a.Field("model") + // not wired yet: the user's provider is there, magpie's is not, and + // nothing is said about wiring magpie never did + if got := f.Get(); got != "" || a.Check() != "" { + t.Fatalf("own: %q %q", got, a.Check()) + } + var vals []string + for _, o := range f.Options(a.Values()) { + vals = append(vals, o.Value) + } + if !reflect.DeepEqual(vals, []string{"magpie"}) { + t.Fatalf("options: %v", vals) + } + + for range 2 { // twice: one magpie provider, and the user's left alone + if err := f.Set("magpie"); err != nil { + t.Fatal(err) + } + } + ps := anteProv(t, path) + if len(ps) != 2 || !reflect.DeepEqual(ps["together"], want["providers"].(map[string]any)["together"]) { + t.Fatalf("providers: %v", ps) + } + if got := anteRead(t, path)["models"]; !reflect.DeepEqual(got, want["models"]) { + t.Fatalf("models: %v", got) + } + mine := anteEntry(t, path) + if mine["base_url"] != gatewayV1() || mine["wire_style"] != "OpenAiCompatible" || mine["display_name"] != "magpie" { + t.Fatalf("magpie provider: %v", mine) + } + // no auth block of magpie's: the gateway lets this machine in with any + // token, and Ante counts a provider with none as authenticated + if _, ok := mine["auth"]; ok { + t.Fatalf("auth written: %v", mine["auth"]) + } + ms := anteModelList(t, path) + if len(ms) == 0 { + t.Fatalf("no models: %v", mine) + } + for _, e := range ms { + id, _ := e["id"].(string) + if id == "" { + t.Fatalf("a model without an id: %v", e) + } + // support_vision is never written: Ante takes a model as seeing + // images unless told otherwise, and magpie's own answer is only as + // good as a provider's list — a relay's models are often not + // described — so writing false would drop the reader's images + if _, ok := e["support_vision"]; ok { + t.Fatalf("%s: support_vision written: %v", id, e) + } + // and no default effort either: written, Ante sends it on every turn, + // and the top rung is its slowest and dearest one + if _, ok := e["effort"]; ok { + t.Fatalf("%s: effort written: %v", id, e) + } + } + if f.Get() != "magpie" || a.Check() != "" { + t.Fatalf("on: %q %q", f.Get(), a.Check()) + } + // Ante starts on magpie: its provider named, and the provider and model + // the user had kept out of the way for when magpie steps out. The model + // goes rather than staying: Ante's settings name the two apart, so + // together's model id beside magpie's provider would have the gateway + // asked for a model it may not serve. + if got := anteRead(t, settings); got["provider"] != magpieID { + t.Fatalf("settings: %v", got) + } + if _, ok := anteRead(t, settings)["model"]; ok { + t.Fatalf("the user's model left beside magpie's provider: %v", anteRead(t, settings)) + } + for _, k := range []string{"permission_mode", "theme"} { + if anteRead(t, settings)[k] != wantSettings[k] { + t.Fatalf("%s lost: %v", k, anteRead(t, settings)) + } + } + + // magpie's provider pointed elsewhere: that is said, and Sync brings it + // back + writeFile(t, path, strings.Replace(readFile(path), gatewayV1(), "http://elsewhere/v1", 1)) + if !strings.Contains(a.Check(), "http://elsewhere/v1") { + t.Fatalf("check: %q", a.Check()) + } + if err := a.Sync(); err != nil { + t.Fatal(err) + } + if got := anteEntry(t, path)["base_url"]; got != gatewayV1() { + t.Fatalf("sync: %v", got) + } + // Ante moved to one of its own providers: magpie's entry is still in the + // catalog, and that is said rather than kept quiet about (#1210) + writeFile(t, settings, `{"provider":"together","model":"moonshotai/Kimi-K2-Instruct","permission_mode":"auto","theme":"default"}`) + if got := a.Check(); !strings.Contains(got, "together") || !strings.Contains(got, "no longer asks magpie") { + t.Fatalf("moved off magpie: %q", got) + } + + // stepping out takes magpie's provider away, leaves the user's file as it + // was, and gives Ante back the provider the user had — the one moved off + // to there being their own choice, which stands + if err := f.Set(""); err != nil { + t.Fatal(err) + } + if got := anteRead(t, path); !reflect.DeepEqual(got, want) { + t.Fatalf("restore: %v", got) + } + if got := anteRead(t, settings); got["provider"] != "together" { + t.Fatalf("restore settings: %v", got) + } + if f.Get() != "" || m.Get() != "" || a.Check() != "" { + t.Fatalf("off: %q %q %q", f.Get(), m.Get(), a.Check()) + } + + // a pair of files magpie made go again, and what Ante had before them was + // nothing: its auto-detection, not a made-up choice + os.Remove(path) + os.Remove(settings) + if err := f.Set("magpie"); err != nil { + t.Fatal(err) + } + if _, ok := anteProv(t, path)[magpieID]; !ok { + t.Fatalf("new: %v", anteRead(t, path)) + } + if err := f.Set(""); err != nil { + t.Fatal(err) + } + if b, err := os.ReadFile(path); err == nil { + t.Fatalf("made catalog left: %s", b) + } + if b, err := os.ReadFile(settings); err == nil { + t.Fatalf("made settings left: %s", b) + } +} + +// The model field is the same wiring spelled as a model: Ante names its +// model on its own, with the provider kept apart from it, so magpie writes +// the catalog id and its provider beside it. A model of the user's own takes +// magpie out again, and Disconnect puts back what Ante had. +func TestAnteModelField(t *testing.T) { + home, path, settings := anteHome(t) + a := ante(home) + m := a.Field("model") + if err := m.Set("deepseek/pro"); err != nil { + t.Fatal(err) + } + got := anteRead(t, settings) + if got["provider"] != magpieID || got["model"] != "deepseek/pro" { + t.Fatalf("on: %v", got) + } + if !a.Wired() || a.Check() != "" || m.Get() != "deepseek/pro" { + t.Fatalf("wired: %v %q %q", a.Wired(), a.Check(), m.Get()) + } + if _, ok := anteProv(t, path)[magpieID]; !ok { + t.Fatalf("no catalog entry: %v", anteProv(t, path)) + } + // one of Ante's own models: magpie steps out, and Ante is left on it — + // its provider back with it, since a bare id means nothing without one + if err := m.Set("moonshotai/Kimi-K2-Instruct"); err != nil { + t.Fatal(err) + } + got = anteRead(t, settings) + if got["provider"] != "together" || got["model"] != "moonshotai/Kimi-K2-Instruct" { + t.Fatalf("own: %v", got) + } + if _, ok := anteProv(t, path)[magpieID]; ok { + t.Fatalf("magpie's entry left: %v", anteProv(t, path)) + } + if a.Wired() || m.Get() != "" { + t.Fatalf("still wired: %v %q", a.Wired(), m.Get()) + } + // Disconnect finds nothing left to undo and writes nothing new + was := readFile(settings) + if err := a.Disconnect(); err != nil { + t.Fatal(err) + } + if got := readFile(settings); got != was { + t.Fatalf("rewritten by Disconnect:\n%s\n---\n%s", was, got) + } +} + +// Connect starts Ante on magpie and Disconnect gives Ante back what it had: +// the save-and-restore the catalog entry alone never was (#1210). +func TestAnteConnectDisconnect(t *testing.T) { + home, path, settings := anteHome(t) + a := ante(home) + if err := a.Connect(); err != nil { + t.Fatal(err) + } + if !a.Wired() { + t.Fatal("not wired after Connect") + } + got := anteRead(t, settings) + if got["provider"] != magpieID { + t.Fatalf("Ante starts on %v, not magpie: %v", got["provider"], got) + } + if got["model"] == nil || got["model"] == "moonshotai/Kimi-K2-Instruct" { + t.Fatalf("Connect left the user's model beside magpie's provider: %v", got) + } + if d := a.Drift(); d != nil { + t.Fatalf("drift after Connect: %+v", d) + } + if err := a.Disconnect(); err != nil { + t.Fatal(err) + } + got = anteRead(t, settings) + if got["provider"] != "together" || got["model"] != "moonshotai/Kimi-K2-Instruct" { + t.Fatalf("not put back: %v", got) + } + if _, ok := anteProv(t, path)[magpieID]; ok { + t.Fatalf("magpie's entry left: %v", anteProv(t, path)) + } + if d := a.Drift(); d != nil { + t.Fatalf("drift after Disconnect: %+v", d) + } +} + +// What the user set in Ante itself is what comes back: a provider picked in +// Ante's /providers while magpie's entry stands is their choice, and magpie +// does not hand over the one it found when it wired in. +func TestAnteKeepsWhatTheUserPickedWhileWired(t *testing.T) { + home, path, settings := anteHome(t) + a := ante(home) + f := a.Field("provider") + if err := f.Set("magpie"); err != nil { + t.Fatal(err) + } + // the user moves to their own provider, and to one of magpie's models + // there, in Ante's picker + writeFile(t, settings, `{"provider":"together","model":"x-ai/grok-4.7","theme":"default"}`+"\n") + if err := f.Set(""); err != nil { + t.Fatal(err) + } + got := anteRead(t, settings) + if got["provider"] != "together" || got["model"] != "x-ai/grok-4.7" { + t.Fatalf("the user's pick was overwritten: %v", got) + } + if got["theme"] != "default" { + t.Fatalf("the user's other keys: %v", got) + } + if _, ok := anteProv(t, path)[magpieID]; ok { + t.Fatalf("magpie's entry left: %v", anteProv(t, path)) + } +} + +// sameText is whether two reads of the same key say the same thing. +// edit.GetJSON hands back an object as JSON and a scalar unquoted (900, +// concise), so a JSON compare alone misses the second: both spellings are +// tried. Indentation is the file's own, not a change of what the user wrote. +func sameText(a, b string) bool { + if a == b { + return true + } + if sameJSON(a, json.RawMessage(b)) || sameJSON(b, json.RawMessage(a)) { + return true + } + return strings.TrimSpace(a) == strings.TrimSpace(b) +} + +// anteEffortModel is a provider of magpie's with one model at the levels c +// names, and the catalog entry Ante is handed for it. +func anteEffortModel(t *testing.T, id string, efforts []string) map[string]any { + t.Helper() + if err := catalog.SaveLive("eff", "https://example.test/v1", []catalog.Model{ + {ID: id, Efforts: efforts}, + }); err != nil { + t.Fatal(err) + } + if err := provider.Save(provider.Provider{ID: "eff", Name: "Eff", Key: "k", + Chat: "https://example.test/v1", Models: []string{id}}); err != nil { + t.Fatal(err) + } + for _, m := range magpieModels("ante") { + if m.ID == "eff/"+id { + e, _ := json.Marshal(anteModelJSON(m)) + var out map[string]any + if err := json.Unmarshal(e, &out); err != nil { + t.Fatal(err) + } + return out + } + } + t.Fatalf("eff/%s is not in magpie's catalog", id) + return nil +} + +// The effort levels magpie knows are put on Ante's ladder, and none of them +// is a default: Ante's lowest rung, min, is the level it sends as +// `reasoning_effort: minimal` — magpie's "minimal", never its "none" — and a +// model magpie knows only "none" of says so with an empty list, which is +// Ante's own way of saying the model takes no effort setting (#1210). +func TestAnteEfforts(t *testing.T) { + for _, c := range []struct { + name string + levels []string + want []string + }{ + {"a ladder", []string{"low", "medium", "high"}, []string{"low", "medium", "high"}}, + {"minimal is Ante's min", []string{"minimal", "high"}, []string{"min", "high"}}, + {"none is no rung of Ante's", []string{"none", "high"}, []string{"high"}}, + {"none alone takes no effort", []string{"none"}, []string{}}, + {"magpie's order, Ante's ladder", []string{"max", "low", "none", "minimal"}, []string{"min", "low", "max"}}, + {"all of them", []string{"none", "minimal", "low", "medium", "high", "xhigh", "max"}, + []string{"min", "low", "medium", "high", "xhigh", "max"}}, + {"nothing known", nil, []string{}}, + } { + t.Run(c.name, func(t *testing.T) { + got := anteEfforts(c.levels) + if len(got) != len(c.want) { + t.Fatalf("anteEfforts(%v) = %v, want %v", c.levels, got, c.want) + } + for i := range got { + if got[i] != c.want[i] { + t.Fatalf("anteEfforts(%v) = %v, want %v", c.levels, got, c.want) + } + } + }) + } + // and what magpie writes into the catalog for each of them + for _, c := range []struct { + efforts []string + want string // supported_efforts as JSON; "" for the key not written + }{ + {[]string{"low", "medium", "high"}, `["low","medium","high"]`}, + {[]string{"minimal", "low"}, `["min","low"]`}, + {[]string{"none"}, `[]`}, + {nil, ""}, + } { + e := anteEffortModel(t, "one", c.efforts) + got, written := "", false + if v, ok := e["supported_efforts"]; ok { + b, _ := json.Marshal(v) + got, written = string(b), true + } + if written != (c.want != "") || got != c.want { + t.Fatalf("efforts %v: supported_efforts %q (%v), want %q", c.efforts, got, written, c.want) + } + if _, ok := e["effort"]; ok { + t.Fatalf("efforts %v: a default effort written: %v", c.efforts, e) + } + } +} + +// Sync and a pick keep the keys the user added inside magpie's own entry — +// the auth block a gateway shared on the local network or reached from WSL +// needs to send a key, its headers, its extra body, a longer idle timeout — +// while the endpoint and the whole model list still follow the catalog +// (#1103, #1210). +func TestAnteSyncKeepsTheUsersKeys(t *testing.T) { + const theirs = `{ + "providers": { + "together": { "base_url": "https://api.together.xyz/v1", "wire_style": "OpenAiCompatible" }, + "magpie": { + "display_name": "old", + "base_url": "http://127.0.0.1:1/v1", + "wire_style": "OpenAiCompatible", + "auth": { "bearer": { "env_key": "MAGPIE_KEY" } }, + "http_headers": { "X-Org": "mine" }, + "extra_body": { "service_tier": "priority" }, + "stream_idle_timeout_secs": 900, + "thinking_display": "concise", + "preferred_models": [ + { "id": "deepseek/flash", "user_note": "keep me" } + ] + } + }, + "models": { "claude-opus-5-5": { "effort": "max" } } +} +` + for _, action := range []string{"sync", "pick"} { + t.Run(action, func(t *testing.T) { + home, path, settings := anteHome(t) + a := ante(home) + f := a.Field("provider") + if err := f.Set("magpie"); err != nil { + t.Fatal(err) + } + writeFile(t, path, theirs) + kept := map[string]string{} + for _, k := range []string{"auth", "http_headers", "extra_body", "stream_idle_timeout_secs", "thinking_display"} { + kept[k], _ = edit.GetJSON(path, anteProvider+"."+k) + } + check := func() { + t.Helper() + for k, was := range kept { + if v, ok := edit.GetJSON(path, anteProvider+"."+k); !ok || !sameText(v, was) { + t.Errorf("the user's %s lost or changed: got %q, want %q", k, v, was) + } + } + if v, _ := edit.GetJSON(path, anteProvider+".preferred_models"); strings.Contains(v, "user_note") { + t.Errorf("preferred_models merged instead of replaced: %s", v) + } + for k, want := range map[string]string{ + "providers.together.base_url": "https://api.together.xyz/v1", + "models.claude-opus-5-5.effort": "max", + } { + if v, ok := edit.GetJSON(path, k); !ok || v != want { + t.Errorf("%s = %q (%v), want %q", k, v, ok, want) + } + } + // the fields magpie owns still follow the gateway + for k, want := range map[string]string{"base_url": gatewayV1(), "display_name": "magpie", "wire_style": "OpenAiCompatible"} { + if v, _ := edit.GetJSON(path, anteProvider+"."+k); v != want { + t.Errorf("%s = %q, want %q", k, v, want) + } + } + // and every model magpie lists is in Ante's picker, and only + // those: flash, gone from the catalog, left the list + got := anteModelList(t, path) + want := magpieModels("ante") + if len(got) != len(want) { + t.Fatalf("preferred_models: %v, want %d models", got, len(want)) + } + for i, e := range got { + if e["id"] != want[i].ID { + t.Fatalf("preferred_models[%d] = %v, want %s", i, e, want[i].ID) + } + } + } + if action == "sync" { + if err := a.Sync(); err != nil { + t.Fatal(err) + } + } else if err := f.Set("magpie"); err != nil { + t.Fatal(err) + } + check() + // Ante still starts on magpie, and names no model of + // together's beside it — the default of preferred_models, one of + // magpie's, is what it starts on + if v, _ := edit.GetJSON(settings, "provider"); v != magpieID { + t.Errorf("settings' provider = %q, want magpie", v) + } + if v, ok := edit.GetJSON(settings, "model"); ok && v != "" { + t.Errorf("settings' model = %q, want none", v) + } + // a catalog that says the same as it does is not written again + before := readFile(path) + stamp := time.Unix(1000000000, 0) + if err := os.Chtimes(path, stamp, stamp); err != nil { + t.Fatal(err) + } + if err := a.Sync(); err != nil { + t.Fatal(err) + } + if st, err := os.Stat(path); err != nil { + t.Fatal(err) + } else if !st.ModTime().Equal(stamp) || readFile(path) != before { + t.Error("an unchanged catalog was rewritten") + } + // a model gone from the catalog leaves Ante's list + if err := provider.Save(provider.Provider{ID: "deepseek", Name: "DeepSeek", Key: "k", + Chat: "https://api.deepseek.com/v1", Models: []string{"pro"}}); err != nil { + t.Fatal(err) + } + if err := a.Sync(); err != nil { + t.Fatal(err) + } + check() + }) + } +} + +// ANTE_HOME moves the folder Ante reads its catalog and its settings from, as +// its reference says of it: magpie writes where Ante looks, not beside it in +// the home folder (#1210). +func TestAnteHome(t *testing.T) { + home, catalogPath, settingsPath := anteHome(t) + elsewhere := filepath.Join(home, "elsewhere", "ante") + t.Setenv("ANTE_HOME", elsewhere) + a := ante(home) + path := filepath.Join(elsewhere, "catalog.json") + settings := filepath.Join(elsewhere, "settings.json") + if a.Dir != elsewhere || a.Path != path { + t.Fatalf("$ANTE_HOME ignored: dir %q path %q", a.Dir, a.Path) + } + if got := anteDir(here(home)); got != elsewhere { + t.Fatalf("anteDir = %q, want %q", got, elsewhere) + } + // a relative ANTE_HOME names no folder to write under the working + // directory (appdir's rule for every agent's path variable) + t.Setenv("ANTE_HOME", "ante-relative") + if got := anteDir(here(home)); got != filepath.Join(home, ".ante") { + t.Fatalf("a relative $ANTE_HOME was taken: %q", got) + } + t.Setenv("ANTE_HOME", elsewhere) + if err := a.Field("provider").Set("magpie"); err != nil { + t.Fatal(err) + } + if _, ok := anteProv(t, path)[magpieID]; !ok { + t.Fatalf("nothing at $ANTE_HOME: %v", anteProv(t, path)) + } + if v, _ := edit.GetJSON(settings, "provider"); v != magpieID { + t.Fatalf("the settings at $ANTE_HOME: %q", v) + } + // the home folder's own .ante is left exactly as the user had it + if b := readFile(catalogPath); strings.Contains(b, magpieID) { + t.Fatalf("magpie written beside the home folder: %s", b) + } + if b := readFile(settingsPath); b != `{ + "provider": "together", + "model": "moonshotai/Kimi-K2-Instruct", + "permission_mode": "auto", + "theme": "default" +} +` { + t.Fatalf("the home folder's settings touched: %s", b) + } + // stepping out writes back to $ANTE_HOME, and nowhere else + if err := a.Field("provider").Set(""); err != nil { + t.Fatal(err) + } + if _, ok := anteProv(t, path)[magpieID]; ok { + t.Fatalf("magpie left at $ANTE_HOME: %v", anteProv(t, path)) + } + if b := readFile(catalogPath); strings.Contains(b, magpieID) { + t.Fatalf("magpie written beside the home folder on the way out: %s", b) + } + // A distro's ANTE_HOME is one magpie can't read, so its Ante is ~/.ante + // there, as MiniMax Code's mcode (see wsl.go). + t.Setenv("ANTE_HOME", elsewhere) + wslHome := filepath.Join(home, "wsl") + // a distro's place spells its paths as the distro names them (wsl.go), + // which is also how magpie knows it can't read the distro's variables + in := anteIn(place{home: wslHome, id: "ante@wsl:Distro", spell: func(p string) string { return p }}) + if want := filepath.Join(wslHome, ".ante", "catalog.json"); in.Path != want { + t.Fatalf("a distro's Ante: %q, want %q", in.Path, want) + } +} + +// Stepping out writes Ante's settings and then its catalog. When the catalog +// can't be written, the settings go back as they were: an agent left with +// magpie's provider taken out of its catalog, or the other way round, is one +// that asks a provider it no longer has. (A catalog that isn't JSON is left +// untouched rather than edited by what gjson of it could read — internal/edit +// since #1242 — which is how this fails one step in.) +func TestAnteWriteFailureRollsBothFilesBack(t *testing.T) { + home, path, settings := anteHome(t) + a := ante(home) + f := a.Field("provider") + if err := f.Set("magpie"); err != nil { + t.Fatal(err) + } + beforeSettings := readFile(settings) + if v, _ := edit.GetJSON(settings, "provider"); v != magpieID { + t.Fatalf("not wired: %q", v) + } + bad := `{"providers": {"magpie": {"base_url": "http://127.0.0.1:1/v1"` + writeFile(t, path, bad) + if err := f.Set(""); err == nil { + t.Fatal("a catalog that can't be written was stepped over") + } + if got := readFile(path); got != bad { + t.Fatalf("the broken catalog was edited:\n%s\n---\n%s", bad, got) + } + if got := readFile(settings); got != beforeSettings { + t.Fatalf("settings.json kept half of a change that failed:\n%s\n---\n%s", beforeSettings, got) + } +} + +// The same pair is written coming the other way: a settings.json that can't be +// written leaves the catalog as it was, and Ante is not left started on a +// provider magpie took back out. +func TestAnteConnectWriteFailureRollsBack(t *testing.T) { + home, path, settings := anteHome(t) + before := readFile(path) + // nothing of it is written over, so the first settings write fails + bad := `{"provider": "together",` + writeFile(t, settings, bad) + if err := ante(home).Field("provider").Set("magpie"); err == nil { + t.Fatal("a settings.json that can't be written was stepped over") + } + if got := readFile(path); got != before { + t.Fatalf("the catalog kept half of a change that failed:\n%s\n---\n%s", before, got) + } + if _, ok := anteProv(t, path)[magpieID]; ok { + t.Fatalf("magpie's entry stayed beside settings magpie never wrote: %v", anteProv(t, path)) + } + if got := readFile(settings); got != bad { + t.Fatalf("the broken settings.json was edited:\n%s\n---\n%s", bad, got) + } +} + +// A catalog Ante can't parse costs magpie's provider, not the user's: Ante +// skips an entry that doesn't deserialize and loads the rest, and magpie +// writes only its own key. +func TestAnteKeepsTheUsersFile(t *testing.T) { + home, path, _ := anteHome(t) + a := ante(home) + if err := a.Field("provider").Set("magpie"); err != nil { + t.Fatal(err) + } + ps := anteProv(t, path) + if _, ok := ps["together"]; !ok { + t.Fatalf("the user's provider went: %v", ps) + } + if _, ok := ps[magpieID]; !ok { + t.Fatalf("magpie's provider went: %v", ps) + } +} + +// A routing group says neither what it reasons at nor what it takes in: which +// member answers, and what that member can do, is the group's own to decide +// per turn. magpie's other agents leave both off for one, and Ante would +// otherwise filter images out before a group whose member takes them, or ask +// for an effort the member the group picked doesn't have. +func TestAnteGroupSaysNoCapabilities(t *testing.T) { + home, path, _ := anteHome(t) + // a group of magpie's own, over a model of a provider of the user's + provider.Save(provider.Provider{ID: "relay", Name: "Relay", Key: "k", + Chat: "http://127.0.0.1:1/v1", Models: []string{"m1"}}) + if err := provider.SaveGroup(provider.Group{ID: "g", Name: "G", Members: []string{"relay/m1"}}); err != nil { + t.Fatal(err) + } + a := ante(home) + if err := a.Field("provider").Set("magpie"); err != nil { + t.Fatal(err) + } + var sawGroup bool + for _, e := range anteModelList(t, path) { + id, _ := e["id"].(string) + if !strings.HasPrefix(id, provider.GroupPrefix) { + continue + } + sawGroup = true + for _, k := range []string{"effort", "supported_efforts", "support_vision"} { + if _, ok := e[k]; ok { + t.Fatalf("the group %s declares %s: %v", id, k, e) + } + } + } + if !sawGroup { + t.Fatalf("no group in the list: %v", anteModelList(t, path)) + } +} diff --git a/internal/agent/wsl.go b/internal/agent/wsl.go index 1520b1a5..2767756b 100644 --- a/internal/agent/wsl.go +++ b/internal/agent/wsl.go @@ -392,6 +392,10 @@ var wslKinds = []wslKind{ }}, {id: "empryo", name: "Empryo", dir: ".empryo", bin: "empryo", in: empryoIn, restart: "reads its config at start-up — restart open empryo sessions to use this."}, + // Ante ships macOS and Linux builds alone, and suggests WSL on Windows, + // so a Windows machine's Ante is usually this one + {id: "ante", name: "Ante", dir: ".ante", bin: "ante", in: anteIn, + restart: "reads its catalog at start-up — restart open ante sessions to use this."}, {id: "muse", name: "Muse Code", dir: ".config/muse", bin: "muse", in: museIn, restart: "reads its settings at start-up — restart open muse sessions to use this."}, {id: "qoder", name: "Qoder", dir: ".qoder", bin: "qodercli", in: qoderIn, diff --git a/internal/agentenv/agentenv.go b/internal/agentenv/agentenv.go index fa1cd703..137ae6a9 100644 --- a/internal/agentenv/agentenv.go +++ b/internal/agentenv/agentenv.go @@ -51,6 +51,8 @@ var Vars = []string{ "GROK_HOME", "GROK_BIN_DIR", // Kimi Code and its shared folder "KIMI_CODE_HOME", "KIMI_SHARE_DIR", + // Ante + "ANTE_HOME", // MiMo Code, MiniMax Code, OpenHanako, Hermes, dsh, WorkBuddy, CodeBuddy Code "MIMOCODE_HOME", "MINIMAX_DATA_DIR", "HANA_HOME", "HERMES_HOME", "DSH_HOME", "WORKBUDDY_CONFIG_DIR", "CODEBUDDY_CONFIG_DIR",