1package agent
2
3import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "io"
8 "os"
9 "path/filepath"
10 "slices"
11 "sort"
12 "sync"
13
14 "github.com/patriceckhart/zot/packages/agent/extensions"
15 "github.com/patriceckhart/zot/packages/agent/skills"
16)
17
18// userSkillSnapshot rediscovers user-facing skills with the same precedence
19// as agent construction. Built-in implementation skills are not pinnable.
20func userSkillSnapshot(args Args) []*skills.Skill {
21 if args.NoSkill {
22 return nil
23 }
24 userHome, _ := os.UserHomeDir()
25 var sources []skills.Source
26 if args.WithSkills {
27 sources = append(sources, skills.SearchSources(ZotHome(), args.CWD, userHome)...)
28 }
29 if !args.NoExt || len(args.Exts) > 0 {
30 extSources, _ := extensions.PlanSkillSources(ZotHome(), args.CWD, args.Exts, !args.NoExt)
31 sources = append(extSources, sources...)
32 }
33 list, _ := skills.DiscoverSources(sources, true)
34 return skills.VisibleSkills(list)
35}
36
37func preloadSkillPins(args Args, fresh bool, prompt string, diagnostics io.Writer) string {
38 if !fresh || args.NoSkill {
39 return prompt
40 }
41 pins, err := loadSkillPins(args.CWD)
42 if err != nil {
43 fmt.Fprintln(diagnostics, "warning:", err)
44 return prompt
45 }
46 selected, warnings := pins.Resolve(userSkillSnapshot(args))
47 for _, warning := range warnings {
48 fmt.Fprintln(diagnostics, "warning:", warning)
49 }
50 return skills.PreloadPrompt(selected, prompt)
51}
52
53// Keep preferences local, even for project pins. Never write into a checkout.
54type skillPinsFile struct {
55 Global []string `json:"global,omitempty"`
56 Projects map[string][]string `json:"projects,omitempty"`
57}
58
59var skillPinsMu sync.Mutex
60
61func skillPinProject(cwd string) (string, error) {
62 path, err := filepath.Abs(cwd)
63 if err != nil {
64 return "", err
65 }
66 // Existing symlink aliases of a directory share their project pins.
67 if resolved, err := filepath.EvalSymlinks(path); err == nil {
68 path = resolved
69 }
70 return filepath.Clean(path), nil
71}
72
73func readSkillPins() (skillPinsFile, error) {
74 var pins skillPinsFile
75 data, err := os.ReadFile(filepath.Join(ZotHome(), "skill-pins.json"))
76 if errors.Is(err, os.ErrNotExist) {
77 return pins, nil
78 }
79 if err != nil {
80 return pins, fmt.Errorf("read skill pins: %w", err)
81 }
82 // Do not echo malformed file contents in diagnostics.
83 if json.Unmarshal(data, &pins) != nil {
84 return skillPinsFile{}, fmt.Errorf("invalid skill-pins.json")
85 }
86 return pins, nil
87}
88
89func loadSkillPins(cwd string) (skills.Pins, error) {
90 skillPinsMu.Lock()
91 defer skillPinsMu.Unlock()
92 project, err := skillPinProject(cwd)
93 if err != nil {
94 return skills.Pins{}, err
95 }
96 pins, err := readSkillPins()
97 return skills.Pins{Global: pins.Global, Project: pins.Projects[project]}, err
98}
99
100func toggleSkillPin(cwd, name string, global bool) error {
101 if name == "" {
102 return fmt.Errorf("skill name is required")
103 }
104 skillPinsMu.Lock()
105 defer skillPinsMu.Unlock()
106 project, err := skillPinProject(cwd)
107 if err != nil {
108 return err
109 }
110 pins, err := readSkillPins()
111 if err != nil {
112 return err
113 }
114 toggle := func(names []string) []string {
115 if slices.Contains(names, name) {
116 return slices.DeleteFunc(names, func(n string) bool { return n == name })
117 }
118 names = append(names, name)
119 sort.Strings(names)
120 return names
121 }
122 if global {
123 pins.Global = toggle(pins.Global)
124 } else {
125 if pins.Projects == nil {
126 pins.Projects = make(map[string][]string)
127 }
128 pins.Projects[project] = toggle(pins.Projects[project])
129 if len(pins.Projects[project]) == 0 {
130 delete(pins.Projects, project)
131 }
132 }
133 data, err := json.MarshalIndent(pins, "", " ")
134 if err != nil {
135 return err
136 }
137 if err := os.MkdirAll(ZotHome(), 0o700); err != nil {
138 return err
139 }
140 f, err := os.CreateTemp(ZotHome(), ".skill-pins-*")
141 if err != nil {
142 return err
143 }
144 defer os.Remove(f.Name())
145 if _, err := f.Write(append(data, '\n')); err != nil {
146 f.Close()
147 return err
148 }
149 if err := f.Close(); err != nil {
150 return err
151 }
152 if err := os.Rename(f.Name(), filepath.Join(ZotHome(), "skill-pins.json")); err != nil {
153 return fmt.Errorf("save skill pins: %w", err)
154 }
155 return nil
156}