feat(skills): support installing skills from Git repositories
- Add support for SSH and HTTPS Git URLs (e.g., git@gitlab.com:user/repo.git) - Scan repository subdirectories to discover multiple skills - Interactive terminal UI for skill selection: - Arrow keys to navigate - Space to toggle selection - 'a' to toggle all - Enter to confirm - q/ESC to quit - Display skill description from SKILL.md 'description:' field - Orange color for selected items, cyan for current cursor - Add fallback text mode for non-interactive terminals
This commit is contained in:
parent
651cb2ebda
commit
2ff84f5bed
4 changed files with 677 additions and 3 deletions
|
|
@ -9,6 +9,8 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/term"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/skills"
|
"github.com/sipeed/picoclaw/pkg/skills"
|
||||||
|
|
@ -45,11 +47,262 @@ func skillsInstallCmd(installer *skills.SkillInstaller, repo string) error {
|
||||||
return fmt.Errorf("failed to install skill: %w", err)
|
return fmt.Errorf("failed to install skill: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("\u2713 Skill '%s' installed successfully!\n", filepath.Base(repo))
|
fmt.Printf("✓ Skill '%s' installed successfully!\n", filepath.Base(repo))
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// skillsInstallFromGitCmd installs a skill from a Git repository URL.
|
||||||
|
func skillsInstallFromGitCmd(installer *skills.SkillInstaller, gitURL string) error {
|
||||||
|
fmt.Printf("Cloning repository: %s...\n", gitURL)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
tempDir, discoveredSkills, err := installer.CloneAndDiscoverSkills(ctx, gitURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to clone and discover skills: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(discoveredSkills) == 0 {
|
||||||
|
_ = os.RemoveAll(tempDir)
|
||||||
|
return fmt.Errorf("no skills found in repository")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Found %d skill(s)\n\n", len(discoveredSkills))
|
||||||
|
|
||||||
|
// Interactive selection.
|
||||||
|
selectedSkills, err := interactiveSkillSelect(discoveredSkills)
|
||||||
|
if err != nil {
|
||||||
|
_ = os.RemoveAll(tempDir)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(selectedSkills) == 0 {
|
||||||
|
_ = os.RemoveAll(tempDir)
|
||||||
|
fmt.Println("No skills selected. Installation cancelled.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install selected skills.
|
||||||
|
fmt.Printf("\nInstalling %d skill(s)...\n", len(selectedSkills))
|
||||||
|
installed, err := installer.InstallSelectedSkills(tempDir, selectedSkills)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
for _, name := range installed {
|
||||||
|
fmt.Printf("✓ Skill '%s' installed successfully!\n", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// interactiveSkillSelect provides an interactive terminal UI for selecting skills.
|
||||||
|
// Press Space to toggle selection, 'a' to toggle all, Enter to confirm.
|
||||||
|
func interactiveSkillSelect(discovered []skills.DiscoveredSkill) ([]skills.DiscoveredSkill, error) {
|
||||||
|
if len(discovered) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If only one skill, ask for simple confirmation.
|
||||||
|
if len(discovered) == 1 {
|
||||||
|
fmt.Print("Install this skill? [Y/n]: ")
|
||||||
|
var input string
|
||||||
|
fmt.Scanln(&input)
|
||||||
|
input = strings.TrimSpace(strings.ToLower(input))
|
||||||
|
if input == "" || input == "y" || input == "yes" {
|
||||||
|
return discovered, nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
selected := make([]bool, len(discovered))
|
||||||
|
cursor := 0
|
||||||
|
|
||||||
|
// Save terminal state and enable raw mode.
|
||||||
|
oldState, err := term.MakeRaw(int(os.Stdin.Fd()))
|
||||||
|
if err != nil {
|
||||||
|
// Fallback to simple input mode.
|
||||||
|
return fallbackSkillSelect(discovered)
|
||||||
|
}
|
||||||
|
defer term.Restore(int(os.Stdin.Fd()), oldState)
|
||||||
|
|
||||||
|
// Hide cursor.
|
||||||
|
fmt.Print("\033[?25l")
|
||||||
|
defer fmt.Print("\033[?25h")
|
||||||
|
|
||||||
|
// ANSI color codes.
|
||||||
|
const (
|
||||||
|
colorReset = "\033[0m"
|
||||||
|
colorOrange = "\033[38;5;208m" // Mantis shrimp orange for selected items.
|
||||||
|
colorCyan = "\033[36m" // Cyan for cursor highlight.
|
||||||
|
colorDim = "\033[2m" // Dim for description.
|
||||||
|
)
|
||||||
|
|
||||||
|
// Total lines: header(1) + list(len).
|
||||||
|
totalLines := len(discovered) + 2
|
||||||
|
|
||||||
|
// Render function.
|
||||||
|
render := func(initial bool) {
|
||||||
|
if !initial {
|
||||||
|
// Move cursor up to beginning.
|
||||||
|
fmt.Printf("\033[%dA\r", totalLines)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header with instructions.
|
||||||
|
fmt.Print("\033[2K") // Clear line.
|
||||||
|
fmt.Print("Select skills to install:\r\n")
|
||||||
|
fmt.Print("\033[2K") // Clear line.
|
||||||
|
fmt.Printf("%s↑/↓ move space toggle a toggle all enter confirm q/esc quit%s\r\n", colorDim, colorReset)
|
||||||
|
|
||||||
|
// List items.
|
||||||
|
for i, skill := range discovered {
|
||||||
|
fmt.Print("\033[2K") // Clear line.
|
||||||
|
|
||||||
|
checkbox := "◻"
|
||||||
|
if selected[i] {
|
||||||
|
checkbox = "◼"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the line content.
|
||||||
|
var line string
|
||||||
|
if i == cursor {
|
||||||
|
// Current cursor - show description after name.
|
||||||
|
desc := skill.Description
|
||||||
|
if desc == "" {
|
||||||
|
desc = "no description"
|
||||||
|
}
|
||||||
|
line = fmt.Sprintf("│ %s%s %s%s %s(%s)%s", colorCyan, checkbox, skill.Name, colorReset, colorDim, desc, colorReset)
|
||||||
|
} else if selected[i] {
|
||||||
|
// Selected item - orange color.
|
||||||
|
line = fmt.Sprintf("│ %s%s %s%s", colorOrange, checkbox, skill.Name, colorReset)
|
||||||
|
} else {
|
||||||
|
// Normal item.
|
||||||
|
line = fmt.Sprintf("│ %s %s", checkbox, skill.Name)
|
||||||
|
}
|
||||||
|
fmt.Print(line + "\r\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial render.
|
||||||
|
render(true)
|
||||||
|
|
||||||
|
buf := make([]byte, 3)
|
||||||
|
for {
|
||||||
|
// Read first byte.
|
||||||
|
n, err := os.Stdin.Read(buf[:1])
|
||||||
|
if err != nil || n == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
switch buf[0] {
|
||||||
|
case ' ': // Space - toggle current.
|
||||||
|
selected[cursor] = !selected[cursor]
|
||||||
|
render(false)
|
||||||
|
|
||||||
|
case 'a', 'A': // Toggle all.
|
||||||
|
// Check if all are selected.
|
||||||
|
allSelected := true
|
||||||
|
for _, s := range selected {
|
||||||
|
if !s {
|
||||||
|
allSelected = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Toggle.
|
||||||
|
for i := range selected {
|
||||||
|
selected[i] = !allSelected
|
||||||
|
}
|
||||||
|
render(false)
|
||||||
|
|
||||||
|
case 13, 10: // Enter - confirm.
|
||||||
|
fmt.Print("\r\n")
|
||||||
|
var result []skills.DiscoveredSkill
|
||||||
|
for i, sel := range selected {
|
||||||
|
if sel {
|
||||||
|
result = append(result, discovered[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
|
||||||
|
case 'q', 'Q': // q - cancel.
|
||||||
|
fmt.Print("\r\n")
|
||||||
|
return nil, nil
|
||||||
|
|
||||||
|
case 27: // Escape - could be standalone or start of arrow key sequence.
|
||||||
|
// Read remaining bytes of escape sequence.
|
||||||
|
os.Stdin.Read(buf[1:3])
|
||||||
|
if buf[1] == '[' {
|
||||||
|
// Arrow keys.
|
||||||
|
switch buf[2] {
|
||||||
|
case 'A': // Up.
|
||||||
|
if cursor > 0 {
|
||||||
|
cursor--
|
||||||
|
render(false)
|
||||||
|
}
|
||||||
|
case 'B': // Down.
|
||||||
|
if cursor < len(discovered)-1 {
|
||||||
|
cursor++
|
||||||
|
render(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Standalone Escape - quit.
|
||||||
|
fmt.Print("\r\n")
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'j': // vim-style down.
|
||||||
|
if cursor < len(discovered)-1 {
|
||||||
|
cursor++
|
||||||
|
render(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'k': // vim-style up.
|
||||||
|
if cursor > 0 {
|
||||||
|
cursor--
|
||||||
|
render(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallbackSkillSelect provides a simple text-based fallback for skill selection.
|
||||||
|
func fallbackSkillSelect(discovered []skills.DiscoveredSkill) ([]skills.DiscoveredSkill, error) {
|
||||||
|
fmt.Println("\nEnter skill numbers to install (comma-separated), 'a' for all, or 'q' to cancel:")
|
||||||
|
fmt.Print("> ")
|
||||||
|
|
||||||
|
var input string
|
||||||
|
fmt.Scanln(&input)
|
||||||
|
input = strings.TrimSpace(strings.ToLower(input))
|
||||||
|
|
||||||
|
if input == "" || input == "q" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if input == "a" || input == "all" {
|
||||||
|
return discovered, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []skills.DiscoveredSkill
|
||||||
|
parts := strings.Split(input, ",")
|
||||||
|
for _, part := range parts {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
var idx int
|
||||||
|
if _, err := fmt.Sscanf(part, "%d", &idx); err == nil {
|
||||||
|
if idx >= 1 && idx <= len(discovered) {
|
||||||
|
result = append(result, discovered[idx-1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
// skillsInstallFromRegistry installs a skill from a named registry (e.g. clawhub).
|
// skillsInstallFromRegistry installs a skill from a named registry (e.g. clawhub).
|
||||||
func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) error {
|
func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) error {
|
||||||
err := utils.ValidateSkillIdentifier(registryName)
|
err := utils.ValidateSkillIdentifier(registryName)
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,11 @@ func newInstallCommand(installerFn func() (*skills.SkillInstaller, error)) *cobr
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "install",
|
Use: "install",
|
||||||
Short: "Install skill from GitHub",
|
Short: "Install skill from GitHub or Git repository",
|
||||||
Example: `
|
Example: `
|
||||||
picoclaw skills install sipeed/picoclaw-skills/weather
|
picoclaw skills install sipeed/picoclaw-skills/weather
|
||||||
|
picoclaw skills install git@gitlab.com:user/my-skill.git
|
||||||
|
picoclaw skills install https://gitlab.com/user/my-skill.git
|
||||||
picoclaw skills install --registry clawhub github
|
picoclaw skills install --registry clawhub github
|
||||||
`,
|
`,
|
||||||
Args: func(cmd *cobra.Command, args []string) error {
|
Args: func(cmd *cobra.Command, args []string) error {
|
||||||
|
|
@ -28,7 +30,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-repo> or <git-url>")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -48,6 +50,11 @@ picoclaw skills install --registry clawhub github
|
||||||
return skillsInstallFromRegistry(cfg, registry, args[0])
|
return skillsInstallFromRegistry(cfg, registry, args[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if input is a Git URL.
|
||||||
|
if skills.IsGitURL(args[0]) {
|
||||||
|
return skillsInstallFromGitCmd(installer, args[0])
|
||||||
|
}
|
||||||
|
|
||||||
return skillsInstallCmd(installer, args[0])
|
return skillsInstallCmd(installer, args[0])
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,10 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/fileutil"
|
"github.com/sipeed/picoclaw/pkg/fileutil"
|
||||||
|
|
@ -67,6 +70,271 @@ func (si *SkillInstaller) InstallFromGitHub(ctx context.Context, repo string) er
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InstallFromGit clones a Git repository to install a skill.
|
||||||
|
// Supports SSH URLs (git@host:user/repo.git) and HTTPS URLs (https://host/user/repo.git).
|
||||||
|
func (si *SkillInstaller) InstallFromGit(ctx context.Context, gitURL string) error {
|
||||||
|
skillName := extractSkillNameFromGitURL(gitURL)
|
||||||
|
if skillName == "" {
|
||||||
|
return fmt.Errorf("failed to extract skill name from git URL: %s", gitURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
skillDir := filepath.Join(si.workspace, "skills", skillName)
|
||||||
|
|
||||||
|
if _, err := os.Stat(skillDir); err == nil {
|
||||||
|
return fmt.Errorf("skill '%s' already exists", skillName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure parent directory exists.
|
||||||
|
skillsDir := filepath.Join(si.workspace, "skills")
|
||||||
|
if err := os.MkdirAll(skillsDir, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create skills directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute git clone.
|
||||||
|
cmd := exec.CommandContext(ctx, "git", "clone", "--depth=1", gitURL, skillDir)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to clone repository: %w\nOutput: %s", err, string(output))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify SKILL.md exists.
|
||||||
|
skillPath := filepath.Join(skillDir, "SKILL.md")
|
||||||
|
if _, err := os.Stat(skillPath); os.IsNotExist(err) {
|
||||||
|
// Clean up if SKILL.md doesn't exist.
|
||||||
|
_ = os.RemoveAll(skillDir)
|
||||||
|
return fmt.Errorf("repository does not contain SKILL.md file")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove .git directory to save space.
|
||||||
|
gitDir := filepath.Join(skillDir, ".git")
|
||||||
|
_ = os.RemoveAll(gitDir)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiscoveredSkill represents a skill found in a Git repository.
|
||||||
|
type DiscoveredSkill struct {
|
||||||
|
Name string // Directory name (skill identifier)
|
||||||
|
Path string // Full path in the cloned repository
|
||||||
|
Description string // First line of SKILL.md (if available)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloneAndDiscoverSkills clones a Git repository and discovers all skills in it.
|
||||||
|
// Skills are expected to be in subdirectories of the repository root, each containing a SKILL.md file.
|
||||||
|
// Returns the temporary directory path and the list of discovered skills.
|
||||||
|
func (si *SkillInstaller) CloneAndDiscoverSkills(ctx context.Context, gitURL string) (string, []DiscoveredSkill, error) {
|
||||||
|
// Create temporary directory for cloning.
|
||||||
|
tempDir, err := os.MkdirTemp("", "picoclaw-skills-*")
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, fmt.Errorf("failed to create temp directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone the repository.
|
||||||
|
cmd := exec.CommandContext(ctx, "git", "clone", "--depth=1", gitURL, tempDir)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
_ = os.RemoveAll(tempDir)
|
||||||
|
return "", nil, fmt.Errorf("failed to clone repository: %w\nOutput: %s", err, string(output))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discover skills in the repository.
|
||||||
|
skills, err := si.discoverSkillsInDir(tempDir)
|
||||||
|
if err != nil {
|
||||||
|
_ = os.RemoveAll(tempDir)
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(skills) == 0 {
|
||||||
|
_ = os.RemoveAll(tempDir)
|
||||||
|
return "", nil, fmt.Errorf("no skills found in repository (no subdirectories with SKILL.md)")
|
||||||
|
}
|
||||||
|
|
||||||
|
return tempDir, skills, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// discoverSkillsInDir scans a directory for skills (subdirectories containing SKILL.md).
|
||||||
|
func (si *SkillInstaller) discoverSkillsInDir(dir string) ([]DiscoveredSkill, error) {
|
||||||
|
var skills []DiscoveredSkill
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip hidden directories.
|
||||||
|
if strings.HasPrefix(entry.Name(), ".") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
skillDir := filepath.Join(dir, entry.Name())
|
||||||
|
skillFile := filepath.Join(skillDir, "SKILL.md")
|
||||||
|
|
||||||
|
if _, err := os.Stat(skillFile); err == nil {
|
||||||
|
skill := DiscoveredSkill{
|
||||||
|
Name: entry.Name(),
|
||||||
|
Path: skillDir,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to extract description from SKILL.md.
|
||||||
|
if content, err := os.ReadFile(skillFile); err == nil {
|
||||||
|
skill.Description = extractFirstLine(string(content))
|
||||||
|
}
|
||||||
|
|
||||||
|
skills = append(skills, skill)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return skills, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractFirstLine extracts the description from SKILL.md content.
|
||||||
|
func extractFirstLine(content string) string {
|
||||||
|
lines := strings.Split(content, "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
// Look for "description:" field.
|
||||||
|
if strings.HasPrefix(strings.ToLower(line), "description:") {
|
||||||
|
desc := strings.TrimSpace(line[len("description:"):])
|
||||||
|
// Remove surrounding quotes if present.
|
||||||
|
desc = strings.Trim(desc, `"'`)
|
||||||
|
if len(desc) > 100 {
|
||||||
|
return desc[:100] + "..."
|
||||||
|
}
|
||||||
|
return desc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstallSelectedSkills installs the selected skills from a cloned repository.
|
||||||
|
// It copies the skill directories to the workspace and cleans up the temp directory.
|
||||||
|
func (si *SkillInstaller) InstallSelectedSkills(tempDir string, skills []DiscoveredSkill) ([]string, error) {
|
||||||
|
skillsDir := filepath.Join(si.workspace, "skills")
|
||||||
|
if err := os.MkdirAll(skillsDir, 0o755); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create skills directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var installed []string
|
||||||
|
var errors []string
|
||||||
|
|
||||||
|
for _, skill := range skills {
|
||||||
|
targetDir := filepath.Join(skillsDir, skill.Name)
|
||||||
|
|
||||||
|
// Check if skill already exists.
|
||||||
|
if _, err := os.Stat(targetDir); err == nil {
|
||||||
|
errors = append(errors, fmt.Sprintf("'%s' already exists, skipped", skill.Name))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy skill directory.
|
||||||
|
if err := copyDir(skill.Path, targetDir); err != nil {
|
||||||
|
errors = append(errors, fmt.Sprintf("'%s' failed: %v", skill.Name, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
installed = append(installed, skill.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up temp directory.
|
||||||
|
_ = os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
if len(errors) > 0 && len(installed) == 0 {
|
||||||
|
return nil, fmt.Errorf("failed to install any skills:\n%s", strings.Join(errors, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
return installed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyDir recursively copies a directory.
|
||||||
|
func copyDir(src, dst string) error {
|
||||||
|
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip .git directory.
|
||||||
|
if info.IsDir() && info.Name() == ".git" {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
|
||||||
|
relPath, err := filepath.Rel(src, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
dstPath := filepath.Join(dst, relPath)
|
||||||
|
|
||||||
|
if info.IsDir() {
|
||||||
|
return os.MkdirAll(dstPath, info.Mode())
|
||||||
|
}
|
||||||
|
|
||||||
|
srcFile, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer srcFile.Close()
|
||||||
|
|
||||||
|
dstFile, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer dstFile.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(dstFile, srcFile)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsGitURL checks if the given string is a Git URL.
|
||||||
|
// Supports SSH format (git@host:user/repo.git) and HTTPS/HTTP format.
|
||||||
|
func IsGitURL(url string) bool {
|
||||||
|
// SSH format: git@host:user/repo.git
|
||||||
|
sshPattern := regexp.MustCompile(`^git@[^:]+:.+\.git$`)
|
||||||
|
if sshPattern.MatchString(url) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSH URL format: ssh://git@host/user/repo.git
|
||||||
|
if strings.HasPrefix(url, "ssh://") && strings.HasSuffix(url, ".git") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTPS/HTTP format: https://host/user/repo.git
|
||||||
|
if (strings.HasPrefix(url, "https://") || strings.HasPrefix(url, "http://")) && strings.HasSuffix(url, ".git") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractSkillNameFromGitURL extracts the repository name from a Git URL.
|
||||||
|
func extractSkillNameFromGitURL(gitURL string) string {
|
||||||
|
// Remove .git suffix.
|
||||||
|
url := strings.TrimSuffix(gitURL, ".git")
|
||||||
|
|
||||||
|
// SSH format: git@host:user/repo -> repo
|
||||||
|
if strings.HasPrefix(url, "git@") {
|
||||||
|
parts := strings.Split(url, ":")
|
||||||
|
if len(parts) == 2 {
|
||||||
|
pathParts := strings.Split(parts[1], "/")
|
||||||
|
return pathParts[len(pathParts)-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTPS/HTTP/SSH URL format: extract last path segment.
|
||||||
|
parts := strings.Split(url, "/")
|
||||||
|
if len(parts) > 0 {
|
||||||
|
return parts[len(parts)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func (si *SkillInstaller) Uninstall(skillName string) error {
|
func (si *SkillInstaller) Uninstall(skillName string) error {
|
||||||
skillDir := filepath.Join(si.workspace, "skills", skillName)
|
skillDir := filepath.Join(si.workspace, "skills", skillName)
|
||||||
|
|
||||||
|
|
|
||||||
146
pkg/skills/installer_test.go
Normal file
146
pkg/skills/installer_test.go
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
package skills
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsGitURL(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
url string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
// SSH format
|
||||||
|
{"SSH GitHub", "git@github.com:user/repo.git", true},
|
||||||
|
{"SSH GitLab", "git@gitlab.com:group/project.git", true},
|
||||||
|
{"SSH custom host", "git@git.mycompany.com:team/skill.git", true},
|
||||||
|
{"SSH nested path", "git@gitlab.com:group/subgroup/project.git", true},
|
||||||
|
|
||||||
|
// SSH URL format
|
||||||
|
{"SSH URL format", "ssh://git@github.com/user/repo.git", true},
|
||||||
|
{"SSH URL custom port", "ssh://git@gitlab.com:2222/user/repo.git", true},
|
||||||
|
|
||||||
|
// HTTPS format
|
||||||
|
{"HTTPS GitHub", "https://github.com/user/repo.git", true},
|
||||||
|
{"HTTPS GitLab", "https://gitlab.com/user/repo.git", true},
|
||||||
|
{"HTTPS with port", "https://git.mycompany.com:8443/team/skill.git", true},
|
||||||
|
|
||||||
|
// HTTP format
|
||||||
|
{"HTTP simple", "http://git.local/user/repo.git", true},
|
||||||
|
|
||||||
|
// Invalid cases
|
||||||
|
{"No .git suffix", "https://github.com/user/repo", false},
|
||||||
|
{"GitHub path format", "sipeed/picoclaw-skills/weather", false},
|
||||||
|
{"Plain text", "my-skill", false},
|
||||||
|
{"Empty string", "", false},
|
||||||
|
{"Just .git", ".git", false},
|
||||||
|
{"SSH without .git", "git@github.com:user/repo", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := IsGitURL(tt.url)
|
||||||
|
assert.Equal(t, tt.expected, result, "IsGitURL(%q) should be %v", tt.url, tt.expected)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractSkillNameFromGitURL(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
url string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
// SSH format
|
||||||
|
{"SSH simple", "git@github.com:user/my-skill.git", "my-skill"},
|
||||||
|
{"SSH nested", "git@gitlab.com:group/subgroup/project.git", "project"},
|
||||||
|
|
||||||
|
// HTTPS format
|
||||||
|
{"HTTPS simple", "https://github.com/user/weather-skill.git", "weather-skill"},
|
||||||
|
{"HTTPS nested", "https://gitlab.com/company/team/skill.git", "skill"},
|
||||||
|
|
||||||
|
// SSH URL format
|
||||||
|
{"SSH URL format", "ssh://git@github.com/user/repo.git", "repo"},
|
||||||
|
|
||||||
|
// HTTP format
|
||||||
|
{"HTTP simple", "http://git.local/user/local-skill.git", "local-skill"},
|
||||||
|
|
||||||
|
// Without .git suffix (edge cases)
|
||||||
|
{"No .git suffix", "https://github.com/user/repo", "repo"},
|
||||||
|
|
||||||
|
// Edge cases
|
||||||
|
{"Empty string", "", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := extractSkillNameFromGitURL(tt.url)
|
||||||
|
assert.Equal(t, tt.expected, result, "extractSkillNameFromGitURL(%q) should be %q", tt.url, tt.expected)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractFirstLine(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
content string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"Simple description field", "description: This is a description", "This is a description"},
|
||||||
|
{"Description with quotes", "description: \"This is a description\"", "This is a description"},
|
||||||
|
{"Description after header", "# Title\ndescription: This is a description", "This is a description"},
|
||||||
|
{"Description case insensitive", "Description: This is a description", "This is a description"},
|
||||||
|
{"Empty content", "", ""},
|
||||||
|
{"No description field", "# Title\n## Subtitle", ""},
|
||||||
|
{"Long description truncated", "description: This is a very long description that exceeds one hundred characters and should be truncated with ellipsis at the end", "This is a very long description that exceeds one hundred characters and should be truncated with ell..."},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := extractFirstLine(tt.content)
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscoverSkillsInDir(t *testing.T) {
|
||||||
|
// Create temp directory structure.
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create skill directories.
|
||||||
|
skill1Dir := filepath.Join(tempDir, "weather")
|
||||||
|
skill2Dir := filepath.Join(tempDir, "news")
|
||||||
|
nonSkillDir := filepath.Join(tempDir, "not-a-skill")
|
||||||
|
hiddenDir := filepath.Join(tempDir, ".hidden")
|
||||||
|
|
||||||
|
assert.NoError(t, os.MkdirAll(skill1Dir, 0o755))
|
||||||
|
assert.NoError(t, os.MkdirAll(skill2Dir, 0o755))
|
||||||
|
assert.NoError(t, os.MkdirAll(nonSkillDir, 0o755))
|
||||||
|
assert.NoError(t, os.MkdirAll(hiddenDir, 0o755))
|
||||||
|
|
||||||
|
// Create SKILL.md files.
|
||||||
|
assert.NoError(t, os.WriteFile(filepath.Join(skill1Dir, "SKILL.md"), []byte("# Weather\nGet weather information"), 0o644))
|
||||||
|
assert.NoError(t, os.WriteFile(filepath.Join(skill2Dir, "SKILL.md"), []byte("# News\nGet latest news"), 0o644))
|
||||||
|
assert.NoError(t, os.WriteFile(filepath.Join(hiddenDir, "SKILL.md"), []byte("# Hidden\nShould be ignored"), 0o644))
|
||||||
|
// nonSkillDir has no SKILL.md
|
||||||
|
|
||||||
|
installer := NewSkillInstaller(tempDir)
|
||||||
|
skills, err := installer.discoverSkillsInDir(tempDir)
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, skills, 2)
|
||||||
|
|
||||||
|
// Check discovered skills.
|
||||||
|
skillNames := make(map[string]bool)
|
||||||
|
for _, s := range skills {
|
||||||
|
skillNames[s.Name] = true
|
||||||
|
}
|
||||||
|
assert.True(t, skillNames["weather"])
|
||||||
|
assert.True(t, skillNames["news"])
|
||||||
|
assert.False(t, skillNames["not-a-skill"])
|
||||||
|
assert.False(t, skillNames[".hidden"])
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue