Files
magpie/internal/agent/agent.go
T
yetone c9d9652380 library: an agent whose folder is a file isn't written, and skills reach 13 more agents with one link in ~/.agents/skills for those that share it (neiko on Discord: with DeepSeek Harness not installed the Library kept failing on "open ~/.dsh/AGENTS.md: not a directory" and ~/.dsh/config.yaml; inaction: the skills list is short of the Agents page, Kimi Code has none). An agent counted as there when its folder path existed or its command was on PATH, never asking whether the path was a folder — a file at ~/.dsh, or another dsh, made DeepSeek Harness look installed. Now an agent whose folder is a file (or under one) isn't detected (agent.Taken; Goose's, Cindy's and Claude Desktop's own checks want a folder too), a Library target leaves out each place a file stands in the way of, one with none left isn't listed, and an agent given no MCP server with none of magpie's isn't read. Skills: Kimi Code, Goose and Cindy get ~/.agents/skills (Kimi's ~/.config/agents/skills when there, as Kimi reads it first; never ~/.kimi/skills, which would hide Claude Code's from it); Command Code, Devin, Hermes, Droid, Cline, Qoder, Qoder CN, Grok, WorkBuddy, Hanako and fx their own skills folder; Kimi, Goose and Grok a project's .agents/skills. The shared folder is filled first, and an agent that reads ~/.agents/skills besides its own (Codex, Gemini CLI, OpenCode, Crush, DeepSeek Harness and those above) gets no second link to a skill already there. Tested with new taken_test.go (neiko's two errors without the change) and agent_skills_test.go (skills folders, written once, a shared skill imported for Kimi), in sandbox HOMEs; library-items and shared-skills 24/24 in Chromium and WebKit; go vet, go build (-tags nogui, GOOS=linux, GOOS=windows), go test -tags nogui ./... pass. Not tried with real Kimi Code, Goose or Cindy.
2026-09-30 14:40:41 +08:00

316 lines
9.5 KiB
Go

