feat(skills): clarify access modes and repair file paths (#31)

* feat(skills): add bulk management actions

* feat(skills): improve operations UX

* feat(skills): clarify access modes and repair file paths
This commit is contained in:
Duy /zuey/ authored and GitHub committed 2026-05-21 16:37:50 +07:00
1 parent 56e227c403
commit 81d96ae014
47 files changed
+1559 -201

No files matched your search

+8 -8
View File
@@ -320,9 +320,9 @@ flowchart TD
---
## 12. Skills Grants & Visibility
## 12. Skills Grants & Access Mode
Skill access is controlled through a 3-tier visibility model with explicit agent and user grants.
Skill access is controlled through a 3-tier `visibility` field with explicit agent and user grants. The web UI labels this as **Access mode** because `public` means tenant-wide access, not internet publishing.
```mermaid
flowchart TD
@@ -335,13 +335,13 @@ flowchart TD
GRANT -->|No grant| DENIED["Not accessible"]
```
### Visibility Levels
### Access Modes
| Visibility | Access Rule |
|------------|------------|
| `public` | All agents and users can discover and use the skill |
| `private` | Only the owner (`skills.owner_id = userID`) can access |
| `internal` | Requires an explicit agent grant or user grant |
| DB value | UI label | Access Rule |
|----------|----------|------------|
| `private` | Owner only | Only the owner (`skills.owner_id = userID`) can access |
| `internal` | Granted agents | Requires an explicit agent grant or user grant |
| `public` | All tenant agents | All agents and users in scope can discover and use the skill |
### Grant Tables
+6 -4
View File
@@ -255,14 +255,16 @@ func (m *SkillsMethods) handleUpdate(ctx context.Context, client *gateway.Client
// Validate visibility enum if present — fail closed before mutating the DB.
if v, ok := params.Updates["visibility"]; ok {
vs, _ := v.(string)
vs, ok := v.(string)
if !ok {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidVisibility, "")))
return
}
if err := skills.ValidateVisibility(vs); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidVisibility, vs)))
return
}
if vs != "" {
params.Updates["visibility"] = skills.NormalizeVisibility(vs)
}
params.Updates["visibility"] = skills.NormalizeVisibility(vs)
}
if err := updater.UpdateSkill(ctx, skillID, params.Updates); err != nil {
+61 -5
View File
@@ -5,6 +5,8 @@ import (
"encoding/json"
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
@@ -41,14 +43,14 @@ func (s *stubSkillStore) LoadSkill(_ context.Context, name string) (string, bool
return c, ok
}
func (s *stubSkillStore) LoadForContext(_ context.Context, _ []string) string { return "" }
func (s *stubSkillStore) BuildSummary(_ context.Context, _ []string) string { return "" }
func (s *stubSkillStore) LoadForContext(_ context.Context, _ []string) string { return "" }
func (s *stubSkillStore) BuildSummary(_ context.Context, _ []string) string { return "" }
func (s *stubSkillStore) FilterSkills(_ context.Context, _ []string) []store.SkillInfo {
return nil
}
func (s *stubSkillStore) Version() int64 { return s.version }
func (s *stubSkillStore) BumpVersion() { s.version++ }
func (s *stubSkillStore) Dirs() []string { return nil }
func (s *stubSkillStore) Version() int64 { return s.version }
func (s *stubSkillStore) BumpVersion() { s.version++ }
func (s *stubSkillStore) Dirs() []string { return nil }
// ---- helpers ----
@@ -161,3 +163,57 @@ func TestSkillsUpdate_EmptyUpdates_ReturnsInvalidRequest(t *testing.T) {
m.handleUpdate(context.Background(), client, req)
// No panic = invalid-request (empty updates) path hit
}
func TestSkillsUpdate_NormalizesEmptyVisibilityToPrivate(t *testing.T) {
skillID := "0193a5b0-7000-7000-8000-000000000321"
store := newUpdatingSkillStore([]store.SkillInfo{{Name: "sk", Slug: "sk", Path: skillID}})
m := NewSkillsMethods(store, nil)
client := nullClient()
req := skillReqFrame(t, protocol.MethodSkillsUpdate, map[string]any{
"id": skillID,
"updates": map[string]any{"visibility": ""},
})
m.handleUpdate(context.Background(), client, req)
if got := store.lastUpdates["visibility"]; got != "private" {
t.Fatalf("visibility update = %#v, want private", got)
}
}
func TestSkillsUpdate_RejectsNonStringVisibilityBeforeUpdate(t *testing.T) {
skillID := "0193a5b0-7000-7000-8000-000000000322"
store := newUpdatingSkillStore([]store.SkillInfo{{Name: "sk", Slug: "sk", Path: skillID}})
m := NewSkillsMethods(store, nil)
client := nullClient()
req := skillReqFrame(t, protocol.MethodSkillsUpdate, map[string]any{
"id": skillID,
"updates": map[string]any{"visibility": true},
})
m.handleUpdate(context.Background(), client, req)
if store.updateCalled {
t.Fatalf("non-string visibility reached UpdateSkill: %+v", store.lastUpdates)
}
}
type updatingSkillStore struct {
*stubSkillStore
lastUpdates map[string]any
updateCalled bool
}
func newUpdatingSkillStore(skills []store.SkillInfo) *updatingSkillStore {
return &updatingSkillStore{stubSkillStore: newStubSkillStore(skills)}
}
func (s *updatingSkillStore) UpdateSkill(_ context.Context, _ uuid.UUID, updates map[string]any) error {
s.updateCalled = true
s.lastUpdates = updates
return nil
}
func (s *updatingSkillStore) GetSkillOwnerID(context.Context, uuid.UUID) (string, bool) {
return "", true
}
+13
View File
@@ -216,6 +216,19 @@ func (h *SkillsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) {
delete(updates, "is_system")
delete(updates, "enabled")
if v, ok := updates["visibility"]; ok {
vs, ok := v.(string)
if !ok {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidVisibility, "")})
return
}
if err := skills.ValidateVisibility(vs); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidVisibility, vs)})
return
}
updates["visibility"] = skills.NormalizeVisibility(vs)
}
if err := h.skills.UpdateSkill(r.Context(), id, updates); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
+1 -1
View File
@@ -146,7 +146,7 @@ func (h *SkillsHandler) writeSkillsExportArchive(ctx context.Context, w io.Write
// SKILL.md — read from filesystem
if sk.FilePath != "" {
fullPath := config.ExpandHome(sk.FilePath)
fullPath := config.ExpandHome(store.SkillMarkdownPath(sk.FilePath))
if data, err := os.ReadFile(fullPath); err == nil {
if err := addToTar(tw, prefix+"SKILL.md", data); err != nil {
slog.Warn("skills.export: write SKILL.md", "slug", sk.Slug, "error", err)
+103
View File
@@ -0,0 +1,103 @@
package http
import (
"bytes"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
func TestSkillsHandlerUpdateAcceptsInternalVisibility(t *testing.T) {
baseDir := t.TempDir()
skillStore := newSkillManageStoreStub(baseDir)
id := skillStore.seedCustomSkill("access-mode-skill", filepath.Join(baseDir, "access-mode-skill", "1"), "active", nil)
handler := NewSkillsHandler(skillStore, baseDir, baseDir, "", bus.New(), nil, nil)
req := httptest.NewRequest(http.MethodPut, "/v1/skills/"+id.String(), bytes.NewBufferString(`{"visibility":"INTERNAL"}`))
req.SetPathValue("id", id.String())
req = req.WithContext(store.WithTenantID(req.Context(), store.MasterTenantID))
rec := httptest.NewRecorder()
handler.handleUpdate(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
if got := skillStore.lastUpdates[id]["visibility"]; got != "internal" {
t.Fatalf("updated visibility = %#v, want internal", got)
}
info, ok := skillStore.GetSkillByID(req.Context(), id)
if !ok {
t.Fatal("GetSkillByID returned !ok")
}
if info.Visibility != "internal" {
t.Fatalf("stored visibility = %q, want internal", info.Visibility)
}
}
func TestSkillsHandlerUpdateNormalizesEmptyVisibilityToPrivate(t *testing.T) {
baseDir := t.TempDir()
skillStore := newSkillManageStoreStub(baseDir)
id := skillStore.seedCustomSkill("empty-access-mode-skill", filepath.Join(baseDir, "empty-access-mode-skill", "1"), "active", nil)
handler := NewSkillsHandler(skillStore, baseDir, baseDir, "", bus.New(), nil, nil)
req := httptest.NewRequest(http.MethodPut, "/v1/skills/"+id.String(), bytes.NewBufferString(`{"visibility":""}`))
req.SetPathValue("id", id.String())
req = req.WithContext(store.WithTenantID(req.Context(), store.MasterTenantID))
rec := httptest.NewRecorder()
handler.handleUpdate(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
if got := skillStore.lastUpdates[id]["visibility"]; got != "private" {
t.Fatalf("updated visibility = %#v, want private", got)
}
}
func TestSkillsHandlerUpdateRejectsInvalidVisibility(t *testing.T) {
baseDir := t.TempDir()
skillStore := newSkillManageStoreStub(baseDir)
id := skillStore.seedCustomSkill("bad-access-mode-skill", filepath.Join(baseDir, "bad-access-mode-skill", "1"), "active", nil)
handler := NewSkillsHandler(skillStore, baseDir, baseDir, "", bus.New(), nil, nil)
req := httptest.NewRequest(http.MethodPut, "/v1/skills/"+id.String(), bytes.NewBufferString(`{"visibility":"team"}`))
req.SetPathValue("id", id.String())
req = req.WithContext(store.WithTenantID(req.Context(), store.MasterTenantID))
rec := httptest.NewRecorder()
handler.handleUpdate(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
if _, ok := skillStore.lastUpdates[id]; ok {
t.Fatalf("invalid visibility reached UpdateSkill: %+v", skillStore.lastUpdates[id])
}
}
func TestSkillsHandlerUpdateRejectsNonStringVisibility(t *testing.T) {
baseDir := t.TempDir()
skillStore := newSkillManageStoreStub(baseDir)
id := skillStore.seedCustomSkill("typed-access-mode-skill", filepath.Join(baseDir, "typed-access-mode-skill", "1"), "active", nil)
handler := NewSkillsHandler(skillStore, baseDir, baseDir, "", bus.New(), nil, nil)
req := httptest.NewRequest(http.MethodPut, "/v1/skills/"+id.String(), bytes.NewBufferString(`{"visibility":true}`))
req.SetPathValue("id", id.String())
req = req.WithContext(store.WithTenantID(req.Context(), store.MasterTenantID))
rec := httptest.NewRecorder()
handler.handleUpdate(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
if _, ok := skillStore.lastUpdates[id]; ok {
t.Fatalf("non-string visibility reached UpdateSkill: %+v", skillStore.lastUpdates[id])
}
}
+6
View File
@@ -767,6 +767,7 @@ type skillManageStoreStub struct {
hashBySlug map[string]string // slug -> SKILL.md content hash (most recent)
grantCalls []skillGrantCall
grantErrors map[uuid.UUID]error
lastUpdates map[uuid.UUID]map[string]any
}
type skillGrantCall struct {
@@ -785,6 +786,7 @@ func newSkillManageStoreStub(baseDir string) *skillManageStoreStub {
systemDirs: map[string]string{},
hashBySlug: map[string]string{},
grantErrors: map[uuid.UUID]error{},
lastUpdates: map[uuid.UUID]map[string]any{},
}
}
@@ -915,6 +917,10 @@ func (s *skillManageStoreStub) UpdateSkill(ctx context.Context, id uuid.UUID, up
if status, ok := updates["status"].(string); ok {
skill.Status = status
}
if visibility, ok := updates["visibility"].(string); ok {
skill.Visibility = visibility
}
s.lastUpdates[id] = maps.Clone(updates)
s.skills[id] = skill
return nil
}
+48 -27
View File
@@ -61,16 +61,6 @@ func walkSkillFiles(root string) []fileEntry {
return files
}
// skillSlugDir derives the slug parent directory from a DB file_path.
// file_path has the form .../slug/version — returns .../slug.
// Returns empty string if filePath is empty or malformed.
func skillSlugDir(filePath string) string {
if filePath == "" {
return ""
}
return filepath.Dir(filePath)
}
// handleListVersions returns all available version numbers for a skill.
func (h *SkillsHandler) handleListVersions(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
@@ -86,7 +76,7 @@ func (h *SkillsHandler) handleListVersions(w http.ResponseWriter, r *http.Reques
return
}
slugDir := skillSlugDir(filePath)
slugDir := store.SkillSlugDir(filePath)
if slugDir == "" {
writeJSON(w, http.StatusOK, map[string]any{
"versions": []int{currentVersion},
@@ -151,7 +141,7 @@ func (h *SkillsHandler) handleListFiles(w http.ResponseWriter, r *http.Request)
version = parsed
}
slugDir := skillSlugDir(filePath)
slugDir := store.SkillSlugDir(filePath)
if slugDir == "" {
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgVersionNotFound)})
return
@@ -159,7 +149,20 @@ func (h *SkillsHandler) handleListFiles(w http.ResponseWriter, r *http.Request)
versionDir := filepath.Join(slugDir, strconv.Itoa(version))
if _, err := os.Stat(versionDir); err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgVersionNotFound)})
if !isSystem || h.bundledDir == "" {
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgVersionNotFound)})
return
}
bundledDir := filepath.Join(h.bundledDir, slug)
if _, err := os.Stat(bundledDir); err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgVersionNotFound)})
return
}
files := walkSkillFiles(bundledDir)
if files == nil {
files = []fileEntry{}
}
writeJSON(w, http.StatusOK, map[string]any{"files": files})
return
}
@@ -216,29 +219,33 @@ func (h *SkillsHandler) handleReadFile(w http.ResponseWriter, r *http.Request) {
version = parsed
}
slugDir := skillSlugDir(filePath)
slugDir := store.SkillSlugDir(filePath)
if slugDir == "" {
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgFileNotFound)})
return
}
versionDir := filepath.Join(slugDir, strconv.Itoa(version))
absPath := filepath.Join(versionDir, filepath.Clean(relPath))
// Verify resolved path is within the version directory
if !strings.HasPrefix(absPath, versionDir+string(filepath.Separator)) {
slog.Warn("security.skill_files_escape", "resolved", absPath, "root", versionDir)
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)})
roots := readableSkillRoots(versionDir, slug, isSystem, h.bundledDir)
if len(roots) == 0 {
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgFileNotFound)})
return
}
// Try reading from managed dir; fall back to bundled dir for system skills.
data, info, readErr := readSkillFile(absPath)
if readErr != nil && isSystem && h.bundledDir != "" {
bundledPath := filepath.Join(h.bundledDir, slug, filepath.Clean(relPath))
bundledRoot := filepath.Join(h.bundledDir, slug)
if strings.HasPrefix(bundledPath, bundledRoot+string(filepath.Separator)) {
data, info, readErr = readSkillFile(bundledPath)
cleanRelPath := filepath.Clean(relPath)
var data []byte
var info os.FileInfo
var readErr error = os.ErrNotExist
for _, root := range roots {
absPath := filepath.Join(root, cleanRelPath)
if !strings.HasPrefix(absPath, root+string(filepath.Separator)) {
slog.Warn("security.skill_files_escape", "resolved", absPath, "root", root)
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)})
return
}
data, info, readErr = readSkillFile(absPath)
if readErr == nil {
break
}
}
if readErr != nil {
@@ -253,6 +260,20 @@ func (h *SkillsHandler) handleReadFile(w http.ResponseWriter, r *http.Request) {
})
}
func readableSkillRoots(versionDir, slug string, isSystem bool, bundledDir string) []string {
var roots []string
if info, err := os.Stat(versionDir); err == nil && info.IsDir() {
roots = append(roots, versionDir)
}
if isSystem && bundledDir != "" {
bundledRoot := filepath.Join(bundledDir, slug)
if info, err := os.Stat(bundledRoot); err == nil && info.IsDir() {
roots = append(roots, bundledRoot)
}
}
return roots
}
// readSkillFile reads a file with security checks (symlink rejection, artifact filtering).
// Returns file data, file info, or error.
func readSkillFile(absPath string) ([]byte, os.FileInfo, error) {
+34
View File
@@ -0,0 +1,34 @@
package http
import (
"os"
"path/filepath"
"testing"
)
func TestReadableSkillRootsFallsBackToBundledSystemSkill(t *testing.T) {
tmp := t.TempDir()
missingManaged := filepath.Join(tmp, "managed", "demo", "1")
bundled := filepath.Join(tmp, "bundled", "demo")
if err := os.MkdirAll(bundled, 0755); err != nil {
t.Fatal(err)
}
roots := readableSkillRoots(missingManaged, "demo", true, filepath.Join(tmp, "bundled"))
if len(roots) != 1 || roots[0] != bundled {
t.Fatalf("roots = %#v, want bundled fallback", roots)
}
}
func TestReadableSkillRootsDoesNotFallbackForCustomSkill(t *testing.T) {
tmp := t.TempDir()
bundled := filepath.Join(tmp, "bundled", "demo")
if err := os.MkdirAll(bundled, 0755); err != nil {
t.Fatal(err)
}
roots := readableSkillRoots(filepath.Join(tmp, "missing"), "demo", false, filepath.Join(tmp, "bundled"))
if len(roots) != 0 {
t.Fatalf("roots = %#v, want no custom fallback", roots)
}
}
+9 -6
View File
@@ -7,8 +7,9 @@ import (
// Skill visibility values.
const (
VisibilityPrivate = "private"
VisibilityPublic = "public"
VisibilityPrivate = "private"
VisibilityInternal = "internal"
VisibilityPublic = "public"
)
// DefaultVisibility is assigned when a caller does not specify one.
@@ -16,10 +17,12 @@ const (
const DefaultVisibility = VisibilityPrivate
// validVisibilities enumerates the accepted enum values. System skills use
// "public"; user-published skills default to "private".
// "public"; grant-scoped skills use "internal"; user-published skills default
// to "private".
var validVisibilities = map[string]struct{}{
VisibilityPrivate: {},
VisibilityPublic: {},
VisibilityPrivate: {},
VisibilityInternal: {},
VisibilityPublic: {},
}
// NormalizeVisibility lowercases + trims the input and returns the default
@@ -39,7 +42,7 @@ func ValidateVisibility(v string) error {
return nil
}
if _, ok := validVisibilities[strings.ToLower(strings.TrimSpace(v))]; !ok {
return fmt.Errorf("invalid visibility %q: must be one of private, public", v)
return fmt.Errorf("invalid visibility %q: must be one of private, internal, public", v)
}
return nil
}
+3 -1
View File
@@ -10,9 +10,10 @@ func TestValidateVisibility(t *testing.T) {
}{
{"empty ok (caller defaults)", "", false},
{"private", "private", false},
{"internal", "internal", false},
{"public", "public", false},
{"uppercase normalized", "PRIVATE", false},
{"whitespace normalized", " public ", false},
{"whitespace normalized", " internal ", false},
{"team rejected (v1 scope)", "team", true},
{"garbage rejected", "nope", true},
}
@@ -30,6 +31,7 @@ func TestNormalizeVisibility(t *testing.T) {
cases := map[string]string{
"": DefaultVisibility,
"private": "private",
"INTERNAL": "internal",
"PUBLIC": "public",
" public ": "public",
}
+11
View File
@@ -619,6 +619,17 @@ func TestBuildSkillInfo_FilePathOverridesBaseDir(t *testing.T) {
}
}
func TestBuildSkillInfo_FilePathMayPointToSkillMarkdown(t *testing.T) {
fp := "/custom/path/to/skill/SKILL.md"
info := buildSkillInfo("id", "n", "s", nil, 1, "/ignored", &fp)
if info.BaseDir != "/custom/path/to/skill" {
t.Errorf("BaseDir should normalize file path: %q", info.BaseDir)
}
if info.Path != "/custom/path/to/skill/SKILL.md" {
t.Errorf("Path = %q", info.Path)
}
}
func TestBuildSkillInfo_EmptyFilePathFallsBack(t *testing.T) {
empty := ""
info := buildSkillInfo("id", "n", "slug", nil, 2, "/base", &empty)
+2 -2
View File
@@ -255,13 +255,13 @@ func buildSkillInfo(id, name, slug string, desc *string, version int, baseDir st
}
skillDir := fmt.Sprintf("%s/%s/%d", baseDir, slug, version)
if filePath != nil && *filePath != "" {
skillDir = *filePath
skillDir = store.SkillBaseDir(*filePath)
}
return store.SkillInfo{
ID: id,
Name: name,
Slug: slug,
Path: skillDir + "/SKILL.md",
Path: store.SkillMarkdownPath(skillDir),
BaseDir: skillDir,
Source: "managed",
Description: d,
+2 -3
View File
@@ -55,16 +55,15 @@ func (s *PGSkillStore) SearchByEmbedding(ctx context.Context, embedding []float3
}
// Use DB file_path when available; fall back to baseDir construction.
if row.FilePath != nil && *row.FilePath != "" {
r.Path = *row.FilePath + "/SKILL.md"
r.Path = store.SkillMarkdownPath(*row.FilePath)
} else {
r.Path = fmt.Sprintf("%s/%s/%d/SKILL.md", s.baseDir, row.Slug, row.Version)
r.Path = store.SkillMarkdownPath(fmt.Sprintf("%s/%s/%d", s.baseDir, row.Slug, row.Version))
}
results = append(results, r)
}
return results, nil
}
func buildSkillEmbeddingTenantCond(scope string) string {
if scope == "" {
return ""
+2 -1
View File
@@ -91,7 +91,8 @@ func (s *PGSkillStore) RevokeFromAgent(ctx context.Context, skillID, agentID uui
_, err = s.db.ExecContext(ctx,
`UPDATE skills SET visibility = 'private', updated_at = NOW()
WHERE id = $1 AND visibility = 'internal' AND (is_system = true OR tenant_id = $2)
AND NOT EXISTS (SELECT 1 FROM skill_agent_grants WHERE skill_id = $1)`,
AND NOT EXISTS (SELECT 1 FROM skill_agent_grants WHERE skill_id = $1)
AND NOT EXISTS (SELECT 1 FROM skill_user_grants WHERE skill_id = $1)`,
skillID, tid)
if err != nil {
slog.Warn("skill_grants: failed to auto-demote visibility", "skill_id", skillID, "error", err)
+42
View File
@@ -0,0 +1,42 @@
package store
import (
"path/filepath"
"strings"
)
const SkillMarkdownFilename = "SKILL.md"
// SkillBaseDir normalizes a persisted skill file_path into the skill directory.
// Older import paths may point directly to SKILL.md; managed uploads usually
// point to the version directory.
func SkillBaseDir(filePath string) string {
if filePath == "" {
return ""
}
cleaned := filepath.Clean(filePath)
if cleaned == "." {
return ""
}
if strings.EqualFold(filepath.Base(cleaned), SkillMarkdownFilename) {
return filepath.Dir(cleaned)
}
return cleaned
}
func SkillMarkdownPath(filePath string) string {
baseDir := SkillBaseDir(filePath)
if baseDir == "" {
return ""
}
return filepath.Join(baseDir, SkillMarkdownFilename)
}
// SkillSlugDir returns the directory that contains version subdirectories.
func SkillSlugDir(filePath string) string {
baseDir := SkillBaseDir(filePath)
if baseDir == "" {
return ""
}
return filepath.Dir(baseDir)
}
+31
View File
@@ -0,0 +1,31 @@
package store
import "testing"
func TestSkillBaseDirAcceptsDirectoryPath(t *testing.T) {
got := SkillBaseDir("/var/lib/goclaw/data/skills-store/demo/3")
if got != "/var/lib/goclaw/data/skills-store/demo/3" {
t.Fatalf("SkillBaseDir() = %q", got)
}
}
func TestSkillBaseDirAcceptsSkillMarkdownPath(t *testing.T) {
got := SkillBaseDir("/var/lib/goclaw/data/skills-store/demo/3/SKILL.md")
if got != "/var/lib/goclaw/data/skills-store/demo/3" {
t.Fatalf("SkillBaseDir() = %q", got)
}
}
func TestSkillMarkdownPath(t *testing.T) {
got := SkillMarkdownPath("/var/lib/goclaw/data/skills-store/demo/3/SKILL.md")
if got != "/var/lib/goclaw/data/skills-store/demo/3/SKILL.md" {
t.Fatalf("SkillMarkdownPath() = %q", got)
}
}
func TestSkillSlugDir(t *testing.T) {
got := SkillSlugDir("/var/lib/goclaw/data/skills-store/demo/3/SKILL.md")
if got != "/var/lib/goclaw/data/skills-store/demo" {
t.Fatalf("SkillSlugDir() = %q", got)
}
}
+2 -2
View File
@@ -218,13 +218,13 @@ func buildSkillInfo(id, name, slug string, desc *string, version int, baseDir st
}
skillDir := fmt.Sprintf("%s/%s/%d", baseDir, slug, version)
if filePath != nil && *filePath != "" {
skillDir = *filePath
skillDir = store.SkillBaseDir(*filePath)
}
return store.SkillInfo{
ID: id,
Name: name,
Slug: slug,
Path: skillDir + "/SKILL.md",
Path: store.SkillMarkdownPath(skillDir),
BaseDir: skillDir,
Source: "managed",
Description: d,
+3 -2
View File
@@ -94,8 +94,9 @@ func (s *SQLiteSkillStore) RevokeFromAgent(ctx context.Context, skillID, agentID
_, err = s.db.ExecContext(ctx,
`UPDATE skills SET visibility = 'private', updated_at = ?
WHERE id = ? AND visibility = 'internal' AND (is_system = 1 OR tenant_id = ?)
AND NOT EXISTS (SELECT 1 FROM skill_agent_grants WHERE skill_id = ?)`,
time.Now().UTC(), skillID, tid, skillID)
AND NOT EXISTS (SELECT 1 FROM skill_agent_grants WHERE skill_id = ?)
AND NOT EXISTS (SELECT 1 FROM skill_user_grants WHERE skill_id = ?)`,
time.Now().UTC(), skillID, tid, skillID, skillID)
if err != nil {
slog.Warn("skill_grants: failed to auto-demote visibility", "skill_id", skillID, "error", err)
}
+125
View File
@@ -216,6 +216,40 @@ func TestSQLiteSkillStore_RevokeFromAgentDoesNotDemoteCrossTenantSkill(t *testin
}
}
func TestSQLiteSkillStore_RevokeFromAgentKeepsInternalWhenUserGrantRemains(t *testing.T) {
_, skillStore, db := newTestSQLiteSkillStoreWithDB(t)
tenantID, agentID := seedSQLiteTenantAgent(t, db)
ctx := store.WithTenantID(context.Background(), tenantID)
skillID, err := skillStore.CreateSkillManaged(ctx, store.SkillCreateParams{
Name: "Shared Internal Skill",
Slug: "shared-internal-skill-" + tenantID.String()[:8],
OwnerID: "owner-user",
Visibility: "internal",
FilePath: filepath.Join(t.TempDir(), "shared-internal-skill", "1"),
})
if err != nil {
t.Fatalf("CreateSkillManaged error: %v", err)
}
if err := skillStore.GrantToAgent(ctx, skillID, agentID, 1, "owner-user", true); err != nil {
t.Fatalf("GrantToAgent error: %v", err)
}
if err := skillStore.GrantToUser(ctx, skillID, "granted-user", "owner-user"); err != nil {
t.Fatalf("GrantToUser error: %v", err)
}
if err := skillStore.RevokeFromAgent(ctx, skillID, agentID); err != nil {
t.Fatalf("RevokeFromAgent error: %v", err)
}
got, ok := skillStore.GetSkillByID(ctx, skillID)
if !ok {
t.Fatal("GetSkillByID returned !ok")
}
if got.Visibility != "internal" {
t.Fatalf("visibility after revoking last agent grant = %q, want internal because a user grant remains", got.Visibility)
}
}
func TestSQLiteSkillStore_ListWithGrantStatusIgnoresForeignTenantGrant(t *testing.T) {
_, skillStore, db := newTestSQLiteSkillStoreWithDB(t)
tenantA, _ := seedSQLiteTenantAgent(t, db)
@@ -253,6 +287,97 @@ func TestSQLiteSkillStore_ListWithGrantStatusIgnoresForeignTenantGrant(t *testin
t.Fatalf("system skill %s not returned for tenant A", skillID)
}
func TestSQLiteSkillStore_ListAccessibleHonorsAccessModes(t *testing.T) {
_, skillStore, db := newTestSQLiteSkillStoreWithDB(t)
tenantID, agentA := seedSQLiteTenantAgent(t, db)
agentB := uuid.New()
if _, err := db.Exec(
`INSERT INTO agents (id, tenant_id, agent_key, agent_type, status, provider, model, owner_id)
VALUES (?, ?, ?, 'predefined', 'active', 'test', 'test-model', 'user-2')`,
agentB.String(), tenantID.String(), "agent-"+agentB.String()[:8],
); err != nil {
t.Fatalf("insert second agent: %v", err)
}
ctx := store.WithTenantID(context.Background(), tenantID)
privateID, err := skillStore.CreateSkillManaged(ctx, store.SkillCreateParams{
Name: "Owner Only Skill",
Slug: "owner-only-skill-" + tenantID.String()[:8],
OwnerID: "owner-user",
Visibility: "private",
FilePath: filepath.Join(t.TempDir(), "owner-only", "1"),
})
if err != nil {
t.Fatalf("CreateSkillManaged private error: %v", err)
}
internalID, err := skillStore.CreateSkillManaged(ctx, store.SkillCreateParams{
Name: "Granted Agents Skill",
Slug: "granted-agents-skill-" + tenantID.String()[:8],
OwnerID: "owner-user",
Visibility: "internal",
FilePath: filepath.Join(t.TempDir(), "granted-agents", "1"),
})
if err != nil {
t.Fatalf("CreateSkillManaged internal error: %v", err)
}
publicID, err := skillStore.CreateSkillManaged(ctx, store.SkillCreateParams{
Name: "Tenant Wide Skill",
Slug: "tenant-wide-skill-" + tenantID.String()[:8],
OwnerID: "owner-user",
Visibility: "public",
FilePath: filepath.Join(t.TempDir(), "tenant-wide", "1"),
})
if err != nil {
t.Fatalf("CreateSkillManaged public error: %v", err)
}
if err := skillStore.GrantToAgent(ctx, internalID, agentA, 1, "owner-user", true); err != nil {
t.Fatalf("GrantToAgent internal error: %v", err)
}
viewerA := listAccessibleSlugs(t, skillStore, ctx, agentA, "viewer-user")
if !viewerA["granted-agents-skill-"+tenantID.String()[:8]] {
t.Fatalf("granted agent did not receive internal skill; got %v", viewerA)
}
if !viewerA["tenant-wide-skill-"+tenantID.String()[:8]] {
t.Fatalf("agent did not receive public skill; got %v", viewerA)
}
if viewerA["owner-only-skill-"+tenantID.String()[:8]] {
t.Fatalf("non-owner received private skill; got %v", viewerA)
}
viewerB := listAccessibleSlugs(t, skillStore, ctx, agentB, "viewer-user")
if viewerB["granted-agents-skill-"+tenantID.String()[:8]] {
t.Fatalf("ungranted agent received internal skill; got %v", viewerB)
}
if !viewerB["tenant-wide-skill-"+tenantID.String()[:8]] {
t.Fatalf("ungranted agent did not receive public skill; got %v", viewerB)
}
owner := listAccessibleSlugs(t, skillStore, ctx, agentB, "owner-user")
if !owner["owner-only-skill-"+tenantID.String()[:8]] {
t.Fatalf("owner did not receive private skill %s; got %v", privateID, owner)
}
if !owner["tenant-wide-skill-"+tenantID.String()[:8]] {
t.Fatalf("owner did not receive public skill %s; got %v", publicID, owner)
}
if owner["granted-agents-skill-"+tenantID.String()[:8]] {
t.Fatalf("owner received internal skill without grant; got %v", owner)
}
}
func listAccessibleSlugs(t *testing.T, skillStore *SQLiteSkillStore, ctx context.Context, agentID uuid.UUID, userID string) map[string]bool {
t.Helper()
skills, err := skillStore.ListAccessible(ctx, agentID, userID)
if err != nil {
t.Fatalf("ListAccessible error: %v", err)
}
slugs := make(map[string]bool, len(skills))
for _, skill := range skills {
slugs[skill.Slug] = true
}
return slugs
}
func newTestSQLiteSkillStore(t *testing.T) (context.Context, *SQLiteSkillStore) {
ctx, skillStore, _ := newTestSQLiteSkillStoreWithDB(t)
return ctx, skillStore
+4
View File
@@ -13,6 +13,8 @@ import (
// - "private" skills are only visible to the owner. Three identity strings
// are considered (actor, user, sender) to match the same identities
// isOwnerOfSkill checks for backward compatibility (#915).
// - "internal" skills require explicit grants, which are evaluated in
// ListAccessible; this baseline helper hides them.
//
// Admin/master-scope bypass is the caller's responsibility — this helper
// reflects the non-privileged baseline.
@@ -34,6 +36,8 @@ func IsSkillVisibleTo(ctx context.Context, ownerID, visibility string, isSystem
userID := UserIDFromContext(ctx)
senderID := SenderIDFromContext(ctx)
return ownerID == actorID || ownerID == userID || ownerID == senderID
case "internal":
return false
default:
// Unknown enum value: fail closed (hide).
return false
+1
View File
@@ -23,6 +23,7 @@ func TestIsSkillVisibleTo(t *testing.T) {
{"private visible to owner", alice, "private", false, true},
{"private hidden from non-owner", bob, "private", false, false},
{"private with no owner treated as public", "", "private", false, true},
{"internal requires explicit grant outside this helper", bob, "internal", false, false},
{"unknown enum fails closed", bob, "team", false, false},
{"uppercase private matched for owner", alice, "PRIVATE", false, true},
{"whitespace public treated as public", bob, " public ", false, true},
+3 -3
View File
@@ -57,7 +57,7 @@ export function Sidebar({ collapsed, onNavItemClick }: SidebarProps) {
return (
<aside
className={cn(
"flex h-full flex-col border-r bg-sidebar text-sidebar-foreground transition-all duration-200",
"flex h-full flex-col overflow-hidden overscroll-none border-r bg-sidebar text-sidebar-foreground transition-all duration-200",
collapsed ? "w-16" : "w-64",
)}
onClick={(e) => {
@@ -83,7 +83,7 @@ export function Sidebar({ collapsed, onNavItemClick }: SidebarProps) {
</div>
{/* Nav items */}
<nav className="flex-1 space-y-4 overflow-y-auto px-2 py-4">
<nav className="flex-1 space-y-4 overflow-y-auto overscroll-contain px-2 py-4">
<SidebarGroup label={t("groups.core")} collapsed={collapsed}>
<SidebarItem to={ROUTES.OVERVIEW} icon={LayoutDashboard} label={t("nav.overview")} collapsed={collapsed} />
<SidebarItem to={ROUTES.CHAT} icon={MessageSquare} label={t("nav.chat")} collapsed={collapsed} />
@@ -151,7 +151,7 @@ export function Sidebar({ collapsed, onNavItemClick }: SidebarProps) {
</nav>
{/* Footer: connection status */}
<div className={cn("border-t py-3", collapsed ? "px-2 flex justify-center" : "px-4")}>
<div className={cn("shrink-0 overscroll-none border-t py-3", collapsed ? "px-2 flex justify-center" : "px-4")}>
<ConnectionStatus collapsed={collapsed} />
</div>
</aside>
+59 -7
View File
@@ -13,11 +13,40 @@
"author": "Author",
"agents": "Agents",
"owner": "Owner",
"visibility": "Visibility",
"visibility": "Access mode",
"accessMode": "Access mode",
"status": "Status",
"actions": "Actions"
},
"noDescription": "No description",
"status": {
"unmanaged": "Unmanaged",
"missingDepsCount": "{{count}} missing"
},
"filters": {
"label": "Filter",
"all": "All skills",
"attention": "Needs attention",
"missing-deps": "Missing deps",
"disabled": "Disabled",
"archived": "Archived",
"unmanaged": "Unmanaged",
"clear": "Clear filters"
},
"sort": {
"label": "Sort",
"name": "Name",
"deps": "Missing deps",
"version": "Version"
},
"health": {
"total": "Total",
"attention": "Needs attention",
"missingDeps": "Missing deps",
"disabled": "Disabled",
"archived": "Archived",
"unmanaged": "Unmanaged"
},
"owner": "Owner",
"unknownOwner": "Unknown",
"grants": {
@@ -54,10 +83,21 @@
"deleted": "Deleted {{count}} skill(s)",
"failed": "Bulk action failed",
"deleteTitle": "Delete selected skills",
"deleteDescription": "This will delete {{count}} selected custom skill(s). Type DELETE to confirm."
"deleteDescription": "This will delete {{count}} selected custom skill(s). Type DELETE to confirm.",
"customAvailable": "{{count}} custom action(s) available",
"skippedSystem": "{{count}} system skill(s) skipped",
"customOnlyReason": "This action only applies to custom skills",
"noAgentsReason": "No current agents available"
},
"visibility": {
"clickToCycle": "Click to change visibility"
"clickToCycle": "Click to change access mode"
},
"accessMode": {
"private": "Owner only",
"internal": "Granted agents",
"public": "All tenant agents",
"unknown": "Unknown ({{value}})",
"clickToCycle": "Click to change access mode"
},
"delete": {
"title": "Delete Skill",
@@ -96,14 +136,21 @@
"agentManagers": "Grant manager access to agents",
"managerAgentsHelp": "Selected current agents can update this skill later.",
"selectAllAgents": "Select all agents",
"selectAllCurrentAgents": "Select all current agents",
"clearAgents": "Clear agents",
"noAgents": "No agents available"
"noAgents": "No agents available",
"selectedAgentsCount": "{{selected}} of {{total}} current agents selected",
"summaryUploaded": "{{count}} uploaded",
"summaryWarnings": "{{count}} warning(s)",
"summaryUnchanged": "{{count}} unchanged",
"summaryFailed": "{{count}} failed"
},
"edit": {
"title": "Edit Skill",
"name": "Name",
"description": "Description",
"visibility": "Visibility",
"visibility": "Access mode",
"accessMode": "Access mode",
"tags": "Tags",
"addTag": "Add tag...",
"add": "Add",
@@ -149,7 +196,7 @@
"pythonLabel": "Python",
"nodeLabel": "Node.js",
"statusActive": "Active",
"statusArchived": "Missing deps",
"statusArchived": "Archived",
"runtimeMissing": "Runtime Prerequisites Missing",
"runtimeMissingDesc": "Core skills require Python and/or Node.js runtimes inside the GoClaw runtime container. Host-installed runtimes are not used here. If you are using the published latest image, missing runtimes can be expected there.",
"runtimeMissingAction": "Open Packages to verify or install runtimes in this container.",
@@ -165,7 +212,12 @@
"files": "Files",
"version": "Version:",
"current": "(current)",
"noContent": "No content available."
"noContent": "No content available.",
"fileSearch": "Search files...",
"filesShown": "{{shown}} of {{total}} files",
"copyLink": "Copy link",
"copySuccess": "Deeplink copied",
"copyFailed": "Failed to copy deeplink"
},
"toast": {
"updated": "Skill updated",
+59 -7
View File
@@ -12,14 +12,50 @@
"source": "Nguồn",
"author": "Tác giả",
"agents": "Agents",
"visibility": "Hiển thị",
"visibility": "Chế độ truy cập",
"accessMode": "Chế độ truy cập",
"status": "Trạng thái",
"actions": "Thao tác",
"owner": "Chủ sở hữu"
},
"noDescription": "Không có mô tả",
"status": {
"unmanaged": "Chưa có agent quản lý",
"missingDepsCount": "Thiếu {{count}}"
},
"filters": {
"label": "Bộ lọc",
"all": "Tất cả skill",
"attention": "Cần xử lý",
"missing-deps": "Thiếu deps",
"disabled": "Đã tắt",
"archived": "Đã lưu trữ",
"unmanaged": "Chưa có quản lý",
"clear": "Xóa bộ lọc"
},
"sort": {
"label": "Sắp xếp",
"name": "Tên",
"deps": "Thiếu deps",
"version": "Phiên bản"
},
"health": {
"total": "Tổng",
"attention": "Cần xử lý",
"missingDeps": "Thiếu deps",
"disabled": "Đã tắt",
"archived": "Đã lưu trữ",
"unmanaged": "Chưa có quản lý"
},
"visibility": {
"clickToCycle": "Nhấp để thay đổi hiển thị"
"clickToCycle": "Nhấp để đổi chế độ truy cập"
},
"accessMode": {
"private": "Chỉ chủ sở hữu",
"internal": "Agent được cấp quyền",
"public": "Tất cả agent trong tenant",
"unknown": "Không rõ ({{value}})",
"clickToCycle": "Nhấp để đổi chế độ truy cập"
},
"delete": {
"title": "Xóa skill",
@@ -58,14 +94,21 @@
"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",
"selectAllCurrentAgents": "Chọn tất cả agent hiện tại",
"clearAgents": "Bỏ chọn agents",
"noAgents": "Chưa có agent"
"noAgents": "Chưa có agent",
"selectedAgentsCount": "Đã chọn {{selected}} / {{total}} agent hiện tại",
"summaryUploaded": "{{count}} đã tải lên",
"summaryWarnings": "{{count}} cảnh báo",
"summaryUnchanged": "{{count}} không đổi",
"summaryFailed": "{{count}} thất bại"
},
"edit": {
"title": "Chỉnh sửa skill",
"name": "Tên",
"description": "Mô tả",
"visibility": "Hiển thị",
"visibility": "Chế độ truy cập",
"accessMode": "Chế độ truy cập",
"tags": "Thẻ",
"addTag": "Thêm thẻ...",
"add": "Thêm",
@@ -111,7 +154,7 @@
"pythonLabel": "Python",
"nodeLabel": "Node.js",
"statusActive": "Active",
"statusArchived": "Missing deps",
"statusArchived": "Đã lưu trữ",
"runtimeMissing": "Runtime Prerequisites Missing",
"runtimeMissingDesc": "Các core skill cần runtime Python và/hoặc Node.js bên trong container runtime GoClaw. Runtime cài trên máy host sẽ không được dùng ở đây. Nếu bạn đang dùng image latest đã phát hành thì việc thiếu runtime có thể là bình thường.",
"runtimeMissingAction": "Mở trang Packages để kiểm tra hoặc cài runtime trong container này.",
@@ -127,7 +170,12 @@
"files": "Tệp",
"version": "Phiên bản:",
"current": "(hiện tại)",
"noContent": "Không có nội dung."
"noContent": "Không có nội dung.",
"fileSearch": "Tìm trong tệp...",
"filesShown": "{{shown}} / {{total}} tệp",
"copyLink": "Copy link",
"copySuccess": "Đã copy deeplink",
"copyFailed": "Không thể copy deeplink"
},
"toast": {
"updated": "Đã cập nhật skill",
@@ -174,6 +222,10 @@
"deleted": "Đã xóa {{count}} skill",
"failed": "Bulk action thất bại",
"deleteTitle": "Xóa các skill đã chọn",
"deleteDescription": "Thao tác này sẽ xóa {{count}} custom skill đã chọn. Gõ DELETE để xác nhận."
"deleteDescription": "Thao tác này sẽ xóa {{count}} custom skill đã chọn. Gõ DELETE để xác nhận.",
"customAvailable": "{{count}} thao tác custom khả dụng",
"skippedSystem": "Bỏ qua {{count}} skill hệ thống",
"customOnlyReason": "Thao tác này chỉ áp dụng cho custom skill",
"noAgentsReason": "Chưa có agent hiện tại"
}
}
+59 -7
View File
@@ -12,14 +12,50 @@
"source": "来源",
"author": "作者",
"agents": "Agent",
"visibility": "可见性",
"visibility": "访问模式",
"accessMode": "访问模式",
"status": "状态",
"actions": "操作",
"owner": "所有者"
},
"noDescription": "暂无描述",
"status": {
"unmanaged": "未管理",
"missingDepsCount": "缺少 {{count}} 项"
},
"filters": {
"label": "筛选",
"all": "全部 Skill",
"attention": "需要处理",
"missing-deps": "缺少依赖",
"disabled": "已禁用",
"archived": "已归档",
"unmanaged": "未管理",
"clear": "清除筛选"
},
"sort": {
"label": "排序",
"name": "名称",
"deps": "缺少依赖",
"version": "版本"
},
"health": {
"total": "总数",
"attention": "需要处理",
"missingDeps": "缺少依赖",
"disabled": "已禁用",
"archived": "已归档",
"unmanaged": "未管理"
},
"visibility": {
"clickToCycle": "点击更改可见性"
"clickToCycle": "点击更改访问模式"
},
"accessMode": {
"private": "仅所有者",
"internal": "已授权 Agent",
"public": "租户内所有 Agent",
"unknown": "未知({{value}})",
"clickToCycle": "点击更改访问模式"
},
"delete": {
"title": "删除Skill",
@@ -58,14 +94,21 @@
"agentManagers": "授予 Agent 管理权限",
"managerAgentsHelp": "所选当前 Agent 以后可以更新此 Skill。",
"selectAllAgents": "选择所有 Agent",
"selectAllCurrentAgents": "选择所有当前 Agent",
"clearAgents": "清除 Agent",
"noAgents": "暂无 Agent"
"noAgents": "暂无 Agent",
"selectedAgentsCount": "已选择 {{selected}} / {{total}} 个当前 Agent",
"summaryUploaded": "已上传 {{count}} 个",
"summaryWarnings": "{{count}} 个警告",
"summaryUnchanged": "{{count}} 个未更改",
"summaryFailed": "{{count}} 个失败"
},
"edit": {
"title": "编辑Skill",
"name": "名称",
"description": "描述",
"visibility": "可见性",
"visibility": "访问模式",
"accessMode": "访问模式",
"tags": "标签",
"addTag": "添加标签...",
"add": "添加",
@@ -111,7 +154,7 @@
"pythonLabel": "Python",
"nodeLabel": "Node.js",
"statusActive": "Active",
"statusArchived": "Missing deps",
"statusArchived": "已归档",
"runtimeMissing": "Runtime Prerequisites Missing",
"runtimeMissingDesc": "核心技能需要在 GoClaw 运行时容器内提供 Python 和/或 Node.js 运行时。宿主机上的运行时不会在这里被使用。如果你使用的是已发布的 latest 镜像,那么缺少这些运行时可能是正常现象。",
"runtimeMissingAction": "打开 Packages 页面,在此容器中检查或安装运行时。",
@@ -127,7 +170,12 @@
"files": "文件",
"version": "版本:",
"current": "(当前)",
"noContent": "无可用内容。"
"noContent": "无可用内容。",
"fileSearch": "搜索文件...",
"filesShown": "{{shown}} / {{total}} 个文件",
"copyLink": "复制链接",
"copySuccess": "已复制 Deeplink",
"copyFailed": "复制 Deeplink 失败"
},
"toast": {
"updated": "技能已更新",
@@ -174,6 +222,10 @@
"deleted": "已删除 {{count}} 个 Skill",
"failed": "批量操作失败",
"deleteTitle": "删除所选 Skill",
"deleteDescription": "这将删除 {{count}} 个所选自定义 Skill。请输入 DELETE 确认。"
"deleteDescription": "这将删除 {{count}} 个所选自定义 Skill。请输入 DELETE 确认。",
"customAvailable": "{{count}} 个自定义操作可用",
"skippedSystem": "已跳过 {{count}} 个系统 Skill",
"customOnlyReason": "此操作仅适用于自定义 Skill",
"noAgentsReason": "暂无当前 Agent"
}
}
+7
View File
@@ -137,6 +137,13 @@
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
html,
body,
#root {
height: 100%;
overflow: hidden;
overscroll-behavior: none;
}
body {
@apply bg-background text-foreground;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { cleanVersion } from "./clean-version";
describe("cleanVersion", () => {
it("keeps beta version identity and drops build slug", () => {
expect(cleanVersion("v3.12.0-beta.13-skills-ux-99f30ecc")).toBe("v3.12.0.beta.13");
});
it("keeps rc version identity", () => {
expect(cleanVersion("v3.12.0-rc.2-linux-amd64")).toBe("v3.12.0.rc.2");
});
it("strips git distance metadata from stable versions", () => {
expect(cleanVersion("v2.5.1-3-g4fd653c1")).toBe("v2.5.1");
});
it("leaves non-semver versions unchanged", () => {
expect(cleanVersion("dev")).toBe("dev");
});
});
+11 -4
View File
@@ -1,6 +1,13 @@
// Strip git build metadata from version string.
// "v2.5.1-3-g4fd653c1" → "v2.5.1", "dev" → "dev"
// Keep release/beta identity visible while hiding build slugs and git metadata.
// "v3.12.0-beta.13-skills-ux-99f30ecc" -> "v3.12.0.beta.13"
// "v2.5.1-3-g4fd653c1" -> "v2.5.1", "dev" -> "dev"
export function cleanVersion(v: string): string {
const match = v.match(/^(v?\d+\.\d+\.\d+)/);
return match?.[1] ?? v;
const match = v.match(/^(v?\d+\.\d+\.\d+)(?:-((?:beta|alpha|rc)\.?\d*))?/i);
if (!match) return v;
const base = match[1]!;
const prerelease = match[2];
if (!prerelease) return base;
const normalizedPrerelease = prerelease.replace(/^(beta|alpha|rc)(\d+)$/i, "$1.$2");
return `${base}.${normalizedPrerelease}`;
}
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import {
getNextSkillAccessMode,
getSkillAccessModeBadgeVariant,
getSkillAccessModeKey,
} from "./skill-access-mode";
describe("skill access mode helpers", () => {
it("maps raw visibility values to stable access-mode keys", () => {
expect(getSkillAccessModeKey("private")).toBe("private");
expect(getSkillAccessModeKey("internal")).toBe("internal");
expect(getSkillAccessModeKey("public")).toBe("public");
expect(getSkillAccessModeKey("team")).toBe("unknown");
});
it("cycles known modes and normalizes unknown values to private", () => {
expect(getNextSkillAccessMode("private")).toBe("internal");
expect(getNextSkillAccessMode("internal")).toBe("public");
expect(getNextSkillAccessMode("public")).toBe("private");
expect(getNextSkillAccessMode("team")).toBe("private");
});
it("uses conservative badge variants for unknown values", () => {
expect(getSkillAccessModeBadgeVariant("public")).toBe("default");
expect(getSkillAccessModeBadgeVariant("internal")).toBe("secondary");
expect(getSkillAccessModeBadgeVariant("private")).toBe("outline");
expect(getSkillAccessModeBadgeVariant("team")).toBe("outline");
});
});
@@ -0,0 +1,31 @@
export type SkillAccessMode = "private" | "internal" | "public";
export const SKILL_ACCESS_MODE_ORDER: SkillAccessMode[] = ["private", "internal", "public"];
export type SkillAccessModeKey = SkillAccessMode | "unknown";
export function getSkillAccessModeKey(visibility?: string): SkillAccessModeKey {
return isSkillAccessMode(visibility) ? visibility : "unknown";
}
export function getNextSkillAccessMode(visibility?: string): SkillAccessMode {
if (!isSkillAccessMode(visibility)) return "private";
const idx = SKILL_ACCESS_MODE_ORDER.indexOf(visibility);
return SKILL_ACCESS_MODE_ORDER[(idx + 1) % SKILL_ACCESS_MODE_ORDER.length] ?? "private";
}
export function getSkillAccessModeBadgeVariant(visibility?: string): "default" | "secondary" | "outline" {
switch (getSkillAccessModeKey(visibility)) {
case "public":
return "default";
case "internal":
return "secondary";
case "private":
case "unknown":
return "outline";
}
}
function isSkillAccessMode(value?: string): value is SkillAccessMode {
return value === "private" || value === "internal" || value === "public";
}
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { summarizeSkillStatuses } from "./skill-upload-summary";
describe("skill upload summary", () => {
it("separates upload result states", () => {
expect(summarizeSkillStatuses([
{ status: "valid" },
{ status: "success" },
{ status: "warning" },
{ status: "unchanged" },
{ status: "invalid" },
{ status: "error" },
{ status: "uploading" },
])).toEqual({
total: 7,
valid: 1,
uploaded: 1,
warnings: 1,
unchanged: 1,
failed: 1,
invalid: 1,
inProgress: 1,
});
});
});
@@ -0,0 +1,62 @@
import type { FileEntry, SkillEntry, SkillStatus } from "./skill-upload-types";
export interface SkillUploadSummary {
total: number;
valid: number;
uploaded: number;
warnings: number;
unchanged: number;
failed: number;
invalid: number;
inProgress: number;
}
const EMPTY_SUMMARY: SkillUploadSummary = {
total: 0,
valid: 0,
uploaded: 0,
warnings: 0,
unchanged: 0,
failed: 0,
invalid: 0,
inProgress: 0,
};
export function summarizeUploadEntries(entries: FileEntry[]): SkillUploadSummary {
return summarizeSkillStatuses(entries.flatMap((entry) => entry.skills));
}
export function summarizeSkillStatuses(skills: Pick<SkillEntry, "status">[]): SkillUploadSummary {
return skills.reduce<SkillUploadSummary>((summary, skill) => {
summary.total += 1;
applyStatus(summary, skill.status);
return summary;
}, { ...EMPTY_SUMMARY });
}
function applyStatus(summary: SkillUploadSummary, status: SkillStatus) {
switch (status) {
case "valid":
summary.valid += 1;
break;
case "success":
summary.uploaded += 1;
break;
case "warning":
summary.warnings += 1;
break;
case "unchanged":
summary.unchanged += 1;
break;
case "invalid":
summary.invalid += 1;
break;
case "error":
summary.failed += 1;
break;
case "validating":
case "uploading":
summary.inProgress += 1;
break;
}
}
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import type { SkillInfo } from "../hooks/use-skills";
import { deriveSkillStats, displayDependencyName, filterSkills, sortSkills } from "./skills-filtering";
function skill(overrides: Partial<SkillInfo>): SkillInfo {
return {
id: overrides.name ?? "id",
name: overrides.name ?? "Skill",
description: overrides.description ?? "",
source: "custom",
...overrides,
};
}
describe("skills filtering", () => {
const skills = [
skill({ name: "Core", is_system: true, manager_agents: [] }),
skill({ name: "Missing", missing_deps: ["pip:google", "npm:zod"], manager_agents: [{ id: "a1" }] }),
skill({ name: "Disabled", enabled: false, manager_agents: [{ id: "a2" }] }),
skill({ name: "Archived", status: "archived", manager_agents: [{ id: "a3" }] }),
skill({ name: "Unmanaged", manager_agents: [] }),
];
it("derives attention stats without counting system skills as unmanaged", () => {
expect(deriveSkillStats(skills)).toEqual({
total: 5,
missingDeps: 1,
disabled: 1,
archived: 1,
unmanaged: 1,
attention: 4,
});
});
it("filters attention and unmanaged skills", () => {
expect(filterSkills(skills, { q: "", filter: "attention", agent: null }).map((s) => s.name))
.toEqual(["Missing", "Disabled", "Archived", "Unmanaged"]);
expect(filterSkills(skills, { q: "", filter: "unmanaged", agent: null }).map((s) => s.name))
.toEqual(["Unmanaged"]);
});
it("searches skill and agent metadata", () => {
expect(filterSkills(skills, { q: "a2", filter: "all", agent: null }).map((s) => s.name)).toEqual(["Disabled"]);
});
it("sorts dependency-heavy skills first", () => {
expect(sortSkills(skills, "deps")[0]?.name).toBe("Missing");
});
it("strips dependency runtime prefixes only for display", () => {
expect(displayDependencyName("pip:google")).toBe("google");
expect(displayDependencyName("core")).toBe("core");
});
});
@@ -0,0 +1,115 @@
import type { SkillInfo } from "../hooks/use-skills";
import type { SkillsFilter, SkillsPageState } from "./skills-page-state";
export interface SkillHealthStats {
total: number;
missingDeps: number;
disabled: number;
archived: number;
unmanaged: number;
attention: number;
}
export function displayDependencyName(dep: string): string {
return dep.replace(/^(pip|npm):/, "");
}
export function deriveSkillStats(skills: SkillInfo[]): SkillHealthStats {
return skills.reduce<SkillHealthStats>((stats, skill) => {
stats.total += 1;
if (hasMissingDeps(skill)) stats.missingDeps += 1;
if (isDisabled(skill)) stats.disabled += 1;
if (isArchived(skill)) stats.archived += 1;
if (isUnmanaged(skill)) stats.unmanaged += 1;
if (needsAttention(skill)) stats.attention += 1;
return stats;
}, { total: 0, missingDeps: 0, disabled: 0, archived: 0, unmanaged: 0, attention: 0 });
}
export function filterSkills(skills: SkillInfo[], state: Pick<SkillsPageState, "q" | "filter" | "agent">): SkillInfo[] {
const q = state.q.trim().toLowerCase();
return skills.filter((skill) => {
if (q && !matchesQuery(skill, q)) return false;
if (state.agent && !matchesAgent(skill, state.agent)) return false;
return matchesFilter(skill, state.filter);
});
}
export function sortSkills(skills: SkillInfo[], sort: SkillsPageState["sort"]): SkillInfo[] {
const next = [...skills];
next.sort((a, b) => {
if (sort === "deps") {
const diff = (b.missing_deps?.length ?? 0) - (a.missing_deps?.length ?? 0);
if (diff !== 0) return diff;
}
if (sort === "version") {
const diff = (b.version ?? 0) - (a.version ?? 0);
if (diff !== 0) return diff;
}
return a.name.localeCompare(b.name);
});
return next;
}
export function needsAttention(skill: SkillInfo): boolean {
return hasMissingDeps(skill) || isDisabled(skill) || isArchived(skill) || isUnmanaged(skill);
}
export function isUnmanaged(skill: SkillInfo): boolean {
return !skill.is_system && (skill.manager_agents?.length ?? 0) === 0;
}
export function isArchived(skill: SkillInfo): boolean {
return skill.status === "archived";
}
export function isDisabled(skill: SkillInfo): boolean {
return skill.enabled === false;
}
export function hasMissingDeps(skill: SkillInfo): boolean {
return (skill.missing_deps?.length ?? 0) > 0;
}
function matchesFilter(skill: SkillInfo, filter: SkillsFilter): boolean {
switch (filter) {
case "attention":
return needsAttention(skill);
case "missing-deps":
return hasMissingDeps(skill);
case "disabled":
return isDisabled(skill);
case "archived":
return isArchived(skill);
case "unmanaged":
return isUnmanaged(skill);
case "all":
return true;
}
}
function matchesQuery(skill: SkillInfo, q: string): boolean {
const agentLabels = [
skill.creator_agent?.display_name,
skill.creator_agent?.agent_key,
skill.creator_agent?.id,
...(skill.manager_agents ?? []).flatMap((agent) => [agent.display_name, agent.agent_key, agent.id]),
];
return [
skill.name,
skill.slug,
skill.description,
skill.author,
...agentLabels,
].some((value) => value?.toLowerCase().includes(q));
}
function matchesAgent(skill: SkillInfo, agentRef: string): boolean {
const candidates = [
skill.creator_agent,
...(skill.manager_agents ?? []),
];
return candidates.some((agent) =>
agent?.id === agentRef || agent?.agent_key === agentRef || agent?.display_name === agentRef,
);
}
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { parseSkillsPageState, serializeSkillsPageState } from "./skills-page-state";
describe("skills page state", () => {
it("uses quiet defaults for empty params", () => {
expect(parseSkillsPageState(new URLSearchParams())).toEqual({
tab: "core",
q: "",
filter: "all",
sort: "name",
agent: null,
});
});
it("rejects invalid params", () => {
const state = parseSkillsPageState(new URLSearchParams("tab=bad&filter=weird&sort=old&q=%20pdf%20"));
expect(state).toMatchObject({ tab: "core", filter: "all", sort: "name", q: "pdf" });
});
it("serializes non-default filters while preserving modal params", () => {
const params = new URLSearchParams("skill=abc&detailTab=files&file=SKILL.md");
const next = serializeSkillsPageState(params, { tab: "custom", q: "pdf", filter: "missing-deps", sort: "deps" });
expect(next.get("tab")).toBe("custom");
expect(next.get("q")).toBe("pdf");
expect(next.get("filter")).toBe("missing-deps");
expect(next.get("sort")).toBe("deps");
expect(next.get("skill")).toBe("abc");
expect(next.get("detailTab")).toBe("files");
expect(next.get("file")).toBe("SKILL.md");
});
it("removes default values instead of making noisy URLs", () => {
const params = new URLSearchParams("tab=custom&q=pdf&filter=attention&sort=deps&agent=a1");
const next = serializeSkillsPageState(params, { tab: "core", q: "", filter: "all", sort: "name", agent: null });
expect(next.toString()).toBe("");
});
});
@@ -0,0 +1,58 @@
export type SkillsTab = "core" | "custom";
export type SkillsFilter = "all" | "attention" | "missing-deps" | "disabled" | "archived" | "unmanaged";
export type SkillsSort = "name" | "deps" | "version";
export interface SkillsPageState {
tab: SkillsTab;
q: string;
filter: SkillsFilter;
sort: SkillsSort;
agent: string | null;
}
const FILTERS = new Set<SkillsFilter>(["all", "attention", "missing-deps", "disabled", "archived", "unmanaged"]);
const SORTS = new Set<SkillsSort>(["name", "deps", "version"]);
export const DEFAULT_SKILLS_PAGE_STATE: SkillsPageState = {
tab: "core",
q: "",
filter: "all",
sort: "name",
agent: null,
};
export function parseSkillsPageState(params: URLSearchParams): SkillsPageState {
const rawFilter = params.get("filter");
const rawSort = params.get("sort");
const q = (params.get("q") ?? "").trim();
const agent = (params.get("agent") ?? "").trim();
return {
tab: params.get("tab") === "custom" ? "custom" : "core",
q,
filter: rawFilter && FILTERS.has(rawFilter as SkillsFilter) ? (rawFilter as SkillsFilter) : "all",
sort: rawSort && SORTS.has(rawSort as SkillsSort) ? (rawSort as SkillsSort) : "name",
agent: agent || null,
};
}
export function serializeSkillsPageState(
currentParams: URLSearchParams,
updates: Partial<SkillsPageState>,
): URLSearchParams {
const next = new URLSearchParams(currentParams);
const state = { ...parseSkillsPageState(currentParams), ...updates };
setParam(next, "tab", state.tab === "core" ? "" : state.tab);
setParam(next, "q", state.q.trim());
setParam(next, "filter", state.filter === "all" ? "" : state.filter);
setParam(next, "sort", state.sort === "name" ? "" : state.sort);
setParam(next, "agent", state.agent ?? "");
return next;
}
function setParam(params: URLSearchParams, key: string, value: string) {
if (value) params.set(key, value);
else params.delete(key);
}
@@ -0,0 +1,42 @@
import { useTranslation } from "react-i18next";
import { Badge } from "@/components/ui/badge";
import type { SkillInfo, SkillAgentRef } from "@/types/skill";
interface SkillAgentChipsProps {
skill: SkillInfo;
}
export function SkillAgentChips({ skill }: SkillAgentChipsProps) {
const { t } = useTranslation("skills");
const managers = skill.manager_agents ?? [];
const visibleManagers = managers.slice(0, 2);
const overflow = managers.length - visibleManagers.length;
const unmanaged = !skill.is_system && managers.length === 0;
return (
<div className="flex max-w-[260px] flex-wrap gap-1">
{skill.creator_agent && (
<Badge variant="outline" className="max-w-full text-2xs" title={t("agents.creator")}>
{t("agents.creator")}: {agentLabel(skill.creator_agent)}
</Badge>
)}
{visibleManagers.map((agent) => (
<Badge key={agent.id || agent.agent_key || agent.display_name} variant="secondary" className="max-w-full text-2xs" title={t("agents.managers")}>
{agentLabel(agent)}
</Badge>
))}
{overflow > 0 && <Badge variant="outline" className="text-2xs">+{overflow}</Badge>}
{unmanaged && <Badge variant="warning" className="text-2xs">{t("status.unmanaged")}</Badge>}
{!skill.creator_agent && managers.length === 0 && skill.author && (
<Badge variant="outline" className="max-w-full text-2xs">{skill.author}</Badge>
)}
{!skill.creator_agent && managers.length === 0 && !skill.author && !unmanaged && (
<span className="text-muted-foreground">—</span>
)}
</div>
);
}
function agentLabel(agent: SkillAgentRef): string {
return agent.display_name || agent.agent_key || agent.id || "unknown";
}
@@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button";
interface SkillBulkActionsToolbarProps {
selectedCount: number;
customSelectedCount: number;
skippedSystemCount: number;
agentCount: number;
loading: boolean;
onEnable: () => void;
@@ -17,6 +18,7 @@ interface SkillBulkActionsToolbarProps {
export function SkillBulkActionsToolbar({
selectedCount,
customSelectedCount,
skippedSystemCount,
agentCount,
loading,
onEnable,
@@ -27,15 +29,25 @@ export function SkillBulkActionsToolbar({
}: SkillBulkActionsToolbarProps) {
const { t } = useTranslation("skills");
const hasSelection = selectedCount > 0;
const customActionDisabledReason = agentCount === 0
? t("bulk.noAgentsReason")
: customSelectedCount === 0
? t("bulk.customOnlyReason")
: undefined;
if (!hasSelection) return null;
return (
<div
className="mt-3 flex flex-wrap items-center gap-2 rounded-md border px-3 py-2 transition-colors"
style={{ visibility: hasSelection ? "visible" : "hidden" }}
>
<span className="text-sm font-medium">
{t("bulk.selected", { count: selectedCount })}
</span>
<div className="mt-3 flex flex-wrap items-center gap-2 rounded-md border px-3 py-2 transition-colors">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">
{t("bulk.selected", { count: selectedCount })}
</span>
<span className="text-xs text-muted-foreground">
{t("bulk.customAvailable", { count: customSelectedCount })}
{skippedSystemCount > 0 ? ` · ${t("bulk.skippedSystem", { count: skippedSystemCount })}` : ""}
</span>
</div>
<div className="ml-auto flex flex-wrap gap-2">
<Button size="sm" variant="outline" className="gap-1" disabled={loading || !hasSelection} onClick={onEnable}>
<CheckCircle2 className="h-3.5 w-3.5" />
@@ -50,6 +62,7 @@ export function SkillBulkActionsToolbar({
variant="outline"
className="gap-1"
disabled={loading || customSelectedCount === 0 || agentCount === 0}
title={customActionDisabledReason}
onClick={onGrantAllAgents}
>
<ShieldCheck className="h-3.5 w-3.5" />
@@ -60,6 +73,7 @@ export function SkillBulkActionsToolbar({
variant="outline"
className="gap-1 text-destructive hover:text-destructive"
disabled={loading || customSelectedCount === 0}
title={customSelectedCount === 0 ? t("bulk.customOnlyReason") : undefined}
onClick={onDelete}
>
<Trash2 className="h-3.5 w-3.5" />
@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Copy } from "lucide-react";
import {
Dialog,
DialogContent,
@@ -15,11 +16,15 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { SearchInput } from "@/components/shared/search-input";
import { MarkdownRenderer } from "@/components/shared/markdown-renderer";
import { toast } from "@/stores/use-toast-store";
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";
import { getSkillAccessModeKey } from "./lib/skill-access-mode";
interface SkillDetailDialogProps {
skill: SkillInfo & { content: string };
@@ -46,6 +51,10 @@ export function SkillDetailDialog({
}: SkillDetailDialogProps) {
const { t } = useTranslation("skills");
const hasFiles = !!skill.id;
const accessModeKey = getSkillAccessModeKey(skill.visibility);
const accessModeLabel = accessModeKey === "unknown"
? t("accessMode.unknown", { value: skill.visibility || t("unknownOwner") })
: t(`accessMode.${accessModeKey}`);
// Version state
const [versions, setVersions] = useState<SkillVersions | null>(null);
@@ -57,18 +66,24 @@ export function SkillDetailDialog({
const [files, setFiles] = useState<SkillFile[]>([]);
const [filesLoading, setFilesLoading] = useState(false);
const [activePath, setActivePath] = useState<string | null>(null);
const [fileQuery, setFileQuery] = useState("");
// File content state
const [fileContent, setFileContent] = useState<{ content: string; path: string; size: number } | null>(null);
const [contentLoading, setContentLoading] = useState(false);
const tree = useMemo(() => buildTree(files), [files]);
const filteredFiles = useMemo(
() => filterSkillFiles(files, fileQuery, activePath),
[activePath, fileQuery, files],
);
const tree = useMemo(() => buildTree(filteredFiles), [filteredFiles]);
useEffect(() => {
setVersions(null);
setSelectedVersion(parseSkillDetailVersionParam(selectedVersionParam));
setFiles([]);
setActivePath(null);
setFileQuery("");
setFileContent(null);
}, [skill.id, selectedVersionParam]);
@@ -155,6 +170,26 @@ export function SkillDetailDialog({
loadFileContent(path);
};
const copyDeeplink = async () => {
const url = new URL(window.location.href);
url.pathname = "/skills";
const next = new URLSearchParams(url.search);
next.set("skill", skill.id || skill.slug || skill.name);
next.set("detailTab", detailTab === "files" ? "files" : "content");
if (detailTab === "files" && selectedVersion != null) next.set("version", String(selectedVersion));
else next.delete("version");
if (detailTab === "files" && activePath) next.set("file", activePath);
else next.delete("file");
url.search = next.toString();
try {
await navigator.clipboard.writeText(url.toString());
toast.success(t("detail.copySuccess"));
} catch (err) {
toast.error(t("detail.copyFailed"), err instanceof Error ? err.message : String(err));
}
};
useEffect(() => {
if (hasFiles) loadVersions();
}, [hasFiles, loadVersions]);
@@ -170,11 +205,15 @@ export function SkillDetailDialog({
{skill.name}
<Badge variant="outline">{skill.source || "file"}</Badge>
{skill.visibility && (
<Badge variant="secondary">{skill.visibility}</Badge>
<Badge variant="secondary">{accessModeLabel}</Badge>
)}
</DialogTitle>
{versions && versions.versions.length > 1 ? (
<div className="flex shrink-0 items-center gap-2">
<div className="flex shrink-0 flex-wrap items-center gap-2">
<Button type="button" variant="outline" size="sm" className="h-8 gap-1" onClick={copyDeeplink}>
<Copy className="h-3.5 w-3.5" />
{t("detail.copyLink")}
</Button>
<span className="text-sm text-muted-foreground">{t("detail.version")}</span>
<Select
value={String(headerVersion ?? versions.current)}
@@ -193,10 +232,21 @@ export function SkillDetailDialog({
</Select>
</div>
) : headerVersion ? (
<Badge variant="outline" className="w-fit shrink-0 font-normal">
v{headerVersion}
</Badge>
) : null}
<div className="flex shrink-0 flex-wrap items-center gap-2">
<Button type="button" variant="outline" size="sm" className="h-8 gap-1" onClick={copyDeeplink}>
<Copy className="h-3.5 w-3.5" />
{t("detail.copyLink")}
</Button>
<Badge variant="outline" className="w-fit shrink-0 font-normal">
v{headerVersion}
</Badge>
</div>
) : (
<Button type="button" variant="outline" size="sm" className="h-8 gap-1" onClick={copyDeeplink}>
<Copy className="h-3.5 w-3.5" />
{t("detail.copyLink")}
</Button>
)}
</div>
{skill.description && (
<p className="text-sm text-muted-foreground">{skill.description}</p>
@@ -231,12 +281,25 @@ export function SkillDetailDialog({
<MarkdownRenderer content={skill.content} />
</div>
) : (
<p className="text-sm text-muted-foreground">{t("detail.noContent")}</p>
<div className="rounded-md border border-dashed bg-muted/20 p-6 text-sm text-muted-foreground">
{t("detail.noContent")}
</div>
)}
</TabsContent>
{hasFiles && (
<TabsContent value="files" className="flex-1 overflow-hidden flex flex-col mt-2 gap-2">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<SearchInput
value={fileQuery}
onChange={setFileQuery}
placeholder={t("detail.fileSearch")}
className="w-full sm:max-w-xs"
/>
<span className="text-xs text-muted-foreground">
{t("detail.filesShown", { shown: filteredFiles.length, total: files.length })}
</span>
</div>
<FileBrowser
tree={tree}
filesLoading={filesLoading}
@@ -252,3 +315,25 @@ export function SkillDetailDialog({
</Dialog>
);
}
function filterSkillFiles(files: SkillFile[], query: string, activePath: string | null): SkillFile[] {
const q = query.trim().toLowerCase();
if (!q) return files;
const byPath = new Map(files.map((file) => [file.path, file]));
const visible = new Set<string>();
for (const file of files) {
const matches = file.path.toLowerCase().includes(q) || file.name.toLowerCase().includes(q) || file.path === activePath;
if (!matches) continue;
visible.add(file.path);
let parentPath = file.path.includes("/") ? file.path.slice(0, file.path.lastIndexOf("/")) : "";
while (parentPath) {
visible.add(parentPath);
const nextSlash = parentPath.lastIndexOf("/");
parentPath = nextSlash >= 0 ? parentPath.slice(0, nextSlash) : "";
}
}
return files.filter((file) => visible.has(file.path) || (file.isDir && byPath.has(file.path) && visible.has(file.path)));
}
@@ -20,6 +20,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { SKILL_ACCESS_MODE_ORDER } from "./lib/skill-access-mode";
import type { SkillInfo } from "@/types/skill";
interface SkillEditDialogProps {
@@ -32,7 +33,7 @@ export function SkillEditDialog({ skill, onClose, onSave }: SkillEditDialogProps
const { t } = useTranslation("skills");
const [name, setName] = useState(skill.name);
const [description, setDescription] = useState(skill.description);
const [visibility, setVisibility] = useState(skill.visibility ?? "private");
const [accessMode, setAccessMode] = useState(skill.visibility ?? "private");
const [tags, setTags] = useState<string[]>(skill.tags ?? []);
const [tagInput, setTagInput] = useState("");
const [loading, setLoading] = useState(false);
@@ -40,7 +41,7 @@ export function SkillEditDialog({ skill, onClose, onSave }: SkillEditDialogProps
useEffect(() => {
setName(skill.name);
setDescription(skill.description);
setVisibility(skill.visibility ?? "private");
setAccessMode(skill.visibility ?? "private");
setTags(skill.tags ?? []);
}, [skill]);
@@ -60,7 +61,7 @@ export function SkillEditDialog({ skill, onClose, onSave }: SkillEditDialogProps
if (!skill.id) return;
setLoading(true);
try {
await onSave(skill.id, { name, description, visibility, tags });
await onSave(skill.id, { name, description, visibility: accessMode, tags });
onClose();
} catch {
// toast shown by hook — keep dialog open
@@ -97,15 +98,15 @@ export function SkillEditDialog({ skill, onClose, onSave }: SkillEditDialogProps
</div>
<div className="space-y-1.5">
<Label>{t("edit.visibility")}</Label>
<Select value={visibility} onValueChange={setVisibility}>
<Label>{t("edit.accessMode")}</Label>
<Select value={accessMode} onValueChange={setAccessMode}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="private">{t("edit.privateOption")}</SelectItem>
<SelectItem value="internal">{t("edit.internalOption")}</SelectItem>
<SelectItem value="public">{t("edit.publicOption")}</SelectItem>
{SKILL_ACCESS_MODE_ORDER.map((mode) => (
<SelectItem key={mode} value={mode}>{t(`accessMode.${mode}`)}</SelectItem>
))}
</SelectContent>
</Select>
</div>
@@ -0,0 +1,50 @@
import { AlertTriangle, Archive, Box, CircleOff, PackageX, ShieldAlert } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import type { SkillHealthStats } from "./lib/skills-filtering";
import type { SkillsFilter } from "./lib/skills-page-state";
interface SkillHealthSummaryProps {
stats: SkillHealthStats;
activeFilter: SkillsFilter;
onFilterChange: (filter: SkillsFilter) => void;
}
export function SkillHealthSummary({ stats, activeFilter, onFilterChange }: SkillHealthSummaryProps) {
const { t } = useTranslation("skills");
const items: Array<{ filter: SkillsFilter; label: string; value: number; icon: typeof Box; tone?: string }> = [
{ filter: "all", label: t("health.total"), value: stats.total, icon: Box },
{ filter: "attention", label: t("health.attention"), value: stats.attention, icon: AlertTriangle, tone: "text-amber-700" },
{ filter: "missing-deps", label: t("health.missingDeps"), value: stats.missingDeps, icon: PackageX, tone: "text-amber-700" },
{ filter: "disabled", label: t("health.disabled"), value: stats.disabled, icon: CircleOff, tone: "text-red-700" },
{ filter: "archived", label: t("health.archived"), value: stats.archived, icon: Archive, tone: "text-muted-foreground" },
{ filter: "unmanaged", label: t("health.unmanaged"), value: stats.unmanaged, icon: ShieldAlert, tone: "text-orange-700" },
];
return (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 xl:grid-cols-6">
{items.map((item) => {
const Icon = item.icon;
const active = activeFilter === item.filter;
return (
<button
key={item.filter}
type="button"
onClick={() => onFilterChange(item.filter)}
className={cn(
"flex min-h-16 items-center justify-between gap-3 rounded-md border px-3 py-2 text-left transition-colors",
active ? "border-primary bg-primary/5 text-primary" : "bg-background hover:bg-muted/40",
)}
aria-pressed={active}
>
<div className="min-w-0">
<div className="truncate text-xs text-muted-foreground">{item.label}</div>
<div className={cn("text-lg font-semibold leading-tight", item.tone)}>{item.value}</div>
</div>
<Icon className={cn("h-4 w-4 shrink-0", active ? "text-primary" : "text-muted-foreground")} />
</button>
);
})}
</div>
);
}
@@ -0,0 +1,33 @@
import { useTranslation } from "react-i18next";
import { Badge } from "@/components/ui/badge";
import type { SkillInfo } from "./hooks/use-skills";
import { displayDependencyName, hasMissingDeps, isArchived, isDisabled } from "./lib/skills-filtering";
interface SkillStatusBadgesProps {
skill: SkillInfo;
}
export function SkillStatusBadges({ skill }: SkillStatusBadgesProps) {
const { t } = useTranslation("skills");
const archived = isArchived(skill);
const disabled = isDisabled(skill);
const missing = skill.missing_deps ?? [];
return (
<div className="flex max-w-[220px] flex-wrap gap-1">
<Badge variant={archived ? "warning" : "success"} className="text-2xs">
{archived ? t("deps.statusArchived") : t("deps.statusActive")}
</Badge>
{disabled && <Badge variant="destructive" className="text-2xs">{t("toggle.disabled")}</Badge>}
{hasMissingDeps(skill) && (
<Badge
variant="warning"
className="text-2xs"
title={missing.map(displayDependencyName).join(", ")}
>
{t("status.missingDepsCount", { count: missing.length })}
</Badge>
)}
</div>
);
}
+21 -53
View File
@@ -5,14 +5,11 @@ import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { SkillTenantOverride } from "./skill-tenant-override";
import { SkillAgentChips } from "./skill-agent-chips";
import { SkillStatusBadges } from "./skill-status-badges";
import { getSkillAccessModeBadgeVariant, getSkillAccessModeKey } from "./lib/skill-access-mode";
import type { SkillInfo } from "./hooks/use-skills";
const visibilityColor: Record<string, string> = {
public: "default",
internal: "secondary",
private: "outline",
};
interface SkillTableRowProps {
skill: SkillInfo;
tab: "core" | "custom";
@@ -25,21 +22,25 @@ interface SkillTableRowProps {
onManageGrants: (skill: SkillInfo) => void;
onDelete: (skill: SkillInfo) => void;
onToggle: (skill: SkillInfo, enabled: boolean) => void;
onCycleVisibility: (skill: SkillInfo) => void;
onCycleAccessMode: (skill: SkillInfo) => void;
onSetTenantConfig: (id: string, enabled: boolean) => Promise<void>;
onDeleteTenantConfig: (id: string) => Promise<void>;
}
/** Single row in the skills table with inline status, visibility, and action controls. */
/** Single row in the skills table with inline status, access mode, and action controls. */
export function SkillTableRow({
skill, tab, hasTenantScope, toggling, selected, onToggleSelect,
onView, onEdit, onManageGrants, onDelete, onToggle, onCycleVisibility,
onView, onEdit, onManageGrants, onDelete, onToggle, onCycleAccessMode,
onSetTenantConfig, onDeleteTenantConfig,
}: SkillTableRowProps) {
const { t } = useTranslation("skills");
const isArchived = skill.status === "archived";
const isDisabled = skill.enabled === false;
const hasMissing = (skill.missing_deps?.length ?? 0) > 0;
const accessModeKey = getSkillAccessModeKey(skill.visibility);
const accessModeLabel = accessModeKey === "unknown"
? t("accessMode.unknown", { value: skill.visibility || t("unknownOwner") })
: t(`accessMode.${accessModeKey}`);
const accessModeVariant = getSkillAccessModeBadgeVariant(skill.visibility);
return (
<tr className={cn("border-b last:border-0 hover:bg-muted/30", selected && "bg-primary/5", (isArchived || isDisabled) && "opacity-60")}>
@@ -77,63 +78,27 @@ export function SkillTableRow({
</td>
{tab === "custom" && (
<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>
<SkillAgentChips skill={skill} />
</td>
)}
<td className="px-4 py-3">
<div className="flex flex-col gap-1">
<Badge
variant="outline"
className={cn(
"text-2xs w-fit",
isArchived
? "border-amber-500 text-amber-600 dark:border-amber-600 dark:text-amber-400"
: "border-emerald-500 text-emerald-600 dark:border-emerald-600 dark:text-emerald-400",
)}
>
{isArchived ? t("deps.statusArchived") : t("deps.statusActive")}
</Badge>
{hasMissing && (() => {
const deps = skill.missing_deps!.map((d) => d.replace(/^(pip|npm):/, ""));
const shown = deps.slice(0, 3);
const rest = deps.length - shown.length;
return (
<span className="text-2xs text-amber-600 dark:text-amber-400 leading-tight">
{shown.join(", ")}{rest > 0 && `, +${rest}`}
</span>
);
})()}
</div>
<SkillStatusBadges skill={skill} />
</td>
{tab === "custom" && (
<td className="px-4 py-3">
{skill.visibility && (
skill.id ? (
<button type="button" onClick={() => onCycleVisibility(skill)} title={t("visibility.clickToCycle")}>
<button type="button" onClick={() => onCycleAccessMode(skill)} title={t("accessMode.clickToCycle")}>
<Badge
variant={visibilityColor[skill.visibility] as "default" | "secondary" | "outline"}
variant={accessModeVariant}
className="cursor-pointer hover:opacity-80 transition-opacity"
>
{skill.visibility}
{accessModeLabel}
</Badge>
</button>
) : (
<Badge variant={visibilityColor[skill.visibility] as "default" | "secondary" | "outline"}>
{skill.visibility}
<Badge variant={accessModeVariant}>
{accessModeLabel}
</Badge>
)
)}
@@ -161,10 +126,12 @@ export function SkillTableRow({
)}
<Button variant="ghost" size="sm" onClick={() => onEdit(skill)} className="gap-1">
<Pencil className="h-3.5 w-3.5" />
<span className="sr-only">{t("edit.title")}</span>
</Button>
{!skill.is_system && (
<Button variant="ghost" size="sm" onClick={() => onManageGrants(skill)} className="gap-1" title={t("grants.manage")}>
<Users className="h-3.5 w-3.5" />
<span className="sr-only">{t("grants.manage")}</span>
</Button>
)}
{!skill.is_system && (
@@ -174,6 +141,7 @@ export function SkillTableRow({
className="gap-1 text-destructive hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
<span className="sr-only">{t("delete.confirmLabel")}</span>
</Button>
)}
</>
@@ -12,9 +12,11 @@ import {
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { useAgents } from "@/pages/agents/hooks/use-agents";
import { createSkillSubZip } from "./lib/create-skill-sub-zip";
import { resolveUploadSkills } from "./lib/resolve-upload-skills";
import { summarizeUploadEntries } from "./lib/skill-upload-summary";
import { uniqueId } from "@/lib/utils";
import type { SkillUploadOptions, SkillUploadResponse } from "./hooks/use-skills";
import type { FileEntry, SkillStatus } from "./lib/skill-upload-types";
@@ -238,9 +240,8 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD
// Derived counts (skill level, not file level)
// ---------------------------------------------------------------------------
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 uploadSummary = summarizeUploadEntries(entries);
const actionableCount = uploadSummary.valid;
const allCurrentAgentsSelected = agents.length > 0 && managerAgentIds.length === agents.length;
const toggleManagerAgent = (id: string) => {
@@ -326,9 +327,12 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD
<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")}
{allCurrentAgentsSelected ? t("upload.clearAgents") : t("upload.selectAllCurrentAgents")}
</Button>
</div>
<p className="text-xs text-muted-foreground">
{t("upload.selectedAgentsCount", { selected: managerAgentIds.length, total: agents.length })}
</p>
<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>
@@ -352,13 +356,18 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD
{/* Summary line */}
{entries.length > 0 && !done && !uploading && (
<p className="text-xs text-muted-foreground">
{t("upload.validCount", { valid: actionableCount, total: allSkills.length })}
{t("upload.validCount", { valid: actionableCount, total: uploadSummary.total })}
</p>
)}
{done && (
<p className="text-sm font-medium text-muted-foreground">
{t("upload.successCount", { success: successCount, total: allSkills.filter((s) => s.status !== "unchanged" && s.status !== "invalid").length })}
</p>
<div className="flex flex-wrap gap-1.5 text-sm">
<Badge variant="success">{t("upload.summaryUploaded", { count: uploadSummary.uploaded })}</Badge>
<Badge variant="warning">{t("upload.summaryWarnings", { count: uploadSummary.warnings })}</Badge>
<Badge variant="outline">{t("upload.summaryUnchanged", { count: uploadSummary.unchanged })}</Badge>
<Badge variant={uploadSummary.failed + uploadSummary.invalid > 0 ? "destructive" : "outline"}>
{t("upload.summaryFailed", { count: uploadSummary.failed + uploadSummary.invalid })}
</Badge>
</div>
)}
<DialogFooter>
@@ -0,0 +1,68 @@
import { X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { SearchInput } from "@/components/shared/search-input";
import type { SkillsFilter, SkillsPageState, SkillsSort } from "./lib/skills-page-state";
interface SkillsFilterBarProps {
state: SkillsPageState;
onChange: (updates: Partial<SkillsPageState>) => void;
}
export function SkillsFilterBar({ state, onChange }: SkillsFilterBarProps) {
const { t } = useTranslation("skills");
const hasFilters = !!state.q || state.filter !== "all" || state.sort !== "name" || !!state.agent;
const filterOptions: SkillsFilter[] = ["all", "attention", "missing-deps", "disabled", "archived", "unmanaged"];
const sortOptions: SkillsSort[] = ["name", "deps", "version"];
return (
<div className="flex flex-col gap-2 md:flex-row md:items-center">
<SearchInput
value={state.q}
onChange={(q) => onChange({ q })}
placeholder={t("searchPlaceholder")}
className="w-full md:max-w-sm"
/>
<div className="flex flex-wrap items-center gap-2">
<Select value={state.filter} onValueChange={(filter) => onChange({ filter: filter as SkillsFilter })}>
<SelectTrigger size="sm" className="w-44">
<SelectValue aria-label={t("filters.label")} />
</SelectTrigger>
<SelectContent>
{filterOptions.map((filter) => (
<SelectItem key={filter} value={filter}>{t(`filters.${filter}`)}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={state.sort} onValueChange={(sort) => onChange({ sort: sort as SkillsSort })}>
<SelectTrigger size="sm" className="w-40">
<SelectValue aria-label={t("sort.label")} />
</SelectTrigger>
<SelectContent>
{sortOptions.map((sort) => (
<SelectItem key={sort} value={sort}>{t(`sort.${sort}`)}</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="ghost"
size="sm"
className="gap-1"
disabled={!hasFilters}
onClick={() => onChange({ q: "", filter: "all", sort: "name", agent: null })}
>
<X className="h-3.5 w-3.5" />
{t("filters.clear")}
</Button>
</div>
</div>
);
}
+59 -27
View File
@@ -1,11 +1,10 @@
import { useState, useEffect, lazy, Suspense, useMemo } from "react";
import { useSearchParams } from "react-router";
import { useNavigate, useParams, useSearchParams } from "react-router";
import { useTranslation } from "react-i18next";
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";
import { SearchInput } from "@/components/shared/search-input";
import { Pagination } from "@/components/shared/pagination";
import { TableSkeleton } from "@/components/shared/loading-skeleton";
import { ConfirmDeleteDialog } from "@/components/shared/confirm-delete-dialog";
@@ -16,6 +15,8 @@ import { SkillDetailDialog } from "./skill-detail-dialog";
import { SkillEditDialog } from "./skill-edit-dialog";
import { SkillAgentGrantsDialog } from "./skill-agent-grants-dialog";
import { SkillBulkActionsToolbar } from "./skill-bulk-actions-toolbar";
import { SkillHealthSummary } from "./skill-health-summary";
import { SkillsFilterBar } from "./skills-filter-bar";
const SkillUploadDialog = lazy(() =>
import("./skill-upload-dialog").then((m) => ({ default: m.SkillUploadDialog }))
@@ -28,6 +29,13 @@ import { useDeferredLoading } from "@/hooks/use-deferred-loading";
import { usePagination } from "@/hooks/use-pagination";
import { useTenants } from "@/hooks/use-tenants";
import { useAgents } from "@/pages/agents/hooks/use-agents";
import { deriveSkillStats, filterSkills, sortSkills } from "./lib/skills-filtering";
import {
parseSkillsPageState,
serializeSkillsPageState,
type SkillsPageState,
} from "./lib/skills-page-state";
import { getNextSkillAccessMode } from "./lib/skill-access-mode";
const MASTER_TENANT_ID = "0193a5b0-7000-7000-8000-000000000001";
@@ -43,15 +51,17 @@ export function SkillsPage() {
setTenantConfig, deleteTenantConfig,
} = useSkills();
const [params, setParams] = useSearchParams();
const routeParams = useParams<{ id?: string }>();
const navigate = useNavigate();
const { runtimes } = useRuntimes();
const { currentTenantId } = useTenants();
const { agents } = useAgents();
const hasTenantScope = !!currentTenantId && currentTenantId !== MASTER_TENANT_ID;
const spinning = useMinLoading(loading);
const showSkeleton = useDeferredLoading(loading && skills.length === 0);
const urlTab = params.get("tab") === "custom" ? "custom" : "core";
const tab: Tab = urlTab;
const [search, setSearch] = useState("");
const pageState = useMemo(() => parseSkillsPageState(params), [params]);
const tab: Tab = pageState.tab;
const skillRef = params.get("skill");
const [selectedSkill, setSelectedSkill] = useState<(SkillInfo & { content: string }) | null>(null);
const [uploadOpen, setUploadOpen] = useState(false);
const [editTarget, setEditTarget] = useState<SkillInfo | null>(null);
@@ -68,27 +78,33 @@ export function SkillsPage() {
const coreSkills = useMemo(() => skills.filter((s: SkillInfo) => s.is_system), [skills]);
const customSkills = useMemo(() => skills.filter((s: SkillInfo) => !s.is_system), [skills]);
const tabSkills = tab === "core" ? coreSkills : customSkills;
const healthStats = useMemo(() => deriveSkillStats(tabSkills), [tabSkills]);
const allMissing = useMemo(
() => [...new Set(tabSkills.flatMap((s: SkillInfo) => s.missing_deps ?? []))],
[tabSkills],
);
const filtered = useMemo(
() => tabSkills.filter(
(s: SkillInfo) =>
s.name.toLowerCase().includes(search.toLowerCase()) ||
s.description.toLowerCase().includes(search.toLowerCase()),
),
[search, tabSkills],
() => sortSkills(filterSkills(tabSkills, pageState), pageState.sort),
[pageState, tabSkills],
);
const { pageItems, pagination, setPage, setPageSize, resetPage } = usePagination(filtered);
const selectedSkills = filtered.filter((skill) => skill.id && selectedIds.has(skill.id));
const selectedCustomSkills = selectedSkills.filter((skill) => !skill.is_system);
const skippedSystemCount = selectedSkills.length - selectedCustomSkills.length;
const pageSelectableIds = pageItems.map((skill) => skill.id).filter((id): id is string => !!id);
const allPageSelected = pageSelectableIds.length > 0 && pageSelectableIds.every((id) => selectedIds.has(id));
const somePageSelected = pageSelectableIds.some((id) => selectedIds.has(id)) && !allPageSelected;
useEffect(() => { resetPage(); }, [search, tab, resetPage]);
useEffect(() => { setSelectedIds(new Set()); }, [search, tab]);
useEffect(() => {
const routeSkillId = routeParams.id;
if (!routeSkillId) return;
const next = new URLSearchParams(params);
next.set("skill", routeSkillId);
navigate({ pathname: "/skills", search: toSearchString(next) }, { replace: true });
}, [navigate, params, routeParams.id]);
useEffect(() => { resetPage(); }, [pageState.q, pageState.filter, pageState.sort, pageState.agent, tab, resetPage]);
useEffect(() => { setSelectedIds(new Set()); }, [pageState.q, pageState.filter, pageState.sort, pageState.agent, tab]);
useEffect(() => {
setSelectedIds((current) => {
const valid = new Set(filtered.map((skill) => skill.id).filter(Boolean));
@@ -97,6 +113,10 @@ export function SkillsPage() {
});
}, [filtered]);
const setPageState = (updates: Partial<SkillsPageState>) => {
setParams(serializeSkillsPageState(params, updates), { replace: true });
};
const setParamValues = (updates: Record<string, string | null>) => {
const next = new URLSearchParams(params);
for (const [key, value] of Object.entries(updates)) {
@@ -108,7 +128,8 @@ export function SkillsPage() {
const setTab = (nextTab: Tab) => {
const next = new URLSearchParams(params);
next.set("tab", nextTab);
if (nextTab === "core") next.delete("tab");
else next.set("tab", nextTab);
next.delete("skill");
next.delete("detailTab");
next.delete("version");
@@ -122,13 +143,14 @@ export function SkillsPage() {
next.delete("detailTab");
next.delete("version");
next.delete("file");
setParams(next, { replace: true });
navigate({ pathname: "/skills", search: toSearchString(next) }, { replace: true });
setSelectedSkill(null);
};
const handleViewSkill = async (skill: SkillInfo) => {
const next = new URLSearchParams(params);
next.set("tab", skill.is_system ? "core" : "custom");
if (skill.is_system) next.delete("tab");
else next.set("tab", "custom");
next.set("skill", skill.id || skill.slug || skill.name);
next.set("detailTab", "content");
next.delete("version");
@@ -137,7 +159,6 @@ export function SkillsPage() {
};
useEffect(() => {
const skillRef = params.get("skill");
if (!skillRef) {
setSelectedSkill(null);
return;
@@ -147,13 +168,11 @@ export function SkillsPage() {
if (!cancelled) setSelectedSkill(detail);
});
return () => { cancelled = true; };
}, [params, getSkill]);
}, [skillRef, getSkill]);
const handleCycleVisibility = async (skill: SkillInfo) => {
const handleCycleAccessMode = async (skill: SkillInfo) => {
if (!skill.id) return;
const order = ["private", "internal", "public"] as const;
const idx = order.indexOf(skill.visibility as typeof order[number]);
await updateSkill(skill.id, { visibility: order[(idx + 1) % order.length] });
await updateSkill(skill.id, { visibility: getNextSkillAccessMode(skill.visibility) });
};
const handleDelete = async () => {
@@ -305,6 +324,7 @@ export function SkillsPage() {
<SkillBulkActionsToolbar
selectedCount={selectedSkills.length}
customSelectedCount={selectedCustomSkills.length}
skippedSystemCount={skippedSystemCount}
agentCount={agents.length}
loading={bulkLoading || deleteLoading}
onEnable={() => handleBulkToggle(true)}
@@ -316,7 +336,14 @@ export function SkillsPage() {
<div className="mt-4">
<MissingDepsPanel missing={allMissing} onInstallItem={installSingleDep} runtimes={tab === "core" ? runtimes : undefined} />
<SearchInput value={search} onChange={setSearch} placeholder={t("searchPlaceholder")} className="max-w-sm" />
<div className="space-y-3">
<SkillHealthSummary
stats={healthStats}
activeFilter={pageState.filter}
onFilterChange={(filter) => setPageState({ filter })}
/>
<SkillsFilterBar state={pageState} onChange={setPageState} />
</div>
</div>
<div className="mt-4">
@@ -325,8 +352,8 @@ export function SkillsPage() {
) : filtered.length === 0 ? (
<EmptyState
icon={Zap}
title={search ? t("noMatchTitle") : t("emptyTitle")}
description={search ? t("noMatchDescription") : t("emptyDescription")}
title={pageState.q || pageState.filter !== "all" ? t("noMatchTitle") : t("emptyTitle")}
description={pageState.q || pageState.filter !== "all" ? t("noMatchDescription") : t("emptyDescription")}
/>
) : (
<div className="overflow-x-auto rounded-md border">
@@ -347,7 +374,7 @@ export function SkillsPage() {
<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.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>}
{tab === "custom" && <th className="px-4 py-3 text-left font-medium">{t("columns.accessMode")}</th>}
<th className="px-4 py-3 text-right font-medium">{t("columns.actions")}</th>
</tr>
</thead>
@@ -366,7 +393,7 @@ export function SkillsPage() {
onManageGrants={setGrantsTarget}
onDelete={setDeleteTarget}
onToggle={handleToggle}
onCycleVisibility={handleCycleVisibility}
onCycleAccessMode={handleCycleAccessMode}
onSetTenantConfig={handleSetTenantConfig}
onDeleteTenantConfig={handleDeleteTenantConfig}
/>
@@ -445,3 +472,8 @@ export function SkillsPage() {
</div>
);
}
function toSearchString(params: URLSearchParams): string {
const serialized = params.toString();
return serialized ? `?${serialized}` : "";
}