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:
Pluckypan 2026-03-04 18:03:15 +08:00 committed by plucky
parent 651cb2ebda
commit 2ff84f5bed
4 changed files with 677 additions and 3 deletions

View file

@ -9,6 +9,8 @@ import (
"strings"
"time"
"golang.org/x/term"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/config"
"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)
}
fmt.Printf("\u2713 Skill '%s' installed successfully!\n", filepath.Base(repo))
fmt.Printf(" Skill '%s' installed successfully!\n", filepath.Base(repo))
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).
func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) error {
err := utils.ValidateSkillIdentifier(registryName)

View file

@ -14,9 +14,11 @@ func newInstallCommand(installerFn func() (*skills.SkillInstaller, error)) *cobr
cmd := &cobra.Command{
Use: "install",
Short: "Install skill from GitHub",
Short: "Install skill from GitHub or Git repository",
Example: `
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
`,
Args: func(cmd *cobra.Command, args []string) error {
@ -28,7 +30,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-repo> or <git-url>")
}
return nil
@ -48,6 +50,11 @@ picoclaw skills install --registry clawhub github
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])
},
}

View file

@ -6,7 +6,10 @@ import (
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/fileutil"
@ -67,6 +70,271 @@ func (si *SkillInstaller) InstallFromGitHub(ctx context.Context, repo string) er
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 {
skillDir := filepath.Join(si.workspace, "skills", skillName)

View 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"])
}