diff --git a/server.go b/server.go index aba6fbd..0083b85 100644 --- a/server.go +++ b/server.go @@ -10,7 +10,6 @@ import ( "net" "os" "os/user" - "path/filepath" "strings" "sync" "time" @@ -30,21 +29,12 @@ type ( StoreEntry = store.StoreEntry Store = store.Store RunnableStore = store.RunnableStore - FlatDirStore = store.Store Session = store.Session SessionStore = store.SessionStore syntheticFileInfo = store.SyntheticFileInfo ) -func NewFlatDirStore(dir string, perm os.FileMode) FlatDirStore { - return store.NewFlatDir(dir, perm) -} - -func NewSkillStore() Store { - return store.NewSkillStore() -} - func syntheticEntry(name string, mode os.FileMode) os.DirEntry { return store.FileEntry(name, mode) } @@ -53,170 +43,22 @@ func syntheticDirEntry(name string, mode os.FileMode) os.DirEntry { return store.DirEntry(name, mode) } -// --- util --- - -// UtilStore is a FlatDirStore backed by the scripts/u/ directory. -type UtilStore struct { - FlatDirStore -} - -func NewUtilStore() *UtilStore { - return &UtilStore{FlatDirStore: NewFlatDirStore(paths.CfgDir()+"/scripts/u", 0555)} -} - -func (s *UtilStore) Stat(name string) (os.FileInfo, error) { - return s.FlatDirStore.Stat(name) -} - -func (s *UtilStore) List() ([]os.DirEntry, error) { - return s.FlatDirStore.List() -} - -func (s *UtilStore) Open(name string) (StoreEntry, error) { - return s.FlatDirStore.Open(name) -} - -// --- exec --- - -// ExecStore is a read-only FlatDirStore backed by the scripts/x/ directory. -type ExecStore struct { - FlatDirStore -} - -func NewExecStore() *ExecStore { - return &ExecStore{FlatDirStore: NewFlatDirStore(execute.PluginsPath(), 0555)} -} - -func (s *ExecStore) Stat(name string) (os.FileInfo, error) { - return s.FlatDirStore.Stat(name) -} - -func (s *ExecStore) List() ([]os.DirEntry, error) { - return s.FlatDirStore.List() -} - -func (s *ExecStore) Open(name string) (StoreEntry, error) { - return s.FlatDirStore.Open(name) -} - -// NewRootStore returns a read-only Store for synthetic root-level files. -func NewRootStore() Store { - helpPath := paths.CfgDir() + "/help.md" - notBlocking := func(context.Context, string) ([]byte, string, error) { - return nil, "", fmt.Errorf("blocking read not supported") - } - readOnly := func([]byte) error { return fmt.Errorf("read-only") } - - entries := map[string]func() ([]byte, error){ - "backends": func() ([]byte, error) { - return []byte(strings.Join(backend.Backends(), "\n") + "\n"), nil - }, - "help": func() ([]byte, error) { - return os.ReadFile(helpPath) - }, - } - - return &rootStore{entries: entries, notBlocking: notBlocking, readOnly: readOnly} -} - -type rootStore struct { - entries map[string]func() ([]byte, error) - notBlocking func(context.Context, string) ([]byte, string, error) - readOnly func([]byte) error -} - -func (r *rootStore) Stat(name string) (os.FileInfo, error) { - if _, ok := r.entries[name]; ok { - return &syntheticFileInfo{Name_: name, Mode_: 0444}, nil - } - return nil, fmt.Errorf("%s: not found", name) -} -func (r *rootStore) List() ([]os.DirEntry, error) { - return []os.DirEntry{ - syntheticEntry("backends", 0444), - syntheticEntry("help", 0444), - }, nil -} -func (r *rootStore) Open(name string) (StoreEntry, error) { - readFn, ok := r.entries[name] - if !ok { - return nil, fmt.Errorf("%s: not found", name) - } - return &store.EntryConfig{ - StatFn: func() (os.FileInfo, error) { return &syntheticFileInfo{Name_: name, Mode_: 0444}, nil }, - ReadFn: readFn, - WriteFn: r.readOnly, - BlockingReadFn: r.notBlocking, - }, nil -} -func (r *rootStore) Create(string) error { return fmt.Errorf("read-only store") } -func (r *rootStore) Delete(string) error { return fmt.Errorf("read-only store") } -func (r *rootStore) Rename(string, string) error { return fmt.Errorf("read-only store") } - -// --- tools --- - -// ToolStore is a BlobStore backed by the tools directory. -// It extends FlatDirStore with a synthetic "idx" entry that lists all tools -// with their descriptions and argument signatures. -// It supports subdirectories (e.g. _lib/) for shared library code. -type ToolStore struct { - FlatDirStore - dir string -} - -func NewToolStore() *ToolStore { - dir := execute.ToolsPath() - return &ToolStore{FlatDirStore: NewFlatDirStore(dir, 0755), dir: dir} -} - -// ListDir lists entries in a subdirectory relative to the tools root. -func (s *ToolStore) ListDir(rel string) ([]os.DirEntry, error) { - return os.ReadDir(filepath.Join(s.dir, rel)) -} - -// MkdirAll creates a directory relative to the tools root. -func (s *ToolStore) MkdirAll(rel string) error { - return os.MkdirAll(filepath.Join(s.dir, rel), 0755) -} - -func (s *ToolStore) Stat(name string) (os.FileInfo, error) { - if name == "idx" { - return &syntheticFileInfo{Name_: "idx", Mode_: 0444}, nil - } - return s.FlatDirStore.Stat(name) -} - -func (s *ToolStore) List() ([]os.DirEntry, error) { - entries, err := s.FlatDirStore.List() - result := make([]os.DirEntry, 0, len(entries)+1) - result = append(result, syntheticEntry("idx", 0444)) - result = append(result, entries...) - return result, err -} - -func (s *ToolStore) Open(name string) (StoreEntry, error) { - if name == "idx" { - return &store.EntryConfig{ - StatFn: func() (os.FileInfo, error) { return &syntheticFileInfo{Name_: "idx", Mode_: 0444}, nil }, - ReadFn: func() ([]byte, error) { return s.index() }, - WriteFn: func([]byte) error { return fmt.Errorf("idx: read-only") }, - BlockingReadFn: func(context.Context, string) ([]byte, string, error) { return nil, "", fmt.Errorf("blocking read not supported") }, - }, nil - } - return s.FlatDirStore.Open(name) -} - -func (s *ToolStore) index() ([]byte, error) { - entries, err := s.FlatDirStore.List() +// toolIndex generates a tool index from a store's file listing. +func toolIndex(s Store) ([]byte, error) { + entries, err := s.List() if err != nil { return nil, err } var sb strings.Builder for _, e := range entries { - if e.IsDir() { + if e.IsDir() || e.Name() == "idx" { continue } - data, err := storeRead(s.FlatDirStore, e.Name()) + entry, err := s.Open(e.Name()) + if err != nil { + continue + } + data, err := entry.Read() if err != nil { continue } @@ -246,6 +88,51 @@ func (s *ToolStore) index() ([]byte, error) { return []byte(sb.String()), nil } +// NewRootStore returns a read-only Store for synthetic root-level files. +func NewRootStore() *store.DirStore { + helpPath := paths.CfgDir() + "/help.md" + entries := map[string]func() ([]byte, error){ + "backends": func() ([]byte, error) { + return []byte(strings.Join(backend.Backends(), "\n") + "\n"), nil + }, + "help": func() ([]byte, error) { + return os.ReadFile(helpPath) + }, + } + notBlocking := func(context.Context, string) ([]byte, string, error) { + return nil, "", fmt.Errorf("blocking read not supported") + } + readOnly := func([]byte) error { return fmt.Errorf("read-only") } + + return store.NewDirStore(nil, 0444, + store.WithReadOnly(), + store.WithResolver(func(_ []string, name string) (string, error) { + if _, ok := entries[name]; ok { + return name, nil // virtual path, handled by opener + } + return "", fmt.Errorf("%s: not found", name) + }), + store.WithLister(func(_ []string) ([]os.DirEntry, error) { + return []os.DirEntry{ + syntheticEntry("backends", 0444), + syntheticEntry("help", 0444), + }, nil + }), + store.WithOpener(func(_ []string, name string) (StoreEntry, error) { + readFn, ok := entries[name] + if !ok { + return nil, fmt.Errorf("%s: not found", name) + } + return &store.EntryConfig{ + StatFn: func() (os.FileInfo, error) { return &syntheticFileInfo{Name_: name, Mode_: 0444}, nil }, + ReadFn: readFn, + WriteFn: readOnly, + BlockingReadFn: notBlocking, + }, nil + }), + ) +} + const ( QTDir = plan9.QTDIR QTFile = plan9.QTFILE @@ -280,14 +167,14 @@ type Server struct { agentStore Store promptStore Store memStore Store - toolStore *ToolStore + toolStore *store.DirStore utilStore Store pluginStore Store skillStore Store sessionStore *SessionStore transcriptStore Store tmpStore Store - rootStore Store + rootStore *store.DirStore strict bool yolo bool groups map[string]map[string]bool // group → set of members @@ -314,15 +201,15 @@ func New(sink *olog.Sink, opts ...ServerOption) *Server { log: sink.Logger("9p", olog.LevelDebug), sink: sink, agentsDir: agentsDir, - agentStore: store.NewFlatDirWritableUnion(agentsDirs, 0644), - promptStore: store.NewFlatDirUnion(agent.PromptsDirs(), 0444), - memStore: NewFlatDirStore(memDir, 0644), - toolStore: NewToolStore(), - utilStore: NewUtilStore(), - pluginStore: NewExecStore(), - skillStore: NewSkillStore(), - transcriptStore: NewFlatDirStore(transcriptDir, 0444), - tmpStore: NewFlatDirStore(tmpDir, 0600), + agentStore: store.NewDirStore(agentsDirs, 0644), + promptStore: store.NewDirStore(agent.PromptsDirs(), 0444, store.WithReadOnly()), + memStore: store.NewDirStore([]string{memDir}, 0644), + toolStore: store.NewDirStore([]string{execute.ToolsPath()}, 0755, store.WithIndex(toolIndex)), + utilStore: store.NewDirStore([]string{paths.CfgDir() + "/scripts/u"}, 0555, store.WithReadOnly()), + pluginStore: store.NewDirStore([]string{execute.PluginsPath()}, 0555, store.WithReadOnly()), + skillStore: store.NewSkillStore(), + transcriptStore: store.NewDirStore([]string{transcriptDir}, 0444, store.WithReadOnly()), + tmpStore: store.NewDirStore([]string{tmpDir}, 0600), rootStore: NewRootStore(), groups: make(map[string]map[string]bool), } diff --git a/store/dirstore.go b/store/dirstore.go new file mode 100644 index 0000000..3cdc042 --- /dev/null +++ b/store/dirstore.go @@ -0,0 +1,243 @@ +package store + +import ( + "context" + "fmt" + "os" + "path/filepath" +) + +// DirStore is a configurable filesystem-backed Store. +// It supports single or multiple directories (union), read-only mode, +// a synthetic index entry, subdirectory operations, and custom path resolution. +type DirStore struct { + dirs []string + perm os.FileMode + readOnly bool + indexFn func(Store) ([]byte, error) + resolveFn func([]string, string) (string, error) + listFn func([]string) ([]os.DirEntry, error) + createFn func([]string, string, os.FileMode) error + deleteFn func([]string, string) error + renameFn func([]string, string, string) error + openFn func([]string, string) (StoreEntry, error) +} + +// DirOption configures a DirStore. +type DirOption func(*DirStore) + +// WithReadOnly makes the store reject writes, creates, deletes, and renames. +func WithReadOnly() DirOption { return func(d *DirStore) { d.readOnly = true } } + +// WithIndex adds a synthetic read-only "idx" entry whose content is +// generated by calling fn with the store itself. +func WithIndex(fn func(Store) ([]byte, error)) DirOption { + return func(d *DirStore) { d.indexFn = fn } +} + +// WithResolver overrides how virtual names map to filesystem paths. +func WithResolver(fn func(dirs []string, name string) (string, error)) DirOption { + return func(d *DirStore) { d.resolveFn = fn } +} + +// WithLister overrides how the store lists its entries. +func WithLister(fn func(dirs []string) ([]os.DirEntry, error)) DirOption { + return func(d *DirStore) { d.listFn = fn } +} + +// WithCreator overrides file creation logic. +func WithCreator(fn func(dirs []string, name string, perm os.FileMode) error) DirOption { + return func(d *DirStore) { d.createFn = fn } +} + +// WithDeleter overrides file deletion logic. +func WithDeleter(fn func(dirs []string, name string) error) DirOption { + return func(d *DirStore) { d.deleteFn = fn } +} + +// WithRenamer overrides rename logic. +func WithRenamer(fn func(dirs []string, old, new string) error) DirOption { + return func(d *DirStore) { d.renameFn = fn } +} + +// WithOpener overrides how entries are opened (bypasses filesystem read). +func WithOpener(fn func(dirs []string, name string) (StoreEntry, error)) DirOption { + return func(d *DirStore) { d.openFn = fn } +} + +// NewDirStore creates a DirStore backed by one or more directories. +// The first directory is the write target for unions. +func NewDirStore(dirs []string, perm os.FileMode, opts ...DirOption) *DirStore { + d := &DirStore{dirs: dirs, perm: perm} + for _, o := range opts { + o(d) + } + return d +} + +func (d *DirStore) resolve(name string) (string, error) { + if d.resolveFn != nil { + return d.resolveFn(d.dirs, name) + } + for _, dir := range d.dirs { + p := filepath.Join(dir, name) + if _, err := os.Stat(p); err == nil { + return p, nil + } + } + return "", fmt.Errorf("%s: not found", name) +} + +func (d *DirStore) writePath(name string) string { + return filepath.Join(d.dirs[0], name) +} + +func (d *DirStore) Stat(name string) (os.FileInfo, error) { + if name == "idx" && d.indexFn != nil { + return &SyntheticFileInfo{Name_: "idx", Mode_: 0444}, nil + } + p, err := d.resolve(name) + if err != nil { + return nil, err + } + info, err := os.Stat(p) + if err != nil { + return nil, err + } + if d.resolveFn != nil { + // Virtual name may differ from physical filename. + return &SyntheticFileInfo{Name_: name, Mode_: info.Mode(), Size_: info.Size()}, nil + } + return info, nil +} + +func (d *DirStore) List() ([]os.DirEntry, error) { + if d.listFn != nil { + entries, err := d.listFn(d.dirs) + if err != nil { + return nil, err + } + if d.indexFn != nil { + result := make([]os.DirEntry, 0, len(entries)+1) + result = append(result, FileEntry("idx", 0444)) + result = append(result, entries...) + return result, nil + } + return entries, nil + } + seen := make(map[string]bool) + var result []os.DirEntry + if d.indexFn != nil { + result = append(result, FileEntry("idx", 0444)) + seen["idx"] = true + } + for _, dir := range d.dirs { + entries, err := os.ReadDir(dir) + if err != nil { + continue + } + for _, e := range entries { + if !seen[e.Name()] { + seen[e.Name()] = true + result = append(result, e) + } + } + } + return result, nil +} + +func (d *DirStore) Open(name string) (StoreEntry, error) { + notBlocking := func(context.Context, string) ([]byte, string, error) { + return nil, "", fmt.Errorf("blocking read not supported") + } + if name == "idx" && d.indexFn != nil { + return &EntryConfig{ + StatFn: func() (os.FileInfo, error) { return &SyntheticFileInfo{Name_: "idx", Mode_: 0444}, nil }, + ReadFn: func() ([]byte, error) { return d.indexFn(d) }, + WriteFn: func([]byte) error { return fmt.Errorf("idx: read-only") }, + BlockingReadFn: notBlocking, + }, nil + } + if d.openFn != nil { + return d.openFn(d.dirs, name) + } + p, err := d.resolve(name) + if err != nil { + if d.readOnly { + return nil, err + } + // Not found anywhere; write path is in first dir. + p = d.writePath(name) + } + wp := d.writePath(name) + writeFn := func(data []byte) error { + if d.readOnly { + return fmt.Errorf("%s: read-only", name) + } + if err := os.MkdirAll(filepath.Dir(wp), 0755); err != nil { + return err + } + return os.WriteFile(wp, data, d.perm) + } + return &EntryConfig{ + StatFn: func() (os.FileInfo, error) { return os.Stat(p) }, + ReadFn: func() ([]byte, error) { return os.ReadFile(p) }, + WriteFn: writeFn, + BlockingReadFn: notBlocking, + }, nil +} + +func (d *DirStore) Create(name string) error { + if d.readOnly { + return fmt.Errorf("read-only store") + } + if d.createFn != nil { + return d.createFn(d.dirs, name, d.perm) + } + p := d.writePath(name) + if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil { + return err + } + return os.WriteFile(p, nil, d.perm) +} + +func (d *DirStore) Delete(name string) error { + if d.readOnly { + return fmt.Errorf("read-only store") + } + if d.deleteFn != nil { + return d.deleteFn(d.dirs, name) + } + p, err := d.resolve(name) + if err != nil { + return err + } + return os.Remove(p) +} + +func (d *DirStore) Rename(old, new string) error { + if d.readOnly { + return fmt.Errorf("read-only store") + } + if d.renameFn != nil { + return d.renameFn(d.dirs, old, new) + } + p, err := d.resolve(old) + if err != nil { + return err + } + return os.Rename(p, filepath.Join(filepath.Dir(p), new)) +} + +// ListDir lists entries in a subdirectory relative to the first backing dir. +func (d *DirStore) ListDir(rel string) ([]os.DirEntry, error) { + return os.ReadDir(filepath.Join(d.dirs[0], rel)) +} + +// MkdirAll creates a directory relative to the first backing dir. +func (d *DirStore) MkdirAll(rel string) error { + if d.readOnly { + return fmt.Errorf("read-only store") + } + return os.MkdirAll(filepath.Join(d.dirs[0], rel), 0755) +} diff --git a/store/skill.go b/store/skill.go index 8b38396..57c6807 100644 --- a/store/skill.go +++ b/store/skill.go @@ -1,7 +1,6 @@ package store import ( - "context" "fmt" "os" "path/filepath" @@ -12,8 +11,13 @@ import ( "ollie/pkg/skills" ) -// SkillStoreConfig holds optional dependencies for NewSkillStore. -// Nil functions default to their os package equivalents. +// NewSkillStore returns a DirStore configured for the skill directory layout. +// Skills are directories containing SKILL.md; virtual names are "skillname.md". +func NewSkillStore() *DirStore { + return NewSkillStoreWith(SkillStoreConfig{}) +} + +// SkillStoreConfig holds optional dependencies for NewSkillStoreWith. type SkillStoreConfig struct { Dirs []string ReadDir func(string) ([]os.DirEntry, error) @@ -25,22 +29,7 @@ type SkillStoreConfig struct { Rename func(string, string) error } -type skillState struct { - dirs []string - readDir func(string) ([]os.DirEntry, error) - openFile func(string) (*os.File, error) - readFile func(string) ([]byte, error) - writeFile func(string, []byte, os.FileMode) error - mkdirAll func(string, os.FileMode) error - removeAll func(string) error - rename func(string, string) error -} - -func NewSkillStore() Store { - return NewSkillStoreWith(SkillStoreConfig{}) -} - -func NewSkillStoreWith(cfg SkillStoreConfig) Store { +func NewSkillStoreWith(cfg SkillStoreConfig) *DirStore { if cfg.Dirs == nil { cfg.Dirs = skillDirs() } @@ -66,25 +55,16 @@ func NewSkillStoreWith(cfg SkillStoreConfig) Store { cfg.Rename = os.Rename } - ss := &skillState{ - dirs: cfg.Dirs, - readDir: cfg.ReadDir, - openFile: cfg.Open, - readFile: cfg.ReadFile, - writeFile: cfg.WriteFile, - mkdirAll: cfg.MkdirAll, - removeAll: cfg.RemoveAll, - rename: cfg.Rename, - } + ss := &skillHelper{cfg: cfg} - return &storeConfig{ - StatFn: ss.stat, - ListFn: ss.list, - OpenFn: ss.open, - DeleteFn: ss.del, - CreateFn: ss.create, - RenameFn: ss.ren, - } + return NewDirStore(cfg.Dirs, 0644, + WithIndex(ss.index), + WithResolver(ss.resolve), + WithLister(ss.list), + WithCreator(ss.create), + WithDeleter(ss.del), + WithRenamer(ss.rename), + ) } func skillDirs() []string { @@ -94,11 +74,15 @@ func skillDirs() []string { return []string{paths.CfgDir() + "/skills"} } -func (ss *skillState) listSkills() []skills.Meta { +type skillHelper struct { + cfg SkillStoreConfig +} + +func (ss *skillHelper) listSkills() []skills.Meta { seen := make(map[string]bool) var result []skills.Meta - for _, dir := range ss.dirs { - entries, err := ss.readDir(dir) + for _, dir := range ss.cfg.Dirs { + entries, err := ss.cfg.ReadDir(dir) if err != nil { continue } @@ -107,7 +91,7 @@ func (ss *skillState) listSkills() []skills.Meta { continue } skillDir := filepath.Join(dir, e.Name()) - f, err := ss.openFile(filepath.Join(skillDir, "SKILL.md")) + f, err := ss.cfg.Open(filepath.Join(skillDir, "SKILL.md")) if err != nil { continue } @@ -124,107 +108,56 @@ func (ss *skillState) listSkills() []skills.Meta { return result } -func (ss *skillState) readSkill(name string) ([]byte, error) { +func (ss *skillHelper) resolve(_ []string, name string) (string, error) { + skillName := strings.TrimSuffix(name, ".md") for _, m := range ss.listSkills() { - if m.Name == name { - return ss.readFile(filepath.Join(m.Dir, "SKILL.md")) + if m.Name == skillName { + return filepath.Join(m.Dir, "SKILL.md"), nil } } - return nil, os.ErrNotExist + return "", fmt.Errorf("%s: not found", name) } -func (ss *skillState) stat(name string) (os.FileInfo, error) { - if name == "idx" { - return &SyntheticFileInfo{Name_: "idx", Mode_: 0444}, nil - } - skillName := strings.TrimSuffix(name, ".md") - if _, err := ss.readSkill(skillName); err != nil { - return nil, fmt.Errorf("%s: not found", name) - } - return &SyntheticFileInfo{Name_: name, Mode_: 0666}, nil -} - -func (ss *skillState) list() ([]os.DirEntry, error) { - result := []os.DirEntry{FileEntry("idx", 0444)} +func (ss *skillHelper) list(_ []string) ([]os.DirEntry, error) { + var result []os.DirEntry for _, m := range ss.listSkills() { result = append(result, FileEntry(m.Name+".md", 0666)) } return result, nil } -func (ss *skillState) open(name string) (StoreEntry, error) { - notBlocking := func(context.Context, string) ([]byte, string, error) { - return nil, "", fmt.Errorf("blocking read not supported") - } - if name == "idx" { - return &EntryConfig{ - StatFn: func() (os.FileInfo, error) { return &SyntheticFileInfo{Name_: "idx", Mode_: 0444}, nil }, - ReadFn: func() ([]byte, error) { return ss.index() }, - WriteFn: func([]byte) error { return fmt.Errorf("idx: read-only") }, - BlockingReadFn: notBlocking, - }, nil - } +func (ss *skillHelper) create(_ []string, name string, _ os.FileMode) error { skillName := strings.TrimSuffix(name, ".md") - return &EntryConfig{ - StatFn: func() (os.FileInfo, error) { - if _, err := ss.readSkill(skillName); err != nil { - return nil, err - } - return &SyntheticFileInfo{Name_: name, Mode_: 0666}, nil - }, - ReadFn: func() ([]byte, error) { return ss.readSkill(skillName) }, - WriteFn: func(data []byte) error { - dir := "" - for _, m := range ss.listSkills() { - if m.Name == skillName { - dir = m.Dir - break - } - } - if dir == "" { - dir = filepath.Join(ss.dirs[0], skillName) - } - if err := ss.mkdirAll(dir, 0755); err != nil { - return err - } - return ss.writeFile(filepath.Join(dir, "SKILL.md"), data, 0644) - }, - BlockingReadFn: notBlocking, - }, nil + dir := filepath.Join(ss.cfg.Dirs[0], skillName) + if err := ss.cfg.MkdirAll(dir, 0755); err != nil { + return err + } + return ss.cfg.WriteFile(filepath.Join(dir, "SKILL.md"), nil, 0644) } -func (ss *skillState) del(name string) error { +func (ss *skillHelper) del(_ []string, name string) error { skillName := strings.TrimSuffix(name, ".md") for _, m := range ss.listSkills() { if m.Name == skillName { - return ss.removeAll(m.Dir) + return ss.cfg.RemoveAll(m.Dir) } } return fmt.Errorf("skill not found: %s", skillName) } -func (ss *skillState) ren(oldName, newName string) error { - oldSkill := strings.TrimSuffix(oldName, ".md") - newSkill := strings.TrimSuffix(newName, ".md") +func (ss *skillHelper) rename(_ []string, old, new string) error { + oldSkill := strings.TrimSuffix(old, ".md") + newSkill := strings.TrimSuffix(new, ".md") for _, m := range ss.listSkills() { if m.Name == oldSkill { newDir := filepath.Join(filepath.Dir(m.Dir), newSkill) - return ss.rename(m.Dir, newDir) + return ss.cfg.Rename(m.Dir, newDir) } } return fmt.Errorf("skill not found: %s", oldSkill) } -func (ss *skillState) create(name string) error { - skillName := strings.TrimSuffix(name, ".md") - dir := filepath.Join(ss.dirs[0], skillName) - if err := ss.mkdirAll(dir, 0755); err != nil { - return err - } - return ss.writeFile(filepath.Join(dir, "SKILL.md"), nil, 0644) -} - -func (ss *skillState) index() ([]byte, error) { +func (ss *skillHelper) index(_ Store) ([]byte, error) { var sb strings.Builder for _, m := range ss.listSkills() { fmt.Fprintf(&sb, "## %s\n", m.Name) diff --git a/store/store.go b/store/store.go index 121215e..968f6e0 100644 --- a/store/store.go +++ b/store/store.go @@ -4,9 +4,7 @@ package store import ( "context" - "fmt" "os" - "path/filepath" "time" ) @@ -87,189 +85,6 @@ func (s *storeConfig) Create(name string) error { return s.CreateF func (s *storeConfig) Delete(name string) error { return s.DeleteFn(name) } func (s *storeConfig) Rename(old, new string) error { return s.RenameFn(old, new) } -// NewFlatDir returns a Store backed by a directory on the local filesystem. -func NewFlatDir(dir string, perm os.FileMode) Store { - join := func(name string) string { return filepath.Join(dir, name) } - notBlocking := func(context.Context, string) ([]byte, string, error) { - return nil, "", fmt.Errorf("blocking read not supported") - } - - return &storeConfig{ - StatFn: func(name string) (os.FileInfo, error) { return os.Stat(join(name)) }, - ListFn: func() ([]os.DirEntry, error) { return os.ReadDir(dir) }, - OpenFn: func(name string) (StoreEntry, error) { - path := join(name) - return &EntryConfig{ - StatFn: func() (os.FileInfo, error) { return os.Stat(path) }, - ReadFn: func() ([]byte, error) { return os.ReadFile(path) }, - WriteFn: func(data []byte) error { - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - return err - } - return os.WriteFile(path, data, perm) - }, - BlockingReadFn: notBlocking, - }, nil - }, - CreateFn: func(name string) error { - path := join(name) - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - return err - } - return os.WriteFile(path, nil, perm) - }, - DeleteFn: func(name string) error { return os.Remove(join(name)) }, - RenameFn: func(old, new string) error { return os.Rename(join(old), join(new)) }, - } -} - -// NewFlatDirWritableUnion returns a Store that reads from all dirs but writes to the first. -// Files are deduplicated by name; the first directory containing a name wins. -func NewFlatDirWritableUnion(dirs []string, perm os.FileMode) Store { - writeDir := dirs[0] - notBlocking := func(context.Context, string) ([]byte, string, error) { - return nil, "", fmt.Errorf("blocking read not supported") - } - - resolve := func(name string) (string, error) { - for _, dir := range dirs { - p := filepath.Join(dir, name) - if _, err := os.Stat(p); err == nil { - return p, nil - } - } - return "", fmt.Errorf("%s: not found", name) - } - - return &storeConfig{ - StatFn: func(name string) (os.FileInfo, error) { - p, err := resolve(name) - if err != nil { - return nil, err - } - return os.Stat(p) - }, - ListFn: func() ([]os.DirEntry, error) { - seen := make(map[string]bool) - var result []os.DirEntry - for _, dir := range dirs { - entries, err := os.ReadDir(dir) - if err != nil { - continue - } - for _, e := range entries { - if !seen[e.Name()] { - seen[e.Name()] = true - result = append(result, e) - } - } - } - return result, nil - }, - OpenFn: func(name string) (StoreEntry, error) { - p, err := resolve(name) - if err != nil { - // Not found anywhere; write path is in writeDir. - p = filepath.Join(writeDir, name) - } - wp := filepath.Join(writeDir, name) - return &EntryConfig{ - StatFn: func() (os.FileInfo, error) { return os.Stat(p) }, - ReadFn: func() ([]byte, error) { return os.ReadFile(p) }, - WriteFn: func(data []byte) error { - if err := os.MkdirAll(filepath.Dir(wp), 0755); err != nil { - return err - } - return os.WriteFile(wp, data, perm) - }, - BlockingReadFn: notBlocking, - }, nil - }, - CreateFn: func(name string) error { - p := filepath.Join(writeDir, name) - if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil { - return err - } - return os.WriteFile(p, nil, perm) - }, - DeleteFn: func(name string) error { - // Delete from wherever it exists. - p, err := resolve(name) - if err != nil { - return err - } - return os.Remove(p) - }, - RenameFn: func(old, new string) error { - p, err := resolve(old) - if err != nil { - return err - } - return os.Rename(p, filepath.Join(filepath.Dir(p), new)) - }, - } -} - -// NewFlatDirUnion returns a read-only Store that merges multiple directories. -// Files are deduplicated by name; the first directory containing a name wins. -func NewFlatDirUnion(dirs []string, perm os.FileMode) Store { - notBlocking := func(context.Context, string) ([]byte, string, error) { - return nil, "", fmt.Errorf("blocking read not supported") - } - - resolve := func(name string) (string, error) { - for _, dir := range dirs { - p := filepath.Join(dir, name) - if _, err := os.Stat(p); err == nil { - return p, nil - } - } - return "", fmt.Errorf("%s: not found", name) - } - - return &storeConfig{ - StatFn: func(name string) (os.FileInfo, error) { - p, err := resolve(name) - if err != nil { - return nil, err - } - return os.Stat(p) - }, - ListFn: func() ([]os.DirEntry, error) { - seen := make(map[string]bool) - var result []os.DirEntry - for _, dir := range dirs { - entries, err := os.ReadDir(dir) - if err != nil { - continue - } - for _, e := range entries { - if !seen[e.Name()] { - seen[e.Name()] = true - result = append(result, e) - } - } - } - return result, nil - }, - OpenFn: func(name string) (StoreEntry, error) { - p, err := resolve(name) - if err != nil { - return nil, err - } - return &EntryConfig{ - StatFn: func() (os.FileInfo, error) { return os.Stat(p) }, - ReadFn: func() ([]byte, error) { return os.ReadFile(p) }, - WriteFn: func([]byte) error { return fmt.Errorf("%s: read-only", name) }, - BlockingReadFn: notBlocking, - }, nil - }, - CreateFn: func(string) error { return fmt.Errorf("read-only store") }, - DeleteFn: func(string) error { return fmt.Errorf("read-only store") }, - RenameFn: func(string, string) error { return fmt.Errorf("read-only store") }, - } -} - // SyntheticFileInfo implements os.FileInfo for entries with no backing file. type SyntheticFileInfo struct { Name_ string diff --git a/store/store_test.go b/store/store_test.go index 07c7520..830736e 100644 --- a/store/store_test.go +++ b/store/store_test.go @@ -323,18 +323,18 @@ func checkStoreContract(t *testing.T, s store.Store, name string) { // ===== FlatDir ===== func TestFlatDirContract(t *testing.T) { - checkStoreContract(t, store.NewFlatDir(t.TempDir(), 0644), "test-file") + checkStoreContract(t, store.NewDirStore([]string{t.TempDir()}, 0644), "test-file") } func TestFlatDirCreateMkdirError(t *testing.T) { - fd := store.NewFlatDir("/nonexistent/path", 0644) + fd := store.NewDirStore([]string{"/nonexistent/path"}, 0644) if err := fd.Create("f"); err == nil { t.Error("Create should fail when dir doesn't exist") } } func TestFlatDirPutMkdirError(t *testing.T) { - fd := store.NewFlatDir("/nonexistent/path", 0644) + fd := store.NewDirStore([]string{"/nonexistent/path"}, 0644) e, err := fd.Open("f") if err != nil { t.Fatalf("Open: %v", err)