diff --git a/README.md b/README.md index e0564de7..adef697a 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ magpie tui # the whole thing, in a terminal
-Claude Code · Claude Desktop · Codex · Gemini CLI · Antigravity CLI · OpenCode · OpenChamber · MiMo Code · Pi · oh-my-pi · Aside · OmO · Goose · Cursor CLI · Cursor Private Inference · Zed · VS Code Chat · VS Code Insiders · VSCodium Chat · JetBrains Air · Copilot (JetBrains) · Copilot CLI · Crush · DeepSeek Harness · Reasonix Studio · Command Code · fx · Devin · Hermes Agent · Mister Morph · Kimi Code · Qwen Code · Muse Code · Empryo · MiniMax Code · Droid · Cline · Qoder · Qoder CN · Grok Build · ZCode · WorkBuddy · CodeBuddy Code · Pencil · T3 Code · OpenHanako · AtomCode · Alma · Cindy +Claude Code · Claude Desktop · Codex · Gemini CLI · Antigravity CLI · OpenCode · OpenChamber · MiMo Code · Pi · oh-my-pi · Aside · OmO · Goose · Cursor CLI · Cursor Private Inference · Zed · VS Code Chat · VS Code Insiders · VSCodium Chat · JetBrains Air · Copilot (JetBrains) · Copilot CLI · Crush · DeepSeek Harness · Reasonix Studio · Command Code · fx · Devin · Hermes Agent · Mister Morph · Kimi Code · Qwen Code · Muse Code · Empryo · MiniMax Code · Droid · Cline · Qoder · Qoder CN · Grok Build · ZCode · WorkBuddy · CodeBuddy Code · Pencil · T3 Code · OpenHanako · AtomCode · Snow CLI · Alma · Cindy
diff --git a/README.zh-CN.md b/README.zh-CN.md index 5330cf91..44c5e3bd 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -81,7 +81,7 @@ magpie tui # 全部功能,在终端里
-Claude Code · Claude Desktop · Codex · Gemini CLI · Antigravity CLI · OpenCode · OpenChamber · MiMo Code · Pi · oh-my-pi · Aside · OmO · Goose · Cursor CLI · Cursor Private Inference · Zed · VS Code Chat · VS Code Insiders · VSCodium Chat · JetBrains Air · Copilot (JetBrains) · Copilot CLI · Crush · DeepSeek Harness · Reasonix Studio · Command Code · fx · Devin · Hermes Agent · Mister Morph · Kimi Code · Qwen Code · Muse Code · Empryo · MiniMax Code · Droid · Cline · Qoder · Qoder CN · Grok Build · ZCode · WorkBuddy · CodeBuddy Code · Pencil · T3 Code · OpenHanako · AtomCode · Alma · Cindy +Claude Code · Claude Desktop · Codex · Gemini CLI · Antigravity CLI · OpenCode · OpenChamber · MiMo Code · Pi · oh-my-pi · Aside · OmO · Goose · Cursor CLI · Cursor Private Inference · Zed · VS Code Chat · VS Code Insiders · VSCodium Chat · JetBrains Air · Copilot (JetBrains) · Copilot CLI · Crush · DeepSeek Harness · Reasonix Studio · Command Code · fx · Devin · Hermes Agent · Mister Morph · Kimi Code · Qwen Code · Muse Code · Empryo · MiniMax Code · Droid · Cline · Qoder · Qoder CN · Grok Build · ZCode · WorkBuddy · CodeBuddy Code · Pencil · T3 Code · OpenHanako · AtomCode · Snow CLI · Alma · Cindy
diff --git a/docs/reference.md b/docs/reference.md index 43bcd663..dc9db3a0 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -129,6 +129,7 @@ line; agents connected to magpie lose it when it quits. | T3 Code | `~/.t3/userdata/settings.json` (`$T3CODE_HOME/userdata`) | provider (a `magpie` provider instance on Claude Code, magpie's models as its custom models) | | OpenHanako | `~/.hanako/provider-catalog.json` + `agents//config.yaml` (`$HANA_HOME`; its local API while it runs) | model (the primary agent's; magpie's models as a provider) | | AtomCode | `~/.atomcode/config.toml` (`$ATOMCODE_HOME`) | model, effort (a `magpie` provider account, one model table per catalog model as its own sign-in writes) | +| Snow CLI | `~/.snow/profiles/magpie.json` (`$SNOW_CONFIG_DIR`), made the active profile and copied to `config.json` | model (the user's other profiles' models too; Snow App takes the profile in with "Sync Snow CLI API config") | | Alma | Alma's local API (`localhost:23001`, while Alma runs; alma-server's data in `$ALMA_DATA_DIR`, `$XDG_DATA_HOME/alma` or `~/.local/share/alma` on Linux) | model (Alma's default; magpie's models as a provider), and Image Generation's model when it is Auto: the one magpie draws with | Provider-scoped agents (OpenCode, MiMo Code, Pi, OmO, Aside, Goose, Crush, omp, Hermes Agent) take `provider/model`. diff --git a/docs/subsystems/agent-wiring.md b/docs/subsystems/agent-wiring.md index fa7e85e6..92944f1e 100644 --- a/docs/subsystems/agent-wiring.md +++ b/docs/subsystems/agent-wiring.md @@ -47,6 +47,7 @@ something else shows up as drift. - Claude Code's sign-in (`login`) is magpie's key (`""`, the default) or claude.ai (`claudeai`). claude.ai writes `ANTHROPIC_AUTH_TOKEN` empty, which Claude Code reads as no key: it keeps its claude.ai login and sends that to magpie, which never passes it on (`claudeSignIn` in the gateway). Every later write keeps the token empty (`wiredKey`), and a key put there by hand is reported by Check, not taken for magpie's. It's offered only where the gateway takes any key (`gwKey() == gateway.Token`: loopback, a WSL distro mirrored), not from a distro under NAT while magpie is shared on the network, where only the sharing key gets in. The choice is stashed, so it survives Disconnect and a port move. An empty token counts as magpie's only beside the address magpie last wrote (`claude.wired_at` in the stash, which Rewire carries to the new port, `keyOurs`): an endpoint of the user's own with an empty token stays theirs when the model goes back to Claude Code's own. - MiniMax Code takes a model ref with one slash in the whole (a SubAgent's `model` must match `^[^/\s]+\/[^/\s]+$`, #1387), and magpie's refs there are `custom_provider:magpie/`. So a model under `custom_provider.magpie` is written with `~` for each slash of its catalog id (`bb-codex~gpt-6.1-sol`, `provider.FlatID`), only when that spelling stands for one served id (`provider.Flattened`); the gateway takes it back. A config an older magpie wrote moves over on the next sync: the user's keys on a model go to its new key, `defaultModel`, `defaultLightModel` and `memory.captureModel`/`consolidationModel` follow (`mcodeRefs`), and the slashed key stays, switched off (`enabled: false`), so a session begun on it still resumes. magpie never edits the user's own `agents//agent.md`. No other agent was found with this limit ([`minimax.go`](../../internal/agent/minimax.go)). - Codex's "subagent model" field writes nothing to Codex's config: its lead names each subagent's model in `spawn_agent`, which `[agents]` can't overrule. It is magpie's setting (`settings.CodexSubagentModel`, [`codex_subagent.go`](../../internal/provider/codex_subagent.go)), and the gateway moves the subagent's request onto it (see [gateway routing](gateway-routing.md)). Its options are only ChatGPT accounts' models, as a subagent's task is sealed for them. Its setter keeps a ChatGPT account's model no account serves now, so a profile or backup put back on another computer still applies (`ApplyFields` stops at the first failed Set), and refuses any other provider's model. +- Snow CLI runs on one profile at a time (`profiles/.json` in `~/.snow` or `$SNOW_CONFIG_DIR`, named in `active-profile.json`) and copies the active one over `config.json` at every start, `snow --ask` too ([`snow.go`](../../internal/agent/snow.go)). magpie writes a `magpie` profile, makes it the active one, and copies it to `config.json` itself, so a Snow already running and Snow App's "Sync Snow CLI API config" see the same. It is made from the profile the user was on, their settings kept and its OAuth sign-in left out. The profile they were on (or that there was no `active-profile.json`) and a `magpie.json` of their own are stashed and put back by Disconnect. A Snow from before profiles (`config.json` alone) gets the `default.json` Snow itself would have made of it, so its next start doesn't take magpie's for the user's. Picking a model of another of the user's profiles switches Snow to that profile. - A value an agent spells as its own provider's model is never taken for one of magpie's, even when magpie has a provider of the same name (`Spelled`, #835). - `unreachable` never sets anything: the config is right, and the page's pill only says what has to listen. The exception is an address a WSL distro reached Windows at before (`distro.Was`, kept in `wsl.json`) while it reaches Windows at another now: the pill offers that one, and `Reapply` moves the config there (`Agent.move`, Codex's `codexMoveGateway`). An address no distro was seen at is the user's own and is never moved. An unknown answer (no probe yet) and a gateway down on loopback too are not drift. Tests stub `reachProbe` in `TestMain`. - Listing never starts a stopped WSL distro. A stopped distro's agent shows what was last seen there (`asleep`, `wsl.json`); setting one of its fields writes its files, which starts the distro. diff --git a/internal/agent/agents.go b/internal/agent/agents.go index 3b124896..f5091d4d 100644 --- a/internal/agent/agents.go +++ b/internal/agent/agents.go @@ -114,6 +114,7 @@ func All() []*Agent { t3code(home), hanako(home), atomcode(home), + snow(home), alma(), cindy(), }, append(ompProfiles(home), wslAgents()...)...) diff --git a/internal/agent/cliupdate.go b/internal/agent/cliupdate.go index 685d89c9..6fa301b0 100644 --- a/internal/agent/cliupdate.go +++ b/internal/agent/cliupdate.go @@ -119,6 +119,8 @@ var cliSpecs = map[string]cliSpec{ // exists, but its installed-binary update path is not exercised here, so // only its npm package and brew cask are recognized "atomcode": {npm: []string{"@atomgit.com/atomcode"}, brew: []string{"atomcode"}}, + // Snow CLI is published on npm alone (snow-ai) + "snow": {npm: []string{"snow-ai"}}, // omo update, however it was installed: OmO's own updater, which moves // the engine it pins (senpi) with it "omo": {npm: []string{"omo-ai"}, diff --git a/internal/agent/snow.go b/internal/agent/snow.go new file mode 100644 index 00000000..f5b8201e --- /dev/null +++ b/internal/agent/snow.go @@ -0,0 +1,451 @@ +package agent + +// Snow CLI (`snow`, npm snow-ai, MayDay-wpf/snow-cli) keeps its settings in +// ~/.snow ($SNOW_CONFIG_DIR when set). Each API setup is a profile, +// profiles/.json, the whole of its config ({"snowcfg": {baseUrl, +// baseUrlMode, apiKey, requestMethod, advancedModel, basicModel, +// maxContextTokens, maxTokens, …}, …}); active-profile.json +// ({"activeProfile": ""}) names the one in use, "default" when it is +// missing, and config.json is a copy of that one, which Snow writes again +// from the profile at every start (utils/config/configManager.ts +// initializeProfiles, switchProfile). +// +// magpie adds a profile of its own, profiles/magpie.json: a copy of the +// profile the user was on — their other settings kept, a subscription +// sign-in of theirs (snowcfg.oauth) left out — with the gateway's /v1 as +// its base URL, a key naming Snow CLI, Chat Completions as its protocol and +// the model picked as its main (advancedModel) and light (basicModel) model, +// the gateway resolving "/" itself. It makes that profile +// the active one and copies it to config.json, as Snow's own profile switch +// does; the profile the user was on is stashed and made active again when +// magpie steps out, and magpie.json goes. Snow App +// (MayDay-wpf/snow-app) keeps API setups of its own: its "Sync Snow CLI API +// config" button copies these profiles, magpie's among them, into it. +// +// Snow's requests carry no User-Agent of its own (an x-snow: +// header), so the key is what tells them apart: gateway.TokenFor("snow"). + +import ( + "encoding/json" + "errors" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/tidwall/gjson" + "github.com/tidwall/jsonc" + + "github.com/yetone/magpie/internal/edit" + "github.com/yetone/magpie/internal/gateway" +) + +// snowProfile is the name of the profile magpie writes. +const snowProfile = magpieID + +// snowContext and snowOutput are what Snow is told of a model whose limits +// magpie doesn't know: its own defaults. +const ( + snowContext = 200000 + snowOutput = 64000 +) + +func snow(home string) *Agent { return snowIn(here(home)) } + +// snowIn is Snow CLI at a place: this machine's home, or a WSL distro's (see +// wsl.go), whose profile names the gateway as the distro reaches it, with +// the key it takes from there. +func snowIn(at place) *Agent { + dir := filepath.Join(at.home, ".snow") + if d := strings.TrimSpace(at.getenv("SNOW_CONFIG_DIR")); filepath.IsAbs(d) { + dir = filepath.Clean(d) + } + profiles := filepath.Join(dir, "profiles") + ours := filepath.Join(profiles, snowProfile+".json") + activeFile := filepath.Join(dir, "active-profile.json") + legacyActive := filepath.Join(dir, "active-profile.txt") + config := filepath.Join(dir, "config.json") + defaultProfile := filepath.Join(profiles, "default.json") + profileOf := func(name string) string { return filepath.Join(profiles, name+".json") } + key := func() string { return agentKeyAt("snow", at.gw()) } + stashKey := "snow:" + dir + // keyActive is the profile the user was on, keyNone that there was no + // active-profile.json to put back, keyHad the bytes of a magpie.json of + // the user's own that magpie's took the place of + keyActive, keyNone, keyHad := stashKey+":active", stashKey+":active.none", stashKey+":had" + + // active is the profile Snow starts on, as it reads it + active := func() string { + if v, ok := edit.GetJSON(activeFile, "activeProfile"); ok { + return v + } + if b, err := edit.Read(legacyActive); err == nil && strings.TrimSpace(string(b)) != "" { + return strings.TrimSpace(string(b)) + } + return "default" + } + // onOurs says Snow is on magpie's profile: the active one, and a key + // magpie gave it, so a profile of the user's own named "magpie" is not + // taken for it + onOurs := func() bool { + if active() != snowProfile { + return false + } + k, ok := edit.GetJSON(ours, "snowcfg.apiKey") + return ok && snowOurKey(k) + } + // model is the active profile's main model: config.json's when the + // profile file isn't there (a Snow from before profiles) + model := func(name string) string { + if v, ok := edit.GetJSON(profileOf(name), "snowcfg.advancedModel"); ok { + return v + } + if _, err := os.Stat(profileOf(name)); errors.Is(err, fs.ErrNotExist) { + v, _ := edit.GetJSON(config, "snowcfg.advancedModel") + return v + } + return "" + } + get := func() string { + if onOurs() { + if v := model(snowProfile); v != "" { + return magpieID + "/" + v + } + return "" + } + return model(active()) + } + + // mirror copies a profile to config.json, as Snow's profile switch does + mirror := func(name string) error { + b, err := edit.Read(profileOf(name)) + if err != nil { + return err + } + if b == nil { + return nil + } + return edit.WriteAtomic(config, b) + } + // setActive names the profile Snow starts on + setActive := func(name string) error { + return edit.WriteAtomic(activeFile, []byte(`{ + "activeProfile": `+jsonString(name)+` +}`)) + } + + // write makes magpie.json for the model ref, from the profile the user + // is on (base) where there is one, and points it at the gateway + write := func(ref, base string) error { + var raw []byte + if b, err := edit.Read(ours); err == nil && b != nil && snowIsOurs(b) { + raw = b + } else if base != "" { + if b, err := edit.Read(profileOf(base)); err == nil && b != nil { + raw = b + } else if b, err := edit.Read(config); err == nil && b != nil { + raw = b + } + } + out, err := snowProfileFor(raw, at.v1(), key(), ref) + if err != nil { + return err + } + return edit.WriteAtomic(ours, out) + } + + // leave puts Snow back on the profile the user had (to, or the stashed + // one), takes magpie.json away (or puts back the user's own of that + // name), and copies the profile it is on to config.json + leave := func(to string) error { + was, none := unstash(keyActive), stashLoad()[keyNone] != "" + forget(keyNone) + // a profile asked for by name is made the active one; else the one + // the user was on, or none at all when Snow had no active file + if to == "" && !none { + to = was + } + switch { + case to != "": + if err := setActive(to); err != nil { + return err + } + case none: + if err := os.Remove(activeFile); err != nil && !errors.Is(err, fs.ErrNotExist) { + return err + } + default: + if err := setActive("default"); err != nil { + return err + } + } + if had := unstash(keyHad); had != "" { + if err := edit.WriteAtomic(ours, []byte(had)); err != nil { + return err + } + } else if b, err := edit.Read(ours); err == nil && b != nil && snowIsOurs(b) { + if err := os.Remove(ours); err != nil && !errors.Is(err, fs.ErrNotExist) { + return err + } + } + now := active() + if _, err := os.Stat(profileOf(now)); err == nil { + return mirror(now) + } + // no profile to copy: a config.json still magpie's goes, or Snow + // would make it the user's default profile at its next start + if b, err := edit.Read(config); err == nil && b != nil && snowIsOurs(b) { + if err := os.Remove(config); err != nil && !errors.Is(err, fs.ErrNotExist) { + return err + } + } + return nil + } + + return atomic(&Agent{ + ID: "snow", Name: "Snow CLI", Icon: "snow", Aliases: []string{"snow-cli", "snow-ai", "snow-app"}, Spelled: prefixed, + Bin: "snow", Dir: dir, Path: ours, + Sync: func() error { + b, err := edit.Read(ours) + if err != nil || b == nil || !snowIsOurs(b) { + return err + } + ref := gjson.GetBytes(jsonc.ToJSONInPlace(b), "snowcfg.advancedModel").String() + if err := write(ref, ""); err != nil { + return err + } + if active() == snowProfile { + return mirror(snowProfile) + } + return nil + }, + Notice: func() string { + if Running(`(^|/)snow( |$)`) { + return "Snow CLI reads its profile at start-up — restart open snow sessions to use this." + } + return "" + }, + Check: func() string { + if active() != snowProfile { + return "" + } + b, _ := edit.Read(ours) + if b == nil { + return "" + } + cfg := gjson.GetBytes(jsonc.ToJSONInPlace(b), "snowcfg") + if !snowOurKey(cfg.Get("apiKey").String()) { + return "" + } + if m := cfg.Get("requestMethod").String(); m != "" && m != "chat" { + return "Snow CLI's requestMethod (profiles/" + snowProfile + ".json) is " + m + ", not chat, so magpie's gateway won't take its requests" + } + return wiringOff("Snow CLI", ours, func(k string) (string, bool) { + g := cfg.Get(k) + return g.String(), g.Exists() + }, "baseUrl", at.v1(), "apiKey", key()) + }, + Fields: []Field{{ + Key: "model", Label: "model", + Get: get, + Set: func(v string) error { + if ref, ok := strings.CutPrefix(v, magpieID+"/"); ok && isMagpie(ref) { + base := active() + if !onOurs() { + if b, err := edit.Read(ours); err == nil && b != nil && !snowIsOurs(b) { + stash(map[string]string{keyHad: string(b)}) + } + if _, err := os.Stat(activeFile); errors.Is(err, fs.ErrNotExist) { + if _, err := os.Stat(legacyActive); errors.Is(err, fs.ErrNotExist) { + stash(map[string]string{keyNone: "1"}) + } + } + stash(map[string]string{keyActive: base}) + // a Snow from before profiles copies config.json + // into a new default profile at its next start and + // makes that the active one: done here, so it is + // the user's config that is copied, not magpie's + if _, err := os.Stat(defaultProfile); errors.Is(err, fs.ErrNotExist) { + if b, err := edit.Read(config); err == nil && b != nil && !snowIsOurs(b) { + if err := edit.WriteAtomic(defaultProfile, b); err != nil { + return err + } + } + } + } else { + base = "" + } + if err := write(ref, base); err != nil { + return err + } + if err := setActive(snowProfile); err != nil { + return err + } + return mirror(snowProfile) + } + if !onOurs() { + // on a profile of the user's own: its model is theirs + // to change, as Snow's own model picker does + if v == "" { + return nil + } + name := active() + if p := snowFindProfile(profiles, v, name); p != "" && p != name { + if err := setActive(p); err != nil { + return err + } + return mirror(p) + } + if _, err := os.Stat(profileOf(name)); err != nil { + return edit.SetJSON(config, edit.KV{Path: "snowcfg.advancedModel", Value: v}) + } + if err := edit.SetJSON(profileOf(name), edit.KV{Path: "snowcfg.advancedModel", Value: v}); err != nil { + return err + } + return mirror(name) + } + // leaving magpie's profile: for the user's profile with + // that model, or the one they were on + to := "" + if v != "" { + was := stashLoad()[keyActive] + to = snowFindProfile(profiles, v, was) + if to == "" { + to = was + if to == "" { + to = "default" + } + if _, err := os.Stat(profileOf(to)); err == nil { + if err := edit.SetJSON(profileOf(to), edit.KV{Path: "snowcfg.advancedModel", Value: v}); err != nil { + return err + } + } + } + } + return leave(to) + }, + Options: func(cur map[string]string) []Option { + return append(snowOwnOptions(profiles, cur["model"]), viaMagpie("snow", magpieID+"/")...) + }, + }}, + }, ours, activeFile, config, defaultProfile) +} + +// snowOurKey says k is a key magpie gives Snow CLI. +func snowOurKey(k string) bool { + return k != "" && (ourKey(k) || strings.HasPrefix(k, gateway.Token+"-")) +} + +// snowIsOurs says a profile (or config.json) is magpie's: its key is one +// magpie gave. +func snowIsOurs(b []byte) bool { + return snowOurKey(gjson.GetBytes(jsonc.ToJSONInPlace(append([]byte(nil), b...)), "snowcfg.apiKey").String()) +} + +// snowProfileFor is the profile magpie writes for ref: from (a profile of +// the user's, or magpie's own already) with the gateway set in, the user's +// sign-in left out; Snow's own defaults where from is empty. +func snowProfileFor(from []byte, v1, key, ref string) ([]byte, error) { + cfg := map[string]any{} + if len(from) > 0 { + if err := json.Unmarshal(jsonc.ToJSON(from), &cfg); err != nil { + return nil, err + } + } + sc, _ := cfg["snowcfg"].(map[string]any) + if sc == nil { + sc = map[string]any{} + } + delete(sc, "oauth") + ctx, out := snowContext, snowOutput + for _, m := range magpieModels("snow") { + if m.ID == ref { + if m.Context > 0 { + ctx = m.Context + } + if n := maxTokens(m); n > 0 { + out = n + } + break + } + } + // an output above the window Snow is told is cut to it, as for a + // model whose window isn't known + out = min(out, ctx) + sc["baseUrl"] = v1 + sc["baseUrlMode"] = "base" + sc["apiKey"] = key + sc["requestMethod"] = "chat" + sc["advancedModel"] = ref + sc["basicModel"] = ref + sc["maxContextTokens"] = ctx + sc["maxTokens"] = out + cfg["snowcfg"] = sc + b, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return nil, err + } + return b, nil +} + +// snowProfiles are the profiles of the user's own: their names, and the +// main model each is on. +func snowProfiles(dir string) (names []string, models map[string]string) { + models = map[string]string{} + es, err := os.ReadDir(dir) + if err != nil { + return nil, models + } + for _, e := range es { + name, ok := strings.CutSuffix(e.Name(), ".json") + if !ok || e.IsDir() { + continue + } + b, err := edit.Read(filepath.Join(dir, e.Name())) + if err != nil || b == nil || snowIsOurs(b) { + continue + } + names = append(names, name) + models[name] = gjson.GetBytes(jsonc.ToJSONInPlace(b), "snowcfg.advancedModel").String() + } + sort.Strings(names) + return names, models +} + +// snowFindProfile is the user's profile on model v: prefer, when it is on +// it, else the first by name; "" for none. +func snowFindProfile(dir, v, prefer string) string { + names, models := snowProfiles(dir) + if prefer != "" && models[prefer] == v { + return prefer + } + for _, n := range names { + if models[n] == v { + return n + } + } + return "" +} + +// snowOwnOptions are the models of the user's own profiles, and the +// current pick. +func snowOwnOptions(dir, cur string) []Option { + names, models := snowProfiles(dir) + seen := map[string]bool{} + var out []Option + for _, n := range names { + m := models[n] + if m == "" || seen[m] { + continue + } + seen[m] = true + out = append(out, Option{Value: m, Icon: modelIcon("", m)}) + } + if cur != "" && !seen[cur] && !strings.HasPrefix(cur, magpieID+"/") { + out = append([]Option{{Value: cur, Icon: modelIcon("", cur)}}, out...) + } + return group("Snow CLI", out) +} + +func jsonString(s string) string { b, _ := json.Marshal(s); return string(b) } diff --git a/internal/agent/snow_test.go b/internal/agent/snow_test.go new file mode 100644 index 00000000..4f7d71b5 --- /dev/null +++ b/internal/agent/snow_test.go @@ -0,0 +1,342 @@ +package agent + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/yetone/magpie/internal/gateway" + "github.com/yetone/magpie/internal/provider" +) + +// snowDefault is a profile as Snow CLI 0.9.2 writes it for someone on a +// provider of their own, with settings magpie has nothing to do with. +const snowDefault = `{ + "snowcfg": { + "baseUrl": "https://idealab.example.com/v1", + "baseUrlMode": "auto", + "apiKey": "team-key", + "requestMethod": "chat", + "advancedModel": "glm-5", + "basicModel": "glm-5-air", + "supportsVision": true, + "maxContextTokens": 200000, + "maxTokens": 64000, + "streamingDisplay": false, + "systemPromptId": "terse" + }, + "companionMuted": true +}` + +// snowWork is a second profile of the user's, signed in to a subscription +// through Snow's own OAuth. +const snowWork = `{ + "snowcfg": { + "baseUrl": "https://api.example.com/v1", + "apiKey": "", + "requestMethod": "responses", + "advancedModel": "kimi-k3", + "basicModel": "kimi-k3", + "oauth": {"provider": "example", "accessToken": "secret"} + } +}` + +func snowHome(t *testing.T) (home, dir string) { + t.Helper() + home = syncHome(t) + if err := provider.Save(provider.Provider{ID: "deepseek", Name: "DeepSeek", Key: "k", Chat: "http://127.0.0.1:1/v1", Models: []string{"pro"}}); err != nil { + t.Fatal(err) + } + dir = filepath.Join(home, ".snow") + os.MkdirAll(filepath.Join(dir, "profiles"), 0o755) + return home, dir +} + +// snowUser lays out ~/.snow as Snow leaves it with the user's two +// profiles, default the active one. +func snowUser(t *testing.T, dir string) { + t.Helper() + os.WriteFile(filepath.Join(dir, "profiles", "default.json"), []byte(snowDefault), 0o644) + os.WriteFile(filepath.Join(dir, "profiles", "work.json"), []byte(snowWork), 0o644) + os.WriteFile(filepath.Join(dir, "active-profile.json"), []byte("{\n \"activeProfile\": \"default\"\n}"), 0o644) + os.WriteFile(filepath.Join(dir, "config.json"), []byte(snowDefault), 0o644) +} + +func snowCfg(t *testing.T, path string) map[string]any { + t.Helper() + var f map[string]any + if err := json.Unmarshal([]byte(readFile(path)), &f); err != nil { + t.Fatalf("%s: %v\n%s", path, err, readFile(path)) + } + sc, _ := f["snowcfg"].(map[string]any) + if sc == nil { + t.Fatalf("%s has no snowcfg:\n%s", path, readFile(path)) + } + return sc +} + +func snowActive(dir string) string { + b, err := os.ReadFile(filepath.Join(dir, "active-profile.json")) + if err != nil { + return "(none)" + } + var v struct{ ActiveProfile string } + json.Unmarshal(b, &v) + return v.ActiveProfile +} + +func TestSnow(t *testing.T) { + _, dir := snowHome(t) + snowUser(t, dir) + a, err := Find("snow") + if err != nil { + t.Fatal(err) + } + if !a.Detected() || a.Dir != dir { + t.Fatalf("not found in %s: %+v", dir, a) + } + if got := a.Values()["model"]; got != "glm-5" { + t.Fatalf("model: %q", got) + } + // the picker: the user's profiles' models, then magpie's catalog + opts := a.Field("model").Options(a.Values()) + if len(opts) < 3 || opts[0].Value != "glm-5" || opts[1].Value != "kimi-k3" || opts[0].Group != "Snow CLI" { + t.Fatalf("own options: %+v", opts) + } + var via bool + for _, o := range opts { + via = via || o.Value == "magpie/deepseek/pro" + } + if !via { + t.Fatalf("no magpie/deepseek/pro in %+v", opts) + } + + if err := a.Apply("model", "magpie/deepseek/pro"); err != nil { + t.Fatal(err) + } + ours := filepath.Join(dir, "profiles", "magpie.json") + sc := snowCfg(t, ours) + for k, want := range map[string]any{ + "baseUrl": gatewayV1(), "baseUrlMode": "base", "apiKey": gateway.TokenFor("snow"), "requestMethod": "chat", + "advancedModel": "deepseek/pro", "basicModel": "deepseek/pro", + // the user's own settings come along + "streamingDisplay": false, "systemPromptId": "terse", + } { + if sc[k] != want { + t.Errorf("snowcfg.%s = %v, want %v", k, sc[k], want) + } + } + if !strings.Contains(readFile(ours), `"companionMuted": true`) { + t.Errorf("the profile's other settings are gone:\n%s", readFile(ours)) + } + if got := snowActive(dir); got != "magpie" { + t.Fatalf("active profile %q", got) + } + // config.json is a copy of the active profile, as Snow's switch makes it + if readFile(filepath.Join(dir, "config.json")) != readFile(ours) { + t.Fatalf("config.json isn't magpie's profile:\n%s", readFile(filepath.Join(dir, "config.json"))) + } + if readFile(filepath.Join(dir, "profiles", "default.json")) != snowDefault || readFile(filepath.Join(dir, "profiles", "work.json")) != snowWork { + t.Fatal("a profile of the user's was rewritten") + } + if got := a.Values()["model"]; got != "magpie/deepseek/pro" { + t.Fatalf("model after pick: %q", got) + } + if !a.Wired() { + t.Fatal("not connected on magpie's model") + } + if msg := a.Check(); msg != "" { + t.Fatalf("check: %s", msg) + } + // its own profile's list doesn't take magpie's for the user's + for _, o := range a.Field("model").Options(a.Values()) { + if o.Group == "Snow CLI" && o.Value == "deepseek/pro" { + t.Fatalf("magpie's profile listed as the user's: %+v", o) + } + } + + // another of magpie's: the same profile, the model moved + if err := a.Apply("model", "magpie/relay/glm-4.6"); err != nil { + t.Fatal(err) + } + if sc := snowCfg(t, ours); sc["advancedModel"] != "relay/glm-4.6" || sc["streamingDisplay"] != false { + t.Fatalf("second pick: %v", sc) + } + // glm-4.6's context, from the catalog + if sc := snowCfg(t, ours); sc["maxContextTokens"] != float64(204800) { + t.Fatalf("context: %v", sc["maxContextTokens"]) + } + + // back: the profile the user was on, magpie's gone + if err := a.Apply("model", ""); err != nil { + t.Fatal(err) + } + if got := snowActive(dir); got != "default" { + t.Fatalf("active after reset: %q", got) + } + if _, err := os.Stat(ours); !os.IsNotExist(err) { + t.Fatalf("magpie.json left: %v", err) + } + if readFile(filepath.Join(dir, "config.json")) != snowDefault { + t.Fatalf("config.json not the user's profile again:\n%s", readFile(filepath.Join(dir, "config.json"))) + } + if got := a.Values()["model"]; got != "glm-5" { + t.Fatalf("model after reset: %q", got) + } + for k := range stashLoad() { + if strings.HasPrefix(k, "snow:") { + t.Errorf("stash keeps %s", k) + } + } +} + +// A model of another profile of the user's, picked while on magpie's, +// switches Snow to that profile; its OAuth sign-in never comes into +// magpie's. +func TestSnowOwnProfile(t *testing.T) { + _, dir := snowHome(t) + snowUser(t, dir) + os.WriteFile(filepath.Join(dir, "active-profile.json"), []byte(`{"activeProfile": "work"}`), 0o644) + os.WriteFile(filepath.Join(dir, "config.json"), []byte(snowWork), 0o644) + a, _ := Find("snow") + if err := a.Apply("model", "magpie/deepseek/pro"); err != nil { + t.Fatal(err) + } + ours := filepath.Join(dir, "profiles", "magpie.json") + if body := readFile(ours); strings.Contains(body, "oauth") || strings.Contains(body, "secret") { + t.Fatalf("the user's sign-in copied into magpie's profile:\n%s", body) + } + if sc := snowCfg(t, ours); sc["requestMethod"] != "chat" { + t.Fatalf("requestMethod %v", sc["requestMethod"]) + } + if err := a.Apply("model", "glm-5"); err != nil { + t.Fatal(err) + } + if got := snowActive(dir); got != "default" { + t.Fatalf("active %q, want default (the profile on glm-5)", got) + } + if _, err := os.Stat(ours); !os.IsNotExist(err) { + t.Fatal("magpie.json left") + } + if readFile(filepath.Join(dir, "config.json")) != snowDefault { + t.Fatal("config.json isn't default's") + } + if readFile(filepath.Join(dir, "profiles", "work.json")) != snowWork { + t.Fatal("work.json rewritten") + } +} + +// A Snow from before profiles (config.json alone) and one never started +// (an empty ~/.snow) are each left as they were. +func TestSnowNoProfiles(t *testing.T) { + t.Run("config.json alone", func(t *testing.T) { + _, dir := snowHome(t) + os.Remove(filepath.Join(dir, "profiles")) + os.WriteFile(filepath.Join(dir, "config.json"), []byte(snowDefault), 0o644) + a, _ := Find("snow") + if got := a.Values()["model"]; got != "glm-5" { + t.Fatalf("model: %q", got) + } + if err := a.Apply("model", "magpie/deepseek/pro"); err != nil { + t.Fatal(err) + } + // Snow makes a default profile of config.json at its next start + // when there is none: the user's, not magpie's + if readFile(filepath.Join(dir, "profiles", "default.json")) != snowDefault { + t.Fatalf("default.json:\n%s", readFile(filepath.Join(dir, "profiles", "default.json"))) + } + if err := a.Apply("model", ""); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(dir, "active-profile.json")); !os.IsNotExist(err) { + t.Fatal("active-profile.json left, there was none") + } + if readFile(filepath.Join(dir, "config.json")) != snowDefault { + t.Fatal("config.json not the user's") + } + if got := a.Values()["model"]; got != "glm-5" { + t.Fatalf("model after: %q", got) + } + }) + t.Run("never started", func(t *testing.T) { + _, dir := snowHome(t) + a, _ := Find("snow") + if err := a.Apply("model", "magpie/deepseek/pro"); err != nil { + t.Fatal(err) + } + sc := snowCfg(t, filepath.Join(dir, "profiles", "magpie.json")) + if sc["baseUrl"] != gatewayV1() || sc["maxContextTokens"] != float64(snowContext) { + t.Fatalf("%v", sc) + } + if err := a.Apply("model", ""); err != nil { + t.Fatal(err) + } + for _, f := range []string{"active-profile.json", "config.json", "profiles/magpie.json", "profiles/default.json"} { + if _, err := os.Stat(filepath.Join(dir, f)); !os.IsNotExist(err) { + t.Errorf("%s left behind", f) + } + } + }) +} + +// A profile of the user's own named magpie is theirs: not read as +// magpie's, and back as it was once magpie steps out. +func TestSnowUsersMagpieProfile(t *testing.T) { + _, dir := snowHome(t) + snowUser(t, dir) + mine := filepath.Join(dir, "profiles", "magpie.json") + os.WriteFile(mine, []byte(snowWork), 0o644) + os.WriteFile(filepath.Join(dir, "active-profile.json"), []byte(`{"activeProfile": "magpie"}`), 0o644) + a, _ := Find("snow") + if got := a.Values()["model"]; got != "kimi-k3" || a.Wired() { + t.Fatalf("the user's own magpie profile reads %q, wired %v", got, a.Wired()) + } + if err := a.Apply("model", "magpie/deepseek/pro"); err != nil { + t.Fatal(err) + } + if sc := snowCfg(t, mine); sc["apiKey"] != gateway.TokenFor("snow") { + t.Fatalf("%v", sc) + } + if err := a.Apply("model", ""); err != nil { + t.Fatal(err) + } + if readFile(mine) != snowWork || snowActive(dir) != "magpie" { + t.Fatalf("the user's magpie profile not back (active %q):\n%s", snowActive(dir), readFile(mine)) + } +} + +// Something else rewrote magpie's profile: Check says what, and Sync puts +// the gateway back; $SNOW_CONFIG_DIR is where Snow looks. +func TestSnowCheckSync(t *testing.T) { + home, _ := snowHome(t) + dir := filepath.Join(home, "elsewhere") + t.Setenv("SNOW_CONFIG_DIR", dir) + os.MkdirAll(filepath.Join(dir, "profiles"), 0o755) + snowUser(t, dir) + a, _ := Find("snow") + if a.Dir != dir { + t.Fatalf("dir %s", a.Dir) + } + if err := a.Apply("model", "magpie/deepseek/pro"); err != nil { + t.Fatal(err) + } + ours := filepath.Join(dir, "profiles", "magpie.json") + os.WriteFile(ours, []byte(strings.Replace(readFile(ours), gatewayV1(), "http://127.0.0.1:9/v1", 1)), 0o644) + if msg := a.Check(); !strings.Contains(msg, "baseUrl") { + t.Fatalf("check: %q", msg) + } + if err := a.Sync(); err != nil { + t.Fatal(err) + } + if msg := a.Check(); msg != "" { + t.Fatalf("after sync: %s", msg) + } + if readFile(filepath.Join(dir, "config.json")) != readFile(ours) { + t.Fatal("config.json not synced") + } + if _, err := os.Stat(filepath.Join(home, ".snow", "profiles", "magpie.json")); !os.IsNotExist(err) { + t.Fatal("wrote ~/.snow with SNOW_CONFIG_DIR set") + } +} diff --git a/internal/agent/spelled_test.go b/internal/agent/spelled_test.go index e885824a..4cda7974 100644 --- a/internal/agent/spelled_test.go +++ b/internal/agent/spelled_test.go @@ -14,7 +14,7 @@ import ( func TestOwnProviderNamedAsMagpies(t *testing.T) { ids := []string{"agy", "opencode", "openchamber", "mimocode", "pi", "aside", "omo", "goose", "zed", "vscode", "crush", "dsh", "commandcode", "fx", "omp", "hermes", "morph", "kimi", "empryo", "minimax-code", "droid", - "qoder", "qoder-cn", "grok", "atomcode", "cline"} + "qoder", "qoder-cn", "grok", "atomcode", "cline", "snow"} for _, id := range ids { t.Run(id, func(t *testing.T) { if id == "aside" { diff --git a/internal/agent/sync_test.go b/internal/agent/sync_test.go index 9c054c2e..c5e66cb0 100644 --- a/internal/agent/sync_test.go +++ b/internal/agent/sync_test.go @@ -133,8 +133,8 @@ func TestCrushModelsCarryMaxTokens(t *testing.T) { // magpie hands an output limit; one whose window isn't known keeps its // output. ZCode and WorkBuddy cap it at zcodeMaxOutput besides, Crush falls // back to 16384 without a known output, OpenCode is handed no limit without -// a window, and AtomCode, told a 128000 window when the model's isn't known, -// is cut to that. +// a window, and AtomCode and Snow CLI, each told a window of its own when +// the model's isn't known, are cut to that. func TestMaxTokensWithinContextWindow(t *testing.T) { home := syncHome(t) check := func(limit string, want int) { @@ -156,6 +156,10 @@ func TestMaxTokensWithinContextWindow(t *testing.T) { t.Fatal(err) } atomcodeCfg, _ := os.ReadFile(atomcode) + snowOut, err := snowProfileFor(nil, gatewayV1(), "k", "relay/glm-4.6") + if err != nil { + t.Fatal(err) + } rules, wb := filepath.Join(t.TempDir(), "provider_config.json"), filepath.Join(t.TempDir(), "models.json") if err := zcodeRules(rules, true, true); err != nil { t.Fatal(err) @@ -170,6 +174,10 @@ func TestMaxTokensWithinContextWindow(t *testing.T) { if !strings.Contains(limit, "context") { told = strconv.Itoa(min(want, atomcodeContext)) } + snowTold := n + if !strings.Contains(limit, "context") { + snowTold = strconv.Itoa(min(want, snowContext)) + } wants := map[string][2]string{ "pi": {string(pi), `"maxTokens":` + n}, "cline": {string(cline), `"maxTokens":` + n}, @@ -183,6 +191,7 @@ func TestMaxTokensWithinContextWindow(t *testing.T) { "zcode rules": {string(zcRules), `"max":` + capped}, "workbuddy": {string(wbModels), `"maxOutputTokens": ` + capped}, "atomcode": {string(atomcodeCfg), "max_tokens = " + told}, + "snow": {string(snowOut), `"maxTokens": ` + snowTold}, } if strings.Contains(limit, "context") { wants["opencode"] = [2]string{string(opencode), `"output":` + n} diff --git a/internal/agent/wsl.go b/internal/agent/wsl.go index da71a587..ae62a6cd 100644 --- a/internal/agent/wsl.go +++ b/internal/agent/wsl.go @@ -440,6 +440,11 @@ var wslKinds = []wslKind{ return append(atomcodeOwnModels("", cur["model"]), viaMagpie("atomcode", magpieID+"/")...) } }}, + // Snow CLI's ~/.snow, its profiles the distro's own; a stopped + // distro's are looked at once it is started + {id: "snow", name: "Snow CLI", dir: ".snow", bin: "snow", in: snowIn, + restart: "reads its profile at start-up — restart open snow sessions to use this.", + asleep: wslOwnAsleep("snow", "model")}, // 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, diff --git a/internal/agent/wsl_more_test.go b/internal/agent/wsl_more_test.go index ce07d9dd..dd41e15f 100644 --- a/internal/agent/wsl_more_test.go +++ b/internal/agent/wsl_more_test.go @@ -39,6 +39,7 @@ var wslMore = []struct { {"codebuddy", ".codebuddy/models.json", "/v1/chat/completions"}, {"cline", ".cline/data/settings/providers.json", "/v1"}, {"atomcode", ".atomcode/config.toml", "/v1"}, + {"snow", ".snow/profiles/magpie.json", "/v1"}, {"goose", ".config/goose/custom_providers/magpie.json", "/v1"}, {"reasonix", ".reasonix/config.toml", "/v1"}, } @@ -67,7 +68,7 @@ func moveHostDirs(t *testing.T) string { 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", - "CODEBUDDY_CONFIG_DIR", "CLINE_DIR", "CLINE_DATA_DIR", "ATOMCODE_HOME", "REASONIX_HOME", "APPDATA"} { + "CODEBUDDY_CONFIG_DIR", "CLINE_DIR", "CLINE_DATA_DIR", "ATOMCODE_HOME", "REASONIX_HOME", "SNOW_CONFIG_DIR", "APPDATA"} { t.Setenv(v, filepath.Join(moved, v)) } return moved diff --git a/internal/agentenv/agentenv.go b/internal/agentenv/agentenv.go index f5a640fc..ffdf42e1 100644 --- a/internal/agentenv/agentenv.go +++ b/internal/agentenv/agentenv.go @@ -68,6 +68,8 @@ var Vars = []string{ "T3CODE_HOME", // AtomCode's config folder "ATOMCODE_HOME", + // Snow CLI's config folder, ~/.snow without it + "SNOW_CONFIG_DIR", // Cursor's CLI: its config folder (its chats) and its data folder "CURSOR_CONFIG_DIR", "CURSOR_DATA_DIR", // OpenCode and OpenChamber diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index ec249448..75922fba 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -166,7 +166,7 @@ function keepIcons(...roots) { keptIcons.get(e.dataset.icon).push(e); } } -const pngIcons = new Set(["crush", "zcode", "alma", "hanako", "cindy", "typesafe", "atomcode", "mlx-serve"]); +const pngIcons = new Set(["crush", "zcode", "alma", "hanako", "cindy", "typesafe", "atomcode", "snow", "mlx-serve"]); function icon(name) { const kept = keptIcons?.get(name || "")?.shift(); diff --git a/internal/gui/assets/icons/snow.png b/internal/gui/assets/icons/snow.png new file mode 100644 index 00000000..02dfd5bb Binary files /dev/null and b/internal/gui/assets/icons/snow.png differ diff --git a/site/public/docs/ja/start.html b/site/public/docs/ja/start.html index 7d90aeea..1cd3d10d 100644 --- a/site/public/docs/ja/start.html +++ b/site/public/docs/ja/start.html @@ -147,6 +147,7 @@ Codex~/.codex/config.toml:[model_providers.magpie] テーブルとモデル、一覧は magpie-models.json。ChatGPT のサインインには手を加えません OpenCode、MiMo Code、Pi、Crushmagpie プロバイダのエントリと magpie/provider/model AtomCode~/.atomcode/config.toml:magpie プロバイダアカウントと、カタログ内の各モデルのエントリ + Snow CLI~/.snow/profiles/magpie.json:magpie プロファイルを作り、使用中のプロファイルにする(Snow App は「Sync Snow CLI API config」で取り込む) Gemini CLIゲートウェイを指す GOOGLE_GEMINI_BASE_URL、API キー認証、そしてモデル

実行中のセッションは、開始時のモデルを使い続けます。 エージェントは起動時に設定を読むので、変更は次のセッションから反映されます。Codex は起動時にモデル一覧も作るため、切り替えたら Codex アプリと開いている codex セッションを再起動してください。

diff --git a/site/public/docs/start.html b/site/public/docs/start.html index a8263d4b..dcae3bc4 100644 --- a/site/public/docs/start.html +++ b/site/public/docs/start.html @@ -147,6 +147,7 @@ Codex~/.codex/config.toml: a [model_providers.magpie] table and the model, with the list in magpie-models.json. Your ChatGPT sign-in is left alone OpenCode, MiMo Code, Pi, CrushA magpie provider entry, and magpie/provider/model AtomCode~/.atomcode/config.toml: a magpie provider account and one model entry per catalog model + Snow CLI~/.snow/profiles/magpie.json: a magpie profile, made the active one (Snow App takes it in with "Sync Snow CLI API config") Gemini CLIGOOGLE_GEMINI_BASE_URL pointed at the gateway, API-key auth, and the model

A running session keeps the model it started with. Agents read their config when they start, so the change applies to the next session. Codex builds its model list at start-up too: restart the Codex app and open codex sessions after a switch.

diff --git a/site/public/docs/zh/start.html b/site/public/docs/zh/start.html index 73c87bd0..aec36b86 100644 --- a/site/public/docs/zh/start.html +++ b/site/public/docs/zh/start.html @@ -147,6 +147,7 @@ Codex~/.codex/config.toml:一个 [model_providers.magpie] 表和模型,模型列表写在 magpie-models.json。你的 ChatGPT 登录不受影响 OpenCode、MiMo Code、Pi、Crush一个 magpie 供应商条目,以及 magpie/provider/model AtomCode~/.atomcode/config.toml:一个 magpie 供应商账号,以及目录中每个模型各自的条目 + Snow CLI~/.snow/profiles/magpie.json:一个 magpie 配置文件,并设为当前配置(Snow App 用“同步 Snow CLI API 配置”导入) Gemini CLIGOOGLE_GEMINI_BASE_URL 指向网关、API 密钥认证,以及模型

已在运行的会话保持它启动时的模型。Agent 启动时读取配置,所以改动从下一个会话生效。Codex 也在启动时生成模型列表:切换后请重启 Codex 应用和已打开的 codex 会话。

diff --git a/site/public/icons/snow.png b/site/public/icons/snow.png new file mode 100644 index 00000000..02dfd5bb Binary files /dev/null and b/site/public/icons/snow.png differ diff --git a/site/public/index.html b/site/public/index.html index 587ef639..9c0ce9dd 100644 --- a/site/public/index.html +++ b/site/public/index.html @@ -478,6 +478,7 @@ footer a:hover { color: var(--ink); }
  • ZCode
  • OpenHanako
  • AtomCode
  • +
  • Snow CLI
  • Alma
  • Every model

    diff --git a/snow_cli_test.go b/snow_cli_test.go new file mode 100644 index 00000000..b68d7870 --- /dev/null +++ b/snow_cli_test.go @@ -0,0 +1,65 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// `magpie snow a/m` puts Snow CLI on a magpie profile and makes it the +// active one; `magpie snow default` puts back the profile the user was on, +// its files as they were. +func TestAgentSnowDefault(t *testing.T) { + groupsHome(t) + t.Setenv("SNOW_CONFIG_DIR", "") + dir := filepath.Join(os.Getenv("HOME"), ".snow") + os.MkdirAll(filepath.Join(dir, "profiles"), 0o755) + own := `{ + "snowcfg": { + "baseUrl": "https://idealab.example.com/v1", + "apiKey": "team-key", + "requestMethod": "chat", + "advancedModel": "glm-5", + "basicModel": "glm-5" + } +}` + active := "{\n \"activeProfile\": \"default\"\n}" + files := map[string]string{ + "profiles/default.json": own, + "config.json": own, + "active-profile.json": active, + } + for f, s := range files { + if err := os.WriteFile(filepath.Join(dir, f), []byte(s), 0o644); err != nil { + t.Fatal(err) + } + } + read := func(f string) string { + b, _ := os.ReadFile(filepath.Join(dir, f)) + return string(b) + } + if err := run([]string{"snow", "a/m"}); err != nil { + t.Fatal(err) + } + s := read("profiles/magpie.json") + for _, want := range []string{`"advancedModel": "a/m"`, `"baseUrlMode": "base"`, `"requestMethod": "chat"`, `/v1"`} { + if !strings.Contains(s, want) { + t.Fatalf("not wired (%s missing):\n%s", want, s) + } + } + if !strings.Contains(read("active-profile.json"), `"magpie"`) || read("config.json") != s { + t.Fatalf("magpie's profile isn't the active one:\n%s\n%s", read("active-profile.json"), read("config.json")) + } + if err := run([]string{"snow", "default"}); err != nil { + t.Fatal(err) + } + for f, want := range files { + if got := read(f); got != want { + t.Errorf("%s not put back:\n%s", f, got) + } + } + if _, err := os.Stat(filepath.Join(dir, "profiles", "magpie.json")); !os.IsNotExist(err) { + t.Error("magpie.json left") + } +}