mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
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:
+42
-14
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user