diff --git a/internal/gateway/methods/skills.go b/internal/gateway/methods/skills.go index 740f699b..37898aae 100644 --- a/internal/gateway/methods/skills.go +++ b/internal/gateway/methods/skills.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "log/slog" + "os" "github.com/google/uuid" @@ -71,6 +72,12 @@ func (m *SkillsMethods) handleList(ctx context.Context, client *gateway.Client, 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 } @@ -130,7 +137,12 @@ func (m *SkillsMethods) handleGet(ctx context.Context, client *gateway.Client, r return } - content, _ := m.store.LoadSkill(ctx, params.Name) + 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, @@ -139,6 +151,7 @@ func (m *SkillsMethods) handleGet(ctx context.Context, client *gateway.Client, r "source": info.Source, "content": content, "version": info.Version, + "enabled": info.Enabled, } if info.ID != "" { resp["id"] = info.ID @@ -149,6 +162,21 @@ func (m *SkillsMethods) handleGet(ctx context.Context, client *gateway.Client, r 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)) } diff --git a/internal/http/skills.go b/internal/http/skills.go index ee1e17dc..34a00145 100644 --- a/internal/http/skills.go +++ b/internal/http/skills.go @@ -26,6 +26,7 @@ const maxSkillUploadSize = 20 << 20 // 20 MB var ( aggregateInstallDeps = skills.AggregateMissingDeps installManagedDeps = skills.InstallDeps + installSingleDep = skills.InstallSingleDep ) // SkillsHandler handles skill management HTTP endpoints. @@ -260,7 +261,7 @@ func (h *SkillsHandler) handleDelete(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"ok": "true"}) } -// handleInstallDeps installs missing dependencies for all system skills, then re-checks status. +// handleInstallDeps installs missing dependencies for all enabled skills, then re-checks status. func (h *SkillsHandler) handleInstallDeps(w http.ResponseWriter, r *http.Request) { if !h.requireMasterTenant(w, r) { return @@ -269,15 +270,23 @@ func (h *SkillsHandler) handleInstallDeps(w http.ResponseWriter, r *http.Request // consistent with rescanAndUpdate() pattern. masterCtx := store.WithTenantID(r.Context(), store.MasterTenantID) - dirs := h.skills.ListSystemSkillDirs(masterCtx) + dirs := h.installableSkillDirs(masterCtx) if len(dirs) == 0 { - writeJSON(w, http.StatusOK, map[string]string{"message": "no system skills"}) + writeJSON(w, http.StatusOK, map[string]string{"message": "no skills"}) return } manifest, missing := aggregateInstallDeps(dirs) if len(missing) == 0 { - writeJSON(w, http.StatusOK, map[string]string{"message": "all deps satisfied"}) + updated, results := h.rescanAndUpdate(masterCtx) + if updated > 0 { + h.emitCacheInvalidate(bus.CacheKindSkills, "", uuid.Nil) + } + writeJSON(w, http.StatusOK, map[string]any{ + "message": "all deps satisfied", + "updated": updated, + "results": results, + }) return } @@ -294,58 +303,22 @@ func (h *SkillsHandler) handleInstallDeps(w http.ResponseWriter, r *http.Request return } - // Re-check all system skills, persist missing deps, and update status. - allSkills := h.skills.ListAllSkills(masterCtx) - statusChanged := false - for _, sk := range allSkills { - if !sk.IsSystem { - continue - } - if _, exists := dirs[sk.Slug]; !exists { - continue - } - m := h.scanWithFallback(sk) - if m == nil || m.IsEmpty() { - continue - } - ok, miss := skills.CheckSkillDeps(m) - id, err := uuid.Parse(sk.ID) - if err != nil { - continue - } - - // Persist actual missing deps to DB so reload reflects reality. - _ = h.skills.StoreMissingDeps(masterCtx, id, miss) - - // Update status in both directions. - switch { - case ok && sk.Status == "archived": - _ = h.skills.UpdateSkill(masterCtx, id, map[string]any{"status": "active"}) - statusChanged = true - case !ok && sk.Status != "archived": - _ = h.skills.UpdateSkill(masterCtx, id, map[string]any{"status": "archived"}) - statusChanged = true - } - - status := "active" - if !ok { - status = "archived" - } + updated, results := h.rescanAndUpdate(masterCtx) + if updated > 0 { + h.emitCacheInvalidate(bus.CacheKindSkills, "", uuid.Nil) + } + for _, depResult := range results { if h.msgBus != nil { h.msgBus.Broadcast(bus.Event{ Name: protocol.EventSkillDepsChecked, Payload: map[string]any{ - "slug": sk.Slug, - "status": status, - "missing": miss, + "slug": depResult.Slug, + "status": depResult.Status, + "missing": depResult.Missing, }, }) } } - if statusChanged { - h.skills.BumpVersion() - h.emitCacheInvalidate(bus.CacheKindSkills, "", uuid.Nil) - } if h.msgBus != nil { h.msgBus.Broadcast(bus.Event{ @@ -382,7 +355,7 @@ func (h *SkillsHandler) handleInstallDep(w http.ResponseWriter, r *http.Request) }) } - ok, errMsg := skills.InstallSingleDep(r.Context(), body.Dep) + ok, errMsg := installSingleDep(r.Context(), body.Dep) if h.msgBus != nil { payload := map[string]any{"dep": body.Dep, "ok": ok} @@ -395,8 +368,9 @@ func (h *SkillsHandler) handleInstallDep(w http.ResponseWriter, r *http.Request) }) } - if ok { - h.rescanAndUpdate() + updated, _ := h.rescanAndUpdate(store.WithTenantID(r.Context(), store.MasterTenantID)) + if updated > 0 { + h.emitCacheInvalidate(bus.CacheKindSkills, "", uuid.Nil) } writeJSON(w, http.StatusOK, map[string]any{"ok": ok, "error": errMsg}) @@ -408,14 +382,28 @@ type depResult struct { Missing []string `json:"missing,omitempty"` } -// rescanAndUpdate re-checks system skills and updates their status + missing deps in DB. -// Only system skills have filesystem dependencies that need rescanning. -func (h *SkillsHandler) rescanAndUpdate() (updated int, results []depResult) { - masterCtx := store.WithTenantID(context.Background(), store.MasterTenantID) - allSkills := h.skills.ListAllSystemSkills(context.Background()) +func (h *SkillsHandler) installableSkillDirs(ctx context.Context) map[string]string { + dirs := make(map[string]string) + for _, sk := range h.skills.ListAllSkills(store.WithCrossTenant(ctx)) { + if !sk.Enabled || sk.BaseDir == "" { + continue + } + key := sk.ID + if key == "" { + key = sk.Slug + } + dirs[key] = sk.BaseDir + } + return dirs +} + +// rescanAndUpdate re-checks enabled skills and updates their status + missing deps in DB. +func (h *SkillsHandler) rescanAndUpdate(ctx context.Context) (updated int, results []depResult) { + allSkills := h.skills.ListAllSkills(store.WithCrossTenant(ctx)) for _, sk := range allSkills { manifest := h.scanWithFallback(sk) + updateCtx := skillTenantContext(ctx, sk) id, err := uuid.Parse(sk.ID) if err != nil { @@ -423,36 +411,50 @@ func (h *SkillsHandler) rescanAndUpdate() (updated int, results []depResult) { } if manifest == nil || manifest.IsEmpty() { - // No deps needed — if archived, recover to active and clear stale deps. + changed := false + // No deps needed — recover archived skills and clear stale persisted deps. + if len(sk.MissingDeps) > 0 { + _ = h.skills.StoreMissingDeps(updateCtx, id, nil) + changed = true + } if sk.Status == "archived" { - _ = h.skills.StoreMissingDeps(masterCtx, id, nil) - _ = h.skills.UpdateSkill(masterCtx, id, map[string]any{"status": "active"}) + _ = h.skills.UpdateSkill(updateCtx, id, map[string]any{"status": "active"}) results = append(results, depResult{Slug: sk.Slug, Status: "active"}) - updated++ + changed = true slog.Debug("rescan: recovered archived skill (no deps)", "slug", sk.Slug) } else { results = append(results, depResult{Slug: sk.Slug, Status: "ok"}) } + if changed { + updated++ + } continue } ok, missing := skills.CheckSkillDeps(manifest) - _ = h.skills.StoreMissingDeps(masterCtx, id, missing) + changed := false + if !stringSlicesEqual(sk.MissingDeps, missing) { + _ = h.skills.StoreMissingDeps(updateCtx, id, missing) + changed = true + } switch { case ok && sk.Status == "archived": - _ = h.skills.UpdateSkill(masterCtx, id, map[string]any{"status": "active"}) + _ = h.skills.UpdateSkill(updateCtx, id, map[string]any{"status": "active"}) results = append(results, depResult{Slug: sk.Slug, Status: "active"}) - updated++ + changed = true case !ok && sk.Status == "active": - _ = h.skills.UpdateSkill(masterCtx, id, map[string]any{"status": "archived"}) + _ = h.skills.UpdateSkill(updateCtx, id, map[string]any{"status": "archived"}) results = append(results, depResult{Slug: sk.Slug, Status: "archived", Missing: missing}) - updated++ + changed = true case !ok: results = append(results, depResult{Slug: sk.Slug, Status: sk.Status, Missing: missing}) default: results = append(results, depResult{Slug: sk.Slug, Status: "ok"}) } + if changed { + updated++ + } slog.Debug("rescan: checked skill", "slug", sk.Slug, "ok", ok, "missing", len(missing)) } @@ -463,6 +465,27 @@ func (h *SkillsHandler) rescanAndUpdate() (updated int, results []depResult) { return updated, results } +func skillTenantContext(ctx context.Context, sk store.SkillInfo) context.Context { + if sk.TenantID != "" { + if tid, err := uuid.Parse(sk.TenantID); err == nil && tid != uuid.Nil { + return store.WithTenantID(ctx, tid) + } + } + return store.WithTenantID(ctx, store.MasterTenantID) +} + +func stringSlicesEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + // scanWithFallback scans skill deps from the managed dir, falling back to the // bundled dir if the managed copy's scripts/ directory is missing or empty. // If a fallback scan succeeds, re-copies the bundled scripts to the managed dir. @@ -506,7 +529,7 @@ func (h *SkillsHandler) handleRescanDeps(w http.ResponseWriter, r *http.Request) if !h.requireMasterTenant(w, r) { return } - updated, results := h.rescanAndUpdate() + updated, results := h.rescanAndUpdate(store.WithTenantID(r.Context(), store.MasterTenantID)) if updated > 0 { // rescanAndUpdate bumped the skills version already; emit a global // invalidate so cached agent Loops pick up the new status set. diff --git a/internal/http/skills_upload.go b/internal/http/skills_upload.go index ea1cbe43..3dd5f159 100644 --- a/internal/http/skills_upload.go +++ b/internal/http/skills_upload.go @@ -4,6 +4,7 @@ import ( "archive/zip" "context" "crypto/sha256" + "encoding/json" "fmt" "io" "log/slog" @@ -23,7 +24,10 @@ import ( "github.com/nextlevelbuilder/goclaw/pkg/protocol" ) -const uploadDepsInstallTimeout = 5 * time.Minute +const ( + uploadDepsInstallTimeout = 5 * time.Minute + maxUploadManagerAgentIDs = 100 +) var ( installUploadedSkillDeps = skills.InstallDeps @@ -48,6 +52,12 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) { } defer file.Close() + managerAgentIDs, err := parseUploadManagerAgentIDs(r) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, err.Error())}) + return + } + // Save to temp file for zip processing tmp, err := os.CreateTemp("", "skill-upload-*.zip") if err != nil { @@ -164,12 +174,23 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) { // could both pass the hash check before either creates a new version. existingHash, existingVer, skillExists := h.skills.GetSkillHashBySlug(r.Context(), slug) if skillExists && existingHash != "" && existingHash == skillHash { - writeJSON(w, http.StatusOK, map[string]any{ + response := map[string]any{ "slug": slug, "version": existingVer, "name": name, "status": "unchanged", - }) + } + if len(managerAgentIDs) > 0 { + if existing, ok := h.skills.GetSkill(r.Context(), slug); ok && existing.ID != "" { + if existingID, err := uuid.Parse(existing.ID); err == nil { + grantErrors := h.grantUploadedSkillManagers(r.Context(), existingID, managerAgentIDs, existingVer, userID) + if len(grantErrors) > 0 { + response["grant_errors"] = grantErrors + } + } + } + } + writeJSON(w, http.StatusOK, response) return } @@ -269,6 +290,12 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) { return } response["id"] = id + if len(managerAgentIDs) > 0 { + grantErrors := h.grantUploadedSkillManagers(depsCtx, id, managerAgentIDs, version, userID) + if len(grantErrors) > 0 { + response["grant_errors"] = grantErrors + } + } h.skills.BumpVersion() h.emitCacheInvalidate(bus.CacheKindSkills, id.String(), uuid.Nil) @@ -279,6 +306,47 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, response) } +func parseUploadManagerAgentIDs(r *http.Request) ([]uuid.UUID, error) { + raw := strings.TrimSpace(r.FormValue("manager_agent_ids")) + if raw == "" { + return nil, nil + } + var values []string + if err := json.Unmarshal([]byte(raw), &values); err != nil { + return nil, fmt.Errorf("manager_agent_ids must be a JSON array") + } + if len(values) > maxUploadManagerAgentIDs { + return nil, fmt.Errorf("manager_agent_ids exceeds limit of %d", maxUploadManagerAgentIDs) + } + out := make([]uuid.UUID, 0, len(values)) + seen := make(map[uuid.UUID]bool, len(values)) + for _, value := range values { + id, err := uuid.Parse(strings.TrimSpace(value)) + if err != nil || id == uuid.Nil { + return nil, fmt.Errorf("invalid manager_agent_ids value") + } + if seen[id] { + continue + } + seen[id] = true + out = append(out, id) + } + return out, nil +} + +func (h *SkillsHandler) grantUploadedSkillManagers(ctx context.Context, skillID uuid.UUID, agentIDs []uuid.UUID, version int, userID string) []string { + if version <= 0 { + version = 1 + } + var errs []string + for _, agentID := range agentIDs { + if err := h.skills.GrantToAgent(ctx, skillID, agentID, version, userID, true); err != nil { + errs = append(errs, fmt.Sprintf("%s: %v", agentID, err)) + } + } + return errs +} + func canAutoInstallUploadedSkillDeps(ctx context.Context) bool { return store.IsOwnerRole(ctx) || store.TenantIDFromContext(ctx) == store.MasterTenantID } diff --git a/internal/http/skills_upload_test.go b/internal/http/skills_upload_test.go index f8ae2935..0b979216 100644 --- a/internal/http/skills_upload_test.go +++ b/internal/http/skills_upload_test.go @@ -5,6 +5,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "maps" "mime/multipart" "net/http" @@ -278,8 +279,8 @@ func TestHandleInstallDeps_ExistingEndpointStillReturnsInstallResult(t *testing. prevAggregate := aggregateInstallDeps prevInstall := installManagedDeps aggregateInstallDeps = func(dirs map[string]string) (*skills.SkillManifest, []string) { - if got := dirs["system-skill"]; got != systemDir { - t.Fatalf("system dir = %q, want %q", got, systemDir) + if !mapContainsValue(dirs, systemDir) { + t.Fatalf("install dirs missing system dir: %v", dirs) } return &skills.SkillManifest{RequiresPython: []string{"requests"}}, []string{"pip:requests"} } @@ -308,6 +309,367 @@ func TestHandleInstallDeps_ExistingEndpointStillReturnsInstallResult(t *testing. } } +func TestHandleInstallDeps_IncludesCustomSkillDirsAndRefreshesStaleDeps(t *testing.T) { + handler, skillStore, ctx, root := newTestUploadHandler(t) + systemDir := filepath.Join(root, "skills-store", "system-skill", "1") + customDir := filepath.Join(root, "skills-store", "custom-skill", "1") + skillStore.seedSystemSkill("system-skill", systemDir) + customID := skillStore.seedCustomSkill("custom-skill", customDir, "archived", []string{"pip:requests"}) + + prevAggregate := aggregateInstallDeps + prevInstall := installManagedDeps + aggregateInstallDeps = func(dirs map[string]string) (*skills.SkillManifest, []string) { + if !mapContainsValue(dirs, systemDir) { + t.Fatalf("install dirs missing system dir: %v", dirs) + } + if !mapContainsValue(dirs, customDir) { + t.Fatalf("install dirs missing custom dir: %v", dirs) + } + return &skills.SkillManifest{RequiresPython: []string{"requests"}}, []string{"pip:requests"} + } + installManagedDeps = func(context.Context, *skills.SkillManifest, []string) (*skills.InstallResult, error) { + return &skills.InstallResult{Pip: []string{"requests"}}, nil + } + t.Cleanup(func() { + aggregateInstallDeps = prevAggregate + installManagedDeps = prevInstall + }) + + req := httptest.NewRequest(http.MethodPost, "/v1/skills/install-deps", http.NoBody).WithContext(ctx) + w := httptest.NewRecorder() + handler.handleInstallDeps(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + info, ok := skillStore.GetSkillByID(ctx, customID) + if !ok { + t.Fatal("custom skill missing") + } + if info.Status != "active" { + t.Fatalf("custom status = %q, want active", info.Status) + } + if len(info.MissingDeps) != 0 { + t.Fatalf("custom missing_deps = %v, want none", info.MissingDeps) + } +} + +func TestHandleInstallDeps_NoMissingPackagesStillRefreshesStaleDeps(t *testing.T) { + handler, skillStore, ctx, root := newTestUploadHandler(t) + customDir := filepath.Join(root, "skills-store", "custom-skill", "1") + customID := skillStore.seedCustomSkill("custom-skill", customDir, "archived", []string{"pip:requests"}) + + prevAggregate := aggregateInstallDeps + prevInstall := installManagedDeps + aggregateInstallDeps = func(dirs map[string]string) (*skills.SkillManifest, []string) { + if !mapContainsValue(dirs, customDir) { + t.Fatalf("install dirs missing custom dir: %v", dirs) + } + return nil, nil + } + installManagedDeps = func(context.Context, *skills.SkillManifest, []string) (*skills.InstallResult, error) { + t.Fatal("install should not run when aggregate reports no missing packages") + return nil, nil + } + t.Cleanup(func() { + aggregateInstallDeps = prevAggregate + installManagedDeps = prevInstall + }) + + req := httptest.NewRequest(http.MethodPost, "/v1/skills/install-deps", http.NoBody).WithContext(ctx) + w := httptest.NewRecorder() + handler.handleInstallDeps(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + info, ok := skillStore.GetSkillByID(ctx, customID) + if !ok { + t.Fatal("custom skill missing") + } + if info.Status != "active" { + t.Fatalf("custom status = %q, want active", info.Status) + } + if len(info.MissingDeps) != 0 { + t.Fatalf("custom missing_deps = %v, want none", info.MissingDeps) + } +} + +func TestHandleInstallDeps_RefreshesNonMasterTenantCustomSkills(t *testing.T) { + handler, skillStore, ctx, root := newTestUploadHandler(t) + tenantID := uuid.New() + customDir := filepath.Join(root, "tenants", tenantID.String(), "skills-store", "tenant-skill", "1") + customID := skillStore.seedCustomSkillForTenant(tenantID, "tenant-skill", customDir, "archived", []string{"pip:requests"}) + + prevAggregate := aggregateInstallDeps + prevInstall := installManagedDeps + aggregateInstallDeps = func(dirs map[string]string) (*skills.SkillManifest, []string) { + if !mapContainsValue(dirs, customDir) { + t.Fatalf("install dirs missing non-master custom dir: %v", dirs) + } + return nil, nil + } + installManagedDeps = func(context.Context, *skills.SkillManifest, []string) (*skills.InstallResult, error) { + t.Fatal("install should not run when aggregate reports no missing packages") + return nil, nil + } + t.Cleanup(func() { + aggregateInstallDeps = prevAggregate + installManagedDeps = prevInstall + }) + + req := httptest.NewRequest(http.MethodPost, "/v1/skills/install-deps", http.NoBody).WithContext(ctx) + w := httptest.NewRecorder() + handler.handleInstallDeps(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + info, ok := skillStore.GetSkillByID(store.WithTenantID(context.Background(), tenantID), customID) + if !ok { + t.Fatal("custom skill missing") + } + if info.Status != "active" { + t.Fatalf("custom status = %q, want active", info.Status) + } + if len(info.MissingDeps) != 0 { + t.Fatalf("custom missing_deps = %v, want none", info.MissingDeps) + } +} + +func TestHandleInstallDep_RescansCustomSkillsAfterSuccessfulInstall(t *testing.T) { + handler, skillStore, ctx, root := newTestUploadHandler(t) + customDir := filepath.Join(root, "skills-store", "custom-skill", "1") + customID := skillStore.seedCustomSkill("custom-skill", customDir, "archived", []string{"pip:requests"}) + + prevInstallSingle := installSingleDep + installSingleDep = func(context.Context, string) (bool, string) { return true, "" } + t.Cleanup(func() { installSingleDep = prevInstallSingle }) + + req := httptest.NewRequest(http.MethodPost, "/v1/skills/install-dep", bytes.NewBufferString(`{"dep":"pip:requests"}`)).WithContext(ctx) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.handleInstallDep(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + info, ok := skillStore.GetSkillByID(ctx, customID) + if !ok { + t.Fatal("custom skill missing") + } + if info.Status != "active" { + t.Fatalf("custom status = %q, want active", info.Status) + } + if len(info.MissingDeps) != 0 { + t.Fatalf("custom missing_deps = %v, want none", info.MissingDeps) + } +} + +func TestHandleInstallDep_RescansCustomSkillsAfterFailedInstall(t *testing.T) { + handler, skillStore, ctx, root := newTestUploadHandler(t) + customDir := filepath.Join(root, "skills-store", "custom-skill", "1") + customID := skillStore.seedCustomSkill("custom-skill", customDir, "archived", []string{"pip:requests"}) + + prevInstallSingle := installSingleDep + installSingleDep = func(context.Context, string) (bool, string) { return false, "install failed" } + t.Cleanup(func() { installSingleDep = prevInstallSingle }) + + req := httptest.NewRequest(http.MethodPost, "/v1/skills/install-dep", bytes.NewBufferString(`{"dep":"pip:requests"}`)).WithContext(ctx) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.handleInstallDep(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + info, ok := skillStore.GetSkillByID(ctx, customID) + if !ok { + t.Fatal("custom skill missing") + } + if info.Status != "active" { + t.Fatalf("custom status = %q, want active", info.Status) + } + if len(info.MissingDeps) != 0 { + t.Fatalf("custom missing_deps = %v, want none", info.MissingDeps) + } +} + +func TestParseUploadManagerAgentIDs_RejectsTooManyValues(t *testing.T) { + values := make([]string, maxUploadManagerAgentIDs+1) + for i := range values { + values[i] = uuid.NewString() + } + raw, err := json.Marshal(values) + if err != nil { + t.Fatalf("marshal manager ids: %v", err) + } + + req := newUploadManagerIDsFormRequest(t, string(raw)) + if _, err := parseUploadManagerAgentIDs(req); err == nil { + t.Fatal("expected too many manager_agent_ids to be rejected") + } +} + +func TestParseUploadManagerAgentIDs_RejectsNilUUID(t *testing.T) { + raw, err := json.Marshal([]string{uuid.Nil.String()}) + if err != nil { + t.Fatalf("marshal manager ids: %v", err) + } + + req := newUploadManagerIDsFormRequest(t, string(raw)) + if _, err := parseUploadManagerAgentIDs(req); err == nil { + t.Fatal("expected nil UUID to be rejected") + } +} + +func TestParseUploadManagerAgentIDs_DeduplicatesValues(t *testing.T) { + id := uuid.New() + raw, err := json.Marshal([]string{id.String(), id.String()}) + if err != nil { + t.Fatalf("marshal manager ids: %v", err) + } + + req := newUploadManagerIDsFormRequest(t, string(raw)) + got, err := parseUploadManagerAgentIDs(req) + if err != nil { + t.Fatalf("parse manager ids: %v", err) + } + if len(got) != 1 || got[0] != id { + t.Fatalf("manager ids = %v, want [%s]", got, id) + } +} + +func TestHandleUpload_GrantsSelectedAgentsCanManageOnCreatedSkill(t *testing.T) { + handler, skillStore, ctx, _ := newTestUploadHandler(t) + stubUploadDepFns(t, + func(context.Context, *skills.SkillManifest, []string) (*skills.InstallResult, error) { + return nil, nil + }, + func(*skills.SkillManifest) (bool, []string) { return true, nil }, + ) + + agentA := uuid.New() + agentB := uuid.New() + req := newZipUploadRequestWithManagers(t, ctx, map[string]string{ + "SKILL.md": skillMarkdown("Managed Skill", "managed-skill"), + }, []string{agentA.String(), agentB.String()}) + w := httptest.NewRecorder() + handler.handleUpload(w, req) + + if w.Code != http.StatusCreated { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + if len(skillStore.grantCalls) != 2 { + t.Fatalf("grant calls = %d, want 2", len(skillStore.grantCalls)) + } + for i, call := range skillStore.grantCalls { + if call.SkillID == uuid.Nil { + t.Fatalf("grant call %d has nil skill id", i) + } + if call.Version != 1 { + t.Fatalf("grant call %d version = %d, want 1", i, call.Version) + } + if call.GrantedBy != "user-1" { + t.Fatalf("grant call %d granted by = %q, want user-1", i, call.GrantedBy) + } + if !call.CanManage { + t.Fatalf("grant call %d canManage = false, want true", i) + } + } + if skillStore.grantCalls[0].AgentID != agentA || skillStore.grantCalls[1].AgentID != agentB { + t.Fatalf("grant agent ids = %s, %s; want %s, %s", + skillStore.grantCalls[0].AgentID, + skillStore.grantCalls[1].AgentID, + agentA, + agentB, + ) + } +} + +func TestHandleUpload_GrantsSelectedAgentsOnUnchangedSkill(t *testing.T) { + handler, skillStore, ctx, _ := newTestUploadHandler(t) + stubUploadDepFns(t, + func(context.Context, *skills.SkillManifest, []string) (*skills.InstallResult, error) { + return nil, nil + }, + func(*skills.SkillManifest) (bool, []string) { return true, nil }, + ) + files := map[string]string{ + "SKILL.md": skillMarkdown("Unchanged Managed Skill", "unchanged-managed-skill"), + } + + w1 := httptest.NewRecorder() + handler.handleUpload(w1, newZipUploadRequest(t, ctx, files)) + if w1.Code != http.StatusCreated { + t.Fatalf("first upload status = %d, body = %s", w1.Code, w1.Body.String()) + } + + agentID := uuid.New() + w2 := httptest.NewRecorder() + handler.handleUpload(w2, newZipUploadRequestWithManagers(t, ctx, files, []string{agentID.String()})) + if w2.Code != http.StatusOK { + t.Fatalf("second upload status = %d, body = %s", w2.Code, w2.Body.String()) + } + + var resp struct { + Status string `json:"status"` + } + if err := json.NewDecoder(w2.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp.Status != "unchanged" { + t.Fatalf("status = %q, want unchanged", resp.Status) + } + if len(skillStore.grantCalls) != 1 { + t.Fatalf("grant calls = %d, want 1", len(skillStore.grantCalls)) + } + call := skillStore.grantCalls[0] + if call.AgentID != agentID { + t.Fatalf("grant agent id = %s, want %s", call.AgentID, agentID) + } + if call.Version != 1 { + t.Fatalf("grant version = %d, want 1", call.Version) + } + if !call.CanManage { + t.Fatal("grant canManage = false, want true") + } +} + +func TestHandleUpload_ReturnsGrantErrors(t *testing.T) { + handler, skillStore, ctx, _ := newTestUploadHandler(t) + stubUploadDepFns(t, + func(context.Context, *skills.SkillManifest, []string) (*skills.InstallResult, error) { + return nil, nil + }, + func(*skills.SkillManifest) (bool, []string) { return true, nil }, + ) + + agentID := uuid.New() + skillStore.grantErrors[agentID] = errors.New("agent tenant mismatch") + req := newZipUploadRequestWithManagers(t, ctx, map[string]string{ + "SKILL.md": skillMarkdown("Grant Error Skill", "grant-error-skill"), + }, []string{agentID.String()}) + w := httptest.NewRecorder() + handler.handleUpload(w, req) + + if w.Code != http.StatusCreated { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + var resp struct { + GrantErrors []string `json:"grant_errors"` + } + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(resp.GrantErrors) != 1 { + t.Fatalf("grant_errors = %v, want one error", resp.GrantErrors) + } + if want := agentID.String() + ": agent tenant mismatch"; resp.GrantErrors[0] != want { + t.Fatalf("grant error = %q, want %q", resp.GrantErrors[0], want) + } +} + func newTestUploadHandler(t *testing.T) (*SkillsHandler, *skillManageStoreStub, context.Context, string) { t.Helper() @@ -328,6 +690,12 @@ func newTestUploadHandler(t *testing.T) (*SkillsHandler, *skillManageStoreStub, func newZipUploadRequest(t *testing.T, ctx context.Context, files map[string]string) *http.Request { t.Helper() + return newZipUploadRequestWithManagers(t, ctx, files, nil) +} + +func newZipUploadRequestWithManagers(t *testing.T, ctx context.Context, files map[string]string, managerAgentIDs []string) *http.Request { + t.Helper() + var zipBuf bytes.Buffer zw := zip.NewWriter(&zipBuf) for name, content := range files { @@ -352,6 +720,15 @@ func newZipUploadRequest(t *testing.T, ctx context.Context, files map[string]str if _, err := part.Write(zipBuf.Bytes()); err != nil { t.Fatalf("multipart write: %v", err) } + if managerAgentIDs != nil { + raw, err := json.Marshal(managerAgentIDs) + if err != nil { + t.Fatalf("marshal manager_agent_ids: %v", err) + } + if err := mw.WriteField("manager_agent_ids", string(raw)); err != nil { + t.Fatalf("multipart manager_agent_ids: %v", err) + } + } if err := mw.Close(); err != nil { t.Fatalf("multipart close: %v", err) } @@ -361,26 +738,53 @@ func newZipUploadRequest(t *testing.T, ctx context.Context, files map[string]str return req.WithContext(ctx) } +func newUploadManagerIDsFormRequest(t *testing.T, raw string) *http.Request { + t.Helper() + + var body bytes.Buffer + mw := multipart.NewWriter(&body) + if err := mw.WriteField("manager_agent_ids", raw); err != nil { + t.Fatalf("multipart manager_agent_ids: %v", err) + } + if err := mw.Close(); err != nil { + t.Fatalf("multipart close: %v", err) + } + req := httptest.NewRequest(http.MethodPost, "/v1/skills/upload", &body) + req.Header.Set("Content-Type", mw.FormDataContentType()) + return req +} + func skillMarkdown(name, slug string) string { return "---\nname: " + name + "\nslug: " + slug + "\n---\nSkill body\n" } type skillManageStoreStub struct { - baseDir string - version int64 - nextBySlug map[string]int - skills map[uuid.UUID]store.SkillInfo - systemDirs map[string]string - hashBySlug map[string]string // slug -> SKILL.md content hash (most recent) + baseDir string + version int64 + nextBySlug map[string]int + skills map[uuid.UUID]store.SkillInfo + systemDirs map[string]string + hashBySlug map[string]string // slug -> SKILL.md content hash (most recent) + grantCalls []skillGrantCall + grantErrors map[uuid.UUID]error +} + +type skillGrantCall struct { + SkillID uuid.UUID + AgentID uuid.UUID + Version int + GrantedBy string + CanManage bool } func newSkillManageStoreStub(baseDir string) *skillManageStoreStub { return &skillManageStoreStub{ - baseDir: baseDir, - nextBySlug: map[string]int{}, - skills: map[uuid.UUID]store.SkillInfo{}, - systemDirs: map[string]string{}, - hashBySlug: map[string]string{}, + baseDir: baseDir, + nextBySlug: map[string]int{}, + skills: map[uuid.UUID]store.SkillInfo{}, + systemDirs: map[string]string{}, + hashBySlug: map[string]string{}, + grantErrors: map[uuid.UUID]error{}, } } @@ -388,6 +792,7 @@ func (s *skillManageStoreStub) seedSystemSkill(slug, dir string) { id := uuid.New() s.skills[id] = store.SkillInfo{ ID: id.String(), + TenantID: store.MasterTenantID.String(), Name: "System Skill", Slug: slug, Path: filepath.Join(dir, "SKILL.md"), @@ -400,6 +805,36 @@ func (s *skillManageStoreStub) seedSystemSkill(slug, dir string) { s.systemDirs[slug] = dir } +func (s *skillManageStoreStub) seedCustomSkill(slug, dir, status string, missing []string) uuid.UUID { + return s.seedCustomSkillForTenant(store.MasterTenantID, slug, dir, status, missing) +} + +func (s *skillManageStoreStub) seedCustomSkillForTenant(tenantID uuid.UUID, slug, dir, status string, missing []string) uuid.UUID { + id := uuid.New() + s.skills[id] = store.SkillInfo{ + ID: id.String(), + TenantID: tenantID.String(), + Name: "Custom Skill", + Slug: slug, + Path: filepath.Join(dir, "SKILL.md"), + BaseDir: dir, + Version: 1, + Status: status, + Enabled: true, + MissingDeps: append([]string(nil), missing...), + } + return id +} + +func mapContainsValue(values map[string]string, want string) bool { + for _, got := range values { + if got == want { + return true + } + } + return false +} + func (s *skillManageStoreStub) ListSkills(context.Context) []store.SkillInfo { return s.ListAllSkills(context.Background()) } @@ -423,8 +858,12 @@ func (s *skillManageStoreStub) Version() int64 { return s.version } func (s *skillManageStoreStub) BumpVersion() { s.version++ } func (s *skillManageStoreStub) Dirs() []string { return []string{s.baseDir} } -func (s *skillManageStoreStub) CreateSkillManaged(_ context.Context, p store.SkillCreateParams) (uuid.UUID, error) { +func (s *skillManageStoreStub) CreateSkillManaged(ctx context.Context, p store.SkillCreateParams) (uuid.UUID, error) { id := uuid.New() + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + tenantID = store.MasterTenantID + } status := p.Status if status == "" { status = "active" @@ -438,6 +877,7 @@ func (s *skillManageStoreStub) CreateSkillManaged(_ context.Context, p store.Ski } s.skills[id] = store.SkillInfo{ ID: id.String(), + TenantID: tenantID.String(), Name: p.Name, Slug: p.Slug, Path: filepath.Join(p.FilePath, "SKILL.md"), @@ -464,11 +904,14 @@ func (s *skillManageStoreStub) GetSkillHashBySlug(_ context.Context, slug string return hash, version, true } -func (s *skillManageStoreStub) UpdateSkill(_ context.Context, id uuid.UUID, updates map[string]any) error { +func (s *skillManageStoreStub) UpdateSkill(ctx context.Context, id uuid.UUID, updates map[string]any) error { skill, ok := s.skills[id] if !ok { return nil } + if !s.canAccessSkill(ctx, skill) { + return nil + } if status, ok := updates["status"].(string); ok { skill.Status = status } @@ -498,9 +941,16 @@ func (s *skillManageStoreStub) IsSystemSkill(slug string) bool { _, ok := s.systemDirs[slug] return ok } -func (s *skillManageStoreStub) ListAllSkills(context.Context) []store.SkillInfo { +func (s *skillManageStoreStub) ListAllSkills(ctx context.Context) []store.SkillInfo { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + tid = store.MasterTenantID + } out := make([]store.SkillInfo, 0, len(s.skills)) for _, skill := range s.skills { + if !store.IsCrossTenant(ctx) && !skill.IsSystem && skill.TenantID != tid.String() { + continue + } out = append(out, skill) } return out @@ -519,16 +969,43 @@ func (s *skillManageStoreStub) ListSystemSkillDirs(context.Context) map[string]s maps.Copy(out, s.systemDirs) return out } -func (s *skillManageStoreStub) StoreMissingDeps(_ context.Context, id uuid.UUID, missing []string) error { +func (s *skillManageStoreStub) StoreMissingDeps(ctx context.Context, id uuid.UUID, missing []string) error { skill, ok := s.skills[id] if !ok { return nil } + if !s.canAccessSkill(ctx, skill) { + return nil + } skill.MissingDeps = append([]string(nil), missing...) s.skills[id] = skill return nil } -func (s *skillManageStoreStub) GrantToAgent(context.Context, uuid.UUID, uuid.UUID, int, string, ...bool) error { + +func (s *skillManageStoreStub) canAccessSkill(ctx context.Context, skill store.SkillInfo) bool { + if skill.IsSystem || store.IsCrossTenant(ctx) { + return true + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + tid = store.MasterTenantID + } + return skill.TenantID == "" || skill.TenantID == tid.String() +} +func (s *skillManageStoreStub) GrantToAgent(_ context.Context, skillID uuid.UUID, agentID uuid.UUID, version int, grantedBy string, canManage ...bool) error { + if err := s.grantErrors[agentID]; err != nil { + return err + } + call := skillGrantCall{ + SkillID: skillID, + AgentID: agentID, + Version: version, + GrantedBy: grantedBy, + } + if len(canManage) > 0 { + call.CanManage = canManage[0] + } + s.grantCalls = append(s.grantCalls, call) return nil } func (s *skillManageStoreStub) RevokeFromAgent(context.Context, uuid.UUID, uuid.UUID) error { diff --git a/internal/store/pg/skills.go b/internal/store/pg/skills.go index e8f35eb8..da3d721d 100644 --- a/internal/store/pg/skills.go +++ b/internal/store/pg/skills.go @@ -88,6 +88,7 @@ func (s *PGSkillStore) ListSkills(ctx context.Context) []store.SkillInfo { for i := range scanned { result = append(result, scanned[i].toSkillInfo(s.baseDir)) } + s.attachSkillAgentMetadata(ctx, result) s.mu.Lock() s.listCache[tid] = &listCacheEntry{skills: result, ver: currentVer, time: time.Now()} @@ -96,19 +97,29 @@ func (s *PGSkillStore) ListSkills(ctx context.Context) []store.SkillInfo { return result } -// ListAllSkills returns system skills + custom skills for the given tenant (for admin operations like rescan-deps). +// ListAllSkills returns system skills + custom skills for the given tenant. +// Cross-tenant callers get every enabled non-deleted skill for global admin operations like rescan-deps. // Disabled skills are excluded — no point scanning or updating them. func (s *PGSkillStore) ListAllSkills(ctx context.Context) []store.SkillInfo { - tid := store.TenantIDFromContext(ctx) - if tid == uuid.Nil { - tid = store.MasterTenantID - } var scanned []skillInfoRow - if err := pkgSqlxDB.SelectContext(ctx, &scanned, - `SELECT id, name, slug, description, visibility, owner_id, tags, version, is_system, status, enabled, deps, file_path - FROM skills WHERE enabled = true AND status != 'deleted' AND (is_system = true OR tenant_id = $1) - ORDER BY name`, tid); err != nil { - return nil + if store.IsCrossTenant(ctx) { + if err := pkgSqlxDB.SelectContext(ctx, &scanned, + `SELECT id, tenant_id, name, slug, description, visibility, owner_id, tags, version, is_system, status, enabled, deps, file_path + FROM skills WHERE enabled = true AND status != 'deleted' + ORDER BY name`); err != nil { + return nil + } + } else { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + tid = store.MasterTenantID + } + if err := pkgSqlxDB.SelectContext(ctx, &scanned, + `SELECT id, tenant_id, name, slug, description, visibility, owner_id, tags, version, is_system, status, enabled, deps, file_path + FROM skills WHERE enabled = true AND status != 'deleted' AND (is_system = true OR tenant_id = $1) + ORDER BY name`, tid); err != nil { + return nil + } } return skillInfoRowsToSlice(scanned, s.baseDir) } @@ -118,7 +129,7 @@ func (s *PGSkillStore) ListAllSkills(ctx context.Context) []store.SkillInfo { func (s *PGSkillStore) ListAllSystemSkills(ctx context.Context) []store.SkillInfo { var scanned []skillInfoRow if err := pkgSqlxDB.SelectContext(ctx, &scanned, - `SELECT id, name, slug, description, visibility, owner_id, tags, version, is_system, status, enabled, deps, file_path + `SELECT id, tenant_id, name, slug, description, visibility, owner_id, tags, version, is_system, status, enabled, deps, file_path FROM skills WHERE is_system = true AND enabled = true AND status != 'deleted' ORDER BY name`); err != nil { return nil diff --git a/internal/store/pg/skills_content.go b/internal/store/pg/skills_content.go index 1ac30c41..1e51775c 100644 --- a/internal/store/pg/skills_content.go +++ b/internal/store/pg/skills_content.go @@ -89,33 +89,54 @@ func (s *PGSkillStore) BuildSummary(ctx context.Context, allowList []string) str } func (s *PGSkillStore) GetSkill(ctx context.Context, name string) (*store.SkillInfo, bool) { - var id uuid.UUID - var skillName, slug, visibility, ownerID string - var desc *string - var tags []string - var version int - var isSystem bool - var filePath *string - q := "SELECT id, name, slug, description, visibility, owner_id, tags, version, is_system, file_path FROM skills WHERE slug = $1 AND status = 'active'" - args := []any{name} + baseSelect := `SELECT id, name, slug, description, visibility, owner_id, tags, version, is_system, status, enabled, deps, frontmatter, file_path + FROM skills WHERE ` + scope := "" + args := []any{} if !store.IsCrossTenant(ctx) { tid := store.TenantIDFromContext(ctx) if tid == uuid.Nil { tid = store.MasterTenantID } - q += " AND (is_system = true OR tenant_id = $2)" args = append(args, tid) + scope = fmt.Sprintf(" AND (is_system = true OR tenant_id = $%d)", len(args)) } - err := s.db.QueryRowContext(ctx, q, args...).Scan(&id, &skillName, &slug, &desc, &visibility, &ownerID, pq.Array(&tags), &version, &isSystem, &filePath) - if err != nil { - return nil, false + + scan := func(q string, qArgs ...any) (*store.SkillInfo, bool) { + var id uuid.UUID + var skillName, slug, visibility, ownerID, status string + var desc *string + var tags []string + var version int + var isSystem, enabled bool + var depsRaw, fmRaw []byte + var filePath *string + err := s.db.QueryRowContext(ctx, q, qArgs...).Scan(&id, &skillName, &slug, &desc, &visibility, &ownerID, pq.Array(&tags), &version, &isSystem, &status, &enabled, &depsRaw, &fmRaw, &filePath) + if err != nil { + return nil, false + } + info := buildSkillInfo(id.String(), skillName, slug, desc, version, s.baseDir, filePath) + info.Visibility = visibility + info.OwnerID = ownerID + info.Tags = tags + info.IsSystem = isSystem + info.Status = status + info.Enabled = enabled + info.Author = parseFrontmatterAuthor(fmRaw) + info.CreatorAgent = parseFrontmatterCreatorAgent(fmRaw) + info.MissingDeps = parseDepsColumn(depsRaw) + enriched := []store.SkillInfo{info} + s.attachSkillAgentMetadata(ctx, enriched) + return &enriched[0], true } - info := buildSkillInfo(id.String(), skillName, slug, desc, version, s.baseDir, filePath) - info.Visibility = visibility - info.OwnerID = ownerID - info.Tags = tags - info.IsSystem = isSystem - return &info, true + + if id, err := uuid.Parse(name); err == nil { + return scan(baseSelect+"id = $"+fmt.Sprint(len(args)+1)+" AND status IN ('active', 'archived')"+scope, append(args, id)...) + } + if info, ok := scan(baseSelect+"slug = $"+fmt.Sprint(len(args)+1)+" AND status = 'active'"+scope, append(args, name)...); ok { + return info, true + } + return scan(baseSelect+"name = $"+fmt.Sprint(len(args)+1)+" AND status = 'active'"+scope+" ORDER BY id LIMIT 1", append(args, name)...) } func (s *PGSkillStore) FilterSkills(ctx context.Context, allowList []string) []store.SkillInfo { diff --git a/internal/store/pg/skills_crud.go b/internal/store/pg/skills_crud.go index fef92e9f..449876d9 100644 --- a/internal/store/pg/skills_crud.go +++ b/internal/store/pg/skills_crud.go @@ -309,6 +309,33 @@ func parseFrontmatterAuthor(raw []byte) string { return fm["author"] } +func parseFrontmatterCreatorAgent(raw []byte) *store.SkillAgentRef { + if len(raw) == 0 { + return nil + } + var fm map[string]string + if err := json.Unmarshal(raw, &fm); err != nil { + return nil + } + ref := store.SkillAgentRef{ + ID: fm["created_by_agent_id"], + AgentKey: firstNonEmpty(fm["created_by_agent_key"], fm["creator_agent_key"]), + } + if ref.ID == "" && ref.AgentKey == "" { + return nil + } + return &ref +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} + func marshalFrontmatter(fm map[string]string) []byte { if len(fm) == 0 { return []byte("{}") diff --git a/internal/store/pg/skills_grants.go b/internal/store/pg/skills_grants.go index 22f8947a..9f53b3a3 100644 --- a/internal/store/pg/skills_grants.go +++ b/internal/store/pg/skills_grants.go @@ -8,6 +8,7 @@ import ( "time" "github.com/google/uuid" + "github.com/lib/pq" "github.com/nextlevelbuilder/goclaw/internal/store" ) @@ -152,13 +153,18 @@ func (s *PGSkillStore) ListAgentGrantsForSkill(ctx context.Context, skillID uuid if err := s.verifySkillInGrantScope(ctx, skillID, tenantIDForInsert(ctx)); err != nil { return nil, err } - tClause, tArgs, _, err := scopeClause(ctx, 2) + tClause, tArgs, _, err := scopeClauseAlias(ctx, 2, "sag") if err != nil { return nil, err } var result []store.SkillAgentGrantInfo err = pkgSqlxDB.SelectContext(ctx, &result, - "SELECT agent_id, pinned_version, granted_by, can_manage FROM skill_agent_grants WHERE skill_id = $1"+tClause+" ORDER BY created_at DESC", + `SELECT sag.agent_id, COALESCE(a.agent_key, '') AS agent_key, COALESCE(a.display_name, '') AS display_name, + sag.pinned_version, sag.granted_by, sag.can_manage + FROM skill_agent_grants sag + LEFT JOIN agents a ON a.id = sag.agent_id + WHERE sag.skill_id = $1`+tClause+` + ORDER BY sag.created_at DESC`, append([]any{skillID}, tArgs...)...) if err != nil { return nil, err @@ -166,6 +172,124 @@ func (s *PGSkillStore) ListAgentGrantsForSkill(ctx context.Context, skillID uuid return result, nil } +func (s *PGSkillStore) attachSkillAgentMetadata(ctx context.Context, skills []store.SkillInfo) { + if len(skills) == 0 { + return + } + ids := make([]uuid.UUID, 0, len(skills)) + byID := make(map[uuid.UUID]int, len(skills)) + creatorIDs := make([]uuid.UUID, 0) + creatorByID := make(map[uuid.UUID][]int) + creatorKeys := make([]string, 0) + creatorByKey := make(map[string][]int) + for i := range skills { + id, err := uuid.Parse(skills[i].ID) + if err != nil { + continue + } + ids = append(ids, id) + byID[id] = i + if ref := skills[i].CreatorAgent; ref != nil { + skills[i].CreatorAgent = nil + if ref.ID != "" { + if agentID, err := uuid.Parse(ref.ID); err == nil { + if _, exists := creatorByID[agentID]; !exists { + creatorIDs = append(creatorIDs, agentID) + } + creatorByID[agentID] = append(creatorByID[agentID], i) + } + } + if ref.AgentKey != "" { + if _, exists := creatorByKey[ref.AgentKey]; !exists { + creatorKeys = append(creatorKeys, ref.AgentKey) + } + creatorByKey[ref.AgentKey] = append(creatorByKey[ref.AgentKey], i) + } + } + } + s.attachVerifiedCreatorAgents(ctx, skills, creatorIDs, creatorByID, creatorKeys, creatorByKey) + if len(ids) == 0 { + return + } + tClause, tArgs, _, err := scopeClauseAlias(ctx, 2, "sag") + if err != nil { + return + } + rows, err := s.db.QueryContext(ctx, + `SELECT sag.skill_id, sag.agent_id, COALESCE(a.agent_key, ''), COALESCE(a.display_name, '') + FROM skill_agent_grants sag + LEFT JOIN agents a ON a.id = sag.agent_id + WHERE sag.skill_id = ANY($1) AND sag.can_manage = true`+tClause+` + ORDER BY sag.created_at DESC`, + append([]any{pq.Array(ids)}, tArgs...)...) + if err != nil { + slog.Warn("skill_grants: failed to attach manager agents", "error", err) + return + } + defer rows.Close() + for rows.Next() { + var skillID, agentID uuid.UUID + var agentKey, displayName string + if err := rows.Scan(&skillID, &agentID, &agentKey, &displayName); err != nil { + continue + } + i, ok := byID[skillID] + if !ok { + continue + } + skills[i].ManagerAgents = append(skills[i].ManagerAgents, store.SkillAgentRef{ + ID: agentID.String(), + AgentKey: agentKey, + DisplayName: displayName, + }) + } +} + +func (s *PGSkillStore) attachVerifiedCreatorAgents( + ctx context.Context, + skills []store.SkillInfo, + creatorIDs []uuid.UUID, + creatorByID map[uuid.UUID][]int, + creatorKeys []string, + creatorByKey map[string][]int, +) { + if len(creatorIDs) == 0 && len(creatorKeys) == 0 { + return + } + tClause, tArgs, _, err := scopeClauseAlias(ctx, 3, "a") + if err != nil { + return + } + rows, err := s.db.QueryContext(ctx, + `SELECT a.id, COALESCE(a.agent_key, ''), COALESCE(a.display_name, '') + FROM agents a + WHERE a.deleted_at IS NULL AND (a.id = ANY($1) OR a.agent_key = ANY($2))`+tClause, + append([]any{pq.Array(creatorIDs), pq.Array(creatorKeys)}, tArgs...)...) + if err != nil { + slog.Warn("skill_grants: failed to resolve creator agents", "error", err) + return + } + defer rows.Close() + for rows.Next() { + var agentID uuid.UUID + var agentKey, displayName string + if err := rows.Scan(&agentID, &agentKey, &displayName); err != nil { + continue + } + ref := store.SkillAgentRef{ + ID: agentID.String(), + AgentKey: agentKey, + DisplayName: displayName, + } + for _, i := range creatorByID[agentID] { + skills[i].CreatorAgent = &ref + } + for _, i := range creatorByKey[agentKey] { + skills[i].CreatorAgent = &ref + } + } +} + // AgentCanManageSkill reports whether an agent has explicit edit/delete rights for a skill. func (s *PGSkillStore) AgentCanManageSkill(ctx context.Context, skillID, agentID uuid.UUID) (bool, error) { if err := s.verifySkillInGrantScope(ctx, skillID, tenantIDForInsert(ctx)); err != nil { diff --git a/internal/store/pg/skills_scan_rows.go b/internal/store/pg/skills_scan_rows.go index a7a568e8..5b6b6c83 100644 --- a/internal/store/pg/skills_scan_rows.go +++ b/internal/store/pg/skills_scan_rows.go @@ -14,6 +14,7 @@ import ( // Used by ListSkills (includes frontmatter) and scanSkillInfoList (no frontmatter). type skillInfoRow struct { ID uuid.UUID `db:"id"` + TenantID uuid.UUID `db:"tenant_id"` Name string `db:"name"` Slug string `db:"slug"` Desc *string `db:"description"` @@ -37,6 +38,7 @@ type skillInfoRowWithFrontmatter struct { // toSkillInfo converts a skillInfoRow to store.SkillInfo, resolving computed fields from baseDir. func (r *skillInfoRow) toSkillInfo(baseDir string) store.SkillInfo { info := buildSkillInfo(r.ID.String(), r.Name, r.Slug, r.Desc, r.Version, baseDir, r.FilePath) + info.TenantID = r.TenantID.String() info.Visibility = r.Visibility info.OwnerID = r.OwnerID info.Tags = []string(r.Tags) @@ -51,6 +53,7 @@ func (r *skillInfoRow) toSkillInfo(baseDir string) store.SkillInfo { func (r *skillInfoRowWithFrontmatter) toSkillInfo(baseDir string) store.SkillInfo { info := r.skillInfoRow.toSkillInfo(baseDir) info.Author = parseFrontmatterAuthor(r.FmRaw) + info.CreatorAgent = parseFrontmatterCreatorAgent(r.FmRaw) return info } diff --git a/internal/store/skill_store.go b/internal/store/skill_store.go index eeac4720..7ac897fd 100644 --- a/internal/store/skill_store.go +++ b/internal/store/skill_store.go @@ -8,22 +8,32 @@ import ( // SkillInfo describes a discovered skill. type SkillInfo struct { - ID string `json:"id,omitempty" db:"id"` // DB UUID - Name string `json:"name" db:"name"` - Slug string `json:"slug" db:"slug"` - Path string `json:"path" db:"path"` - BaseDir string `json:"baseDir" db:"-"` - Source string `json:"source" db:"-"` - Description string `json:"description" db:"description"` - Visibility string `json:"visibility,omitempty" db:"visibility"` - OwnerID string `json:"-" db:"owner_id"` - Tags []string `json:"tags,omitempty" db:"tags"` - Version int `json:"version,omitempty" db:"version"` - IsSystem bool `json:"is_system,omitempty" db:"is_system"` - Status string `json:"status,omitempty" db:"status"` - Enabled bool `json:"enabled" db:"enabled"` - Author string `json:"author,omitempty" db:"author"` - MissingDeps []string `json:"missing_deps,omitempty" db:"missing_deps"` + ID string `json:"id,omitempty" db:"id"` // DB UUID + TenantID string `json:"-" db:"tenant_id"` + Name string `json:"name" db:"name"` + Slug string `json:"slug" db:"slug"` + Path string `json:"path" db:"path"` + BaseDir string `json:"baseDir" db:"-"` + Source string `json:"source" db:"-"` + Description string `json:"description" db:"description"` + Visibility string `json:"visibility,omitempty" db:"visibility"` + OwnerID string `json:"-" db:"owner_id"` + Tags []string `json:"tags,omitempty" db:"tags"` + Version int `json:"version,omitempty" db:"version"` + IsSystem bool `json:"is_system,omitempty" db:"is_system"` + Status string `json:"status,omitempty" db:"status"` + Enabled bool `json:"enabled" db:"enabled"` + Author string `json:"author,omitempty" db:"author"` + CreatorAgent *SkillAgentRef `json:"creator_agent,omitempty" db:"-"` + ManagerAgents []SkillAgentRef `json:"manager_agents,omitempty" db:"-"` + MissingDeps []string `json:"missing_deps,omitempty" db:"missing_deps"` +} + +// SkillAgentRef is a small UI/API-safe agent reference for skill metadata. +type SkillAgentRef struct { + ID string `json:"id,omitempty" db:"id"` + AgentKey string `json:"agent_key,omitempty" db:"agent_key"` + DisplayName string `json:"display_name,omitempty" db:"display_name"` } // SkillSearchResult is a scored skill returned from embedding search. @@ -97,6 +107,8 @@ type SkillWithGrantStatus struct { // SkillAgentGrantInfo is a grant row for one skill across agents. type SkillAgentGrantInfo struct { AgentID uuid.UUID `json:"agent_id" db:"agent_id"` + AgentKey string `json:"agent_key,omitempty" db:"agent_key"` + DisplayName string `json:"display_name,omitempty" db:"display_name"` PinnedVersion int `json:"pinned_version" db:"pinned_version"` GrantedBy string `json:"granted_by" db:"granted_by"` CanManage bool `json:"can_manage" db:"can_manage"` diff --git a/internal/store/sqlitestore/skills.go b/internal/store/sqlitestore/skills.go index 9d41ae43..d7181c14 100644 --- a/internal/store/sqlitestore/skills.go +++ b/internal/store/sqlitestore/skills.go @@ -102,12 +102,14 @@ func (s *SQLiteSkillStore) ListSkills(ctx context.Context) []store.SkillInfo { info.Enabled = enabled info.MissingDeps = parseDepsColumn(depsRaw) info.Author = parseFrontmatterAuthor(fmRaw) + info.CreatorAgent = parseFrontmatterCreatorAgent(fmRaw) result = append(result, info) } if err := rows.Err(); err != nil { slog.Warn("ListSkills: rows iteration error", "error", err) return nil } + s.attachSkillAgentMetadata(ctx, result) s.mu.Lock() s.listCache[tid] = &skillListCacheEntry{skills: result, ver: currentVer, time: time.Now()} @@ -117,14 +119,23 @@ func (s *SQLiteSkillStore) ListSkills(ctx context.Context) []store.SkillInfo { } func (s *SQLiteSkillStore) ListAllSkills(ctx context.Context) []store.SkillInfo { - tid := store.TenantIDFromContext(ctx) - if tid == uuid.Nil { - tid = store.MasterTenantID + var rows *sql.Rows + var err error + if store.IsCrossTenant(ctx) { + rows, err = s.db.QueryContext(ctx, + `SELECT id, tenant_id, name, slug, description, visibility, tags, version, is_system, status, enabled, deps, file_path + FROM skills WHERE enabled = 1 AND status != 'deleted' + ORDER BY name`) + } else { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + tid = store.MasterTenantID + } + rows, err = s.db.QueryContext(ctx, + `SELECT id, tenant_id, name, slug, description, visibility, tags, version, is_system, status, enabled, deps, file_path + FROM skills WHERE enabled = 1 AND status != 'deleted' AND (is_system = 1 OR tenant_id = ?) + ORDER BY name`, tid) } - rows, err := s.db.QueryContext(ctx, - `SELECT id, name, slug, description, visibility, tags, version, is_system, status, enabled, deps, file_path - FROM skills WHERE enabled = 1 AND status != 'deleted' AND (is_system = 1 OR tenant_id = ?) - ORDER BY name`, tid) if err != nil { return nil } @@ -134,7 +145,7 @@ func (s *SQLiteSkillStore) ListAllSkills(ctx context.Context) []store.SkillInfo func (s *SQLiteSkillStore) ListAllSystemSkills(ctx context.Context) []store.SkillInfo { rows, err := s.db.QueryContext(ctx, - `SELECT id, name, slug, description, visibility, tags, version, is_system, status, enabled, deps, file_path + `SELECT id, tenant_id, name, slug, description, visibility, tags, version, is_system, status, enabled, deps, file_path FROM skills WHERE is_system = 1 AND enabled = 1 AND status != 'deleted' ORDER BY name`) if err != nil { @@ -148,6 +159,7 @@ func (s *SQLiteSkillStore) scanSkillInfoList(rows *sql.Rows) []store.SkillInfo { var result []store.SkillInfo for rows.Next() { var id uuid.UUID + var tenantID uuid.UUID var name, slug, visibility, status string var desc *string var tagsJSON []byte @@ -155,11 +167,12 @@ func (s *SQLiteSkillStore) scanSkillInfoList(rows *sql.Rows) []store.SkillInfo { var isSystem, enabled bool var depsRaw []byte var filePath *string - if err := rows.Scan(&id, &name, &slug, &desc, &visibility, &tagsJSON, &version, + if err := rows.Scan(&id, &tenantID, &name, &slug, &desc, &visibility, &tagsJSON, &version, &isSystem, &status, &enabled, &depsRaw, &filePath); err != nil { continue } info := buildSkillInfo(id.String(), name, slug, desc, version, s.baseDir, filePath) + info.TenantID = tenantID.String() info.Visibility = visibility scanJSONStringArray(tagsJSON, &info.Tags) info.IsSystem = isSystem @@ -257,6 +270,33 @@ func parseFrontmatterAuthor(raw []byte) string { return fm["author"] } +func parseFrontmatterCreatorAgent(raw []byte) *store.SkillAgentRef { + if len(raw) == 0 { + return nil + } + var fm map[string]string + if err := json.Unmarshal(raw, &fm); err != nil { + return nil + } + ref := store.SkillAgentRef{ + ID: fm["created_by_agent_id"], + AgentKey: firstNonEmpty(fm["created_by_agent_key"], fm["creator_agent_key"]), + } + if ref.ID == "" && ref.AgentKey == "" { + return nil + } + return &ref +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} + func marshalFrontmatter(fm map[string]string) []byte { if len(fm) == 0 { return []byte("{}") diff --git a/internal/store/sqlitestore/skills_content.go b/internal/store/sqlitestore/skills_content.go index ad27c604..e47c06a4 100644 --- a/internal/store/sqlitestore/skills_content.go +++ b/internal/store/sqlitestore/skills_content.go @@ -84,32 +84,53 @@ func (s *SQLiteSkillStore) BuildSummary(ctx context.Context, allowList []string) } func (s *SQLiteSkillStore) GetSkill(ctx context.Context, name string) (*store.SkillInfo, bool) { - var id uuid.UUID - var skillName, slug, visibility, ownerID string - var desc *string - var tagsJSON []byte - var version int - var isSystem bool - var filePath *string - q := "SELECT id, name, slug, description, visibility, owner_id, tags, version, is_system, file_path FROM skills WHERE slug = ? AND status = 'active'" - args := []any{name} + baseSelect := `SELECT id, name, slug, description, visibility, owner_id, tags, version, is_system, status, enabled, deps, frontmatter, file_path + FROM skills WHERE ` + scope := "" + args := []any{} if !store.IsCrossTenant(ctx) { tid := store.TenantIDFromContext(ctx) if tid == uuid.Nil { tid = store.MasterTenantID } - q += " AND (is_system = 1 OR tenant_id = ?)" args = append(args, tid) + scope = " AND (is_system = 1 OR tenant_id = ?)" } - if err := s.db.QueryRowContext(ctx, q, args...).Scan(&id, &skillName, &slug, &desc, &visibility, &ownerID, &tagsJSON, &version, &isSystem, &filePath); err != nil { - return nil, false + + scan := func(q string, qArgs ...any) (*store.SkillInfo, bool) { + var id uuid.UUID + var skillName, slug, visibility, ownerID, status string + var desc *string + var tagsJSON []byte + var version int + var isSystem, enabled bool + var depsRaw, fmRaw []byte + var filePath *string + if err := s.db.QueryRowContext(ctx, q, qArgs...).Scan(&id, &skillName, &slug, &desc, &visibility, &ownerID, &tagsJSON, &version, &isSystem, &status, &enabled, &depsRaw, &fmRaw, &filePath); err != nil { + return nil, false + } + info := buildSkillInfo(id.String(), skillName, slug, desc, version, s.baseDir, filePath) + info.Visibility = visibility + info.OwnerID = ownerID + scanJSONStringArray(tagsJSON, &info.Tags) + info.IsSystem = isSystem + info.Status = status + info.Enabled = enabled + info.Author = parseFrontmatterAuthor(fmRaw) + info.CreatorAgent = parseFrontmatterCreatorAgent(fmRaw) + info.MissingDeps = parseDepsColumn(depsRaw) + enriched := []store.SkillInfo{info} + s.attachSkillAgentMetadata(ctx, enriched) + return &enriched[0], true } - info := buildSkillInfo(id.String(), skillName, slug, desc, version, s.baseDir, filePath) - info.Visibility = visibility - info.OwnerID = ownerID - scanJSONStringArray(tagsJSON, &info.Tags) - info.IsSystem = isSystem - return &info, true + + if id, err := uuid.Parse(name); err == nil { + return scan(baseSelect+"id = ? AND status IN ('active', 'archived')"+scope, append([]any{id}, args...)...) + } + if info, ok := scan(baseSelect+"slug = ? AND status = 'active'"+scope, append([]any{name}, args...)...); ok { + return info, true + } + return scan(baseSelect+"name = ? AND status = 'active'"+scope+" ORDER BY id LIMIT 1", append([]any{name}, args...)...) } func (s *SQLiteSkillStore) FilterSkills(ctx context.Context, allowList []string) []store.SkillInfo { @@ -290,4 +311,3 @@ func (s *SQLiteSkillStore) IsSystemSkill(slug string) bool { err := s.db.QueryRow("SELECT is_system FROM skills WHERE slug = ?", slug).Scan(&isSystem) return err == nil && isSystem } - diff --git a/internal/store/sqlitestore/skills_grants.go b/internal/store/sqlitestore/skills_grants.go index d9dba9b6..e0e2ab72 100644 --- a/internal/store/sqlitestore/skills_grants.go +++ b/internal/store/sqlitestore/skills_grants.go @@ -7,6 +7,7 @@ import ( "database/sql" "fmt" "log/slog" + "strings" "time" "github.com/google/uuid" @@ -170,7 +171,12 @@ func (s *SQLiteSkillStore) ListAgentGrantsForSkill(ctx context.Context, skillID return nil, err } rows, err := s.db.QueryContext(ctx, - "SELECT agent_id, pinned_version, granted_by, can_manage FROM skill_agent_grants WHERE skill_id = ?"+tClause+" ORDER BY created_at DESC", + `SELECT sag.agent_id, COALESCE(a.agent_key, ''), COALESCE(a.display_name, ''), + sag.pinned_version, sag.granted_by, sag.can_manage + FROM skill_agent_grants sag + LEFT JOIN agents a ON a.id = sag.agent_id + WHERE sag.skill_id = ?`+strings.ReplaceAll(tClause, "tenant_id", "sag.tenant_id")+` + ORDER BY sag.created_at DESC`, append([]any{skillID}, tArgs...)...) if err != nil { return nil, err @@ -180,7 +186,7 @@ func (s *SQLiteSkillStore) ListAgentGrantsForSkill(ctx context.Context, skillID var result []store.SkillAgentGrantInfo for rows.Next() { var g store.SkillAgentGrantInfo - if err := rows.Scan(&g.AgentID, &g.PinnedVersion, &g.GrantedBy, &g.CanManage); err != nil { + if err := rows.Scan(&g.AgentID, &g.AgentKey, &g.DisplayName, &g.PinnedVersion, &g.GrantedBy, &g.CanManage); err != nil { slog.Warn("skill_grants: scan error in ListAgentGrantsForSkill", "error", err) continue } @@ -189,6 +195,150 @@ func (s *SQLiteSkillStore) ListAgentGrantsForSkill(ctx context.Context, skillID return result, rows.Err() } +func (s *SQLiteSkillStore) attachSkillAgentMetadata(ctx context.Context, skills []store.SkillInfo) { + if len(skills) == 0 { + return + } + ids := make([]uuid.UUID, 0, len(skills)) + byID := make(map[uuid.UUID]int, len(skills)) + creatorIDs := make([]uuid.UUID, 0) + creatorByID := make(map[uuid.UUID][]int) + creatorKeys := make([]string, 0) + creatorByKey := make(map[string][]int) + for i := range skills { + id, err := uuid.Parse(skills[i].ID) + if err != nil { + continue + } + ids = append(ids, id) + byID[id] = i + if ref := skills[i].CreatorAgent; ref != nil { + skills[i].CreatorAgent = nil + if ref.ID != "" { + if agentID, err := uuid.Parse(ref.ID); err == nil { + if _, exists := creatorByID[agentID]; !exists { + creatorIDs = append(creatorIDs, agentID) + } + creatorByID[agentID] = append(creatorByID[agentID], i) + } + } + if ref.AgentKey != "" { + if _, exists := creatorByKey[ref.AgentKey]; !exists { + creatorKeys = append(creatorKeys, ref.AgentKey) + } + creatorByKey[ref.AgentKey] = append(creatorByKey[ref.AgentKey], i) + } + } + } + s.attachVerifiedCreatorAgents(ctx, skills, creatorIDs, creatorByID, creatorKeys, creatorByKey) + if len(ids) == 0 { + return + } + placeholders := make([]string, len(ids)) + args := make([]any, 0, len(ids)+1) + for i, id := range ids { + placeholders[i] = "?" + args = append(args, id) + } + tClause, tArgs, err := scopeClause(ctx) + if err != nil { + return + } + args = append(args, tArgs...) + rows, err := s.db.QueryContext(ctx, + `SELECT sag.skill_id, sag.agent_id, COALESCE(a.agent_key, ''), COALESCE(a.display_name, '') + FROM skill_agent_grants sag + LEFT JOIN agents a ON a.id = sag.agent_id + WHERE sag.skill_id IN (`+strings.Join(placeholders, ",")+`) AND sag.can_manage = 1`+strings.ReplaceAll(tClause, "tenant_id", "sag.tenant_id")+` + ORDER BY sag.created_at DESC`, + args...) + if err != nil { + slog.Warn("skill_grants: failed to attach manager agents", "error", err) + return + } + defer rows.Close() + for rows.Next() { + var skillID, agentID uuid.UUID + var agentKey, displayName string + if err := rows.Scan(&skillID, &agentID, &agentKey, &displayName); err != nil { + continue + } + i, ok := byID[skillID] + if !ok { + continue + } + skills[i].ManagerAgents = append(skills[i].ManagerAgents, store.SkillAgentRef{ + ID: agentID.String(), + AgentKey: agentKey, + DisplayName: displayName, + }) + } +} + +func (s *SQLiteSkillStore) attachVerifiedCreatorAgents( + ctx context.Context, + skills []store.SkillInfo, + creatorIDs []uuid.UUID, + creatorByID map[uuid.UUID][]int, + creatorKeys []string, + creatorByKey map[string][]int, +) { + if len(creatorIDs) == 0 && len(creatorKeys) == 0 { + return + } + clauses := make([]string, 0, 2) + args := make([]any, 0, len(creatorIDs)+len(creatorKeys)+1) + if len(creatorIDs) > 0 { + placeholders := make([]string, len(creatorIDs)) + for i, id := range creatorIDs { + placeholders[i] = "?" + args = append(args, id) + } + clauses = append(clauses, "a.id IN ("+strings.Join(placeholders, ",")+")") + } + if len(creatorKeys) > 0 { + placeholders := make([]string, len(creatorKeys)) + for i, key := range creatorKeys { + placeholders[i] = "?" + args = append(args, key) + } + clauses = append(clauses, "a.agent_key IN ("+strings.Join(placeholders, ",")+")") + } + tClause, tArgs, err := scopeClause(ctx) + if err != nil { + return + } + args = append(args, tArgs...) + rows, err := s.db.QueryContext(ctx, + `SELECT a.id, COALESCE(a.agent_key, ''), COALESCE(a.display_name, '') + FROM agents a + WHERE a.deleted_at IS NULL AND (`+strings.Join(clauses, " OR ")+`)`+strings.ReplaceAll(tClause, "tenant_id", "a.tenant_id"), + args...) + if err != nil { + slog.Warn("skill_grants: failed to resolve creator agents", "error", err) + return + } + defer rows.Close() + for rows.Next() { + var agentID uuid.UUID + var agentKey, displayName string + if err := rows.Scan(&agentID, &agentKey, &displayName); err != nil { + continue + } + ref := store.SkillAgentRef{ + ID: agentID.String(), + AgentKey: agentKey, + DisplayName: displayName, + } + for _, i := range creatorByID[agentID] { + skills[i].CreatorAgent = &ref + } + for _, i := range creatorByKey[agentKey] { + skills[i].CreatorAgent = &ref + } + } +} + // AgentCanManageSkill reports whether an agent has explicit edit/delete rights for a skill. func (s *SQLiteSkillStore) AgentCanManageSkill(ctx context.Context, skillID, agentID uuid.UUID) (bool, error) { if err := s.verifySkillInGrantScope(ctx, skillID, tenantIDForInsert(ctx)); err != nil { diff --git a/internal/store/sqlitestore/skills_test.go b/internal/store/sqlitestore/skills_test.go index 3e640ca2..f7b3b69b 100644 --- a/internal/store/sqlitestore/skills_test.go +++ b/internal/store/sqlitestore/skills_test.go @@ -70,6 +70,82 @@ func TestSQLiteSkillStore_CreateSkillManaged_PersistsArchivedDependencyState(t * } } +func TestSQLiteSkillStore_GetSkill_UUIDCanReadArchivedSlugStaysActiveOnly(t *testing.T) { + ctx, skillStore := newTestSQLiteSkillStore(t) + skillID, err := skillStore.CreateSkillManaged(ctx, store.SkillCreateParams{ + Name: "Archived Detail", + Slug: "archived-detail", + OwnerID: "user-1", + Visibility: "private", + Status: "archived", + FilePath: filepath.Join(t.TempDir(), "archived-detail", "1"), + }) + if err != nil { + t.Fatalf("CreateSkillManaged error: %v", err) + } + + if _, ok := skillStore.GetSkill(ctx, "archived-detail"); ok { + t.Fatal("GetSkill by slug returned archived skill; want active-only slug lookup") + } + info, ok := skillStore.GetSkill(ctx, skillID.String()) + if !ok { + t.Fatal("GetSkill by UUID returned !ok for archived skill") + } + if info.Status != "archived" { + t.Fatalf("Status = %q, want archived", info.Status) + } +} + +func TestSQLiteSkillStore_ListSkills_ResolvesCreatorAgentWithinTenant(t *testing.T) { + _, skillStore, db := newTestSQLiteSkillStoreWithDB(t) + tenantID, agentID := seedSQLiteTenantAgent(t, db) + if _, err := db.Exec(`UPDATE agents SET display_name = ? WHERE id = ?`, "Creator Agent", agentID.String()); err != nil { + t.Fatalf("update agent display_name: %v", err) + } + ctx := store.WithTenantID(context.Background(), tenantID) + if _, err := skillStore.CreateSkillManaged(ctx, store.SkillCreateParams{ + Name: "Verified Creator", + Slug: "verified-creator", + OwnerID: "user-1", + Visibility: "private", + FilePath: filepath.Join(t.TempDir(), "verified-creator", "1"), + Frontmatter: map[string]string{ + "created_by_agent_id": agentID.String(), + "created_by_agent": "Spoofed Name", + }, + }); err != nil { + t.Fatalf("CreateSkillManaged error: %v", err) + } + if _, err := skillStore.CreateSkillManaged(ctx, store.SkillCreateParams{ + Name: "Spoofed Creator", + Slug: "spoofed-creator", + OwnerID: "user-1", + Visibility: "private", + FilePath: filepath.Join(t.TempDir(), "spoofed-creator", "1"), + Frontmatter: map[string]string{ + "created_by_agent": "Only A String", + }, + }); err != nil { + t.Fatalf("CreateSkillManaged error: %v", err) + } + + list := skillStore.ListSkills(ctx) + bySlug := map[string]store.SkillInfo{} + for _, info := range list { + bySlug[info.Slug] = info + } + verified := bySlug["verified-creator"].CreatorAgent + if verified == nil { + t.Fatal("CreatorAgent = nil, want verified creator") + } + if verified.ID != agentID.String() || verified.DisplayName != "Creator Agent" { + t.Fatalf("CreatorAgent = %+v, want resolved DB agent", verified) + } + if got := bySlug["spoofed-creator"].CreatorAgent; got != nil { + t.Fatalf("CreatorAgent = %+v, want nil for display-only spoof", got) + } +} + func TestSQLiteSkillStore_GrantToAgentRejectsCrossTenantSkill(t *testing.T) { _, skillStore, db := newTestSQLiteSkillStoreWithDB(t) tenantA, agentA := seedSQLiteTenantAgent(t, db) diff --git a/ui/web/src/hooks/use-pagination.ts b/ui/web/src/hooks/use-pagination.ts index 04ce074d..57572642 100644 --- a/ui/web/src/hooks/use-pagination.ts +++ b/ui/web/src/hooks/use-pagination.ts @@ -1,4 +1,4 @@ -import { useState, useMemo, useCallback } from "react"; +import { useState, useMemo, useCallback, useEffect } from "react"; import { useUiStore } from "@/stores/use-ui-store"; export interface PaginationState { @@ -30,6 +30,12 @@ export function usePagination( const [page, setPageRaw] = useState(1); const [pageSize, setPageSizeRaw] = useState(options.defaultPageSize ?? globalPageSize); + useEffect(() => { + if (options.defaultPageSize === undefined) { + setPageSizeRaw(globalPageSize); + } + }, [globalPageSize, options.defaultPageSize]); + const total = items.length; const totalPages = Math.max(1, Math.ceil(total / pageSize)); const safePage = Math.min(page, totalPages); diff --git a/ui/web/src/i18n/locales/en/skills.json b/ui/web/src/i18n/locales/en/skills.json index c8df5947..185e1ca1 100644 --- a/ui/web/src/i18n/locales/en/skills.json +++ b/ui/web/src/i18n/locales/en/skills.json @@ -11,6 +11,7 @@ "description": "Description", "source": "Source", "author": "Author", + "agents": "Agents", "owner": "Owner", "visibility": "Visibility", "status": "Status", @@ -72,7 +73,12 @@ "skillUnchanged": "Content unchanged — skipped", "slugConflict": "Slug conflicts with a system skill", "unchanged": "Unchanged", - "new": "New" + "new": "New", + "agentManagers": "Grant manager access to agents", + "managerAgentsHelp": "Selected current agents can update this skill later.", + "selectAllAgents": "Select all agents", + "clearAgents": "Clear agents", + "noAgents": "No agents available" }, "edit": { "title": "Edit Skill", @@ -112,6 +118,7 @@ "rescanSuccess": "Updated {{count}} skills", "missing": "Missing: {{deps}}", "install": "Install Dependencies", + "installAll": "Install all dependencies", "installing": "Installing...", "installSuccess": "Dependencies installed successfully", "installPartial": "Some dependencies failed to install", @@ -129,6 +136,10 @@ "runtimeMissingAction": "Open Packages to verify or install runtimes in this container.", "runtimeRequired": "Install container runtimes first" }, + "agents": { + "creator": "Creator", + "managers": "Managers" + }, "detail": { "title": "Skill Detail", "content": "Content", diff --git a/ui/web/src/i18n/locales/vi/skills.json b/ui/web/src/i18n/locales/vi/skills.json index 5833c4c4..6df870ca 100644 --- a/ui/web/src/i18n/locales/vi/skills.json +++ b/ui/web/src/i18n/locales/vi/skills.json @@ -11,6 +11,7 @@ "description": "Mô tả", "source": "Nguồn", "author": "Tác giả", + "agents": "Agents", "visibility": "Hiển thị", "status": "Trạng thái", "actions": "Thao tác", @@ -53,7 +54,12 @@ "skillUnchanged": "Nội dung không đổi — đã bỏ qua", "slugConflict": "Slug trùng với kỹ năng hệ thống", "unchanged": "Không đổi", - "new": "Mới" + "new": "Mới", + "agentManagers": "Cấp quyền quản lý cho agents", + "managerAgentsHelp": "Các agent hiện tại được chọn có thể cập nhật skill này sau.", + "selectAllAgents": "Chọn tất cả agents", + "clearAgents": "Bỏ chọn agents", + "noAgents": "Chưa có agent" }, "edit": { "title": "Chỉnh sửa skill", @@ -93,6 +99,7 @@ "rescanSuccess": "Updated {{count}} skills", "missing": "Missing: {{deps}}", "install": "Install Dependencies", + "installAll": "Install all dependencies", "installing": "Installing...", "installSuccess": "Dependencies installed", "installPartial": "Some dependencies failed", @@ -110,6 +117,10 @@ "runtimeMissingAction": "Mở trang Packages để kiểm tra hoặc cài runtime trong container này.", "runtimeRequired": "Hãy cài runtime trong container trước" }, + "agents": { + "creator": "Agent tạo", + "managers": "Agent quản lý" + }, "detail": { "title": "Chi tiết skill", "content": "Nội dung", diff --git a/ui/web/src/i18n/locales/zh/skills.json b/ui/web/src/i18n/locales/zh/skills.json index b3c0444d..7a52b3fa 100644 --- a/ui/web/src/i18n/locales/zh/skills.json +++ b/ui/web/src/i18n/locales/zh/skills.json @@ -11,6 +11,7 @@ "description": "描述", "source": "来源", "author": "作者", + "agents": "Agent", "visibility": "可见性", "status": "状态", "actions": "操作", @@ -53,7 +54,12 @@ "skillUnchanged": "内容未更改 — 已跳过", "slugConflict": "Slug 与系统技能冲突", "unchanged": "未更改", - "new": "新建" + "new": "新建", + "agentManagers": "授予 Agent 管理权限", + "managerAgentsHelp": "所选当前 Agent 以后可以更新此 Skill。", + "selectAllAgents": "选择所有 Agent", + "clearAgents": "清除 Agent", + "noAgents": "暂无 Agent" }, "edit": { "title": "编辑Skill", @@ -93,6 +99,7 @@ "rescanSuccess": "Updated {{count}} skills", "missing": "Missing: {{deps}}", "install": "Install Dependencies", + "installAll": "Install all dependencies", "installing": "Installing...", "installSuccess": "Dependencies installed", "installPartial": "Some dependencies failed", @@ -110,6 +117,10 @@ "runtimeMissingAction": "打开 Packages 页面,在此容器中检查或安装运行时。", "runtimeRequired": "请先安装容器内运行时" }, + "agents": { + "creator": "创建 Agent", + "managers": "管理 Agent" + }, "detail": { "title": "Skill详情", "content": "内容", diff --git a/ui/web/src/pages/agents/hooks/use-agents.ts b/ui/web/src/pages/agents/hooks/use-agents.ts index 7fc0f474..84490a7c 100644 --- a/ui/web/src/pages/agents/hooks/use-agents.ts +++ b/ui/web/src/pages/agents/hooks/use-agents.ts @@ -21,7 +21,7 @@ export function useAgents() { const connected = useAuthStore((s) => s.connected); const queryClient = useQueryClient(); - const { data: agents = [], isPending: loading, error: queryError } = useQuery({ + const { data: agents = [], isPending: loading, error: queryError, refetch } = useQuery({ queryKey: queryKeys.agents.all, queryFn: async () => { // Try HTTP first (returns full agent data, filtered by user access) @@ -59,8 +59,11 @@ export function useAgents() { const error = queryError instanceof Error ? queryError.message : queryError ? "Failed to load agents" : null; const invalidate = useCallback( - () => queryClient.invalidateQueries({ queryKey: queryKeys.agents.all }), - [queryClient], + async () => { + await queryClient.invalidateQueries({ queryKey: queryKeys.agents.all }); + await refetch(); + }, + [queryClient, refetch], ); const createAgent = useCallback( diff --git a/ui/web/src/pages/skills/hooks/use-skills.ts b/ui/web/src/pages/skills/hooks/use-skills.ts index 8dc3ac9a..9614a448 100644 --- a/ui/web/src/pages/skills/hooks/use-skills.ts +++ b/ui/web/src/pages/skills/hooks/use-skills.ts @@ -24,6 +24,11 @@ export type SkillUploadResponse = { deps_errors?: string[]; missing_deps?: string[]; deps_installed?: boolean; + grant_errors?: string[]; +}; + +export type SkillUploadOptions = { + managerAgentIds?: string[]; }; export function useSkills() { @@ -62,9 +67,12 @@ export function useSkills() { ); const uploadSkill = useCallback( - async (file: File) => { + async (file: File, options?: SkillUploadOptions) => { const formData = new FormData(); formData.append("file", file); + if (options?.managerAgentIds?.length) { + formData.append("manager_agent_ids", JSON.stringify(options.managerAgentIds)); + } const res = await http.upload( "/v1/skills/upload", formData, diff --git a/ui/web/src/pages/skills/lib/skill-detail-deeplink.test.ts b/ui/web/src/pages/skills/lib/skill-detail-deeplink.test.ts new file mode 100644 index 00000000..27759b5a --- /dev/null +++ b/ui/web/src/pages/skills/lib/skill-detail-deeplink.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { + parseSkillDetailVersionParam, + shouldLoadSkillDetailFile, +} from "./skill-detail-deeplink"; + +describe("skill detail deeplink helpers", () => { + it("parses valid version params", () => { + expect(parseSkillDetailVersionParam("1")).toBe(1); + expect(parseSkillDetailVersionParam("42")).toBe(42); + }); + + it("rejects malformed version params", () => { + expect(parseSkillDetailVersionParam(null)).toBeNull(); + expect(parseSkillDetailVersionParam("")).toBeNull(); + expect(parseSkillDetailVersionParam("abc")).toBeNull(); + expect(parseSkillDetailVersionParam("1.5")).toBeNull(); + expect(parseSkillDetailVersionParam("0")).toBeNull(); + expect(parseSkillDetailVersionParam("-1")).toBeNull(); + }); + + it("loads a deeplinked file only from the files tab when a file list exists", () => { + expect(shouldLoadSkillDetailFile("files", "scripts/run.py", 3, null)).toBe(true); + expect(shouldLoadSkillDetailFile("content", "scripts/run.py", 3, null)).toBe(false); + expect(shouldLoadSkillDetailFile("files", null, 3, null)).toBe(false); + expect(shouldLoadSkillDetailFile("files", "scripts/run.py", 0, null)).toBe(false); + expect(shouldLoadSkillDetailFile("files", "scripts/run.py", 3, "scripts/run.py")).toBe(false); + }); +}); diff --git a/ui/web/src/pages/skills/lib/skill-detail-deeplink.ts b/ui/web/src/pages/skills/lib/skill-detail-deeplink.ts new file mode 100644 index 00000000..396b2da5 --- /dev/null +++ b/ui/web/src/pages/skills/lib/skill-detail-deeplink.ts @@ -0,0 +1,15 @@ +export function parseSkillDetailVersionParam(value: string | null): number | null { + if (!value) return null; + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) return null; + return parsed; +} + +export function shouldLoadSkillDetailFile( + detailTab: string, + selectedFilePath: string | null, + filesCount: number, + activePath: string | null, +): selectedFilePath is string { + return detailTab === "files" && !!selectedFilePath && filesCount > 0 && activePath !== selectedFilePath; +} diff --git a/ui/web/src/pages/skills/skill-agent-grants-dialog.tsx b/ui/web/src/pages/skills/skill-agent-grants-dialog.tsx index 399d0481..5f8087e9 100644 --- a/ui/web/src/pages/skills/skill-agent-grants-dialog.tsx +++ b/ui/web/src/pages/skills/skill-agent-grants-dialog.tsx @@ -125,7 +125,7 @@ export function SkillAgentGrantsDialog({ {grants.map((grant) => (
-

