+// 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、Crush | magpie プロバイダのエントリと 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, Crush | A 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 CLI | GOOGLE_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 CLI | GOOGLE_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")
+ }
+}