// Package agent describes every coding agent magpie can drive: where its config
// lives, which fields matter (model, effort, …) and which values to offer.
package agent
import (
"debug/buildinfo"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strings"
"sync"
"time"
"github.com/yetone/magpie/internal/edit"
"github.com/yetone/magpie/internal/proc"
"github.com/yetone/magpie/internal/provider"
)
// Option is one value the picker offers for a field.
type Option struct {
Value string `json:"value"`
Label string `json:"label,omitempty"` // display name, when the value is an id
Note string `json:"note"`
Icon string `json:"icon,omitempty"` // bundled icon name
Icons []string `json:"icons,omitempty"` // a routing group's providers' icons, stacked
Group string `json:"group,omitempty"` // section header in the picker
// GroupIcon is the group's own logo in the picker's rail, when it is
// not the first model's (a provider serving other vendors' models)
GroupIcon string `json:"groupIcon,omitempty"`
Ref string `json:"ref,omitempty"` // the catalog model, the same in every agent
Free bool `json:"free,omitempty"` // costs its subscription nothing
// Context is the tokens the model takes, when known; the picker marks
// the large ones
Context int `json:"context,omitempty"`
}
// Field is one tunable setting of an agent. Set with an empty value puts
// the field back to the agent's own default: anything magpie wired in (the
// gateway as a provider, its model catalog) comes out and the key is removed.
type Field struct {
Key string
Label string
Get func() string
Set func(string) error
Options func(cur map[string]string) []Option
// Quiet fields are left out of listings while empty: they follow
// another field until set (Claude Code's per-tier models).
Quiet bool
}
// Agent is one supported coding agent.
type Agent struct {
ID string
Name string
Icon string // bundled icon name (internal/gui/assets/icons)
Aliases []string
Bin string // executable name, used for detection
Dir string // config directory, used for detection
Path string // config file magpie edits
Fields []Field
// Notice, if set, is advice worth showing after a change: agents that
// read their config once at start-up need a restart to see it.
Notice func() string
// UA is what the agent's User-Agent begins with, lower-case: how the
// gateway tells its requests from others'
UA []string
// Sync, for an agent that reads magpie's models from a file of its own
// rather than asking the gateway, rewrites that list as the catalog is
// now — where magpie wrote one; nothing else changes (see SyncCatalog).
Sync func() error
// Check, for an agent magpie wires in beyond its model field, says what
// of that wiring is gone while the model is still one of magpie's —
// something else rewrote the config — or "" when it is all there
// (see Drift).
Check func() string
// LastUsed, for an agent that keeps a log of its own prompts, is when
// it was last used — each prompt a request the gateway should have
// seen, so one it didn't went round magpie (see Drift). Zero if unknown.
LastUsed func() time.Time
// Reached, for an agent that logs where its model requests go, is its
// newest one since a time: when, the address it went to, and whether
// nothing answered there. Zero if there is none.
Reached func(since time.Time) (at time.Time, to string, refused bool)
// WSL is the distro an agent inside WSL lives in, "" for this
// machine's own (see wsl.go).
WSL string
// Import, for an app that takes magpie only through an import link of
// its own, which the user confirms there (Cindy), is that link; the app
// has no fields magpie sets. Added says whether it has magpie already.
Import func() string
Added func() bool
// Launch, for an agent that takes the gateway only from its
// environment (agy), is the command that starts it on magpie, while
// it is on one of magpie's models; "" otherwise.
Launch func() string
// detect, when set, says whether the agent is here in place of looking
// for its files and binary: a distro's, probed once.
detect func() bool
}
// Running reports whether a process whose command line matches any pattern
// (an extended regexp, as for pgrep -f) is alive. Unknown on Windows.
func Running(patterns ...string) bool {
if runtime.GOOS == "windows" {
return false
}
for _, pat := range patterns {
if err := proc.Command("pgrep", "-f", pat).Run(); err == nil {
return true
}
}
return false
}
// Detected reports whether the agent seems to be installed or configured.
func (a *Agent) Detected() bool {
if a.detect != nil {
return a.detect()
}
// a file where the agent keeps its folder is another tool's (a shell's
// ~/.dsh), and the agent can't be here: it couldn't make its folder
if a.Dir != "" && Taken(a.Dir) {
return false
}
if _, err := os.Stat(a.Path); err == nil {
return true
}
if a.Dir != "" && isDir(a.Dir) {
return true
}
if a.Bin != "" {
if _, err := exec.LookPath(a.Bin); err == nil {
return true
}
}
return false
}
// Taken reports whether something that isn't a folder is where the folder
// p, or one it is in, would be: nothing can be written under it.
func Taken(p string) bool {
for d := filepath.Clean(p); ; {
if st, err := os.Stat(d); err == nil {
return !st.IsDir()
}
up := filepath.Dir(d)
if up == d {
return false
}
d = up
}
}
// goProgram reports whether bin was built by Go: another tool of the same
// name, not the agent, when the agent is not written in Go. Reading a
// binary's build info parses its whole symbol table (~150ms for a large
// one), and detection runs on every state the window asks for, so the
// answer is kept while the file is the same.
func goProgram(bin string) bool {
st, err := os.Stat(bin)
if err != nil {
return false
}
key := goProgramKey{bin, st.Size(), st.ModTime()}
goPrograms.Lock()
defer goPrograms.Unlock()
if v, ok := goPrograms.m[key]; ok {
return v
}
_, err = buildinfo.ReadFile(bin)
if goPrograms.m == nil {
goPrograms.m = map[goProgramKey]bool{}
}
goPrograms.m[key] = err == nil
return err == nil
}
type goProgramKey struct {
path string
size int64
mod time.Time
}
var goPrograms struct {
sync.Mutex
m map[goProgramKey]bool
}
// Field looks a field up by key.
func (a *Agent) Field(key string) *Field {
for i := range a.Fields {
if a.Fields[i].Key == key {
return &a.Fields[i]
}
}
for i := range a.Fields {
if a.Fields[i].Label == key { // `magpie gemini auth …`: the label as shown
return &a.Fields[i]
}
}
return nil
}
// Values reads every field.
func (a *Agent) Values() map[string]string {
m := make(map[string]string, len(a.Fields))
for _, f := range a.Fields {
m[f.Key] = f.Get()
}
return m
}
// Detected returns the agents present on this machine, in display order.
func Detected() []*Agent {
var out []*Agent
for _, a := range All() {
if a.Detected() {
out = append(out, a)
}
}
return out
}
// Find resolves a user-typed name (id, alias, or unique prefix).
func Find(q string) (*Agent, error) {
q = strings.ToLower(strings.TrimSpace(q))
all := All()
var prefix []*Agent
for _, a := range all {
if strings.EqualFold(a.ID, q) { // codex@wsl:Ubuntu
return a, nil
}
for _, al := range a.Aliases {
if al == q {
return a, nil
}
}
if strings.HasPrefix(a.ID, q) || strings.HasPrefix(strings.ToLower(a.Name), q) {
prefix = append(prefix, a)
}
}
switch len(prefix) {
case 1:
return prefix[0], nil
case 0:
return nil, fmt.Errorf("unknown agent %q (try: %s)", q, strings.Join(ids(all), ", "))
}
return nil, fmt.Errorf("%q is ambiguous: %s", q, strings.Join(ids(prefix), ", "))
}
func ids(as []*Agent) []string {
s := make([]string, len(as))
for i, a := range as {
s[i] = a.ID
}
sort.Strings(s)
return s
}
// Spell puts a value typed for field key the way this agent spells it: a
// catalog model is "copilot/gpt-6" in some agents and "magpie/copilot/gpt-6"
// in others, and either is taken in both. A value the picker offers as is
// stays, and so does one it doesn't know (a model the agent reaches on its
// own that isn't listed); a "magpie/…" value the catalog doesn't have is an
// error rather than a model the agent would ask its own vendor for.
func (a *Agent) Spell(key, v string) (string, error) {
f := a.Field(key)
if f == nil || f.Options == nil || v == "" {
return v, nil
}
opts := f.Options(a.Values())
for _, o := range opts {
if o.Value == v {
return v, nil
}
}
ref, prefixed := strings.CutPrefix(v, magpieID+"/")
// a provider renamed since (a profile saved before) is the same one
refs := []string{ref}
if r := provider.RenamedRef(ref); r != ref {
refs = append(refs, r)
}
for _, r := range refs {
for _, o := range opts {
if o.Ref != "" && o.Ref == r {
return o.Value, nil
}
}
}
if prefixed {
return "", fmt.Errorf("%s isn't a model in magpie's catalog (magpie models lists them)", ref)
}
return v, nil
}
// atomic makes each of an agent's field sets, and its Sync, one edit of the
// files at paths: one that fails part way puts them all back as they were,
// rather than leaving, say, Codex's config.toml with magpie's provider table
// written but its model not (#253).
func atomic(a *Agent, paths ...string) *Agent {
for i := range a.Fields {
if set := a.Fields[i].Set; set != nil {
a.Fields[i].Set = func(v string) error {
return edit.Atomically(func() error { return set(v) }, paths...)
}
}
}
if sync := a.Sync; sync != nil {
a.Sync = func() error { return edit.Atomically(sync, paths...) }
}
return a
}