301 lines
7.3 KiB
Go
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
|
|
}
|