refactor skills registries and add GitHub-backed skill discovery (#2442)

* refactor skills registries and add GitHub-backed skill discovery

* fix ci

* fix command error

* fix default skills install registry behavior

* fix github registry URL parsing and versioned skill links

* fix skills registry config compatibility and URL installs

* * fix lint

* fix deprecated github base url compatibility

* fix skills registry yaml and github default branch handling

* fix github skills registry fallback and install metadata

* fix cli skills install origin metadata

* fix clawhub registry env compatibility

* fix skills registry config merge compatibility

* fix skill install metadata consistency and onboard template copy

* fix yaml overrides for default skills registries

* fix install_skill registry metadata normalization

* fix github skill URL parsing for slash branch names

* fix skills registry install/search validation and github URLs

* fix github skill URL host validation

* fix install_skill validation for invalid registry archives

* fix redundant skills registry names in saved config

* fix github blob skill URL installs and metadata links

* fix github registry URL scheme validation

* fix v0 skills migration preserving github registry defaults

* fix github blob skill install directory resolution

* fix install_skill rollback on origin metadata write failure

* fix github skill URL validation and registry JSON merging

* fix github registry target resolution and metadata links

* fix install_skill force reinstall rollback

* fix skills config compatibility and legacy security overlays

* fix ci
This commit is contained in:
lxowalle
2026-04-14 15:14:16 +08:00
committed by GitHub
parent df9124b824
commit 0425cd4d77
40 changed files with 4213 additions and 326 deletions
+42 -14
View File
@@ -438,23 +438,51 @@ func applyConfigSecretsFromMap(cfg *config.Config, raw map[string]any) {
// Handle tools secrets
tools, hasTools := asMapField(raw, "tools")
if hasTools {
skills, hasSkills := asMapField(tools, "skills")
if hasSkills {
if github, hasGithub := asMapField(skills, "github"); hasGithub {
if token, hasToken := getSecretString(github, "token"); hasToken {
cfg.Tools.Skills.Github.Token.Set(token)
}
if !hasTools {
return
}
skills, hasSkills := asMapField(tools, "skills")
if !hasSkills {
return
}
if github, hasGithub := asMapField(skills, "github"); hasGithub {
if token, hasToken := getSecretString(github, "token"); hasToken {
cfg.Tools.Skills.Github.Token.Set(token)
}
}
if registries, hasRegistries := asMapField(skills, "registries"); hasRegistries {
for registryName, rawRegistry := range registries {
registryMap, ok := rawRegistry.(map[string]any)
if !ok {
continue
}
registries, hasRegistries := asMapField(skills, "registries")
if hasRegistries {
if clawHub, hasClawHub := asMapField(registries, "clawhub"); hasClawHub {
if authToken, hasAuthToken := getSecretString(clawHub, "auth_token"); hasAuthToken {
cfg.Tools.Skills.Registries.ClawHub.AuthToken.Set(authToken)
}
}
if authToken, hasAuthToken := getSecretString(registryMap, "auth_token"); hasAuthToken {
registryCfg, _ := cfg.Tools.Skills.Registries.Get(registryName)
registryCfg.AuthToken.Set(authToken)
cfg.Tools.Skills.Registries.Set(registryName, registryCfg)
}
}
return
}
registriesList, hasRegistries := skills["registries"].([]any)
if !hasRegistries {
return
}
for _, rawRegistry := range registriesList {
registryMap, ok := rawRegistry.(map[string]any)
if !ok {
continue
}
name, _ := registryMap["name"].(string)
if name == "" {
continue
}
if authToken, hasAuthToken := getSecretString(registryMap, "auth_token"); hasAuthToken {
registryCfg, _ := cfg.Tools.Skills.Registries.Get(name)
registryCfg.AuthToken.Set(authToken)
cfg.Tools.Skills.Registries.Set(name, registryCfg)
}
}
}
+52
View File
@@ -6,6 +6,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/sipeed/picoclaw/pkg/config"
@@ -392,6 +393,57 @@ func TestHandlePatchConfig_SavesDiscordTokenFromPayload(t *testing.T) {
}
}
func TestHandlePatchConfig_DoesNotPersistShadowRegistryAuthTokenField(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
"tools": {
"skills": {
"registries": {
"github": {
"_auth_token": "ghp-shadow-token"
}
}
}
}
}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
githubRegistry, ok := cfg.Tools.Skills.Registries.Get("github")
if !ok {
t.Fatal("github registry missing after PATCH")
}
if got := githubRegistry.AuthToken.String(); got != "ghp-shadow-token" {
t.Fatalf("github registry auth token = %q, want %q", got, "ghp-shadow-token")
}
if got := githubRegistry.BaseURL; got != "https://github.com" {
t.Fatalf("github registry base_url = %q, want %q", got, "https://github.com")
}
rawConfig, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("ReadFile(configPath) error = %v", err)
}
if strings.Contains(string(rawConfig), "_auth_token") {
t.Fatalf("config.json should not persist _auth_token shadow field, got:\n%s", string(rawConfig))
}
}
func TestHandlePatchConfig_AllowsInvalidDenyRegexPatternsWhenDenyPatternsDisabled(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
+5 -1
View File
@@ -650,7 +650,11 @@ func TestHandleWebSocketProxyLoadsPidDataOnDemand(t *testing.T) {
}
func TestHandleWebSocketProxyRejectsStalePidDataAfterProcessExit(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
tmpDir := t.TempDir()
t.Setenv("HOME", tmpDir)
t.Setenv("PICOCLAW_HOME", filepath.Join(tmpDir, ".picoclaw"))
configPath := filepath.Join(tmpDir, "config.json")
h := NewHandler(configPath)
handler := h.handleWebSocketProxy()
+58 -53
View File
@@ -8,7 +8,6 @@ import (
"io"
"io/fs"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
@@ -23,6 +22,8 @@ import (
"github.com/sipeed/picoclaw/pkg/utils"
)
const defaultInstallSkillRegistry = "github"
type skillSupportResponse struct {
Skills []skillSupportItem `json:"skills"`
}
@@ -241,6 +242,15 @@ func (h *Handler) handleSearchSkills(w http.ResponseWriter, r *http.Request) {
response := make([]skillSearchResultItem, 0, len(pageResults))
for _, result := range pageResults {
installedSkill, installed := installedSkills[result.Slug]
if !installed {
registry := registryMgr.GetRegistry(result.RegistryName)
if registry != nil {
dirName, err := registry.ResolveInstallDirName(result.Slug)
if err == nil {
installedSkill, installed = installedSkills[dirName]
}
}
}
item := skillSearchResultItem{
Score: result.Score,
Slug: result.Slug,
@@ -248,7 +258,7 @@ func (h *Handler) handleSearchSkills(w http.ResponseWriter, r *http.Request) {
Summary: result.Summary,
Version: result.Version,
RegistryName: result.RegistryName,
URL: registrySkillURL(cfg, result.RegistryName, result.Slug),
URL: registrySkillURL(cfg, result.RegistryName, result.Slug, result.Version),
Installed: installed,
}
if installed {
@@ -292,15 +302,10 @@ func (h *Handler) handleInstallSkill(w http.ResponseWriter, r *http.Request) {
req.Slug = strings.TrimSpace(req.Slug)
req.Registry = strings.TrimSpace(req.Registry)
req.Version = strings.TrimSpace(req.Version)
if validateErr := utils.ValidateSkillIdentifier(req.Slug); validateErr != nil {
http.Error(
w,
fmt.Sprintf("invalid slug %q: error: %s", req.Slug, validateErr.Error()),
http.StatusBadRequest,
)
return
if req.Registry == "" {
req.Registry = defaultInstallSkillRegistry
}
if validateErr := utils.ValidateSkillIdentifier(req.Registry); validateErr != nil {
http.Error(
w,
@@ -316,10 +321,15 @@ func (h *Handler) handleInstallSkill(w http.ResponseWriter, r *http.Request) {
http.Error(w, fmt.Sprintf("registry %q not found", req.Registry), http.StatusBadRequest)
return
}
dirName, err := registry.ResolveInstallDirName(req.Slug)
if err != nil {
http.Error(w, fmt.Sprintf("invalid slug %q: error: %s", req.Slug, err.Error()), http.StatusBadRequest)
return
}
workspace := cfg.WorkspacePath()
skillsRoot := filepath.Join(workspace, "skills")
targetDir := filepath.Join(workspace, "skills", req.Slug)
targetDir := filepath.Join(workspace, "skills", dirName)
workspaceSkillWriteMu.Lock()
defer workspaceSkillWriteMu.Unlock()
@@ -332,15 +342,15 @@ func (h *Handler) handleInstallSkill(w http.ResponseWriter, r *http.Request) {
}
if !req.Force && targetExists {
http.Error(w, fmt.Sprintf("skill %q already installed at %s", req.Slug, targetDir), http.StatusConflict)
http.Error(w, fmt.Sprintf("skill %q already installed at %s", dirName, targetDir), http.StatusConflict)
return
}
if err := os.MkdirAll(skillsRoot, 0o755); err != nil {
http.Error(w, fmt.Sprintf("Failed to create skills directory: %v", err), http.StatusInternalServerError)
if mkdirErr := os.MkdirAll(skillsRoot, 0o755); mkdirErr != nil {
http.Error(w, fmt.Sprintf("Failed to create skills directory: %v", mkdirErr), http.StatusInternalServerError)
return
}
stagedWorkspaceRoot, stagedTargetDir, err := createStagedSkillInstall(skillsRoot, req.Slug)
stagedWorkspaceRoot, stagedTargetDir, err := createStagedSkillInstall(skillsRoot, dirName)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to prepare staged install: %v", err), http.StatusInternalServerError)
return
@@ -361,7 +371,7 @@ func (h *Handler) handleInstallSkill(w http.ResponseWriter, r *http.Request) {
return
}
if findWorkspaceSkillInfoByDirectory(stagedWorkspaceRoot, req.Slug) == nil {
if findWorkspaceSkillInfoByDirectory(stagedWorkspaceRoot, dirName) == nil {
http.Error(
w,
fmt.Sprintf("Failed to install skill: registry archive for %q is not a valid skill", req.Slug),
@@ -371,12 +381,13 @@ func (h *Handler) handleInstallSkill(w http.ResponseWriter, r *http.Request) {
}
installedAt := time.Now().UnixMilli()
normalizedSlug, registryURL := skills.BuildInstallMetadataForRegistryInstance(registry, req.Slug, result.Version)
if err := persistSkillOriginMeta(stagedTargetDir, installedSkillOriginMeta{
Version: 1,
OriginKind: "third_party",
Registry: registry.Name(),
Slug: req.Slug,
RegistryURL: registrySkillURL(cfg, registry.Name(), req.Slug),
Slug: normalizedSlug,
RegistryURL: registryURL,
InstalledVersion: result.Version,
InstalledAt: installedAt,
}); err != nil {
@@ -394,7 +405,7 @@ func (h *Handler) handleInstallSkill(w http.ResponseWriter, r *http.Request) {
return
}
validatedSkill := findWorkspaceSkillByDirectory(cfg, req.Slug)
validatedSkill := findWorkspaceSkillByDirectory(cfg, dirName)
if validatedSkill == nil {
http.Error(
w,
@@ -411,7 +422,7 @@ func (h *Handler) handleInstallSkill(w http.ResponseWriter, r *http.Request) {
Description: validatedSkill.Description,
OriginKind: "third_party",
RegistryName: registry.Name(),
RegistryURL: registrySkillURL(cfg, registry.Name(), req.Slug),
RegistryURL: registryURL,
InstalledVersion: result.Version,
InstalledAt: installedAt,
}
@@ -482,13 +493,14 @@ func (h *Handler) handleDeleteSkill(w http.ResponseWriter, r *http.Request) {
workspaceSkillWriteMu.Lock()
defer workspaceSkillWriteMu.Unlock()
var matchedNonWorkspace bool
for _, skill := range loader.ListSkills() {
if skill.Name != name {
continue
}
if skill.Source != "workspace" {
http.Error(w, "only workspace skills can be deleted", http.StatusBadRequest)
return
matchedNonWorkspace = true
continue
}
if err := os.RemoveAll(filepath.Dir(skill.Path)); err != nil {
http.Error(w, fmt.Sprintf("Failed to delete skill: %v", err), http.StatusInternalServerError)
@@ -498,6 +510,10 @@ func (h *Handler) handleDeleteSkill(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
return
}
if matchedNonWorkspace {
http.Error(w, "only workspace skills can be deleted", http.StatusBadRequest)
return
}
http.Error(w, "Skill not found", http.StatusNotFound)
}
@@ -511,21 +527,7 @@ func newSkillsLoader(workspace string) *skills.SkillsLoader {
}
func newSkillsRegistryManager(cfg *config.Config) *skills.RegistryManager {
clawHubConfig := cfg.Tools.Skills.Registries.ClawHub
return skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
ClawHub: skills.ClawHubConfig{
Enabled: clawHubConfig.Enabled,
BaseURL: clawHubConfig.BaseURL,
AuthToken: clawHubConfig.AuthToken.String(),
SearchPath: clawHubConfig.SearchPath,
SkillsPath: clawHubConfig.SkillsPath,
DownloadPath: clawHubConfig.DownloadPath,
Timeout: clawHubConfig.Timeout,
MaxZipSize: clawHubConfig.MaxZipSize,
MaxResponseSize: clawHubConfig.MaxResponseSize,
},
})
return skills.NewRegistryManagerFromToolsConfig(cfg.Tools.Skills)
}
func ensureSkillRegistryToolEnabled(cfg *config.Config, toolName string) error {
@@ -581,14 +583,19 @@ func buildOccupiedWorkspaceSkillsByDirectory(cfg *config.Config) (map[string]ski
continue
}
key := filepath.Base(filepath.Dir(skill.Path))
dirName := filepath.Base(filepath.Dir(skill.Path))
if dirName != "" {
result[dirName] = skill
}
if meta, err := readInstalledSkillOriginMeta(skill.Path); err == nil && meta != nil && meta.Slug != "" {
key = meta.Slug
key := skills.NormalizeInstallTargetForRegistry(cfg.Tools.Skills, meta.Registry, meta.Slug)
if key == "" {
key = meta.Slug
}
if key != "" {
result[key] = skill
}
}
if key == "" {
continue
}
result[key] = skill
}
return result, nil
}
@@ -739,17 +746,15 @@ func writeSkillOriginMeta(targetDir string, meta installedSkillOriginMeta) error
return fileutil.WriteFileAtomic(filepath.Join(targetDir, ".skill-origin.json"), data, 0o600)
}
func registrySkillURL(cfg *config.Config, registryName, slug string) string {
switch registryName {
case "clawhub":
baseURL := strings.TrimRight(cfg.Tools.Skills.Registries.ClawHub.BaseURL, "/")
if baseURL == "" {
baseURL = "https://clawhub.ai"
}
return baseURL + "/skills/" + url.PathEscape(slug)
default:
func registrySkillURL(cfg *config.Config, registryName, slug, version string) string {
if cfg == nil || registryName == "" || slug == "" {
return ""
}
registry := skills.LookupRegistryFromToolsConfig(cfg.Tools.Skills, registryName)
if registry == nil {
return ""
}
return registry.SkillURL(slug, version)
}
func registrySkillURLFromMeta(cfg *config.Config, meta *installedSkillOriginMeta) string {
@@ -762,7 +767,7 @@ func registrySkillURLFromMeta(cfg *config.Config, meta *installedSkillOriginMeta
if cfg == nil || meta.Registry == "" {
return ""
}
return registrySkillURL(cfg, meta.Registry, meta.Slug)
return registrySkillURL(cfg, meta.Registry, meta.Slug, meta.InstalledVersion)
}
func normalizeImportedSkillName(filename string, content []byte) (string, error) {
+457 -12
View File
@@ -15,9 +15,26 @@ import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/sipeed/picoclaw/pkg/config"
)
func setClawHubBaseURL(cfg *config.Config, baseURL string) {
registryCfg, _ := cfg.Tools.Skills.Registries.Get("clawhub")
registryCfg.BaseURL = baseURL
cfg.Tools.Skills.Registries.Set("clawhub", registryCfg)
}
func setGithubBaseURL(cfg *config.Config, baseURL string) {
registryCfg, ok := cfg.Tools.Skills.Registries.Get("github")
if !ok {
return
}
registryCfg.BaseURL = baseURL
cfg.Tools.Skills.Registries.Set("github", registryCfg)
}
func TestHandleListSkills(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
@@ -532,6 +549,65 @@ func TestHandleDeleteSkill(t *testing.T) {
}
}
func TestHandleDeleteSkillPrefersWorkspaceMatch(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
homeDir := t.TempDir()
t.Setenv(config.EnvHome, homeDir)
workspace := filepath.Join(t.TempDir(), "workspace")
cfg.Agents.Defaults.Workspace = workspace
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
workspaceSkillDir := filepath.Join(workspace, "skills", "delete-me-workspace")
if err := os.MkdirAll(workspaceSkillDir, 0o755); err != nil {
t.Fatalf("MkdirAll(workspace) error = %v", err)
}
if err := os.WriteFile(
filepath.Join(workspaceSkillDir, "SKILL.md"),
[]byte("---\nname: delete-me\ndescription: workspace delete me\n---\n"),
0o644,
); err != nil {
t.Fatalf("WriteFile(workspace) error = %v", err)
}
globalSkillDir := filepath.Join(homeDir, "skills", "delete-me-global")
if err := os.MkdirAll(globalSkillDir, 0o755); err != nil {
t.Fatalf("MkdirAll(global) error = %v", err)
}
if err := os.WriteFile(
filepath.Join(globalSkillDir, "SKILL.md"),
[]byte("---\nname: delete-me\ndescription: global delete me\n---\n"),
0o644,
); err != nil {
t.Fatalf("WriteFile(global) error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodDelete, "/api/skills/delete-me", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if _, err := os.Stat(workspaceSkillDir); !os.IsNotExist(err) {
t.Fatalf("workspace skill directory should be removed, stat err=%v", err)
}
if _, err := os.Stat(globalSkillDir); err != nil {
t.Fatalf("global skill directory should remain, stat err=%v", err)
}
}
func TestHandleSearchSkills(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
@@ -554,7 +630,8 @@ func TestHandleSearchSkills(t *testing.T) {
t.Fatalf("WriteFile() error = %v", err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/search" {
http.NotFound(w, r)
return
@@ -583,7 +660,7 @@ func TestHandleSearchSkills(t *testing.T) {
}))
defer server.Close()
cfg.Tools.Skills.Registries.ClawHub.BaseURL = server.URL
setClawHubBaseURL(cfg, server.URL)
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
@@ -627,7 +704,73 @@ func TestHandleSearchSkills(t *testing.T) {
}
}
func TestHandleSearchSkillsPagination(t *testing.T) {
func TestHandleSearchSkillsUsesGitHubResultVersionInURL(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
workspace := filepath.Join(t.TempDir(), "workspace")
cfg.Agents.Defaults.Workspace = workspace
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v3/search/code" {
http.NotFound(w, r)
return
}
json.NewEncoder(w).Encode(map[string]any{
"items": []map[string]any{
{
"path": "skills/pr-review/SKILL.md",
"score": 10,
"repository": map[string]any{
"full_name": "foo/bar",
"name": "bar",
"description": "Review pull requests",
"default_branch": "master",
},
},
},
})
}))
defer server.Close()
setGithubBaseURL(cfg, server.URL)
clawHubRegistry, _ := cfg.Tools.Skills.Registries.Get("clawhub")
clawHubRegistry.Enabled = false
cfg.Tools.Skills.Registries.Set("clawhub", clawHubRegistry)
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/skills/search?q=pr+review&limit=5", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp skillSearchResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if len(resp.Results) != 1 {
t.Fatalf("results count = %d, want 1", len(resp.Results))
}
if resp.Results[0].URL != server.URL+"/foo/bar/tree/master/skills/pr-review" {
t.Fatalf("result URL = %q", resp.Results[0].URL)
}
}
func TestHandleSearchSkillsGitHubRateLimitDegradesGracefully(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
@@ -639,6 +782,57 @@ func TestHandleSearchSkillsPagination(t *testing.T) {
cfg.Agents.Defaults.Workspace = workspace
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v3/search/code" {
http.NotFound(w, r)
return
}
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"message":"API rate limit exceeded for 1.2.3.4"}`))
}))
defer server.Close()
setGithubBaseURL(cfg, server.URL)
clawHubRegistry, _ := cfg.Tools.Skills.Registries.Get("clawhub")
clawHubRegistry.Enabled = false
cfg.Tools.Skills.Registries.Set("clawhub", clawHubRegistry)
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/skills/search?q=pr+review&limit=5", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp skillSearchResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if len(resp.Results) != 0 {
t.Fatalf("results count = %d, want 0", len(resp.Results))
}
}
func TestHandleSearchSkillsPagination(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
workspace := filepath.Join(t.TempDir(), "workspace")
cfg.Agents.Defaults.Workspace = workspace
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/search" {
http.NotFound(w, r)
return
@@ -681,7 +875,7 @@ func TestHandleSearchSkillsPagination(t *testing.T) {
}))
defer server.Close()
cfg.Tools.Skills.Registries.ClawHub.BaseURL = server.URL
setClawHubBaseURL(cfg, server.URL)
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
@@ -733,7 +927,8 @@ func TestHandleSearchSkillsClampsRegistryFanout(t *testing.T) {
workspace := filepath.Join(t.TempDir(), "workspace")
cfg.Agents.Defaults.Workspace = workspace
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/search" {
http.NotFound(w, r)
return
@@ -755,7 +950,7 @@ func TestHandleSearchSkillsClampsRegistryFanout(t *testing.T) {
}))
defer server.Close()
cfg.Tools.Skills.Registries.ClawHub.BaseURL = server.URL
setClawHubBaseURL(cfg, server.URL)
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
@@ -838,7 +1033,7 @@ func TestHandleInstallSkill(t *testing.T) {
}))
defer server.Close()
cfg.Tools.Skills.Registries.ClawHub.BaseURL = server.URL
setClawHubBaseURL(cfg, server.URL)
if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
t.Fatalf("SaveConfig() error = %v", saveErr)
}
@@ -972,7 +1167,7 @@ func TestHandleInstallSkillForcePreservesExistingSkillOnFailure(t *testing.T) {
}))
defer server.Close()
cfg.Tools.Skills.Registries.ClawHub.BaseURL = server.URL
setClawHubBaseURL(cfg, server.URL)
if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
t.Fatalf("SaveConfig() error = %v", saveErr)
}
@@ -1008,6 +1203,256 @@ func TestHandleInstallSkillForcePreservesExistingSkillOnFailure(t *testing.T) {
}
}
func TestHandleInstallSkillDefaultsRegistryToGitHub(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
cfg, loadErr := config.LoadConfig(configPath)
if loadErr != nil {
t.Fatalf("LoadConfig() error = %v", loadErr)
}
workspace := filepath.Join(t.TempDir(), "workspace")
cfg.Agents.Defaults.Workspace = workspace
if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
t.Fatalf("SaveConfig() error = %v", saveErr)
}
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v3/repos/foo/bar":
json.NewEncoder(w).Encode(map[string]any{"default_branch": "master"})
case "/api/v3/repos/foo/bar/contents/.agents/skills/pr-review":
assert.Equal(t, "ref=master", r.URL.RawQuery)
json.NewEncoder(w).Encode([]map[string]any{
{
"type": "file",
"name": "SKILL.md",
"download_url": server.URL + "/raw/foo/bar/master/.agents/skills/pr-review/SKILL.md",
},
})
case "/raw/foo/bar/master/.agents/skills/pr-review/SKILL.md":
_, _ = w.Write([]byte("---\nname: pr-review\ndescription: PR review skill\n---\n# PR Review\n"))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
githubRegistry, ok := cfg.Tools.Skills.Registries.Get("github")
if !ok {
t.Fatalf("github registry missing from default config")
}
githubRegistry.BaseURL = server.URL
cfg.Tools.Skills.Registries.Set("github", githubRegistry)
if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
t.Fatalf("SaveConfig() error = %v", saveErr)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
body, err := json.Marshal(installSkillRequest{
Slug: "foo/bar/.agents/skills/pr-review",
})
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/skills/install", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp installSkillResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if resp.Registry != "github" {
t.Fatalf("resp.Registry = %q, want github", resp.Registry)
}
}
func TestHandleInstallSkillTracksGitHubURLInstallsAsInstalled(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
cfg, loadErr := config.LoadConfig(configPath)
if loadErr != nil {
t.Fatalf("LoadConfig() error = %v", loadErr)
}
workspace := filepath.Join(t.TempDir(), "workspace")
cfg.Agents.Defaults.Workspace = workspace
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v3/repos/foo/bar":
json.NewEncoder(w).Encode(map[string]any{"default_branch": "master"})
case "/api/v3/repos/foo/bar/contents/.agents/skills/pr-review":
assert.Equal(t, "ref=master", r.URL.RawQuery)
json.NewEncoder(w).Encode([]map[string]any{{
"type": "file",
"name": "SKILL.md",
"download_url": server.URL + "/raw/foo/bar/master/.agents/skills/pr-review/SKILL.md",
}})
case "/api/v3/search/code":
json.NewEncoder(w).Encode(map[string]any{
"items": []map[string]any{{
"path": ".agents/skills/pr-review/SKILL.md",
"score": 10,
"repository": map[string]any{
"full_name": "foo/bar",
"name": "bar",
"description": "PR review skill",
"default_branch": "master",
},
}},
})
case "/raw/foo/bar/master/.agents/skills/pr-review/SKILL.md":
_, _ = w.Write([]byte("---\nname: pr-review\ndescription: PR review skill\n---\n# PR Review\n"))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
setGithubBaseURL(cfg, server.URL)
clawHubRegistry, _ := cfg.Tools.Skills.Registries.Get("clawhub")
clawHubRegistry.Enabled = false
cfg.Tools.Skills.Registries.Set("clawhub", clawHubRegistry)
if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
t.Fatalf("SaveConfig() error = %v", saveErr)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
installBody, err := json.Marshal(installSkillRequest{
Slug: server.URL + "/foo/bar/tree/master/.agents/skills/pr-review",
})
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
installRec := httptest.NewRecorder()
installReq := httptest.NewRequest(http.MethodPost, "/api/skills/install", bytes.NewReader(installBody))
installReq.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(installRec, installReq)
if installRec.Code != http.StatusOK {
t.Fatalf("install status = %d, want %d, body=%s", installRec.Code, http.StatusOK, installRec.Body.String())
}
searchRec := httptest.NewRecorder()
searchReq := httptest.NewRequest(http.MethodGet, "/api/skills/search?q=pr+review&limit=5", nil)
mux.ServeHTTP(searchRec, searchReq)
if searchRec.Code != http.StatusOK {
t.Fatalf("search status = %d, want %d, body=%s", searchRec.Code, http.StatusOK, searchRec.Body.String())
}
var searchResp skillSearchResponse
if err := json.Unmarshal(searchRec.Body.Bytes(), &searchResp); err != nil {
t.Fatalf("Unmarshal(search response) error = %v", err)
}
if len(searchResp.Results) != 1 {
t.Fatalf("search results count = %d, want 1", len(searchResp.Results))
}
if !searchResp.Results[0].Installed || searchResp.Results[0].InstalledName != "pr-review" {
t.Fatalf("search result should be treated as installed after URL install, got %#v", searchResp.Results[0])
}
}
func TestHandleSearchSkillsMarksDirectoryCollisionAsInstalled(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
cfg, loadErr := config.LoadConfig(configPath)
if loadErr != nil {
t.Fatalf("LoadConfig() error = %v", loadErr)
}
workspace := filepath.Join(t.TempDir(), "workspace")
cfg.Agents.Defaults.Workspace = workspace
skillDir := filepath.Join(workspace, "skills", "pr-review")
if err := os.MkdirAll(skillDir, 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
if err := os.WriteFile(
filepath.Join(skillDir, "SKILL.md"),
[]byte("---\nname: pr-review\ndescription: Workspace PR review skill\n---\n# PR Review\n"),
0o644,
); err != nil {
t.Fatalf("WriteFile(SKILL.md) error = %v", err)
}
if err := writeSkillOriginMeta(skillDir, installedSkillOriginMeta{
Version: 1,
OriginKind: "third_party",
Registry: "github",
Slug: "foo/bar/.agents/skills/pr-review",
RegistryURL: "https://github.com/foo/bar/tree/master/.agents/skills/pr-review",
InstalledVersion: "master",
InstalledAt: time.Now().UnixMilli(),
}); err != nil {
t.Fatalf("writeSkillOriginMeta() error = %v", err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/search":
json.NewEncoder(w).Encode(map[string]any{
"results": []map[string]any{{
"slug": "pr-review",
"displayName": "PR Review",
"summary": "ClawHub PR review skill",
"version": "1.2.3",
}},
})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
setClawHubBaseURL(cfg, server.URL)
githubRegistry, _ := cfg.Tools.Skills.Registries.Get("github")
githubRegistry.Enabled = false
cfg.Tools.Skills.Registries.Set("github", githubRegistry)
if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
t.Fatalf("SaveConfig() error = %v", saveErr)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/skills/search?q=pr+review&limit=5", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp skillSearchResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if len(resp.Results) != 1 {
t.Fatalf("results count = %d, want 1", len(resp.Results))
}
if !resp.Results[0].Installed || resp.Results[0].InstalledName != "pr-review" {
t.Fatalf("search result should be treated as installed when directory is occupied, got %#v", resp.Results[0])
}
}
func TestHandleInstallSkillRollsBackOnOriginMetadataWriteFailure(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
@@ -1047,7 +1492,7 @@ func TestHandleInstallSkillRollsBackOnOriginMetadataWriteFailure(t *testing.T) {
}))
defer server.Close()
cfg.Tools.Skills.Registries.ClawHub.BaseURL = server.URL
setClawHubBaseURL(cfg, server.URL)
if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
t.Fatalf("SaveConfig() error = %v", saveErr)
}
@@ -1135,7 +1580,7 @@ func TestHandleInstallSkillSerializesConcurrentRequests(t *testing.T) {
}))
defer server.Close()
cfg.Tools.Skills.Registries.ClawHub.BaseURL = server.URL
setClawHubBaseURL(cfg, server.URL)
if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
t.Fatalf("SaveConfig() error = %v", saveErr)
}
@@ -1248,7 +1693,7 @@ func TestHandleImportSkillWaitsForConcurrentInstall(t *testing.T) {
}))
defer server.Close()
cfg.Tools.Skills.Registries.ClawHub.BaseURL = server.URL
setClawHubBaseURL(cfg, server.URL)
if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
t.Fatalf("SaveConfig() error = %v", saveErr)
}
@@ -1365,7 +1810,7 @@ func TestHandleInstallSkillRejectsInvalidArchive(t *testing.T) {
}))
defer server.Close()
cfg.Tools.Skills.Registries.ClawHub.BaseURL = server.URL
setClawHubBaseURL(cfg, server.URL)
if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil {
t.Fatalf("SaveConfig() error = %v", saveErr)
}