feat(skills): add management metadata and deeplinks

Closes #26
This commit is contained in:
Duy /zuey/ authored and GitHub committed 2026-05-19 16:35:56 +07:00
1 parent 328cbf3d61
commit 1858cebe9c
28 files changed
+1604 -200

No files matched your search

+29 -1
View File
@@ -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))
}
+89 -66
View File
@@ -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.
+71 -3
View File
@@ -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
}
+495 -18
View File
@@ -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 {
+22 -11
View File
@@ -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
+40 -19
View File
@@ -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 {
+27
View File
@@ -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("{}")
+126 -2
View File
@@ -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 {
+3
View File
@@ -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
}
+28 -16
View File
@@ -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"`
+49 -9
View File
@@ -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("{}")
+39 -19
View File
@@ -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
}
+152 -2
View File
@@ -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 {
+76
View File
@@ -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)
+7 -1
View File
@@ -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<T>(
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);
+12 -1
View File
@@ -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",
+12 -1
View File
@@ -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",
+12 -1
View File
@@ -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": "内容",
+6 -3
View File
@@ -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(
+9 -1
View File
@@ -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<SkillUploadResponse>(
"/v1/skills/upload",
formData,
@@ -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);
});
});
@@ -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;
}
@@ -125,7 +125,7 @@ export function SkillAgentGrantsDialog({
{grants.map((grant) => (
<div key={grant.agent_id} className="flex items-center justify-between gap-3 px-3 py-2.5">
<div className="min-w-0">
<p className="truncate text-sm font-medium">{agentNames.get(grant.agent_id) || grant.agent_id}</p>
<p className="truncate text-sm font-medium">{grant.display_name || grant.agent_key || agentNames.get(grant.agent_id) || grant.agent_id}</p>
<div className="mt-1 flex items-center gap-1.5">
<Badge variant="secondary" className="text-2xs">v{grant.pinned_version}</Badge>
{grant.can_manage && (
@@ -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<string, string | null>) => void;
onClose: () => void;
getSkillVersions: (id: string) => Promise<SkillVersions>;
getSkillFiles: (id: string, version?: number) => Promise<SkillFile[]>;
@@ -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<SkillVersions | null>(null);
const [selectedVersion, setSelectedVersion] = useState<number | null>(null);
const [selectedVersion, setSelectedVersion] = useState<number | null>(
parseSkillDetailVersionParam(selectedVersionParam),
);
// File tree state
const [files, setFiles] = useState<SkillFile[]>([]);
@@ -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 (
<Dialog open onOpenChange={() => onClose()}>
<DialogContent className="max-h-[85vh] md:min-h-[60vh] overflow-hidden flex flex-col sm:max-w-2xl md:max-w-4xl lg:max-w-5xl xl:max-w-6xl 2xl:max-w-7xl">
@@ -117,6 +172,15 @@ export function SkillDetailDialog({
{skill.description && (
<p className="text-sm text-muted-foreground">{skill.description}</p>
)}
<div className="flex flex-wrap gap-1 pt-1 text-xs text-muted-foreground">
{skill.author && <span>{t("columns.author")}: {skill.author}</span>}
{skill.creator_agent && (
<span>{t("agents.creator")}: {skill.creator_agent.display_name || skill.creator_agent.agent_key || skill.creator_agent.id}</span>
)}
{skill.manager_agents && skill.manager_agents.length > 0 && (
<span>{t("agents.managers")}: {skill.manager_agents.map((agent) => agent.display_name || agent.agent_key || agent.id).join(", ")}</span>
)}
</div>
{skill.tags && skill.tags.length > 0 && (
<div className="flex flex-wrap gap-1 pt-1">
{skill.tags.map((tag) => (
@@ -126,7 +190,7 @@ export function SkillDetailDialog({
)}
</DialogHeader>
<Tabs defaultValue="content" className="flex-1 overflow-hidden flex flex-col" onValueChange={handleTabChange}>
<Tabs value={detailTab === "files" && hasFiles ? "files" : "content"} className="flex-1 overflow-hidden flex flex-col" onValueChange={handleTabChange}>
<TabsList>
<TabsTrigger value="content">{t("detail.content")}</TabsTrigger>
{hasFiles && <TabsTrigger value="files">{t("detail.files")}</TabsTrigger>}
@@ -149,7 +213,7 @@ export function SkillDetailDialog({
<span className="text-sm text-muted-foreground">{t("detail.version")}</span>
<Select
value={String(selectedVersion ?? versions.current)}
onValueChange={(v) => setSelectedVersion(Number(v))}
onValueChange={handleVersionChange}
>
<SelectTrigger className="w-40 h-8">
<SelectValue />
@@ -169,7 +233,7 @@ export function SkillDetailDialog({
tree={tree}
filesLoading={filesLoading}
activePath={activePath}
onSelect={loadFileContent}
onSelect={handleFileSelect}
contentLoading={contentLoading}
fileContent={fileContent}
/>
+19 -3
View File
@@ -18,7 +18,7 @@ interface SkillTableRowProps {
tab: "core" | "custom";
hasTenantScope: boolean;
toggling: string | null;
onView: (name: string) => void;
onView: (skill: SkillInfo) => void;
onEdit: (skill: SkillInfo) => void;
onManageGrants: (skill: SkillInfo) => void;
onDelete: (skill: SkillInfo) => void;
@@ -47,7 +47,7 @@ export function SkillTableRow({
<button
type="button"
className="font-medium text-left hover:underline cursor-pointer"
onClick={() => onView(skill.name)}
onClick={() => onView(skill)}
>
{skill.name}
</button>
@@ -63,7 +63,23 @@ export function SkillTableRow({
{skill.description || t("noDescription")}
</td>
{tab === "custom" && (
<td className="px-4 py-3 text-sm text-muted-foreground">{skill.author || "—"}</td>
<td className="px-4 py-3 text-sm text-muted-foreground">
<div className="flex max-w-[220px] flex-col gap-1">
{skill.author && <span className="truncate">{skill.author}</span>}
{skill.creator_agent && (
<span className="truncate text-2xs">
{t("agents.creator")}: {skill.creator_agent.display_name || skill.creator_agent.agent_key || skill.creator_agent.id}
</span>
)}
{skill.manager_agents && skill.manager_agents.length > 0 ? (
<span className="truncate text-2xs">
{t("agents.managers")}: {skill.manager_agents.map((agent) => agent.display_name || agent.agent_key || agent.id).join(", ")}
</span>
) : !skill.author && !skill.creator_agent ? (
<span>—</span>
) : null}
</div>
</td>
)}
<td className="px-4 py-3">
<div className="flex flex-col gap-1">
@@ -1,4 +1,4 @@
import { useState, useRef } from "react";
import { useEffect, useState, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Upload } from "lucide-react";
import {
@@ -10,10 +10,13 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useAgents } from "@/pages/agents/hooks/use-agents";
import { createSkillSubZip } from "./lib/create-skill-sub-zip";
import { resolveUploadSkills } from "./lib/resolve-upload-skills";
import { uniqueId } from "@/lib/utils";
import type { SkillUploadResponse } from "./hooks/use-skills";
import type { SkillUploadOptions, SkillUploadResponse } from "./hooks/use-skills";
import type { FileEntry, SkillStatus } from "./lib/skill-upload-types";
import { FileEntryBlock } from "./skill-upload-entry";
import JSZip from "jszip";
@@ -21,7 +24,7 @@ import JSZip from "jszip";
interface SkillUploadDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onUpload: (file: File) => Promise<SkillUploadResponse>;
onUpload: (file: File, options?: SkillUploadOptions) => Promise<SkillUploadResponse>;
}
export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadDialogProps) {
@@ -30,8 +33,15 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD
const [uploading, setUploading] = useState(false);
const [dragging, setDragging] = useState(false);
const [done, setDone] = useState(false);
const [grantManagers, setGrantManagers] = useState(true);
const [managerAgentIds, setManagerAgentIds] = useState<string[]>([]);
const { agents, refresh: refreshAgents } = useAgents();
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (open) refreshAgents();
}, [open, refreshAgents]);
// ---------------------------------------------------------------------------
// File handling
// ---------------------------------------------------------------------------
@@ -116,24 +126,44 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD
uploadFile = fileEntry.file;
}
const result = await onUpload(uploadFile);
const result = await onUpload(uploadFile, {
managerAgentIds: grantManagers ? managerAgentIds : [],
});
if (result.status === "unchanged") {
const grantDetail = result.grant_errors?.length
? result.grant_errors.join("; ")
: undefined;
setEntries((prev) =>
prev.map((e) =>
e.id === fileEntry.id
? { ...e, skills: e.skills.map((s) => s.id === skill.id ? { ...s, status: "unchanged" as SkillStatus } : s) }
? {
...e,
skills: e.skills.map((s) =>
s.id === skill.id
? {
...s,
status: grantDetail ? ("warning" as SkillStatus) : ("unchanged" as SkillStatus),
error: grantDetail,
}
: s,
),
}
: e,
),
);
continue;
}
const grantDetail = result.grant_errors?.length
? result.grant_errors.join("; ")
: undefined;
const depDetail = result.deps_warning
? result.deps_errors?.length
? `${result.deps_warning}: ${result.deps_errors.join("; ")}`
: result.deps_warning
: undefined;
const warningDetail = [depDetail, grantDetail].filter(Boolean).join("; ") || undefined;
setEntries((prev) =>
prev.map((e) =>
@@ -144,8 +174,8 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD
s.id === skill.id
? {
...s,
status: result.deps_warning ? ("warning" as SkillStatus) : ("success" as SkillStatus),
error: depDetail,
status: warningDetail ? ("warning" as SkillStatus) : ("success" as SkillStatus),
error: warningDetail,
}
: s,
),
@@ -188,6 +218,8 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD
setEntries([]);
setDragging(false);
setDone(false);
setGrantManagers(true);
setManagerAgentIds([]);
onOpenChange(v);
};
@@ -209,6 +241,17 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD
const allSkills = entries.flatMap((e) => e.skills);
const actionableCount = allSkills.filter((s) => s.status === "valid").length;
const successCount = allSkills.filter((s) => s.status === "success" || s.status === "warning").length;
const allCurrentAgentsSelected = agents.length > 0 && managerAgentIds.length === agents.length;
const toggleManagerAgent = (id: string) => {
setManagerAgentIds((current) =>
current.includes(id) ? current.filter((item) => item !== id) : [...current, id],
);
};
const toggleAllAgents = () => {
setManagerAgentIds(allCurrentAgentsSelected ? [] : agents.map((agent) => agent.id));
};
// ---------------------------------------------------------------------------
// Render
@@ -272,6 +315,40 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD
</div>
)}
{entries.length > 0 && !uploading && !done && (
<div className="space-y-3 rounded-md border p-3">
<label className="flex items-center justify-between gap-3">
<span className="text-sm font-medium">{t("upload.agentManagers")}</span>
<Switch checked={grantManagers} onCheckedChange={setGrantManagers} />
</label>
{grantManagers && (
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label className="text-xs text-muted-foreground">{t("upload.managerAgentsHelp")}</Label>
<Button type="button" variant="ghost" size="sm" onClick={toggleAllAgents} disabled={agents.length === 0}>
{allCurrentAgentsSelected ? t("upload.clearAgents") : t("upload.selectAllAgents")}
</Button>
</div>
<div className="max-h-32 overflow-y-auto rounded-md border">
{agents.length === 0 ? (
<p className="px-3 py-2 text-sm text-muted-foreground">{t("upload.noAgents")}</p>
) : agents.map((agent) => (
<label key={agent.id} className="flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted/40">
<input
type="checkbox"
checked={managerAgentIds.includes(agent.id)}
onChange={() => toggleManagerAgent(agent.id)}
className="h-4 w-4"
/>
<span className="min-w-0 truncate">{agent.display_name || agent.agent_key}</span>
</label>
))}
</div>
</div>
)}
</div>
)}
{/* Summary line */}
{entries.length > 0 && !done && !uploading && (
<p className="text-xs text-muted-foreground">
+72 -9
View File
@@ -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<Tab>("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<SkillInfo | null>(null);
const [deleteLoading, setDeleteLoading] = useState(false);
const [rescanning, setRescanning] = useState(false);
const [installingDeps, setInstallingDeps] = useState(false);
const [toggling, setToggling] = useState<string | null>(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<string, string | null>) => {
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() {
<Button variant="outline" size="sm" onClick={handleRescanDeps} disabled={rescanning} className="gap-1">
<ScanSearch className="h-3.5 w-3.5" /> {t("deps.rescan")}
</Button>
<Button variant="outline" size="sm" onClick={handleInstallDeps} disabled={installingDeps || allMissing.length === 0} className="gap-1">
<Download className="h-3.5 w-3.5" /> {installingDeps ? t("deps.installing") : t("deps.installAll")}
</Button>
<Button variant="outline" size="sm" onClick={refresh} disabled={spinning} className="gap-1">
<RefreshCw className={"h-3.5 w-3.5" + (spinning ? " animate-spin" : "")} /> {t("refresh", { ns: "common" })}
</Button>
@@ -167,7 +226,7 @@ export function SkillsPage() {
<tr className="border-b bg-muted/50">
<th className="px-4 py-3 text-left font-medium">{t("columns.name")}</th>
<th className="px-4 py-3 text-left font-medium">{t("columns.description")}</th>
{tab === "custom" && <th className="px-4 py-3 text-left font-medium">{t("columns.author")}</th>}
{tab === "custom" && <th className="px-4 py-3 text-left font-medium">{t("columns.agents")}</th>}
<th className="px-4 py-3 text-left font-medium">{t("columns.status")}</th>
{tab === "custom" && <th className="px-4 py-3 text-left font-medium">{t("columns.visibility")}</th>}
<th className="px-4 py-3 text-right font-medium">{t("columns.actions")}</th>
@@ -208,7 +267,11 @@ export function SkillsPage() {
{selectedSkill && (
<SkillDetailDialog
skill={selectedSkill}
onClose={() => 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() {
)}
<Suspense fallback={null}>
<SkillUploadDialog open={uploadOpen} onOpenChange={setUploadOpen} onUpload={(f) => uploadSkill(f)} />
<SkillUploadDialog open={uploadOpen} onOpenChange={setUploadOpen} onUpload={uploadSkill} />
</Suspense>
<ConfirmDeleteDialog
+10
View File
@@ -12,9 +12,17 @@ export interface SkillInfo {
enabled?: boolean;
tenant_enabled?: boolean | null;
author?: string;
creator_agent?: SkillAgentRef;
manager_agents?: SkillAgentRef[];
missing_deps?: string[];
}
export interface SkillAgentRef {
id?: string;
agent_key?: string;
display_name?: string;
}
export interface SkillFile {
path: string;
name: string;
@@ -42,6 +50,8 @@ export interface SkillWithGrant {
export interface SkillAgentGrant {
agent_id: string;
agent_key?: string;
display_name?: string;
pinned_version: number;
granted_by: string;
can_manage: boolean;