diff --git a/cmd/picoclaw/internal/skills/helpers.go b/cmd/picoclaw/internal/skills/helpers.go index a59a2013a..aefcb3506 100644 --- a/cmd/picoclaw/internal/skills/helpers.go +++ b/cmd/picoclaw/internal/skills/helpers.go @@ -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) diff --git a/cmd/picoclaw/internal/skills/install.go b/cmd/picoclaw/internal/skills/install.go index 78bc421db..35be2e416 100644 --- a/cmd/picoclaw/internal/skills/install.go +++ b/cmd/picoclaw/internal/skills/install.go @@ -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: ") + return fmt.Errorf("exactly 1 argument is required: or ") } 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]) }, } diff --git a/pkg/skills/installer.go b/pkg/skills/installer.go index c9f19f25d..c6cf743f1 100644 --- a/pkg/skills/installer.go +++ b/pkg/skills/installer.go @@ -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) diff --git a/pkg/skills/installer_test.go b/pkg/skills/installer_test.go new file mode 100644 index 000000000..1e04d4145 --- /dev/null +++ b/pkg/skills/installer_test.go @@ -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"]) +}