{agentNames.get(grant.agent_id) || grant.agent_id}

+

{grant.display_name || grant.agent_key || agentNames.get(grant.agent_id) || grant.agent_id}

v{grant.pinned_version} {grant.can_manage && ( diff --git a/ui/web/src/pages/skills/skill-detail-dialog.tsx b/ui/web/src/pages/skills/skill-detail-dialog.tsx index 1c209a24..28fec33f 100644 --- a/ui/web/src/pages/skills/skill-detail-dialog.tsx +++ b/ui/web/src/pages/skills/skill-detail-dialog.tsx @@ -19,9 +19,14 @@ import { MarkdownRenderer } from "@/components/shared/markdown-renderer"; import type { SkillInfo, SkillFile, SkillVersions } from "@/types/skill"; import { buildTree } from "./skill-file-helpers"; import { FileBrowser } from "./skill-file-browser"; +import { parseSkillDetailVersionParam, shouldLoadSkillDetailFile } from "./lib/skill-detail-deeplink"; interface SkillDetailDialogProps { skill: SkillInfo & { content: string }; + detailTab: string; + selectedVersionParam: string | null; + selectedFilePath: string | null; + onStateChange: (updates: Record) => void; onClose: () => void; getSkillVersions: (id: string) => Promise; getSkillFiles: (id: string, version?: number) => Promise; @@ -30,6 +35,10 @@ interface SkillDetailDialogProps { export function SkillDetailDialog({ skill, + detailTab, + selectedVersionParam, + selectedFilePath, + onStateChange, onClose, getSkillVersions, getSkillFiles, @@ -40,7 +49,9 @@ export function SkillDetailDialog({ // Version state const [versions, setVersions] = useState(null); - const [selectedVersion, setSelectedVersion] = useState(null); + const [selectedVersion, setSelectedVersion] = useState( + parseSkillDetailVersionParam(selectedVersionParam), + ); // File tree state const [files, setFiles] = useState([]); @@ -53,12 +64,22 @@ export function SkillDetailDialog({ const tree = useMemo(() => buildTree(files), [files]); + useEffect(() => { + setVersions(null); + setSelectedVersion(parseSkillDetailVersionParam(selectedVersionParam)); + setFiles([]); + setActivePath(null); + setFileContent(null); + }, [skill.id, selectedVersionParam]); + const loadVersions = useCallback(async () => { if (!skill.id || versions) return; const v = await getSkillVersions(skill.id); setVersions(v); - setSelectedVersion(v.current); - }, [skill.id, versions, getSkillVersions]); + if (!selectedVersionParam) { + setSelectedVersion(v.current); + } + }, [skill.id, versions, selectedVersionParam, getSkillVersions]); const loadFiles = useCallback(async (version?: number) => { if (!skill.id) return; @@ -91,7 +112,26 @@ export function SkillDetailDialog({ } }, [selectedVersion, loadFiles]); + useEffect(() => { + if (detailTab !== "files" || !hasFiles) return; + loadVersions(); + const versionParam = parseSkillDetailVersionParam(selectedVersionParam); + if (versionParam !== null && versionParam !== selectedVersion) { + setSelectedVersion(versionParam); + return; + } + if (selectedVersion == null && skill.version) { + setSelectedVersion(skill.version); + } + }, [detailTab, hasFiles, loadVersions, selectedVersion, selectedVersionParam, skill.version]); + + useEffect(() => { + if (!shouldLoadSkillDetailFile(detailTab, selectedFilePath, files.length, activePath)) return; + loadFileContent(selectedFilePath); + }, [activePath, detailTab, files.length, loadFileContent, selectedFilePath]); + const handleTabChange = (tab: string) => { + onStateChange({ detailTab: tab }); if (tab === "files" && hasFiles) { loadVersions(); if (files.length === 0 && !filesLoading) { @@ -100,6 +140,21 @@ export function SkillDetailDialog({ } }; + const handleVersionChange = (v: string) => { + const next = Number(v); + setSelectedVersion(next); + onStateChange({ version: v, file: null }); + }; + + const handleFileSelect = (path: string) => { + onStateChange({ + detailTab: "files", + version: selectedVersion != null ? String(selectedVersion) : null, + file: path, + }); + loadFileContent(path); + }; + return ( onClose()}> @@ -117,6 +172,15 @@ export function SkillDetailDialog({ {skill.description && (

{skill.description}

)} +
+ {skill.author && {t("columns.author")}: {skill.author}} + {skill.creator_agent && ( + {t("agents.creator")}: {skill.creator_agent.display_name || skill.creator_agent.agent_key || skill.creator_agent.id} + )} + {skill.manager_agents && skill.manager_agents.length > 0 && ( + {t("agents.managers")}: {skill.manager_agents.map((agent) => agent.display_name || agent.agent_key || agent.id).join(", ")} + )} +
{skill.tags && skill.tags.length > 0 && (
{skill.tags.map((tag) => ( @@ -126,7 +190,7 @@ export function SkillDetailDialog({ )} - + {t("detail.content")} {hasFiles && {t("detail.files")}} @@ -149,7 +213,7 @@ export function SkillDetailDialog({ {t("detail.version")} toggleManagerAgent(agent.id)} + className="h-4 w-4" + /> + {agent.display_name || agent.agent_key} + + ))} +
+
+ )} +
+ )} + {/* Summary line */} {entries.length > 0 && !done && !uploading && (

diff --git a/ui/web/src/pages/skills/skills-page.tsx b/ui/web/src/pages/skills/skills-page.tsx index d7e9afe2..29b65de2 100644 --- a/ui/web/src/pages/skills/skills-page.tsx +++ b/ui/web/src/pages/skills/skills-page.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, lazy, Suspense } from "react"; +import { useSearchParams } from "react-router"; import { useTranslation } from "react-i18next"; -import { Zap, RefreshCw, Upload, ScanSearch } from "lucide-react"; +import { Zap, RefreshCw, Upload, ScanSearch, Download } from "lucide-react"; import { Button } from "@/components/ui/button"; import { PageHeader } from "@/components/shared/page-header"; import { EmptyState } from "@/components/shared/empty-state"; @@ -34,15 +35,17 @@ export function SkillsPage() { const { skills, loading, refresh, getSkill, uploadSkill, updateSkill, deleteSkill, listAgentGrants, grantSkillToAgent, revokeSkillFromAgent, - getSkillVersions, getSkillFiles, getSkillFileContent, rescanDeps, installSingleDep, toggleSkill, + getSkillVersions, getSkillFiles, getSkillFileContent, rescanDeps, installDeps, installSingleDep, toggleSkill, setTenantConfig, deleteTenantConfig, } = useSkills(); + const [params, setParams] = useSearchParams(); const { runtimes } = useRuntimes(); const { currentTenantId } = useTenants(); const hasTenantScope = !!currentTenantId && currentTenantId !== MASTER_TENANT_ID; const spinning = useMinLoading(loading); const showSkeleton = useDeferredLoading(loading && skills.length === 0); - const [tab, setTab] = useState("core"); + const urlTab = params.get("tab") === "custom" ? "custom" : "core"; + const tab: Tab = urlTab; const [search, setSearch] = useState(""); const [selectedSkill, setSelectedSkill] = useState<(SkillInfo & { content: string }) | null>(null); const [uploadOpen, setUploadOpen] = useState(false); @@ -51,6 +54,7 @@ export function SkillsPage() { const [deleteTarget, setDeleteTarget] = useState(null); const [deleteLoading, setDeleteLoading] = useState(false); const [rescanning, setRescanning] = useState(false); + const [installingDeps, setInstallingDeps] = useState(false); const [toggling, setToggling] = useState(null); const coreSkills = skills.filter((s: SkillInfo) => s.is_system); @@ -66,11 +70,58 @@ export function SkillsPage() { useEffect(() => { resetPage(); }, [search, tab, resetPage]); - const handleViewSkill = async (name: string) => { - const detail = await getSkill(name); - if (detail) setSelectedSkill(detail); + const setParamValues = (updates: Record) => { + const next = new URLSearchParams(params); + for (const [key, value] of Object.entries(updates)) { + if (value) next.set(key, value); + else next.delete(key); + } + setParams(next, { replace: true }); }; + const setTab = (nextTab: Tab) => { + const next = new URLSearchParams(params); + next.set("tab", nextTab); + next.delete("skill"); + next.delete("detailTab"); + next.delete("version"); + next.delete("file"); + setParams(next, { replace: true }); + }; + + const closeDetail = () => { + const next = new URLSearchParams(params); + next.delete("skill"); + next.delete("detailTab"); + next.delete("version"); + next.delete("file"); + setParams(next, { replace: true }); + setSelectedSkill(null); + }; + + const handleViewSkill = async (skill: SkillInfo) => { + const next = new URLSearchParams(params); + next.set("tab", skill.is_system ? "core" : "custom"); + next.set("skill", skill.id || skill.slug || skill.name); + next.set("detailTab", "content"); + next.delete("version"); + next.delete("file"); + setParams(next, { replace: true }); + }; + + useEffect(() => { + const skillRef = params.get("skill"); + if (!skillRef) { + setSelectedSkill(null); + return; + } + let cancelled = false; + getSkill(skillRef).then((detail) => { + if (!cancelled) setSelectedSkill(detail); + }); + return () => { cancelled = true; }; + }, [params, getSkill]); + const handleCycleVisibility = async (skill: SkillInfo) => { if (!skill.id) return; const order = ["private", "internal", "public"] as const; @@ -90,6 +141,11 @@ export function SkillsPage() { try { await rescanDeps(); } finally { setRescanning(false); } }; + const handleInstallDeps = async () => { + setInstallingDeps(true); + try { await installDeps(); } finally { setInstallingDeps(false); } + }; + const handleToggle = async (skill: SkillInfo, enabled: boolean) => { if (!skill.id) return; setToggling(skill.id); @@ -121,6 +177,9 @@ export function SkillsPage() { + @@ -167,7 +226,7 @@ export function SkillsPage() { {t("columns.name")} {t("columns.description")} - {tab === "custom" && {t("columns.author")}} + {tab === "custom" && {t("columns.agents")}} {t("columns.status")} {tab === "custom" && {t("columns.visibility")}} {t("columns.actions")} @@ -208,7 +267,11 @@ export function SkillsPage() { {selectedSkill && ( setSelectedSkill(null)} + detailTab={params.get("detailTab") || "content"} + selectedVersionParam={params.get("version")} + selectedFilePath={params.get("file")} + onStateChange={setParamValues} + onClose={closeDetail} getSkillVersions={getSkillVersions} getSkillFiles={getSkillFiles} getSkillFileContent={getSkillFileContent} @@ -234,7 +297,7 @@ export function SkillsPage() { )} - uploadSkill(f)} /> +