Files
yetone 6f65ffb5ff agent: CodeBuddy Code, Cline, Gemini CLI, AtomCode, Goose and Reasonix installed in a WSL distro are found and wired there (lgtm on Discord: magpie 现在无法检测到安装在wsl中的codebuddy呀?)
wslKinds had none of these CLIs, so each was set up only on Windows.
Each now has a place-aware constructor (codebuddyIn, clineIn, geminiIn,
atomcodeIn, gooseIn, reasonixIn). It writes the distro's own config with
the gateway as the distro reaches it (mirrored or NAT) and the key it
takes from there. A distro reads none of Windows' variables
(CODEBUDDY_CONFIG_DIR, CLINE_DIR, APPDATA...). Kinds are found by their
folder or their command. Gemini CLI is found by its command alone,
because Antigravity keeps ~/.gemini too. Goose is found by its folder
alone, because a goose command can be pressly's. Reasonix also needs a
native version (new wslKind.accept). Gemini CLI also counts a .env that
names an old address with magpie's key as magpie's own. Before this, a
WSL restart that moved the distro's address made it read "custom", and
picking the model again stashed magpie's old address as the user's.

Verified:
- On the Windows test box (WSL2 magpie-desktop, NAT), with a sandboxed
  nogui magpie.exe (own USERPROFILE/APPDATA, port 3441), real CodeBuddy
  Code 2.161.4, Gemini CLI 0.63.0 and Cline CLI 3.0.68 were installed in
  the distro. `magpie ls` listed each as "· WSL magpie-desktop".
  Picking relay/glm-4.6 wrote ~/.codebuddy/models.json, ~/.gemini/.env +
  settings.json and ~/.cline/data/settings/providers.json in the distro
  at http://172.17.32.1:3441, and wrote nothing in the sandbox home's
  agent folders. The installs were removed afterwards.
- Not tried for real: AtomCode, Goose and Reasonix in a distro, and a
  chat request from a WSL CLI through the gateway.
- Tests: gofmt; go vet ./...; -race on internal/agent; the full
  `go test -tags nogui ./...`; GOOS=windows and GOOS=linux builds.
  TestWSLProbeFindsMoreAgents and the rest of TestWSLMore* panic with
  "no WSL agent codebuddy" on the old wsl.go.
  TestWSLGeminiAddressMoved fails ("auth custom") without the gemini
  routed change.
2026-10-07 05:37:27 +08:00

300 lines
9.5 KiB
Go

