Initial commit: CarrotAssistant backend (Go + Gin + SQLite, OpenAI-compatible agent runtime)
This commit is contained in:
100
internal/skill/manager.go
Normal file
100
internal/skill/manager.go
Normal file
@@ -0,0 +1,100 @@
|
||||
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(), "-")
|
||||
}
|
||||
Reference in New Issue
Block a user