diff --git a/README.md b/README.md index 2d6f609b..8ee1e7c4 100644 --- a/README.md +++ b/README.md @@ -222,7 +222,7 @@ Write your instructions, MCP servers and skills once. magpie writes them into ea
-Claude Code · Claude Desktop · Codex · Gemini CLI · OpenCode · OpenChamber · MiMo Code · Pi · Aside · OmO · Goose · Cursor CLI · Zed · VS Code Chat · JetBrains Air · Copilot CLI · Crush · DeepSeek Harness · Command Code · fx · oh-my-pi · Devin · Hermes Agent · Mister Morph · Kimi Code · Muse Code · Empryo · MiniMax Code · Droid · Cline · Qoder · Qoder CN · Grok Build · ZCode · WorkBuddy · T3 Code · OpenHanako · AtomCode · Alma +Claude Code · Claude Desktop · Codex · Gemini CLI · OpenCode · OpenChamber · MiMo Code · Pi · Aside · OmO · Goose · Cursor CLI · Zed · VS Code Chat · VS Code Insiders · JetBrains Air · Copilot CLI · Crush · DeepSeek Harness · Command Code · fx · oh-my-pi · Devin · Hermes Agent · Mister Morph · Kimi Code · Muse Code · Empryo · MiniMax Code · Droid · Cline · Qoder · Qoder CN · Grok Build · ZCode · WorkBuddy · T3 Code · OpenHanako · AtomCode · Alma
diff --git a/docs/reference.md b/docs/reference.md index 77172a86..a4204e24 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -90,6 +90,7 @@ line; agents connected to magpie lose it when it quits. | Cursor CLI | `~/.cursor/cli-config.json` | model | | Zed | `~/.config/zed/settings.json` (`$XDG_CONFIG_HOME` on Linux, `%APPDATA%\Zed` on Windows) | model (a `magpie` OpenAI-compatible provider; its catalog in Zed's picker) | | VS Code (Chat) | `~/Library/Application Support/Code/User/settings.json` + `chatLanguageModels.json` (`~/.config/Code/User` on Linux, `%APPDATA%\Code\User` on Windows) | model (`chat.defaultModel`; a `magpie` Custom Endpoint group, its catalog in Chat's model picker; VS Code 1.122+, no Copilot sign-in or key needed). Each profile's own pair under `User/profiles//` (listed in `globalStorage/storage.json`) gets the same, unless the profile uses the default's | +| VS Code Insiders (Chat) | the same files under `Code - Insiders/User` in place of `Code/User` | as VS Code, a row of its own; its models send the token `magpie-vscode-insiders`, since its chat's User-Agent is VS Code's | | JetBrains Air | `acp.json` in `~/Library/Application Support/JetBrains/Air` (`~/.config/JetBrains/Air` on Linux, `%APPDATA%\JetBrains\Air` on Windows) + `magpie-opencode.json` beside it | model (a `Magpie` ACP agent: OpenCode's `opencode acp` on magpie's provider alone, its models and routing groups in Air's model menu; needs OpenCode installed) | | Copilot CLI | `~/.copilot/settings.json` | model | | Crush | `~/.config/crush/crush.json` | large, small | diff --git a/internal/agent/agents.go b/internal/agent/agents.go index cc95a77a..5c84332c 100644 --- a/internal/agent/agents.go +++ b/internal/agent/agents.go @@ -80,6 +80,7 @@ func All() []*Agent { cursorLocal(), zed(home, cfg), vscode(home, cfg), + vscodeInsidersAgent(home, cfg), air(home, cfg), copilot(home), crush(home, cfg), diff --git a/internal/agent/vscode.go b/internal/agent/vscode.go index 58d8498c..c73ef24b 100644 --- a/internal/agent/vscode.go +++ b/internal/agent/vscode.go @@ -43,9 +43,39 @@ const vscodeUA = "githubcopilotchat" // vscodeGroup is how magpie's group is found among the user's. var vscodeGroup = map[string]string{"vendor": "customendpoint", "name": magpieID} +// vscodeKind is one build of VS Code, each with its own User folder, its +// own app and its own row: VS Code, and VS Code Insiders beside it (wani on +// Discord), whose chat is the same and is set up the same way. +type vscodeKind struct { + id, name, folder, bin string + aliases []string + // ua is what its chat's User-Agent begins with: Stable is told by it. + // Insiders' chat names itself the same, so it is told by its token + // instead (gateway.TokenFor), which its models send. + ua []string +} + +var ( + vscodeStable = vscodeKind{id: "vscode", name: "VS Code", folder: "Code", bin: "code", aliases: []string{"vs-code", "copilot-chat", "vscode-chat"}, ua: []string{vscodeUA}} + vscodeInsiders = vscodeKind{id: "vscode-insiders", name: "VS Code Insiders", folder: "Code - Insiders", bin: "code-insiders", aliases: []string{"vs-code-insiders", "code-insiders"}} +) + +// token is the bearer token its models send: Stable's the gateway's own, +// as it always was. +func (k vscodeKind) token() string { + if k.ua != nil { + return gateway.Token + } + return gateway.TokenFor(k.id) +} + // VS Code keeps its User folder under Application Support on macOS, Roaming -// AppData on Windows and XDG on Linux. -func vscode(home, cfg string) *Agent { +// AppData on Windows and XDG on Linux, Insiders' beside it. +func vscode(home, cfg string) *Agent { return vscodeOf(vscodeStable, home, cfg) } + +func vscodeInsidersAgent(home, cfg string) *Agent { return vscodeOf(vscodeInsiders, home, cfg) } + +func vscodeOf(k vscodeKind, home, cfg string) *Agent { switch runtime.GOOS { case "darwin": cfg = filepath.Join(home, "Library", "Application Support") @@ -55,13 +85,15 @@ func vscode(home, cfg string) *Agent { cfg = filepath.Join(home, "AppData", "Roaming") } } - return vscodeAt(filepath.Join(cfg, "Code", "User")) + return vscodeKindAt(k, filepath.Join(cfg, k.folder, "User")) } -func vscodeAt(dir string) *Agent { +func vscodeAt(dir string) *Agent { return vscodeKindAt(vscodeStable, dir) } + +func vscodeKindAt(k vscodeKind, dir string) *Agent { path := filepath.Join(dir, "settings.json") models := filepath.Join(dir, "chatLanguageModels.json") - key := func(p string) string { return "vscode:" + p + ":model" } + key := func(p string) string { return k.id + ":" + p + ":model" } getAt := func(p string) string { v, _ := edit.GetJSON(p, vscodeDefault); return v } get := func() string { return getAt(path) } ours := func() (string, bool) { return edit.GetJSONItem(models, vscodeGroup) } @@ -102,7 +134,7 @@ func vscodeAt(dir string) *Agent { } // setGroup writes magpie's group in the default's and each profile's setGroup := func(lms []string) error { - v := vscodeGroupJSON() + v := vscodeGroupJSON(k) for _, lm := range append([]string{models}, lms...) { if cur, ok := edit.GetJSONItem(lm, vscodeGroup); ok && sameJSON(cur, v) { continue @@ -114,9 +146,9 @@ func vscodeAt(dir string) *Agent { return nil } return atomic(&Agent{ - ID: "vscode", Name: "VS Code", Icon: "vscode", Aliases: []string{"vs-code", "copilot-chat", "vscode-chat"}, Spelled: prefixed, - Bin: "code", Dir: dir, Path: path, - UA: []string{vscodeUA}, + ID: k.id, Name: k.name, Icon: "vscode", Aliases: k.aliases, Spelled: prefixed, + Bin: k.bin, Dir: dir, Path: path, + UA: k.ua, detect: func() bool { if Taken(dir) { return false @@ -124,12 +156,12 @@ func vscodeAt(dir string) *Agent { if isDir(dir) { return true } - bin, err := exec.LookPath("code") + bin, err := exec.LookPath(k.bin) return err == nil && vscodeCodeBinary(bin) }, Notice: func() string { if joined() { - return "magpie's models are in VS Code's Chat model picker, under magpie, in each of its profiles (VS Code 1.122 or later). If they don't show, run Developer: Reload Window in VS Code." + return "magpie's models are in " + k.name + "'s Chat model picker, under magpie, in each of its profiles (VS Code 1.122 or later). If they don't show, run Developer: Reload Window in " + k.name + "." } return "" }, @@ -166,10 +198,10 @@ func vscodeAt(dir string) *Agent { if !ok || !gjson.Get(g, "models.0").Exists() { return "" } - return wiringOff("VS Code", models, func(k string) (string, bool) { + return wiringOff(k.name, models, func(k string) (string, bool) { r := gjson.Get(g, "models.0."+k) return r.String(), r.Exists() - }, "url", vscodeURL(), "requestHeaders.Authorization", "Bearer "+gateway.Token) + }, "url", vscodeURL(), "requestHeaders.Authorization", "Bearer "+k.token()) }, Fields: []Field{{ Key: "model", Label: "model", Get: model, @@ -215,7 +247,7 @@ func vscodeAt(dir string) *Agent { if v := cur["model"]; v != "" && v != "auto" && !usesMagpie(v) { own = append(own, Option{Value: v, Icon: modelIcon("", v)}) } - return append(group("VS Code", own), viaMagpie("vscode", magpieID+"/")...) + return append(group(k.name, own), viaMagpie(k.id, magpieID+"/")...) }, }}, }, path, models, stashPath()) @@ -300,10 +332,10 @@ func vscodeProfiles(dir string) (settings, models []string) { func vscodeURL() string { return gatewayV1() + "/chat/completions" } // vscodeGroupJSON is magpie's group in chatLanguageModels.json: the catalog -// as VS Code's chat is shown it. -func vscodeGroupJSON() map[string]any { +// as the chat of the VS Code k is shown it. +func vscodeGroupJSON(k vscodeKind) map[string]any { list := []any{} - for _, m := range magpieModels("vscode") { + for _, m := range magpieModels(k.id) { context := m.Context if context == 0 { context = 128000 // what VS Code takes a model of unknown window for @@ -320,7 +352,7 @@ func vscodeGroupJSON() map[string]any { "id": m.ID, "name": m.Name, "url": vscodeURL(), "toolCalling": true, "vision": m.Images, "contextWindow": context, "maxOutputTokens": output, - "requestHeaders": map[string]string{"Authorization": "Bearer " + gateway.Token}, + "requestHeaders": map[string]string{"Authorization": "Bearer " + k.token()}, } if len(m.Efforts) > 0 { entry["supportsReasoningEffort"] = m.Efforts diff --git a/internal/agent/vscode_test.go b/internal/agent/vscode_test.go index 5d53abd3..ccc319d6 100644 --- a/internal/agent/vscode_test.go +++ b/internal/agent/vscode_test.go @@ -233,3 +233,59 @@ func TestVSCodeUA(t *testing.T) { t.Fatalf("%q", got) } } + +// VS Code Insiders is its own row (wani on Discord): its User folder is +// "Code - Insiders" beside VS Code's "Code", connected it writes only there, +// and its models send a token of its own, since its chat's User-Agent is +// Stable's; VS Code's files are left alone +func TestVSCodeInsiders(t *testing.T) { + home := syncHome(t) + ins, err := Find("vscode-insiders") + if err != nil { + t.Fatal(err) + } + st, err := Find("vscode") + if err != nil { + t.Fatal(err) + } + if ins.Name != "VS Code Insiders" || ins.Bin != "code-insiders" || filepath.Base(filepath.Dir(ins.Dir)) != "Code - Insiders" || filepath.Base(ins.Dir) != "User" || + filepath.Dir(filepath.Dir(ins.Dir)) != filepath.Dir(filepath.Dir(st.Dir)) || ins.UA != nil { + t.Fatalf("insiders: %+v\nstable dir %s", ins, st.Dir) + } + if !strings.HasPrefix(ins.Dir, home) { + t.Fatalf("insiders dir %s outside %s", ins.Dir, home) + } + for _, n := range []string{"code-insiders", "vs-code-insiders"} { + if a, err := Find(n); err != nil || a.ID != "vscode-insiders" { + t.Fatalf("Find(%q): %v %v", n, a, err) + } + } + writeFile(t, ins.Path, "{}\n") + writeFile(t, st.Path, `{"chat.defaultModel": "gpt-5"}`+"\n") + if !ins.Detected() { + t.Fatal("not detected") + } + if err := ins.Connect(); err != nil { + t.Fatal(err) + } + g, ok := edit.GetJSONItem(filepath.Join(ins.Dir, "chatLanguageModels.json"), vscodeGroup) + if !ok || gjson.Get(g, "models.0.requestHeaders.Authorization").String() != "Bearer magpie-vscode-insiders" || !ins.Wired() || ins.Check() != "" { + t.Fatalf("connected: %s %v %q", g, ins.Wired(), ins.Check()) + } + if readFile(st.Path) != `{"chat.defaultModel": "gpt-5"}`+"\n" || isFile(filepath.Join(st.Dir, "chatLanguageModels.json")) || st.Wired() { + t.Fatalf("VS Code touched:\n%s", readFile(st.Path)) + } + // its requests are its own, Stable's still Stable's + if got := usage.AgentOf("vscode-insiders"); got != "vscode-insiders" { + t.Fatalf("token: %q", got) + } + if got := usage.AgentOf("GitHubCopilotChat/0.69.0"); got != "vscode" { + t.Fatalf("UA: %q", got) + } + if err := ins.Disconnect(); err != nil { + t.Fatal(err) + } + if _, ok := edit.GetJSONItem(filepath.Join(ins.Dir, "chatLanguageModels.json"), vscodeGroup); ok || ins.Wired() { + t.Fatal("disconnected, magpie's group kept") + } +}