Files
magpie/internal/profile/profile.go
T

301 lines
7.3 KiB
Go

// Package profile stores named snapshots of every agent's settings, and of
// who gets what from the library, so a whole setup can be switched in one
// move.
package profile
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/yetone/magpie/internal/agent"
"github.com/yetone/magpie/internal/edit"
"github.com/yetone/magpie/internal/library"
"github.com/yetone/magpie/internal/provider"
)
// Profile is every agent's fields, and the library's setup.
type Profile struct {
// Fields maps "agent.field" to a value.
Fields map[string]string
// Library is which agents got which servers and skills, and the
// instructions; nil for a profile saved while the library was empty,
// or before profiles kept it, which leaves the library as it is.
Library *library.Setup
}
// libraryKey holds the library's setup among a profile's "agent.field"
// keys: it has no dot, so it is no agent's field, and a profile saved
// before it is read as it always was.
const libraryKey = "library"
// MarshalJSON writes the fields and the library's setup side by side.
func (p Profile) MarshalJSON() ([]byte, error) {
m := make(map[string]any, len(p.Fields)+1)
for k, v := range p.Fields {
m[k] = v
}
if p.Library != nil {
m[libraryKey] = p.Library
}
return json.Marshal(m)
}
// UnmarshalJSON reads what MarshalJSON writes, and a profile of before,
// which was the fields alone.
func (p *Profile) UnmarshalJSON(b []byte) error {
var m map[string]json.RawMessage
if err := json.Unmarshal(b, &m); err != nil {
return err
}
*p = Profile{Fields: map[string]string{}}
for k, raw := range m {
if k == libraryKey {
if string(raw) == "null" {
continue
}
p.Library = &library.Setup{}
if err := json.Unmarshal(raw, p.Library); err != nil {
return fmt.Errorf("%s: %w", k, err)
}
continue
}
var v string
if err := json.Unmarshal(raw, &v); err != nil {
return fmt.Errorf("%s: %w", k, err)
}
p.Fields[k] = v
}
return nil
}
// Path is the profiles file.
func Path() string {
if x := os.Getenv("XDG_CONFIG_HOME"); x != "" {
return filepath.Join(x, "magpie", "profiles.json")
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config", "magpie", "profiles.json")
}
// Load reads every profile.
func Load() (map[string]Profile, error) {
b, err := edit.Read(Path())
if err != nil {
return nil, err
}
out := map[string]Profile{}
if len(b) == 0 {
return out, nil
}
if err := json.Unmarshal(b, &out); err != nil {
return nil, fmt.Errorf("%s: %w", Path(), err)
}
return out, nil
}
// Names lists profiles alphabetically.
func Names(ps map[string]Profile) []string {
names := make([]string, 0, len(ps))
for n := range ps {
names = append(names, n)
}
sort.Strings(names)
return names
}
func store(ps map[string]Profile) error {
b, err := json.MarshalIndent(ps, "", " ")
if err != nil {
return err
}
return edit.WriteAtomic(Path(), append(b, '\n'))
}
// Fields captures the current value of every detected agent's fields.
func Fields() map[string]string {
p := map[string]string{}
for _, a := range agent.Detected() {
for k, v := range a.Values() {
if v != "" {
p[a.ID+"."+k] = v
}
}
}
return p
}
// Snapshot captures every detected agent's fields, and the library's setup
// unless the library is empty.
func Snapshot() (Profile, error) {
p := Profile{Fields: Fields()}
s, err := library.Snapshot()
if err != nil {
return p, err
}
if !s.Empty() {
p.Library = s
}
return p, nil
}
// Save stores p under name, replacing any existing profile.
func Save(name string, p Profile) error {
name = strings.TrimSpace(name)
if name == "" {
return fmt.Errorf("profile name is empty")
}
ps, err := Load()
if err != nil {
return err
}
ps[name] = p
return store(ps)
}
// Delete removes a profile.
func Delete(name string) error {
ps, err := Load()
if err != nil {
return err
}
if _, ok := ps[name]; !ok {
return fmt.Errorf("no profile named %q", name)
}
delete(ps, name)
return store(ps)
}
// Applied is what applying a profile did.
type Applied struct {
Changed int // agent fields changed
// Library is what bringing the library's setup back wrote into the
// agents; nil for a profile that carries none.
Library *library.Result
}
// Apply writes every field in p that differs from what is set now, then
// brings the library's setup back when p carries one and writes it into
// the agents (their files kept aside first). It stops at the first error.
func Apply(p Profile) (Applied, error) {
n, err := ApplyFields(p.Fields)
out := Applied{Changed: n}
if err != nil || p.Library == nil {
return out, err
}
out.Library, err = library.Restore(p.Library)
return out, err
}
// ApplyFields writes every value in p that differs from what is set now.
// It returns the number of changes made and the first error encountered.
func ApplyFields(p map[string]string) (int, error) {
agents := map[string]*agent.Agent{}
for _, a := range agent.All() {
agents[a.ID] = a
}
keys := make([]string, 0, len(p))
for k := range p {
keys = append(keys, k)
}
// providers first: switching one re-settles the model behind it; then
// models, which settle what the other fields (Claude Code's tiers) hang on
rank := func(k string) int {
switch {
case strings.HasSuffix(k, ".provider"):
return 0
case strings.HasSuffix(k, ".model"):
return 1
}
return 2
}
sort.Slice(keys, func(i, j int) bool {
if ri, rj := rank(keys[i]), rank(keys[j]); ri != rj {
return ri < rj
}
return keys[i] < keys[j]
})
changed := 0
for _, k := range keys {
id, field, ok := strings.Cut(k, ".")
if !ok {
continue
}
a := agents[id]
if a == nil {
continue
}
f := a.Field(field)
if f == nil {
continue
}
v := p[k]
// a model of a provider renamed since the profile was saved is
// the same model by its new id
if r := provider.RenamedRef(v); r != v {
if s, err := a.Spell(f.Key, r); err == nil {
v = s
}
}
if f.Get() == v {
continue
}
if err := a.Apply(f.Key, v); err != nil {
return changed, fmt.Errorf("%s: %w", k, err)
}
changed++
}
return changed, nil
}
// Summary renders a profile's models as a short one-line description.
func Summary(p Profile) string {
keys := make([]string, 0, len(p.Fields))
for k := range p.Fields {
if strings.HasSuffix(k, ".model") {
keys = append(keys, k)
}
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
parts = append(parts, strings.TrimSuffix(k, ".model")+" "+p.Fields[k])
}
return strings.Join(parts, " · ")
}
// LongSummary is Summary, and what the profile gives out from the library.
func LongSummary(p Profile) string {
s := Summary(p)
if p.Library == nil {
return s
}
if s == "" {
return "+ " + p.Library.Summary()
}
return s + " · + " + p.Library.Summary()
}
// Report is what applying a profile did to the library, a line each, for
// the terminal: none for a profile without the library's setup.
func Report(a Applied) []string {
r := a.Library
if r == nil {
return nil
}
var out []string
if len(r.Changed) > 0 {
out = append(out, "library written into "+strings.Join(r.Changed, ", "))
}
if len(r.Missing) > 0 {
out = append(out, "skipped, no longer in the library: "+strings.Join(r.Missing, ", "))
}
for _, p := range r.Problems {
out = append(out, p.Agent+" "+p.What+": "+p.Error)
}
return out
}