1package agent
2
3import (
4 "bytes"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "io"
9 "os"
10 "path/filepath"
11 "slices"
12 "sort"
13 "strings"
14 "sync"
15
16 "github.com/bnema/zut/packages/agent/skills"
17)
18
19// skillPinsFileName lives directly under $ZUT_HOME. Pins are local
20// preferences, so even project pins never write into a checkout.
21const skillPinsFileName = "skill-pins.json"
22
23type skillPinsFile struct {
24 Global []string `json:"global,omitempty"`
25 Projects map[string][]string `json:"projects,omitempty"`
26}
27
28// skillPinsMu serializes read-modify-write cycles within this process.
29// Concurrent zut processes are not merged; the last atomic rename wins.
30var skillPinsMu sync.Mutex
31
32// userSkillSnapshot rediscovers the user-facing skills with the same
33// precedence as agent construction (user/project locations, then
34// extension-bundled skills). Built-ins are not pinnable and are filtered out.
35// bundled is the running extension manager's skill list, or nil.
36func userSkillSnapshot(args Args, cwd string, bundled []*skills.Skill) []*skills.Skill {
37 if args.NoSkill {
38 return nil
39 }
40 userHome, _ := os.UserHomeDir()
41 list, _ := skills.Discover(ZutHome(), cwd, userHome, args.WithSkills)
42 list = mergeExtensionSkills(skills.NewTool(list), bundled)
43 return skills.VisibleSkills(list)
44}
45
46// preloadSkillPins folds the pinned skills' full bodies into the first prompt
47// of a fresh non-interactive session. Resumed sessions and --no-skill runs
48// return the prompt unchanged. Problems are reported as warnings on
49// diagnostics (stderr), never on protocol output.
50func preloadSkillPins(args Args, fresh bool, prompt string, bundled []*skills.Skill, diagnostics io.Writer) string {
51 if !fresh || args.NoSkill {
52 return prompt
53 }
54 pins, err := loadSkillPins(args.CWD)
55 if err != nil {
56 fmt.Fprintln(diagnostics, "warning:", err)
57 return prompt
58 }
59 selected, warnings := pins.Resolve(userSkillSnapshot(args, args.CWD, bundled))
60 for _, warning := range warnings {
61 fmt.Fprintln(diagnostics, "warning:", warning)
62 }
63 return skills.PreloadPrompt(selected, prompt)
64}
65
66// skillPinProject returns the canonical project key: the absolute starting
67// directory with symlinks resolved when possible (not the Git root).
68func skillPinProject(cwd string) (string, error) {
69 path, err := filepath.Abs(cwd)
70 if err != nil {
71 return "", err
72 }
73 if resolved, err := filepath.EvalSymlinks(path); err == nil {
74 path = resolved
75 }
76 return filepath.Clean(path), nil
77}
78
79// normalizePinNames drops blanks and duplicates and sorts, so unions and
80// saved files are deterministic.
81func normalizePinNames(names []string) []string {
82 out := make([]string, 0, len(names))
83 for _, n := range names {
84 if n = strings.TrimSpace(n); n != "" {
85 out = append(out, n)
86 }
87 }
88 sort.Strings(out)
89 return slices.Compact(out)
90}
91
92func readSkillPins() (skillPinsFile, error) {
93 var pins skillPinsFile
94 data, err := os.ReadFile(filepath.Join(ZutHome(), skillPinsFileName))
95 if errors.Is(err, os.ErrNotExist) {
96 return pins, nil
97 }
98 if err != nil {
99 return pins, fmt.Errorf("read skill pins: %w", err)
100 }
101 // Do not echo file contents in diagnostics. The root must be a JSON
102 // object: json.Unmarshal accepts null (a silent no-op), which would let a
103 // toggle replace a file that is not a preferences document. Unknown
104 // top-level keys fail closed too, because saving would drop them.
105 invalid := fmt.Errorf("invalid %s: expected an object with only \"global\" and \"projects\" (fix or remove it; it will not be overwritten)", skillPinsFileName)
106 if !isJSONObject(data) {
107 return skillPinsFile{}, invalid
108 }
109 dec := json.NewDecoder(bytes.NewReader(data))
110 dec.DisallowUnknownFields()
111 if dec.Decode(&pins) != nil {
112 return skillPinsFile{}, invalid
113 }
114 if _, err := dec.Token(); err != io.EOF { // trailing data
115 return skillPinsFile{}, invalid
116 }
117 return pins, nil
118}
119
120func isJSONObject(data []byte) bool {
121 trimmed := bytes.TrimSpace(data)
122 return len(trimmed) > 0 && trimmed[0] == '{'
123}
124
125// loadSkillPins returns the global and current-project pins. On a malformed
126// or unreadable file it returns the error and no pins.
127func loadSkillPins(cwd string) (skills.Pins, error) {
128 skillPinsMu.Lock()
129 defer skillPinsMu.Unlock()
130 project, err := skillPinProject(cwd)
131 if err != nil {
132 return skills.Pins{}, err
133 }
134 pins, err := readSkillPins()
135 if err != nil {
136 return skills.Pins{}, err
137 }
138 return skills.Pins{
139 Global: normalizePinNames(pins.Global),
140 Project: normalizePinNames(pins.Projects[project]),
141 }, nil
142}
143
144// toggleSkillPin flips one name in the global or current-project scope and
145// saves via temp-file replacement. A malformed existing file is never
146// overwritten.
147func toggleSkillPin(cwd, name string, global bool) error {
148 name = strings.TrimSpace(name)
149 if name == "" {
150 return fmt.Errorf("skill name is required")
151 }
152 skillPinsMu.Lock()
153 defer skillPinsMu.Unlock()
154 project, err := skillPinProject(cwd)
155 if err != nil {
156 return err
157 }
158 // A symlinked preferences file is read-only: renaming over it would
159 // silently replace the link, and writing through it would modify a file
160 // zut does not own. Loading may still follow it.
161 path := filepath.Join(ZutHome(), skillPinsFileName)
162 if info, lerr := os.Lstat(path); lerr == nil && info.Mode()&os.ModeSymlink != 0 {
163 return fmt.Errorf("%s is a symbolic link; skill pins can be read from it but not saved (replace it with a regular file to change pins)", skillPinsFileName)
164 }
165 pins, err := readSkillPins()
166 if err != nil {
167 return err
168 }
169 toggle := func(names []string) []string {
170 names = normalizePinNames(names)
171 if slices.Contains(names, name) {
172 return slices.DeleteFunc(names, func(n string) bool { return n == name })
173 }
174 return normalizePinNames(append(names, name))
175 }
176 if global {
177 pins.Global = toggle(pins.Global)
178 } else {
179 if pins.Projects == nil {
180 pins.Projects = make(map[string][]string)
181 }
182 pins.Projects[project] = toggle(pins.Projects[project])
183 if len(pins.Projects[project]) == 0 {
184 delete(pins.Projects, project)
185 }
186 }
187 data, err := json.MarshalIndent(pins, "", " ")
188 if err != nil {
189 return err
190 }
191 return writeSkillPinsAtomic(path, append(data, '\n'))
192}
193
194// writeSkillPinsAtomic replaces the preferences file with data through a
195// same-directory temp file (mode 0600 from CreateTemp), so readers never see
196// a partial file.
197func writeSkillPinsAtomic(path string, data []byte) error {
198 dir := filepath.Dir(path)
199 if err := os.MkdirAll(dir, 0o700); err != nil {
200 return err
201 }
202 f, err := os.CreateTemp(dir, ".skill-pins-*")
203 if err != nil {
204 return err
205 }
206 tmp := f.Name()
207 defer os.Remove(tmp) // no-op after a successful rename
208 if _, err := f.Write(data); err != nil {
209 f.Close()
210 return err
211 }
212 if err := f.Sync(); err != nil {
213 f.Close()
214 return err
215 }
216 if err := f.Close(); err != nil {
217 return err
218 }
219 if err := os.Rename(tmp, path); err != nil {
220 return fmt.Errorf("save skill pins: %w", err)
221 }
222 return nil
223}