From 190bba4ca3f1f765a350c2c86338e850e20c5381 Mon Sep 17 00:00:00 2001 From: SiYue-ZO <2835601846@qq.com> Date: Sat, 28 Mar 2026 14:10:36 +0800 Subject: [PATCH] feat(skills): add .well-known skill installation support - Add resolveSkillSource function for URL resolution - Support domain -> .well-known/agent-skills/index.json auto-resolution - Support index.json parsing and batch skill installation - Add error handling for 404, invalid JSON, and empty skills - Improve CLI output for skill installation progress - Add comprehensive tests for well-known resolution --- cmd/picoclaw/internal/skills/command.go | 4 +- cmd/picoclaw/internal/skills/install.go | 98 ++++++- cmd/picoclaw/internal/skills/install_test.go | 10 +- pkg/skills/wellknown.go | 132 ++++++++++ pkg/skills/wellknown_test.go | 264 +++++++++++++++++++ 5 files changed, 498 insertions(+), 10 deletions(-) create mode 100644 pkg/skills/wellknown.go create mode 100644 pkg/skills/wellknown_test.go diff --git a/cmd/picoclaw/internal/skills/command.go b/cmd/picoclaw/internal/skills/command.go index e8b884977..578925982 100644 --- a/cmd/picoclaw/internal/skills/command.go +++ b/cmd/picoclaw/internal/skills/command.go @@ -14,6 +14,7 @@ type deps struct { workspace string installer *skills.SkillInstaller skillsLoader *skills.SkillsLoader + proxy string } func NewSkillsCommand() *cobra.Command { @@ -29,6 +30,7 @@ func NewSkillsCommand() *cobra.Command { } d.workspace = cfg.WorkspacePath() + d.proxy = cfg.Tools.Skills.Github.Proxy installer, err := skills.NewSkillInstaller( d.workspace, cfg.Tools.Skills.Github.Token.String(), @@ -75,7 +77,7 @@ func NewSkillsCommand() *cobra.Command { cmd.AddCommand( newListCommand(loaderFn), - newInstallCommand(installerFn), + newInstallCommand(installerFn, d.proxy), newInstallBuiltinCommand(workspaceFn), newListBuiltinCommand(), newRemoveCommand(installerFn), diff --git a/cmd/picoclaw/internal/skills/install.go b/cmd/picoclaw/internal/skills/install.go index 78bc421db..cc435edec 100644 --- a/cmd/picoclaw/internal/skills/install.go +++ b/cmd/picoclaw/internal/skills/install.go @@ -1,7 +1,11 @@ package skills import ( + "context" "fmt" + "net/http" + "strings" + "time" "github.com/spf13/cobra" @@ -9,15 +13,16 @@ import ( "github.com/sipeed/picoclaw/pkg/skills" ) -func newInstallCommand(installerFn func() (*skills.SkillInstaller, error)) *cobra.Command { +func newInstallCommand(installerFn func() (*skills.SkillInstaller, error), proxy string) *cobra.Command { var registry string cmd := &cobra.Command{ Use: "install", - Short: "Install skill from GitHub", + Short: "Install skill from GitHub, URL, or domain with .well-known support", Example: ` picoclaw skills install sipeed/picoclaw-skills/weather picoclaw skills install --registry clawhub github +picoclaw skills install https://example.com `, Args: func(cmd *cobra.Command, args []string) error { if registry != "" { @@ -28,7 +33,7 @@ picoclaw skills install --registry clawhub github } if len(args) != 1 { - return fmt.Errorf("exactly 1 argument is required: ") + return fmt.Errorf("exactly 1 argument is required: , , or ") } return nil @@ -48,7 +53,7 @@ picoclaw skills install --registry clawhub github return skillsInstallFromRegistry(cfg, registry, args[0]) } - return skillsInstallCmd(installer, args[0]) + return skillsInstallCmdWithProxy(installer, args[0], proxy) }, } @@ -56,3 +61,88 @@ picoclaw skills install --registry clawhub github return cmd } + +func skillsInstallCmdWithProxy(installer *skills.SkillInstaller, input string, proxy string) error { + client, err := skills.CreateHTTPClient(proxy, 30*time.Second) + if err != nil { + return fmt.Errorf("failed to create HTTP client: %w", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + resolved, err := skills.ResolveSkillSource(ctx, client, input) + if err != nil { + return fmt.Errorf("failed to resolve skill source: %w", err) + } + + switch resolved.Type { + case "well_known": + return installFromWellKnown(ctx, installer, client, resolved) + case "github": + return skillsInstallCmd(installer, input) + case "json_url": + return installFromJSONURL(ctx, installer, client, resolved.URL) + default: + return skillsInstallCmd(installer, input) + } +} + +func installFromWellKnown(ctx context.Context, installer *skills.SkillInstaller, client *http.Client, resolved *skills.ResolvedSource) error { + index := resolved.SkillIndex + fmt.Printf("Resolving skills from %s...\n", resolved.URL) + fmt.Printf("Found %d skills:\n", len(index.Skills)) + + for _, skill := range index.Skills { + fmt.Printf(" - %s\n", skill.Name) + } + + fmt.Println("\nInstalling...") + + var installed []string + var failed []string + + for _, skill := range index.Skills { + fmt.Printf("Installing skill '%s' from %s...\n", skill.Name, skill.URL) + + if strings.HasPrefix(skill.URL, "http://") || strings.HasPrefix(skill.URL, "https://") { + if strings.Contains(skill.URL, "github.com") { + if err := installer.InstallFromGitHub(ctx, skill.URL); err != nil { + fmt.Printf(" ✗ Failed to install '%s': %v\n", skill.Name, err) + failed = append(failed, skill.Name) + continue + } + } else { + if err := installFromJSONURL(ctx, installer, client, skill.URL); err != nil { + fmt.Printf(" ✗ Failed to install '%s': %v\n", skill.Name, err) + failed = append(failed, skill.Name) + continue + } + } + } else { + fmt.Printf(" ✗ Invalid URL for skill '%s': %s\n", skill.Name, skill.URL) + failed = append(failed, skill.Name) + continue + } + + fmt.Printf(" ✓ Skill '%s' installed successfully!\n", skill.Name) + installed = append(installed, skill.Name) + } + + fmt.Printf("\nInstallation complete: %d succeeded, %d failed\n", len(installed), len(failed)) + if len(failed) > 0 { + fmt.Printf("Failed skills: %s\n", strings.Join(failed, ", ")) + } + + return nil +} + +func installFromJSONURL(ctx context.Context, installer *skills.SkillInstaller, client *http.Client, url string) error { + fmt.Printf("Installing skill from %s...\n", url) + + if strings.Contains(url, "github.com") { + return installer.InstallFromGitHub(ctx, url) + } + + return fmt.Errorf("direct JSON URL installation not yet supported for non-GitHub URLs") +} diff --git a/cmd/picoclaw/internal/skills/install_test.go b/cmd/picoclaw/internal/skills/install_test.go index 6b362822d..1752dfcb0 100644 --- a/cmd/picoclaw/internal/skills/install_test.go +++ b/cmd/picoclaw/internal/skills/install_test.go @@ -8,12 +8,12 @@ import ( ) func TestNewInstallSubcommand(t *testing.T) { - cmd := newInstallCommand(nil) + cmd := newInstallCommand(nil, "") require.NotNil(t, cmd) assert.Equal(t, "install", cmd.Use) - assert.Equal(t, "Install skill from GitHub", cmd.Short) + assert.Equal(t, "Install skill from GitHub, URL, or domain with .well-known support", cmd.Short) assert.Nil(t, cmd.Run) assert.NotNil(t, cmd.RunE) @@ -46,14 +46,14 @@ func TestInstallCommandArgs(t *testing.T) { args: []string{}, registry: "", expectError: true, - errorMsg: "exactly 1 argument is required: ", + errorMsg: "exactly 1 argument is required: , , or ", }, { name: "no registry, too many args", args: []string{"arg1", "arg2"}, registry: "", expectError: true, - errorMsg: "exactly 1 argument is required: ", + errorMsg: "exactly 1 argument is required: , , or ", }, { name: "with registry, one arg", @@ -79,7 +79,7 @@ func TestInstallCommandArgs(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - cmd := newInstallCommand(nil) + cmd := newInstallCommand(nil, "") if tt.registry != "" { require.NoError(t, cmd.Flags().Set("registry", tt.registry)) diff --git a/pkg/skills/wellknown.go b/pkg/skills/wellknown.go new file mode 100644 index 000000000..79376d6fc --- /dev/null +++ b/pkg/skills/wellknown.go @@ -0,0 +1,132 @@ +package skills + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/utils" +) + +const wellKnownPath = "/.well-known/agent-skills/index.json" + +type SkillIndex struct { + Skills []SkillIndexEntry `json:"skills"` +} + +type SkillIndexEntry struct { + Name string `json:"name"` + URL string `json:"url"` +} + +type ResolvedSource struct { + Type string + URL string + SkillIndex *SkillIndex +} + +func ResolveSkillSource(ctx context.Context, client *http.Client, input string) (*ResolvedSource, error) { + input = strings.TrimSpace(input) + + if isLocalPath(input) { + return &ResolvedSource{Type: "local", URL: input}, nil + } + + if isJSONURL(input) { + return &ResolvedSource{Type: "json_url", URL: input}, nil + } + + if isGitHubShorthand(input) { + return &ResolvedSource{Type: "github", URL: input}, nil + } + + if isDomain(input) { + indexURL := buildWellKnownURL(input) + skillIndex, err := fetchSkillIndex(ctx, client, indexURL) + if err != nil { + return nil, fmt.Errorf("failed to fetch skill index from %s: %w", indexURL, err) + } + return &ResolvedSource{ + Type: "well_known", + URL: indexURL, + SkillIndex: skillIndex, + }, nil + } + + return nil, fmt.Errorf("invalid skill source: %s", input) +} + +func isLocalPath(input string) bool { + return strings.HasPrefix(input, "/") || + strings.HasPrefix(input, "./") || + strings.HasPrefix(input, "../") || + strings.HasPrefix(input, "~") +} + +func isJSONURL(input string) bool { + if !strings.HasPrefix(input, "http://") && !strings.HasPrefix(input, "https://") { + return false + } + return strings.HasSuffix(strings.ToLower(input), ".json") +} + +func isGitHubShorthand(input string) bool { + if strings.HasPrefix(input, "http://") || strings.HasPrefix(input, "https://") { + return false + } + if strings.Contains(input, "/") && !strings.Contains(input, "://") { + parts := strings.Split(input, "/") + return len(parts) >= 2 + } + return false +} + +func isDomain(input string) bool { + return strings.HasPrefix(input, "http://") || strings.HasPrefix(input, "https://") +} + +func buildWellKnownURL(domain string) string { + domain = strings.TrimSuffix(domain, "/") + return domain + wellKnownPath +} + +func fetchSkillIndex(ctx context.Context, client *http.Client, url string) (*SkillIndex, error) { + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + resp, err := utils.DoRequestWithRetry(client, req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("skill index not found (404)") + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP error: %d", resp.StatusCode) + } + + var index SkillIndex + if err := json.NewDecoder(resp.Body).Decode(&index); err != nil { + return nil, fmt.Errorf("invalid JSON format: %w", err) + } + + if len(index.Skills) == 0 { + return nil, fmt.Errorf("no skills found in index") + } + + return &index, nil +} + +func CreateHTTPClient(proxy string, timeout time.Duration) (*http.Client, error) { + return utils.CreateHTTPClient(proxy, timeout) +} diff --git a/pkg/skills/wellknown_test.go b/pkg/skills/wellknown_test.go new file mode 100644 index 000000000..44159ca22 --- /dev/null +++ b/pkg/skills/wellknown_test.go @@ -0,0 +1,264 @@ +package skills + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestResolveSkillSource_LocalPath(t *testing.T) { + client := &http.Client{Timeout: 5 * time.Second} + ctx := context.Background() + + tests := []struct { + input string + wantType string + }{ + {"/path/to/skill", "local"}, + {"./relative/path", "local"}, + {"../parent/path", "local"}, + {"~/home/path", "local"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + resolved, err := ResolveSkillSource(ctx, client, tt.input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resolved.Type != tt.wantType { + t.Errorf("expected type %s, got %s", tt.wantType, resolved.Type) + } + }) + } +} + +func TestResolveSkillSource_JSONURL(t *testing.T) { + client := &http.Client{Timeout: 5 * time.Second} + ctx := context.Background() + + tests := []struct { + input string + wantType string + }{ + {"https://example.com/skill.json", "json_url"}, + {"http://example.com/path/to/skill.JSON", "json_url"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + resolved, err := ResolveSkillSource(ctx, client, tt.input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resolved.Type != tt.wantType { + t.Errorf("expected type %s, got %s", tt.wantType, resolved.Type) + } + }) + } +} + +func TestResolveSkillSource_GitHubShorthand(t *testing.T) { + client := &http.Client{Timeout: 5 * time.Second} + ctx := context.Background() + + tests := []struct { + input string + wantType string + }{ + {"owner/repo", "github"}, + {"owner/repo/path/to/skill", "github"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + resolved, err := ResolveSkillSource(ctx, client, tt.input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resolved.Type != tt.wantType { + t.Errorf("expected type %s, got %s", tt.wantType, resolved.Type) + } + }) + } +} + +func TestResolveSkillSource_WellKnown(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/.well-known/agent-skills/index.json" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{ + "skills": [ + {"name": "weather", "url": "https://example.com/weather.json"}, + {"name": "search", "url": "https://github.com/owner/search-skill"} + ] + }`)) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + client := server.Client() + ctx := context.Background() + + resolved, err := ResolveSkillSource(ctx, client, server.URL) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if resolved.Type != "well_known" { + t.Errorf("expected type well_known, got %s", resolved.Type) + } + + if resolved.SkillIndex == nil { + t.Fatal("expected SkillIndex to be non-nil") + } + + if len(resolved.SkillIndex.Skills) != 2 { + t.Errorf("expected 2 skills, got %d", len(resolved.SkillIndex.Skills)) + } + + if resolved.SkillIndex.Skills[0].Name != "weather" { + t.Errorf("expected first skill name 'weather', got %s", resolved.SkillIndex.Skills[0].Name) + } +} + +func TestResolveSkillSource_WellKnown_404(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + client := server.Client() + ctx := context.Background() + + _, err := ResolveSkillSource(ctx, client, server.URL) + if err == nil { + t.Fatal("expected error for 404 response") + } +} + +func TestResolveSkillSource_WellKnown_InvalidJSON(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`invalid json`)) + })) + defer server.Close() + + client := server.Client() + ctx := context.Background() + + _, err := ResolveSkillSource(ctx, client, server.URL) + if err == nil { + t.Fatal("expected error for invalid JSON") + } +} + +func TestResolveSkillSource_WellKnown_EmptySkills(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"skills": []}`)) + })) + defer server.Close() + + client := server.Client() + ctx := context.Background() + + _, err := ResolveSkillSource(ctx, client, server.URL) + if err == nil { + t.Fatal("expected error for empty skills") + } +} + +func TestBuildWellKnownURL(t *testing.T) { + tests := []struct { + domain string + expected string + }{ + {"https://example.com", "https://example.com/.well-known/agent-skills/index.json"}, + {"https://example.com/", "https://example.com/.well-known/agent-skills/index.json"}, + {"http://test.org", "http://test.org/.well-known/agent-skills/index.json"}, + } + + for _, tt := range tests { + t.Run(tt.domain, func(t *testing.T) { + result := buildWellKnownURL(tt.domain) + if result != tt.expected { + t.Errorf("expected %s, got %s", tt.expected, result) + } + }) + } +} + +func TestIsLocalPath(t *testing.T) { + tests := []struct { + input string + expected bool + }{ + {"/absolute/path", true}, + {"./relative/path", true}, + {"../parent/path", true}, + {"~/home/path", true}, + {"https://example.com", false}, + {"owner/repo", false}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := isLocalPath(tt.input) + if result != tt.expected { + t.Errorf("expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestIsJSONURL(t *testing.T) { + tests := []struct { + input string + expected bool + }{ + {"https://example.com/skill.json", true}, + {"http://example.com/skill.JSON", true}, + {"https://example.com/skill", false}, + {"owner/repo.json", false}, + {"/local/path.json", false}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := isJSONURL(tt.input) + if result != tt.expected { + t.Errorf("expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestIsGitHubShorthand(t *testing.T) { + tests := []struct { + input string + expected bool + }{ + {"owner/repo", true}, + {"owner/repo/path", true}, + {"https://github.com/owner/repo", false}, + {"single", false}, + {"", false}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := isGitHubShorthand(tt.input) + if result != tt.expected { + t.Errorf("expected %v, got %v", tt.expected, result) + } + }) + } +}