Files
magpie/internal/library/projects.go
T
yetone c9d9652380 library: an agent whose folder is a file isn't written, and skills reach 13 more agents with one link in ~/.agents/skills for those that share it (neiko on Discord: with DeepSeek Harness not installed the Library kept failing on "open ~/.dsh/AGENTS.md: not a directory" and ~/.dsh/config.yaml; inaction: the skills list is short of the Agents page, Kimi Code has none). An agent counted as there when its folder path existed or its command was on PATH, never asking whether the path was a folder — a file at ~/.dsh, or another dsh, made DeepSeek Harness look installed. Now an agent whose folder is a file (or under one) isn't detected (agent.Taken; Goose's, Cindy's and Claude Desktop's own checks want a folder too), a Library target leaves out each place a file stands in the way of, one with none left isn't listed, and an agent given no MCP server with none of magpie's isn't read. Skills: Kimi Code, Goose and Cindy get ~/.agents/skills (Kimi's ~/.config/agents/skills when there, as Kimi reads it first; never ~/.kimi/skills, which would hide Claude Code's from it); Command Code, Devin, Hermes, Droid, Cline, Qoder, Qoder CN, Grok, WorkBuddy, Hanako and fx their own skills folder; Kimi, Goose and Grok a project's .agents/skills. The shared folder is filled first, and an agent that reads ~/.agents/skills besides its own (Codex, Gemini CLI, OpenCode, Crush, DeepSeek Harness and those above) gets no second link to a skill already there. Tested with new taken_test.go (neiko's two errors without the change) and agent_skills_test.go (skills folders, written once, a shared skill imported for Kimi), in sandbox HOMEs; library-items and shared-skills 24/24 in Chromium and WebKit; go vet, go build (-tags nogui, GOOS=linux, GOOS=windows), go test -tags nogui ./... pass. Not tried with real Kimi Code, Goose or Cindy.
2026-09-30 14:40:41 +08:00

441 lines
12 KiB
Go

