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:
parent
f1cb7cc8f5
commit
190bba4ca3
5 changed files with 498 additions and 10 deletions
|
|
@ -14,6 +14,7 @@ type deps struct {
|
||||||
workspace string
|
workspace string
|
||||||
installer *skills.SkillInstaller
|
installer *skills.SkillInstaller
|
||||||
skillsLoader *skills.SkillsLoader
|
skillsLoader *skills.SkillsLoader
|
||||||
|
proxy string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSkillsCommand() *cobra.Command {
|
func NewSkillsCommand() *cobra.Command {
|
||||||
|
|
@ -29,6 +30,7 @@ func NewSkillsCommand() *cobra.Command {
|
||||||
}
|
}
|
||||||
|
|
||||||
d.workspace = cfg.WorkspacePath()
|
d.workspace = cfg.WorkspacePath()
|
||||||
|
d.proxy = cfg.Tools.Skills.Github.Proxy
|
||||||
installer, err := skills.NewSkillInstaller(
|
installer, err := skills.NewSkillInstaller(
|
||||||
d.workspace,
|
d.workspace,
|
||||||
cfg.Tools.Skills.Github.Token.String(),
|
cfg.Tools.Skills.Github.Token.String(),
|
||||||
|
|
@ -75,7 +77,7 @@ func NewSkillsCommand() *cobra.Command {
|
||||||
|
|
||||||
cmd.AddCommand(
|
cmd.AddCommand(
|
||||||
newListCommand(loaderFn),
|
newListCommand(loaderFn),
|
||||||
newInstallCommand(installerFn),
|
newInstallCommand(installerFn, d.proxy),
|
||||||
newInstallBuiltinCommand(workspaceFn),
|
newInstallBuiltinCommand(workspaceFn),
|
||||||
newListBuiltinCommand(),
|
newListBuiltinCommand(),
|
||||||
newRemoveCommand(installerFn),
|
newRemoveCommand(installerFn),
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
package skills
|
package skills
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
|
@ -9,15 +13,16 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/skills"
|
"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
|
var registry string
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "install",
|
Use: "install",
|
||||||
Short: "Install skill from GitHub",
|
Short: "Install skill from GitHub, URL, or domain with .well-known support",
|
||||||
Example: `
|
Example: `
|
||||||
picoclaw skills install sipeed/picoclaw-skills/weather
|
picoclaw skills install sipeed/picoclaw-skills/weather
|
||||||
picoclaw skills install --registry clawhub github
|
picoclaw skills install --registry clawhub github
|
||||||
|
picoclaw skills install https://example.com
|
||||||
`,
|
`,
|
||||||
Args: func(cmd *cobra.Command, args []string) error {
|
Args: func(cmd *cobra.Command, args []string) error {
|
||||||
if registry != "" {
|
if registry != "" {
|
||||||
|
|
@ -28,7 +33,7 @@ picoclaw skills install --registry clawhub github
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(args) != 1 {
|
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
|
return nil
|
||||||
|
|
@ -48,7 +53,7 @@ picoclaw skills install --registry clawhub github
|
||||||
return skillsInstallFromRegistry(cfg, registry, args[0])
|
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
|
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")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,12 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewInstallSubcommand(t *testing.T) {
|
func TestNewInstallSubcommand(t *testing.T) {
|
||||||
cmd := newInstallCommand(nil)
|
cmd := newInstallCommand(nil, "")
|
||||||
|
|
||||||
require.NotNil(t, cmd)
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
assert.Equal(t, "install", cmd.Use)
|
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.Nil(t, cmd.Run)
|
||||||
assert.NotNil(t, cmd.RunE)
|
assert.NotNil(t, cmd.RunE)
|
||||||
|
|
@ -46,14 +46,14 @@ func TestInstallCommandArgs(t *testing.T) {
|
||||||
args: []string{},
|
args: []string{},
|
||||||
registry: "",
|
registry: "",
|
||||||
expectError: true,
|
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",
|
name: "no registry, too many args",
|
||||||
args: []string{"arg1", "arg2"},
|
args: []string{"arg1", "arg2"},
|
||||||
registry: "",
|
registry: "",
|
||||||
expectError: true,
|
expectError: true,
|
||||||
errorMsg: "exactly 1 argument is required: <github>",
|
errorMsg: "exactly 1 argument is required: <github>, <url>, or <domain>",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "with registry, one arg",
|
name: "with registry, one arg",
|
||||||
|
|
@ -79,7 +79,7 @@ func TestInstallCommandArgs(t *testing.T) {
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
cmd := newInstallCommand(nil)
|
cmd := newInstallCommand(nil, "")
|
||||||
|
|
||||||
if tt.registry != "" {
|
if tt.registry != "" {
|
||||||
require.NoError(t, cmd.Flags().Set("registry", tt.registry))
|
require.NoError(t, cmd.Flags().Set("registry", tt.registry))
|
||||||
|
|
|
||||||
132
pkg/skills/wellknown.go
Normal file
132
pkg/skills/wellknown.go
Normal 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)
|
||||||
|
}
|
||||||
264
pkg/skills/wellknown_test.go
Normal file
264
pkg/skills/wellknown_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue