v0.48.1 deaab59

skill_pins.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close