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