package library
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"runtime"
"slices"
"sort"
"strings"
"github.com/yetone/magpie/internal/edit"
)
// Project is a folder of the user's whose agents get some of the library's
// skills there, as the project's own: each is placed in the folder the
// agent reads a project's skills from, and kept out of git.
type Project struct {
Dir string `json:"dir"`
// Copy puts copies of the skills there, not links to the library's
// (for Windows, or a folder synced or shared where a link won't do);
// a copy is made again when the library's skill changes
Copy bool `json:"copy,omitempty"`
// Skills are the library's skills the project has, and for which agents
Skills map[string][]string `json:"skills,omitempty"`
// Placed are the entries magpie put in the folder (.claude/skills/pdf),
// the only ones it ever takes away
Placed []string `json:"placed,omitempty"`
// Made are the folders magpie made to place them in, taken away again
// once they're empty; Gitignore is whether it made the .gitignore
Made []string `json:"made,omitempty"`
Gitignore bool `json:"gitignore,omitempty"`
}
// projectSkillsDirs is where, in a project, each agent reads skills of its
// own from — only an agent that does: Claude Code its .claude/skills, and
// those that read the shared .agents/skills (Codex from where it runs to
// the repository's root, Gemini CLI, OpenCode, Pi, Cursor, Copilot, Antigravity,
// Kimi Code, Goose and Grok Build) that one, so that one folder serves them all. MiMo Code, Crush, ZCode,
// DeepSeek Harness and oh-my-pi say of no project folder magpie can rely on.
var projectSkillsDirs = map[string]string{
"claude": ".claude/skills",
"codex": ".agents/skills",
"gemini": ".agents/skills",
"opencode": ".agents/skills",
"pi": ".agents/skills",
"cursor": ".agents/skills",
"copilot": ".agents/skills",
"agy": ".agents/skills",
"kimi": ".agents/skills",
"goose": ".agents/skills",
"grok": ".agents/skills",
}
// ProjectSkillsDir is the folder, in a project, an agent reads its skills
// from: "" for one magpie can't give a project's skills to.
func ProjectSkillsDir(agent string) string { return projectSkillsDirs[agent] }
func (l *Library) project(dir string) *Project {
for _, p := range l.Projects {
if p.Dir == dir {
return p
}
}
return nil
}
// The .gitignore lines magpie keeps between these two, and only there.
const (
ignoreBegin = "# >>> magpie: library skills placed here (magpie adds and removes these lines) >>>"
ignoreEnd = "# <<< magpie <<<"
)
// syncProjects places the library's skills in every project as it says.
func (l *Library) syncProjects(res *Result) {
for _, p := range l.Projects {
l.syncProject(p, res)
}
}
func (l *Library) syncProject(p *Project, res *Result) {
fail := func(name string, err error) {
res.Problems = append(res.Problems, Problem{Agent: p.Dir, What: "project:" + name, Error: err.Error()})
}
if fi, err := os.Stat(p.Dir); err != nil || !fi.IsDir() {
if len(p.Skills) > 0 || len(p.Placed) > 0 {
fail("", fmt.Errorf("the folder is gone"))
}
return
}
want := map[string]string{} // entry → the skill
for name, agents := range p.Skills {
if l.skill(name) == nil {
continue
}
for _, id := range agents {
if d := ProjectSkillsDir(id); d != "" {
want[d+"/"+name] = name
}
}
}
var placed []string
for _, e := range p.Placed {
if _, ok := want[e]; ok {
continue
}
name, abs := filepath.Base(filepath.FromSlash(e)), filepath.Join(p.Dir, filepath.FromSlash(e))
if !ours(abs, name) {
continue // gone, or the user's own by now: forgotten, left as it is
}
if err := unlink(abs); err != nil {
fail(name, err)
placed = append(placed, e)
}
}
entries := slices.Sorted(func(yield func(string) bool) {
for e := range want {
if !yield(e) {
return
}
}
})
for _, e := range entries {
name := want[e]
switch err := p.place(e, name); {
case err == nil:
placed = append(placed, e)
case errors.Is(err, errForeign):
fail(name, fmt.Errorf("%s is in the project already and isn't magpie's: it's left as it is", e))
default:
fail(name, err)
if ours(filepath.Join(p.Dir, filepath.FromSlash(e)), name) {
placed = append(placed, e)
}
}
}
sort.Strings(placed)
p.Placed = placed
if err := p.writeIgnore(); err != nil {
fail("", fmt.Errorf(".gitignore: %w", err))
}
p.tidy()
}
var errForeign = errors.New("not magpie's")
// place puts a skill at e in the project: a link to the library's, or a
// copy made again whenever the library's differs from it.
func (p *Project) place(e, name string) error {
abs := filepath.Join(p.Dir, filepath.FromSlash(e))
lib := skillDir(name)
if fi, err := os.Lstat(abs); err == nil {
if !ours(abs, name) {
return errForeign
}
link := fi.Mode()&fs.ModeSymlink != 0
// a copy stands for a link on Windows without the right to make one
if !p.Copy && link || !link && (p.Copy || runtime.GOOS == "windows") && hashDir(abs) == hashDir(realDir(lib)) {
return nil
}
}
if err := p.mkdirs(filepath.Dir(abs)); err != nil {
return err
}
if !p.Copy {
if err := unlink(abs); err != nil {
return err
}
return link(abs, name)
}
// the new copy is made beside and put in the old one's place
next := filepath.Join(filepath.Dir(abs), "."+name+".magpie-next")
os.RemoveAll(next)
if err := copyDir(realDir(lib), next); err != nil {
os.RemoveAll(next)
return err
}
if err := os.WriteFile(filepath.Join(next, marker), []byte("copied from "+lib+" by magpie, and copied again when it changes\n"), 0o644); err != nil {
os.RemoveAll(next)
return err
}
if err := unlink(abs); err != nil {
os.RemoveAll(next)
return err
}
return os.Rename(next, abs)
}
// mkdirs makes dir and the folders above it the project hasn't, noting
// which magpie made.
func (p *Project) mkdirs(dir string) error {
var made []string
for d := dir; d != p.Dir && strings.HasPrefix(d, p.Dir+string(filepath.Separator)); d = filepath.Dir(d) {
if _, err := os.Lstat(d); err == nil {
break
}
made = append(made, d)
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
for _, d := range made {
rel, _ := filepath.Rel(p.Dir, d)
if rel = filepath.ToSlash(rel); !slices.Contains(p.Made, rel) {
p.Made = append(p.Made, rel)
}
}
return nil
}
// tidy takes away the folders magpie made that are empty now, the deepest
// first; one with anything in it stays.
func (p *Project) tidy() {
sort.Slice(p.Made, func(i, j int) bool { return len(p.Made[i]) > len(p.Made[j]) })
var kept []string
for _, rel := range p.Made {
d := filepath.Join(p.Dir, filepath.FromSlash(rel))
if es, err := os.ReadDir(d); err == nil && len(es) == 0 {
if os.Remove(d) == nil {
continue
}
} else if os.IsNotExist(err) {
continue
}
kept = append(kept, rel)
}
sort.Strings(kept)
p.Made = kept
}
// writeIgnore keeps the project's .gitignore listing what magpie placed,
// between its two lines; the rest of the file is the user's and stays.
// A .gitignore magpie made is taken away once nothing's left in it.
func (p *Project) writeIgnore() error {
f := filepath.Join(p.Dir, ".gitignore")
b, err := os.ReadFile(f)
if err != nil && !os.IsNotExist(err) {
return err
}
had := err == nil
nl := "\n"
if strings.Contains(string(b), "\r\n") {
nl = "\r\n"
}
rest := stripIgnore(strings.ReplaceAll(string(b), "\r\n", "\n"))
out := rest
if len(p.Placed) > 0 {
if out != "" {
out += "\n"
}
out += ignoreBegin + "\n"
for _, e := range p.Placed {
out += "/" + e + "\n"
}
out += ignoreEnd + "\n"
}
out = strings.ReplaceAll(out, "\n", nl)
switch {
case out == string(b) && had:
return nil
case strings.TrimSpace(out) == "":
if had && p.Gitignore {
p.Gitignore = false
return os.Remove(f)
}
if !had {
return nil
}
case !had:
p.Gitignore = true
}
return edit.WriteAtomic(f, []byte(out))
}
// stripIgnore is a .gitignore without magpie's lines, and the blank line
// left before them.
func stripIgnore(s string) string {
var out []string
in := false
for _, line := range strings.Split(s, "\n") {
switch t := strings.TrimSpace(line); {
case t == ignoreBegin:
in = true
case in && t == ignoreEnd:
in = false
case !in:
out = append(out, line)
}
}
r := strings.TrimRight(strings.Join(out, "\n"), "\n")
if r != "" {
r += "\n"
}
return r
}
// ---- changing -------------------------------------------------------------
// AddProject adds a folder whose agents can be given library skills.
func AddProject(dir string) (*Result, error) {
dir = strings.TrimSpace(dir)
if dir == "" {
return nil, fmt.Errorf("choose a project's folder")
}
dir = filepath.Clean(expand(dir))
if !filepath.IsAbs(dir) {
return nil, fmt.Errorf("%s isn't a folder's full path", dir)
}
if fi, err := os.Stat(dir); err != nil || !fi.IsDir() {
return nil, fmt.Errorf("%s isn't a folder", dir)
}
if err := notGlobal(dir); err != nil {
return nil, err
}
return change(func(l *Library) error {
for _, p := range l.Projects {
if realDir(p.Dir) == realDir(dir) {
return fmt.Errorf("%s is a project already", p.Dir)
}
}
l.Projects = append(l.Projects, &Project{Dir: dir})
return nil
})
}
// notGlobal refuses a folder whose skills folders are an agent's own for
// every project: the home folder, where .claude/skills is Claude Code's.
func notGlobal(dir string) error {
if realDir(dir) == realDir(home()) {
return fmt.Errorf("the home folder's skills are every project's: give skills to the agents themselves instead")
}
for _, t := range Targets() {
if t.Skills == "" {
continue
}
for _, d := range projectSkillsDirs {
if realDir(filepath.Join(dir, filepath.FromSlash(d))) == realDir(t.Skills) {
return fmt.Errorf("%s in there is %s's own skills folder", d, t.Agent.Name)
}
}
}
return nil
}
// RemoveProject takes every skill magpie placed out of a project, and the
// project off the list; what's left, it couldn't take away and says why.
func RemoveProject(dir string) (*Result, error) {
return change(func(l *Library) error {
p := l.project(dir)
if p == nil {
return fmt.Errorf("no project %s", dir)
}
p.Skills = nil
l.syncProject(p, &Result{})
if len(p.Placed) == 0 {
l.Projects = slices.DeleteFunc(l.Projects, func(x *Project) bool { return x == p })
}
return nil
})
}
// ProjectSkill sets which agents get a library skill in a project; none
// takes it out of the project.
func ProjectSkill(dir, name string, agents []string) (*Result, error) {
return change(func(l *Library) error {
p := l.project(dir)
if p == nil {
return fmt.Errorf("no project %s", dir)
}
if l.skill(name) == nil {
return fmt.Errorf("no skill called %s", name)
}
var ids []string
for _, id := range agents {
if ProjectSkillsDir(id) == "" {
return fmt.Errorf("magpie knows of no folder %s reads a project's skills from", id)
}
ids = set(ids, id, true)
}
if len(ids) == 0 {
delete(p.Skills, name)
return nil
}
if p.Skills == nil {
p.Skills = map[string][]string{}
}
p.Skills[name] = ids
return nil
})
}
// ProjectCopy sets whether a project gets copies of its skills rather than
// links; those there are made again the other way.
func ProjectCopy(dir string, copy bool) (*Result, error) {
return change(func(l *Library) error {
p := l.project(dir)
if p == nil {
return fmt.Errorf("no project %s", dir)
}
p.Copy = copy
return nil
})
}
// ProjectView is a project as the page shows it.
type ProjectView struct {
Dir string `json:"dir"`
Name string `json:"name"`
Copy bool `json:"copy"`
Skills map[string][]string `json:"skills"`
Placed []string `json:"placed"`
Missing bool `json:"missing,omitempty"` // the folder is gone
// Problems are, by skill ("" for the project's own), what couldn't be done
Problems map[string]string `json:"problems,omitempty"`
}
func projectViews(l *Library, problems []Problem) []ProjectView {
out := []ProjectView{}
for _, p := range l.Projects {
v := ProjectView{Dir: p.Dir, Name: filepath.Base(p.Dir), Copy: p.Copy, Skills: map[string][]string{}, Placed: append([]string{}, p.Placed...)}
for k, a := range p.Skills {
v.Skills[k] = append([]string{}, a...)
}
if fi, err := os.Stat(p.Dir); err != nil || !fi.IsDir() {
v.Missing = true
}
for _, x := range problems {
if x.Agent == p.Dir {
if v.Problems == nil {
v.Problems = map[string]string{}
}
v.Problems[strings.TrimPrefix(x.What, "project:")] = x.Error
}
}
out = append(out, v)
}
return out
}