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
This commit is contained in:
SiYue-ZO 2026-03-28 14:10:36 +08:00
parent f1cb7cc8f5
commit 190bba4ca3
5 changed files with 498 additions and 10 deletions

View file

@ -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),

View file

@ -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: <github>")
return fmt.Errorf("exactly 1 argument is required: <github>, <url>, or <domain>")
}
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")
}

View file

@ -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: <github>",
errorMsg: "exactly 1 argument is required: <github>, <url>, or <domain>",
},
{
name: "no registry, too many args",
args: []string{"arg1", "arg2"},
registry: "",
expectError: true,
errorMsg: "exactly 1 argument is required: <github>",
errorMsg: "exactly 1 argument is required: <github>, <url>, or <domain>",
},
{
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))

132
pkg/skills/wellknown.go Normal file
View file

@ -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)
}

View file

@ -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)
}
})
}
}