mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 08:15:29 +00:00
* feat(skills): add bulk management actions * feat(skills): improve operations UX * feat(skills): clarify access modes and repair file paths
279 lines
8.8 KiB
Go
279 lines
8.8 KiB
Go
package methods
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"os"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/gateway"
|
|
"github.com/nextlevelbuilder/goclaw/internal/i18n"
|
|
"github.com/nextlevelbuilder/goclaw/internal/permissions"
|
|
"github.com/nextlevelbuilder/goclaw/internal/skills"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
|
)
|
|
|
|
// skillOwnerGetter is an optional interface for stores that can return a skill's owner ID.
|
|
type skillOwnerGetter interface {
|
|
GetSkillOwnerID(ctx context.Context, id uuid.UUID) (string, bool)
|
|
}
|
|
|
|
// SkillsMethods handles skills.list, skills.get, skills.update.
|
|
type SkillsMethods struct {
|
|
store store.SkillStore
|
|
tenantCfgStore store.SkillTenantConfigStore
|
|
}
|
|
|
|
func NewSkillsMethods(s store.SkillStore, tenantCfg store.SkillTenantConfigStore) *SkillsMethods {
|
|
return &SkillsMethods{store: s, tenantCfgStore: tenantCfg}
|
|
}
|
|
|
|
func (m *SkillsMethods) Register(router *gateway.MethodRouter) {
|
|
router.Register(protocol.MethodSkillsList, m.handleList)
|
|
router.Register(protocol.MethodSkillsGet, m.handleGet)
|
|
router.Register(protocol.MethodSkillsUpdate, m.handleUpdate)
|
|
}
|
|
|
|
func (m *SkillsMethods) handleList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
allSkills := m.store.ListSkills(ctx)
|
|
|
|
// Visibility filter: non-admins see system skills, public skills, and
|
|
// their own private skills. Admins see everything in the tenant.
|
|
if !permissions.HasMinRole(client.Role(), permissions.RoleAdmin) {
|
|
allSkills = store.FilterVisibleSkills(ctx, allSkills)
|
|
}
|
|
|
|
result := make([]map[string]any, 0, len(allSkills))
|
|
for _, s := range allSkills {
|
|
entry := map[string]any{
|
|
"name": s.Name,
|
|
"slug": s.Slug,
|
|
"description": s.Description,
|
|
"source": s.Source,
|
|
"version": s.Version,
|
|
"is_system": s.IsSystem,
|
|
"enabled": s.Enabled,
|
|
}
|
|
if s.ID != "" {
|
|
entry["id"] = s.ID
|
|
}
|
|
if s.Visibility != "" {
|
|
entry["visibility"] = s.Visibility
|
|
}
|
|
if len(s.Tags) > 0 {
|
|
entry["tags"] = s.Tags
|
|
}
|
|
if s.Status != "" {
|
|
entry["status"] = s.Status
|
|
}
|
|
if s.Author != "" {
|
|
entry["author"] = s.Author
|
|
}
|
|
if s.CreatorAgent != nil {
|
|
entry["creator_agent"] = s.CreatorAgent
|
|
}
|
|
if len(s.ManagerAgents) > 0 {
|
|
entry["manager_agents"] = s.ManagerAgents
|
|
}
|
|
if len(s.MissingDeps) > 0 {
|
|
entry["missing_deps"] = s.MissingDeps
|
|
}
|
|
result = append(result, entry)
|
|
}
|
|
|
|
// Merge per-tenant overrides when tenant-scoped
|
|
tid := store.TenantIDFromContext(ctx)
|
|
if tid != uuid.Nil && m.tenantCfgStore != nil {
|
|
overrides, err := m.tenantCfgStore.ListAll(ctx, tid)
|
|
if err != nil {
|
|
slog.Warn("skill tenant config list failed", "tenant", tid, "error", err)
|
|
}
|
|
if err == nil && len(overrides) > 0 {
|
|
for i, entry := range result {
|
|
idStr, _ := entry["id"].(string)
|
|
if idStr == "" {
|
|
continue
|
|
}
|
|
if skID, err := uuid.Parse(idStr); err == nil {
|
|
if enabled, ok := overrides[skID]; ok {
|
|
result[i]["tenant_enabled"] = enabled
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
|
|
"skills": result,
|
|
}))
|
|
}
|
|
|
|
func (m *SkillsMethods) handleGet(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
locale := store.LocaleFromContext(ctx)
|
|
var params struct {
|
|
Name string `json:"name"`
|
|
}
|
|
if req.Params != nil {
|
|
json.Unmarshal(req.Params, ¶ms)
|
|
}
|
|
if params.Name == "" {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "name")))
|
|
return
|
|
}
|
|
|
|
info, ok := m.store.GetSkill(ctx, params.Name)
|
|
if !ok {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "skill", params.Name)))
|
|
return
|
|
}
|
|
|
|
// Visibility gate: hide private skills from non-owners (admins bypass).
|
|
if !permissions.HasMinRole(client.Role(), permissions.RoleAdmin) &&
|
|
!store.IsSkillVisibleTo(ctx, info.OwnerID, info.Visibility, info.IsSystem) {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "skill", params.Name)))
|
|
return
|
|
}
|
|
|
|
content, ok := m.store.LoadSkill(ctx, info.Slug)
|
|
if !ok && info.Path != "" {
|
|
if b, err := os.ReadFile(info.Path); err == nil {
|
|
content = string(b)
|
|
}
|
|
}
|
|
|
|
resp := map[string]any{
|
|
"name": info.Name,
|
|
"slug": info.Slug,
|
|
"description": info.Description,
|
|
"source": info.Source,
|
|
"content": content,
|
|
"version": info.Version,
|
|
"enabled": info.Enabled,
|
|
}
|
|
if info.ID != "" {
|
|
resp["id"] = info.ID
|
|
}
|
|
if info.Visibility != "" {
|
|
resp["visibility"] = info.Visibility
|
|
}
|
|
if len(info.Tags) > 0 {
|
|
resp["tags"] = info.Tags
|
|
}
|
|
if info.Status != "" {
|
|
resp["status"] = info.Status
|
|
}
|
|
if info.Author != "" {
|
|
resp["author"] = info.Author
|
|
}
|
|
if info.CreatorAgent != nil {
|
|
resp["creator_agent"] = info.CreatorAgent
|
|
}
|
|
if len(info.ManagerAgents) > 0 {
|
|
resp["manager_agents"] = info.ManagerAgents
|
|
}
|
|
if len(info.MissingDeps) > 0 {
|
|
resp["missing_deps"] = info.MissingDeps
|
|
}
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, resp))
|
|
}
|
|
|
|
// skillUpdater is an optional interface for stores that support skill updates (e.g. PGSkillStore).
|
|
type skillUpdater interface {
|
|
UpdateSkill(ctx context.Context, id uuid.UUID, updates map[string]any) error
|
|
}
|
|
|
|
func (m *SkillsMethods) handleUpdate(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
locale := store.LocaleFromContext(ctx)
|
|
var params struct {
|
|
Name string `json:"name"`
|
|
ID string `json:"id"`
|
|
Updates map[string]any `json:"updates"`
|
|
}
|
|
if req.Params != nil {
|
|
json.Unmarshal(req.Params, ¶ms)
|
|
}
|
|
if params.Name == "" && params.ID == "" {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "name or id")))
|
|
return
|
|
}
|
|
|
|
// Check if the store supports updates (PGSkillStore does, FileSkillStore doesn't)
|
|
updater, ok := m.store.(skillUpdater)
|
|
if !ok {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgSkillsUpdateNotSupported)))
|
|
return
|
|
}
|
|
|
|
// Resolve skill ID
|
|
var skillID uuid.UUID
|
|
if params.ID != "" {
|
|
parsed, err := uuid.Parse(params.ID)
|
|
if err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "skill")))
|
|
return
|
|
}
|
|
skillID = parsed
|
|
} else {
|
|
// Look up by name — use GetSkill which returns path info, but we need DB ID
|
|
// For PGSkillStore, the name is the slug
|
|
info, exists := m.store.GetSkill(ctx, params.Name)
|
|
if !exists {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "skill", params.Name)))
|
|
return
|
|
}
|
|
// Try to parse Path as UUID (PGSkillStore stores DB ID in Path field for managed skills)
|
|
parsed, err := uuid.Parse(info.Path)
|
|
if err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgCannotResolveSkillID)))
|
|
return
|
|
}
|
|
skillID = parsed
|
|
}
|
|
|
|
if params.Updates == nil || len(params.Updates) == 0 {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "updates")))
|
|
return
|
|
}
|
|
|
|
// Ownership check first: only skill owner or admin can update.
|
|
// Fail-closed: if store doesn't implement skillOwnerGetter, deny non-admin callers.
|
|
// Auth-before-validate avoids leaking skill-existence info via validation errors.
|
|
if !permissions.HasMinRole(client.Role(), permissions.RoleAdmin) {
|
|
ownerGetter, ok := m.store.(skillOwnerGetter)
|
|
if !ok {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "skills.update")))
|
|
return
|
|
}
|
|
if ownerID, found := ownerGetter.GetSkillOwnerID(ctx, skillID); found && ownerID != client.UserID() {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "skills.update")))
|
|
return
|
|
}
|
|
}
|
|
|
|
// Validate visibility enum if present — fail closed before mutating the DB.
|
|
if v, ok := params.Updates["visibility"]; ok {
|
|
vs, ok := v.(string)
|
|
if !ok {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidVisibility, "")))
|
|
return
|
|
}
|
|
if err := skills.ValidateVisibility(vs); err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidVisibility, vs)))
|
|
return
|
|
}
|
|
params.Updates["visibility"] = skills.NormalizeVisibility(vs)
|
|
}
|
|
|
|
if err := updater.UpdateSkill(ctx, skillID, params.Updates); err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error()))
|
|
return
|
|
}
|
|
|
|
m.store.BumpVersion()
|
|
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]string{"ok": "true"}))
|
|
}
|