package agent
// WorkBuddy (Tencent's CodeBuddy desktop app) takes models of one's own
// from ~/.workbuddy/models.json ($WORKBUDDY_CONFIG_DIR if set): a list of
// OpenAI chat-completions models, or {"models":[…],"availableModels":[…]}
// as CodeBuddy Code's CLI writes it. Its own "local models" are entries of
// the same list (local: true).
//
// [{"id":"deepseek/pro","name":…,"vendor":"magpie","apiKey":"magpie-workbuddy",
// "url":"http://127.0.0.1:3425/v1/chat/completions",
// "maxInputTokens":…,"maxOutputTokens":…,
// "supportsToolCall":true,"supportsImages":…,"supportsReasoning":true,
// "reasoning":{"supportedEfforts":[…],"canDisableThinking":…,"defaultEffort":…}}]
//
// WorkBuddy shows them in its picker as "custom-local:<id>", and asks url
// for model <id>, with reasoning_effort, under Bearer apiKey. It watches the
// file and picks a change up without a restart. The model is picked per
// task in WorkBuddy's window, not in a file, so what magpie sets is whether
// its models are in that picker. Its requests say "CLI/<ver> WorkBuddy/<ver>",
// so they are told apart by their key.
import (
"bytes"
"cmp"
"encoding/json"
"path/filepath"
"slices"
"github.com/yetone/magpie/internal/appdir"
"github.com/yetone/magpie/internal/edit"
"github.com/yetone/magpie/internal/gateway"
)
func workbuddy(home string) *Agent {
dir := appdir.Getenv("WORKBUDDY_CONFIG_DIR")
if dir == "" {
dir = filepath.Join(home, ".workbuddy")
}
path := filepath.Join(dir, "models.json")
return &Agent{
ID: "workbuddy", Name: "WorkBuddy", Icon: "workbuddy-color", Aliases: []string{"work-buddy"},
UA: []string{"workbuddy"},
Dir: dir, Path: path,
Sync: func() error {
if !workbuddyWired(path) {
return nil
}
return workbuddyWrite(path, true)
},
Fields: []Field{{
Key: "provider", Label: "provider",
Get: func() string {
if workbuddyWired(path) {
return magpieID
}
return ""
},
Set: func(v string) error { return workbuddyWrite(path, v != "") },
Options: func(map[string]string) []Option {
return []Option{{Value: magpieID, Label: "magpie", Icon: "magpie", Note: "every magpie model in WorkBuddy's picker"}}
},
}},
}
}
// workbuddyDoc is models.json as read: its entries, and the rest of the
// object when it is one (nil for a bare list).
type workbuddyDoc struct {
models []json.RawMessage
rest map[string]json.RawMessage
}
func workbuddyRead(path string) (workbuddyDoc, error) {
var d workbuddyDoc
b, err := edit.Read(path)
if err != nil || len(bytes.TrimSpace(b)) == 0 {
return d, err
}
if bytes.TrimSpace(b)[0] == '[' {
return d, json.Unmarshal(b, &d.models)
}
if err := json.Unmarshal(b, &d.rest); err != nil {
return d, err
}
if raw, ok := d.rest["models"]; ok {
if err := json.Unmarshal(raw, &d.models); err != nil {
return d, err
}
}
return d, nil
}
// workbuddyEntry is what magpie reads of an entry.
type workbuddyEntry struct {
ID string `json:"id"`
Vendor string `json:"vendor"`
Disabled *bool `json:"disabled"`
URL string `json:"url"`
APIKey string `json:"apiKey"`
Reasoning *struct {
DefaultEffort string `json:"defaultEffort"`
Effort string `json:"effort"` // the older key WorkBuddy still reads
} `json:"reasoning"`
}
func workbuddyMine(raw json.RawMessage) (workbuddyEntry, bool) {
var e workbuddyEntry
return e, json.Unmarshal(raw, &e) == nil && e.Vendor == magpieID
}
// workbuddyWired reports whether models.json has magpie's models.
func workbuddyWired(path string) bool {
d, _ := workbuddyRead(path)
return slices.ContainsFunc(d.models, func(raw json.RawMessage) bool { _, ok := workbuddyMine(raw); return ok })
}
// workbuddyWrite puts magpie's models into models.json (on) or takes them
// out, leaving every other entry and key as WorkBuddy wrote it. A model
// turned off in WorkBuddy stays off, and models the user pointed at a magpie
// on another machine (a NAS's) stay there with its key, as ZCode's do
// (zcodeAddress): a sync brings the models up to date, not the address.
func workbuddyWrite(path string, on bool) error { return buddyWrite(path, "workbuddy", on) }
// buddyWrite is workbuddyWrite for an agent that reads this models.json:
// WorkBuddy, or CodeBuddy Code (codebuddy.go), whose models go under the
// agent's own key. A new file is a bare list for WorkBuddy, as it writes
// one, and {"models":[…]} for CodeBuddy Code, as its docs give it.
func buddyWrite(path, agent string, on bool) error { return buddyWriteAt(path, agent, on, place{}) }
// buddyWriteAt is buddyWrite for the agent at a place. One in a WSL distro
// (where.base set) is written the gateway as the distro reaches it, with the
// key it takes from there, at every write: its address is the distro's
// view of Windows, which moves, so it is never kept as a magpie of the
// user's on another machine.
func buddyWriteAt(path, agent string, on bool, where place) error {
away := where.base != nil
d, err := workbuddyRead(path)
if err != nil {
return err
}
if d.rest == nil && d.models == nil && agent != "workbuddy" {
d.rest = map[string]json.RawMessage{}
}
// the keys magpie's models are asked with: the gateway's, or that of a
// magpie on another machine the user pointed them at
keys := map[string]bool{gateway.TokenFor(agent): true}
if away {
keys[agentKeyAt(agent, where.gw())] = true
}
for _, raw := range d.models {
if e, ok := workbuddyMine(raw); ok && e.APIKey != "" {
keys[e.APIKey] = true
}
}
var kept []json.RawMessage
var ours []string
off := map[string]bool{}
effort := map[string]string{} // the default effort each model was given in WorkBuddy, "" for its Auto
var remote, remoteKey string
at := -1 // where magpie's were, which they keep
for _, raw := range d.models {
e, ok := workbuddyMine(raw)
// WorkBuddy's model settings save an edited entry under the vendor
// of the provider it matches, "Custom" for magpie's: still magpie's
// model by its key, which a sync must not add a second time
if !ok && e.ID != "" && keys[e.APIKey] {
ok = true
}
if ok {
if at < 0 {
at = len(kept)
}
if !away && remote == "" && onAnotherMachine(e.URL) {
remote, remoteKey = e.URL, e.APIKey
}
ours = append(ours, e.ID)
if e.Disabled != nil && *e.Disabled {
off[e.ID] = true
}
if r := e.Reasoning; r != nil {
effort[e.ID] = cmp.Or(r.DefaultEffort, r.Effort)
}
continue
}
kept = append(kept, raw)
}
if !on && len(ours) == 0 {
return nil
}
var add []json.RawMessage
var ids []string
if on {
for _, m := range magpieModels(agent) {
e := buddyModel(agent, m.ID, m.Name, m.Context, maxTokens(m), m.Images, m.Efforts)
if off[m.ID] {
e["disabled"] = true
}
// the default effort set in WorkBuddy's model settings (or by
// hand) stays while the model still has it, Auto included
if r, ok := e["reasoning"].(map[string]any); ok {
if was, set := effort[m.ID]; set {
if was == "" {
delete(r, "defaultEffort")
} else {
r["defaultEffort"] = keptEffort(was, r["supportedEfforts"].([]string), r["defaultEffort"].(string))
}
}
}
if away {
e["url"], e["apiKey"] = where.v1()+"/chat/completions", agentKeyAt(agent, where.gw())
}
if remote != "" {
e["url"] = remote
if remoteKey != "" {
e["apiKey"] = remoteKey
}
}
b, err := json.Marshal(e)
if err != nil {
return err
}
add = append(add, b)
ids = append(ids, m.ID)
}
}
if at < 0 {
at = len(kept)
}
models := slices.Concat(kept[:at], add, kept[at:])
if models == nil {
models = []json.RawMessage{}
}
var out any = models
if d.rest != nil {
// a list of the models to show keeps showing magpie's
if raw, ok := d.rest["availableModels"]; ok {
var avail []string
if json.Unmarshal(raw, &avail) == nil && len(avail) > 0 {
avail = slices.DeleteFunc(avail, func(id string) bool { return slices.Contains(ours, id) })
avail = append(avail, ids...)
b, _ := json.Marshal(avail)
d.rest["availableModels"] = b
}
}
b, err := json.Marshal(models)
if err != nil {
return err
}
d.rest["models"] = b
out = d.rest
}
b, err := json.MarshalIndent(out, "", " ")
if err != nil {
return err
}
return edit.WriteAtomic(path, append(b, '\n'))
}
// buddyModel is one of magpie's models as a models.json entry, under
// agent's key.
func buddyModel(agent, id, name string, context, output int, images bool, efforts []string) map[string]any {
if context == 0 {
context = 200000
}
e := map[string]any{
"id": id, "name": name, "vendor": magpieID,
"apiKey": gateway.TokenFor(agent), "url": gatewayV1() + "/chat/completions",
"maxInputTokens": context,
"supportsToolCall": true, "supportsImages": images, "supportsReasoning": false,
}
// without it WorkBuddy caps every reply at its own default
if out := zcodeOutput(output); out > 0 {
e["maxOutputTokens"] = out
}
var levels []string
for _, l := range efforts {
if l != "none" && !slices.Contains(levels, l) {
levels = append(levels, l)
}
}
if len(levels) > 0 {
e["supportsReasoning"] = true
e["reasoning"] = map[string]any{"supportedEfforts": levels,
"canDisableThinking": slices.Contains(efforts, "none"), "defaultEffort": zcodeDefaultLevel(levels)}
}
return e
}
// keptEffort is the default effort a sync leaves on one of magpie's models:
// the one the user set (in the agent's own settings, or by hand) while the
// model still offers it, else magpie's def. magpie's default never
// overrides the user's own.
func keptEffort(was string, levels []string, def string) string {
if was != "" && slices.Contains(levels, was) {
return was
}
return def
}