101 lines
2.9 KiB
Go
101 lines
2.9 KiB
Go
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(), "-")
|
|
}
|