package skill import ( "fmt" "path/filepath" "strings" "code.littlelan.cn/CarrotAssistant/backend/internal/store" ) // Manager resolves skill file paths and bridges DB metadata with the on-disk // markdown. It is stateless aside from its root directory, which comes from // config and is shared with the HTTP layer. type Manager struct { // Root is the skills directory (config.SkillsDir). Each app gets a // subdirectory named by its slug; each skill is a single .md file. Root string } // NewManager constructs a Manager rooted at root. func NewManager(root string) *Manager { return &Manager{Root: root} } // FilePath returns the on-disk path for a skill, given app slug and skill slug. func (m *Manager) FilePath(appSlug, skillSlug string) string { return filepath.Join(m.Root, appSlug, skillSlug+".md") } // RelPath returns the path relative to Root (the form stored in the DB). func (m *Manager) RelPath(appSlug, skillSlug string) string { return filepath.ToSlash(filepath.Join(appSlug, skillSlug+".md")) } // Load reads a skill from disk by its DB row. The row's FilePath is expected // to be relative to Root. func (m *Manager) Load(row *store.Skill) (*Skill, error) { if row == nil { return nil, fmt.Errorf("nil skill row") } abs := filepath.Join(m.Root, filepath.Clean("/"+row.FilePath)) return Load(abs) } // Write persists a skill to disk at the path derived from app/skill slug and // returns the relative path to store in the DB row. func (m *Manager) Write(appSlug, skillSlug string, s *Skill) (relPath string, err error) { s.AppSlug = appSlug if s.Name == "" { s.Name = skillSlug } rel := m.RelPath(appSlug, skillSlug) abs := filepath.Join(m.Root, filepath.Clean("/"+rel)) if err := Write(abs, s); err != nil { return "", err } return rel, nil } // Delete removes a skill file by its DB row's relative FilePath. Missing files // are not an error. func (m *Manager) Delete(relPath string) error { if relPath == "" { return nil } abs := filepath.Join(m.Root, filepath.Clean("/"+relPath)) return removeIfExists(abs) } // EnsureAppDir creates the per-app directory. Called when the first skill for // an app is written. func (m *Manager) EnsureAppDir(appSlug string) error { return mkdirAll(filepath.Join(m.Root, appSlug)) } // SkillSlug derives a filesystem-safe slug from a candidate, falling back to a // random suffix when the candidate yields nothing usable. func SkillSlug(candidate, fallback string) string { s := slugify(candidate) if s == "" { s = slugify(fallback) } return s } // slugify mirrors security.Slugify but lives here to avoid an import cycle // back into security for one helper. func slugify(s string) string { s = strings.ToLower(strings.TrimSpace(s)) var b strings.Builder for _, r := range s { switch { case r >= 'a' && r <= 'z', r >= '0' && r <= '9': b.WriteRune(r) case r == ' ' || r == '-' || r == '_': b.WriteRune('-') } } return strings.Trim(b.String(), "-") }