diff --git a/README.md b/README.md index 1976e4ab..c8697eaa 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ and there is a terminal version (`magpie tui`) and a plain CLI. Codex gpt-6-astra effort medium Gemini CLI gemini-3.1-pro OpenCode anthropic/claude-sonnet-5 small anthropic/claude-haiku-4-5 + MiMo Code anthropic/claude-sonnet-5 Pi openrouter/z-ai/glm-5.2:batch Goose anthropic/claude-sonnet-5 Cursor auto @@ -68,6 +69,7 @@ and there is a terminal version (`magpie tui`) and a plain CLI. | Codex | `~/.codex/config.toml` | provider, model, effort | | Gemini CLI | `~/.gemini/settings.json`, `~/.gemini/.env` | auth, model | | OpenCode | `~/.config/opencode/opencode.json(c)` | model, small | +| MiMo Code | `~/.config/mimocode/mimocode.json(c)` | model, small | | Pi | `~/.pi/agent/settings.json` | model | | Goose | `~/.config/goose/config.yaml` | model | | Cursor CLI | `~/.cursor/cli-config.json` | model | @@ -85,7 +87,7 @@ and there is a terminal version (`magpie tui`) and a plain CLI. | ZCode | `~/.zcode/v2/config.json` | provider (magpie's models in ZCode's picker) | | Alma | Alma's local API (`localhost:23001`, while Alma runs) | model (Alma's default; magpie's models as a provider) | -Provider-scoped agents (OpenCode, Pi, Goose, Crush, omp, Hermes Agent) take `provider/model`. +Provider-scoped agents (OpenCode, MiMo Code, Pi, Goose, Crush, omp, Hermes Agent) take `provider/model`. Only agents that are installed or configured are shown. ## Providers and the gateway @@ -359,6 +361,7 @@ magpie claude haiku "" # back to the main model magpie gemini auth api-key magpie opencode anthropic/claude-sonnet-5 magpie oc small anthropic/claude-haiku-4-5 +magpie mimo anthropic/claude-sonnet-5 magpie save work # snapshot everything as a profile magpie use work # switch back diff --git a/internal/agent/agents.go b/internal/agent/agents.go index 60440ab5..94ea7027 100644 --- a/internal/agent/agents.go +++ b/internal/agent/agents.go @@ -53,6 +53,7 @@ func All() []*Agent { codex(home), gemini(home), opencode(home, cfg), + mimocode(home, cfg), pi(home), goose(home, cfg), cursor(home), @@ -187,7 +188,14 @@ func ownOptions(authFile string, cur string, extra ...string) []Option { // magpieProviderJSON is the provider block agents with JSON configs get. func magpieProviderJSON(shape string) any { - models := magpieModels(shape) // the shape is the agent's + return magpieProviderJSONFor(shape, shape) +} + +// magpieProviderJSONFor is magpieProviderJSON for an agent whose catalog is +// narrowed under a different id than its config shape (mimocode shares +// OpenCode's shape but is seen as itself by magpie visible). +func magpieProviderJSONFor(shape, catalog string) any { + models := magpieModels(catalog) // the catalog is the agent's switch shape { case "opencode": ms := map[string]any{} @@ -268,16 +276,18 @@ func piThinkingLevels(efforts []string) map[string]any { return levels } -func opencode(home, cfg string) *Agent { - dir := filepath.Join(cfg, "opencode") - path := filepath.Join(dir, "opencode.json") - if _, err := os.Stat(filepath.Join(dir, "opencode.jsonc")); err == nil { - path = filepath.Join(dir, "opencode.jsonc") +// openCodeLike is OpenCode and the forks that keep its config shape +// (mimocode): a provider block with npm/@ai-sdk/openai-compatible, model and +// small_model fields, and its own auth file beside its config. +func openCodeLike(id, name, icon, bin, dir, auth string, ua []string, aliases ...string) *Agent { + path := filepath.Join(dir, id+".json") + if _, err := os.Stat(filepath.Join(dir, id+".jsonc")); err == nil { + path = filepath.Join(dir, id+".jsonc") } - auth := filepath.Join(home, ".local", "share", "opencode", "auth.json") + provider := func() any { return magpieProviderJSONFor("opencode", id) } opts := func(key string) func(map[string]string) []Option { return func(cur map[string]string) []Option { - return append(ownOptions(auth, cur[key]), viaMagpie("opencode", magpieID+"/")...) + return append(ownOptions(auth, cur[key]), viaMagpie(id, magpieID+"/")...) } } get := func(k string) string { v, _ := edit.GetJSON(path, k); return v } @@ -299,7 +309,7 @@ func opencode(home, cfg string) *Agent { if own := ownGatewayProvider(path, ref); own != "" { return edit.SetJSON(path, edit.KV{Path: key, Value: own + "/" + ref}) } - if err := edit.SetJSON(path, edit.KV{Path: "provider." + magpieID, Value: magpieProviderJSON("opencode")}); err != nil { + if err := edit.SetJSON(path, edit.KV{Path: "provider." + magpieID, Value: provider()}); err != nil { return err } } @@ -307,23 +317,23 @@ func opencode(home, cfg string) *Agent { } } return &Agent{ - ID: "opencode", Name: "OpenCode", Icon: "opencode", Aliases: []string{"oc"}, - UA: []string{"opencode"}, - Bin: "opencode", Dir: dir, Path: path, + ID: id, Name: name, Icon: icon, Aliases: aliases, + UA: ua, + Bin: bin, Dir: dir, Path: path, Check: func() string { if !usesMagpie(get("model"), get("small_model")) { return "" } - return wiringOff("OpenCode", path, func(k string) (string, bool) { return edit.GetJSON(path, "provider."+magpieID+".options."+k) }, + return wiringOff(name, path, func(k string) (string, bool) { return edit.GetJSON(path, "provider."+magpieID+".options."+k) }, "baseURL", gatewayV1(), "apiKey", gateway.Token) }, Sync: func() error { // a model of magpie's chosen, but its provider gone from the - // file: put it back, or OpenCode has nothing to send it to + // file: put it back, or the agent has nothing to send it to if _, ok := edit.GetJSON(path, "provider."+magpieID); !ok && usesMagpie(get("model"), get("small_model")) { - return edit.SetJSON(path, edit.KV{Path: "provider." + magpieID, Value: magpieProviderJSON("opencode")}) + return edit.SetJSON(path, edit.KV{Path: "provider." + magpieID, Value: provider()}) } - return syncJSON(path, "provider."+magpieID, func() any { return magpieProviderJSON("opencode") }) + return syncJSON(path, "provider."+magpieID, provider) }, Fields: []Field{ {Key: "model", Label: "model", Get: jsonGet(path, "model"), Set: set("model"), Options: opts("model")}, @@ -374,6 +384,18 @@ func sameGateway(base string) bool { return base != "" && norm(base) == norm(gatewayV1()) } +func opencode(home, cfg string) *Agent { + return openCodeLike("opencode", "OpenCode", "opencode", "opencode", + filepath.Join(cfg, "opencode"), filepath.Join(home, ".local", "share", "opencode", "auth.json"), + []string{"opencode"}, "oc") +} + +func mimocode(home, cfg string) *Agent { + return openCodeLike("mimocode", "MiMo Code", "mimocode", "mimo", + filepath.Join(cfg, "mimocode"), filepath.Join(home, ".local", "share", "mimocode", "auth.json"), + []string{"mimocode"}, "mimo") +} + func pi(home string) *Agent { dir := filepath.Join(home, ".pi", "agent") path := filepath.Join(dir, "settings.json") diff --git a/internal/agent/sync_test.go b/internal/agent/sync_test.go index bf1bb74b..8106400f 100644 --- a/internal/agent/sync_test.go +++ b/internal/agent/sync_test.go @@ -12,6 +12,7 @@ import ( "github.com/yetone/magpie/internal/codexcat" "github.com/yetone/magpie/internal/edit" "github.com/yetone/magpie/internal/provider" + "github.com/yetone/magpie/internal/settings" ) // syncHome is a sandbox home with a models.dev catalog that knows glm-4.6's @@ -235,3 +236,46 @@ func TestOpenCodeKeepsItsOwnGatewayProviders(t *testing.T) { t.Fatal("magpie's provider not added") } } + +// MiMo Code shares OpenCode's config shape: a sync writes magpie's provider +// into its own mimocode.json, and reads its own model list. +func TestMiMoCodeMirrorsOpenCode(t *testing.T) { + home := syncHome(t) + cfg := filepath.Join(home, ".config", "mimocode", "mimocode.json") + writeFile(t, cfg, `{"model":"magpie/relay/glm-4.6","provider":{"mine":{"name":"mine"}}}`) + if err := mimocode(home, filepath.Join(home, ".config")).Sync(); err != nil { + t.Fatal(err) + } + s := readFile(cfg) + if !strings.Contains(s, `"magpie"`) || !strings.Contains(s, `"mine"`) || !strings.Contains(s, `204800`) { + t.Fatalf("%s", s) + } + // one that doesn't use magpie gets nothing + body := `{"model":"mine/x","provider":{"mine":{"name":"mine"}}}` + writeFile(t, cfg, body) + if err := mimocode(home, filepath.Join(home, ".config")).Sync(); err != nil { + t.Fatal(err) + } + if s := readFile(cfg); s != body { + t.Fatalf("%s", s) + } +} + +// magpieProviderJSONFor reads the catalog narrowed under the agent's own id, +// while the config shape stays OpenCode's: a visibility on mimocode narrows +// its provider block and leaves OpenCode's alone. +func TestMiMoCodeCatalogNarrowedSeparately(t *testing.T) { + syncHome(t) + if err := settings.Save(settings.Settings{Visible: map[string][]string{"mimocode": {"relay"}}}); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { settings.Save(settings.Settings{}) }) + mimo := magpieProviderJSONFor("opencode", "mimocode").(map[string]any)["models"].(map[string]any) + oc := magpieProviderJSON("opencode").(map[string]any)["models"].(map[string]any) + if len(mimo) != 1 || mimo["relay/glm-4.6"] == nil { + t.Fatalf("mimocode catalog: %v", mimo) + } + if oc["relay/glm-4.6"] == nil { + t.Fatalf("opencode catalog was narrowed too: %v", oc) + } +} diff --git a/internal/agent/useragent_test.go b/internal/agent/useragent_test.go index e89a4df3..de2ef456 100644 --- a/internal/agent/useragent_test.go +++ b/internal/agent/useragent_test.go @@ -13,6 +13,7 @@ func TestAgentOf(t *testing.T) { "codex_cli_rs/0.40.0 (Mac OS 26.0; arm64)": "codex", "GeminiCLI/0.9.0 (darwin; arm64)": "gemini", "opencode/1.2.3": "opencode", + "mimocode/1.2.3": "mimocode", "curl/8.4.0": "curl", "oh-my-pi/1.0": "omp", "command-code/2.0": "commandcode", diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index d6fc04d1..8b818110 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -900,7 +900,7 @@ const I18N = { "{agent} is on — it gets the shared instructions once there are some": "{agent} 已开启 — 有共享指令后就会写入", "{agent} reads the shared instructions now": "{agent} 现在读取共享指令", "{agent} reads {file} instead of this file while it has anything in it.": "只要 {file} 有内容,{agent} 就会读它而不是这个文件。", - "OpenCode reads Claude Code's CLAUDE.md when it has no AGENTS.md of its own.": "OpenCode 在没有自己的 AGENTS.md 时会读取 Claude Code 的 CLAUDE.md。", + "{agent} reads Claude Code's CLAUDE.md when it has no AGENTS.md of its own.": "{agent} 在没有自己的 AGENTS.md 时会读取 Claude Code 的 CLAUDE.md。", "Only for {agent}, after the shared text": "仅给 {agent},接在共享内容之后", "Anything only {agent} should be told": "只需告诉 {agent} 的内容", "Switch {agent} on for it to read this.": "开启 {agent} 后它才会读到这些。", diff --git a/internal/gui/assets/icons/mimocode.svg b/internal/gui/assets/icons/mimocode.svg new file mode 100644 index 00000000..5bf10b41 --- /dev/null +++ b/internal/gui/assets/icons/mimocode.svg @@ -0,0 +1 @@ + diff --git a/internal/gui/assets/library.js b/internal/gui/assets/library.js index 677c9f5b..308d8072 100644 --- a/internal/gui/assets/library.js +++ b/internal/gui/assets/library.js @@ -479,7 +479,7 @@ w.append(el("span", "", t("{agent} reads {file} instead of this file while it has anything in it.", { agent: a.name, file: tilde(a.override) }) + " "), pathLink(a.override)); det.append(w); } - if (a.agent === "opencode") det.append(el("p", "lib-aside", t("OpenCode reads Claude Code's CLAUDE.md when it has no AGENTS.md of its own."))); + if (a.agent === "opencode" || a.agent === "mimocode") det.append(el("p", "lib-aside", t("{agent} reads Claude Code's CLAUDE.md when it has no AGENTS.md of its own.", { agent: a.name }))); const lab = el("label", "lib-lab", t("Only for {agent}, after the shared text", { agent: a.name })); const ta = el("textarea", "lib-text small"); ta.dataset.lib = "extra:" + a.agent; diff --git a/internal/gui/assets/routing.js b/internal/gui/assets/routing.js index 9e3f8d85..70516127 100644 --- a/internal/gui/assets/routing.js +++ b/internal/gui/assets/routing.js @@ -555,6 +555,7 @@ const HUES = { claude: "#d97757", codex: "#6366f1", gemini: "#0ea5e9", copilot: "#a855f7", cursor: "#14b8a6", opencode: "#eab308", crush: "#ec4899", goose: "#84cc16", pi: "#10b981", omp: "#f43f5e", dsh: "#06b6d4", commandcode: "#f97316", + mimocode: "#3b82f6", }; const SPARE = ["#8b5cf6", "#22c55e", "#e11d48", "#0891b2", "#ca8a04", "#db2777", "#2563eb", "#65a30d"]; function hueOf(id) { diff --git a/internal/library/library_test.go b/internal/library/library_test.go index dcd0fcdb..7ffe48fa 100644 --- a/internal/library/library_test.go +++ b/internal/library/library_test.go @@ -34,7 +34,7 @@ func sandbox(t *testing.T) string { } for _, f := range []string{ ".claude/settings.json", ".codex/config.toml", ".gemini/settings.json", - ".config/opencode/opencode.json", ".pi/agent/settings.json", ".config/goose/config.yaml", + ".config/opencode/opencode.json", ".config/mimocode/mimocode.json", ".pi/agent/settings.json", ".config/goose/config.yaml", ".cursor/cli-config.json", ".copilot/settings.json", ".config/crush/crush.json", } { write(t, filepath.Join(h, f), "") @@ -86,7 +86,7 @@ func ids(ts []*Target) []string { func TestTargets(t *testing.T) { sandbox(t) got := ids(Targets()) - for _, id := range []string{"claude", "codex", "gemini", "opencode", "pi", "goose", "cursor", "copilot", "crush"} { + for _, id := range []string{"claude", "codex", "gemini", "opencode", "mimocode", "pi", "goose", "cursor", "copilot", "crush"} { if !slices.Contains(got, id) { t.Errorf("%s not a target: %v", id, got) } diff --git a/internal/library/targets.go b/internal/library/targets.go index 7059b0a3..8f7f38ee 100644 --- a/internal/library/targets.go +++ b/internal/library/targets.go @@ -83,6 +83,14 @@ func targetOf(a *agent.Agent) *Target { t.MCP = &mcpFile{Path: a.Path, Format: fmtOpenCode} t.Skills = filepath.Join(d, "skills") t.SkillsAlso = []string{"claude"} + case "mimocode": + // MiMo Code is OpenCode's config shape; it reads Claude Code's + // CLAUDE.md but its own skills only (a flag opens .claude's), so the + // shared skills go into its own folder, not Claude Code's. + d := filepath.Dir(a.Path) + t.Instructions = filepath.Join(d, "AGENTS.md") + t.MCP = &mcpFile{Path: a.Path, Format: fmtOpenCode} + t.Skills = filepath.Join(d, "skills") case "pi": d := os.Getenv("PI_CODING_AGENT_DIR") if d == "" { diff --git a/main.go b/main.go index 6b424100..130a6712 100644 --- a/main.go +++ b/main.go @@ -74,7 +74,7 @@ const usage = `magpie — one place to pick every agent's model magpie agents list every supported agent magpie update [check] install the newest release (check: only say if there is one) -agents: claude (cc), codex, gemini, opencode (oc), pi, goose, cursor, copilot, crush +agents: claude (cc), codex, gemini, opencode (oc), mimocode, pi, goose, cursor, copilot, crush ` var ( diff --git a/site/public/docs/start.html b/site/public/docs/start.html index bf46ceba..6650bfdc 100644 --- a/site/public/docs/start.html +++ b/site/public/docs/start.html @@ -115,7 +115,7 @@
Keys live in ~/.config/magpie/providers.json, readable by you alone. magpie never reads keys from your shell environment: what you add is what it uses.
The Agents tab has a row for each agent installed or configured on this machine. magpie knows Claude Code, Codex, Gemini CLI, OpenCode, Pi, Goose, Cursor, Copilot CLI, Crush, DeepSeek Harness, Command Code, omp, Devin, Hermes Agent, Grok Build, ZCode and Alma.
+The Agents tab has a row for each agent installed or configured on this machine. magpie knows Claude Code, Codex, Gemini CLI, OpenCode, MiMo Code, Pi, Goose, Cursor, Copilot CLI, Crush, DeepSeek Harness, Command Code, omp, Devin, Hermes Agent, Grok Build, ZCode and Alma.
@@ -132,7 +132,7 @@
~/.claude/settings.json: ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN and the model variables in its env block~/.codex/config.toml: a [model_providers.magpie] table and the model, with the list in magpie-models.json. Your ChatGPT sign-in is left alonemagpie provider entry, and magpie/provider/modelmagpie provider entry, and magpie/provider/modelGOOGLE_GEMINI_BASE_URL pointed at the gateway, API-key auth, and the modelA 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.
密钥保存在 ~/.config/magpie/providers.json,只有你自己可读。magpie 从不读取 shell 环境变量里的密钥:你添加什么,它就用什么。
Agent 标签页里,本机装了或配置过的每个 Agent 各占一行。magpie 支持 Claude Code、Codex、Gemini CLI、OpenCode、Pi、Goose、Cursor、Copilot CLI、Crush、DeepSeek Harness、Command Code、omp、Devin、Hermes Agent、Grok Build、ZCode 和 Alma。
+Agent 标签页里,本机装了或配置过的每个 Agent 各占一行。magpie 支持 Claude Code、Codex、Gemini CLI、OpenCode、MiMo Code、Pi、Goose、Cursor、Copilot CLI、Crush、DeepSeek Harness、Command Code、omp、Devin、Hermes Agent、Grok Build、ZCode 和 Alma。
@@ -132,7 +132,7 @@
~/.claude/settings.json:env 里的 ANTHROPIC_BASE_URL、ANTHROPIC_AUTH_TOKEN 和模型变量~/.codex/config.toml:一个 [model_providers.magpie] 表和模型,模型列表写在 magpie-models.json。你的 ChatGPT 登录不受影响magpie 供应商条目,以及 magpie/provider/modelmagpie 供应商条目,以及 magpie/provider/modelGOOGLE_GEMINI_BASE_URL 指向网关、API 密钥认证,以及模型已在运行的会话保持它启动时的模型。Agent 启动时读取配置,所以改动从下一个会话生效。Codex 也在启动时生成模型列表:切换后请重启 Codex 应用和已打开的 codex 会话。