feat(skills): support overwrite install and read name from SKILL.md

- Add --force flag (default: true) to overwrite existing skills
- Parse SKILL.md frontmatter to extract skill name and description
- Add DirName field to DiscoveredSkill for preserving folder structure
- Display skill name from metadata instead of directory name
- Show (new) or (updated) status after installation
This commit is contained in:
Pluckypan 2026-03-05 11:01:46 +08:00 committed by plucky
parent 538e7a521a
commit 29de0f02d3
3 changed files with 109 additions and 16 deletions

View file

@ -53,7 +53,7 @@ func skillsInstallCmd(installer *skills.SkillInstaller, repo string) error {
} }
// skillsInstallFromGitCmd installs a skill from a Git repository URL. // skillsInstallFromGitCmd installs a skill from a Git repository URL.
func skillsInstallFromGitCmd(installer *skills.SkillInstaller, gitURL string) error { func skillsInstallFromGitCmd(installer *skills.SkillInstaller, gitURL string, force bool) error {
fmt.Printf("Cloning repository: %s...\n", gitURL) fmt.Printf("Cloning repository: %s...\n", gitURL)
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
@ -86,7 +86,7 @@ func skillsInstallFromGitCmd(installer *skills.SkillInstaller, gitURL string) er
// Install selected skills. // Install selected skills.
fmt.Printf("\nInstalling %d skill(s)...\n", len(selectedSkills)) fmt.Printf("\nInstalling %d skill(s)...\n", len(selectedSkills))
installed, err := installer.InstallSelectedSkills(tempDir, selectedSkills) installed, err := installer.InstallSelectedSkills(tempDir, selectedSkills, force)
if err != nil { if err != nil {
return err return err
} }

View file

@ -11,6 +11,7 @@ import (
func newInstallCommand(installerFn func() (*skills.SkillInstaller, error)) *cobra.Command { func newInstallCommand(installerFn func() (*skills.SkillInstaller, error)) *cobra.Command {
var registry string var registry string
var force bool
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "install", Use: "install",
@ -19,6 +20,7 @@ func newInstallCommand(installerFn func() (*skills.SkillInstaller, error)) *cobr
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 git@gitlab.com:user/my-skill.git
picoclaw skills install https://gitlab.com/user/my-skill.git picoclaw skills install https://gitlab.com/user/my-skill.git
picoclaw skills install --force git@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 {
@ -52,7 +54,7 @@ picoclaw skills install --registry clawhub github
// Check if input is a Git URL. // Check if input is a Git URL.
if skills.IsGitURL(args[0]) { if skills.IsGitURL(args[0]) {
return skillsInstallFromGitCmd(installer, args[0]) return skillsInstallFromGitCmd(installer, args[0], force)
} }
return skillsInstallCmd(installer, args[0]) return skillsInstallCmd(installer, args[0])
@ -60,6 +62,7 @@ picoclaw skills install --registry clawhub github
} }
cmd.Flags().StringVar(&registry, "registry", "", "Install from registry: --registry <name> <slug>") cmd.Flags().StringVar(&registry, "registry", "", "Install from registry: --registry <name> <slug>")
cmd.Flags().BoolVarP(&force, "force", "f", true, "Overwrite existing skills (default: true, use --force=false to skip)")
return cmd return cmd
} }

View file

@ -114,9 +114,10 @@ func (si *SkillInstaller) InstallFromGit(ctx context.Context, gitURL string) err
// DiscoveredSkill represents a skill found in a Git repository. // DiscoveredSkill represents a skill found in a Git repository.
type DiscoveredSkill struct { type DiscoveredSkill struct {
Name string // Directory name (skill identifier) Name string // Skill name from SKILL.md (or directory name as fallback)
DirName string // Directory name (used for installation path)
Path string // Full path in the cloned repository Path string // Full path in the cloned repository
Description string // First line of SKILL.md (if available) Description string // Description from SKILL.md (if available)
} }
// CloneAndDiscoverSkills clones a Git repository and discovers all skills in it. // CloneAndDiscoverSkills clones a Git repository and discovers all skills in it.
@ -176,13 +177,23 @@ func (si *SkillInstaller) discoverSkillsInDir(dir string) ([]DiscoveredSkill, er
if _, err := os.Stat(skillFile); err == nil { if _, err := os.Stat(skillFile); err == nil {
skill := DiscoveredSkill{ skill := DiscoveredSkill{
Name: entry.Name(), Name: entry.Name(), // Default to directory name.
DirName: entry.Name(),
Path: skillDir, Path: skillDir,
} }
// Try to extract description from SKILL.md. // Try to extract metadata from SKILL.md.
if content, err := os.ReadFile(skillFile); err == nil { if content, err := os.ReadFile(skillFile); err == nil {
skill.Description = extractFirstLine(string(content)) contentStr := string(content)
metadata := extractSkillMetadata(contentStr)
if metadata.Name != "" {
skill.Name = metadata.Name
}
if metadata.Description != "" {
skill.Description = metadata.Description
} else {
skill.Description = extractFirstLine(contentStr)
}
} }
skills = append(skills, skill) skills = append(skills, skill)
@ -192,6 +203,60 @@ func (si *SkillInstaller) discoverSkillsInDir(dir string) ([]DiscoveredSkill, er
return skills, nil return skills, nil
} }
// skillMetadata holds parsed metadata from SKILL.md frontmatter.
type skillMetadata struct {
Name string
Description string
}
// extractSkillMetadata parses the frontmatter of a SKILL.md file.
func extractSkillMetadata(content string) skillMetadata {
var meta skillMetadata
// Normalize line endings.
normalized := strings.ReplaceAll(content, "\r\n", "\n")
normalized = strings.ReplaceAll(normalized, "\r", "\n")
// Check for YAML frontmatter (--- delimited).
if !strings.HasPrefix(normalized, "---") {
return meta
}
// Find end of frontmatter.
endIdx := strings.Index(normalized[3:], "\n---")
if endIdx == -1 {
return meta
}
frontmatter := normalized[3 : 3+endIdx]
// Parse simple YAML key: value format.
for _, line := range strings.Split(frontmatter, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
value = strings.Trim(value, `"'`)
switch key {
case "name":
meta.Name = value
case "description":
meta.Description = value
}
}
return meta
}
// extractFirstLine extracts the description from SKILL.md content. // extractFirstLine extracts the description from SKILL.md content.
func extractFirstLine(content string) string { func extractFirstLine(content string) string {
lines := strings.Split(content, "\n") lines := strings.Split(content, "\n")
@ -213,23 +278,35 @@ func extractFirstLine(content string) string {
// InstallSelectedSkills installs the selected skills from a cloned repository. // InstallSelectedSkills installs the selected skills from a cloned repository.
// It copies the skill directories to the workspace and cleans up the temp directory. // It copies the skill directories to the workspace and cleans up the temp directory.
func (si *SkillInstaller) InstallSelectedSkills(tempDir string, skills []DiscoveredSkill) ([]string, error) { // If force is true, existing skills will be overwritten.
func (si *SkillInstaller) InstallSelectedSkills(tempDir string, skills []DiscoveredSkill, force bool) ([]string, error) {
skillsDir := filepath.Join(si.workspace, "skills") skillsDir := filepath.Join(si.workspace, "skills")
if err := os.MkdirAll(skillsDir, 0o755); err != nil { if err := os.MkdirAll(skillsDir, 0o755); err != nil {
return nil, fmt.Errorf("failed to create skills directory: %w", err) return nil, fmt.Errorf("failed to create skills directory: %w", err)
} }
var installed []string var installed []string
var updated []string
var errors []string var errors []string
for _, skill := range skills { for _, skill := range skills {
targetDir := filepath.Join(skillsDir, skill.Name) // Use DirName for target directory (preserves original folder structure).
targetDir := filepath.Join(skillsDir, skill.DirName)
// Check if skill already exists. // Check if skill already exists.
exists := false
if _, err := os.Stat(targetDir); err == nil { if _, err := os.Stat(targetDir); err == nil {
errors = append(errors, fmt.Sprintf("'%s' already exists, skipped", skill.Name)) if !force {
errors = append(errors, fmt.Sprintf("'%s' already exists, use --force to overwrite", skill.Name))
continue continue
} }
// Remove existing directory for force update.
if err := os.RemoveAll(targetDir); err != nil {
errors = append(errors, fmt.Sprintf("'%s' failed to remove existing: %v", skill.Name, err))
continue
}
exists = true
}
// Copy skill directory. // Copy skill directory.
if err := copyDir(skill.Path, targetDir); err != nil { if err := copyDir(skill.Path, targetDir); err != nil {
@ -237,17 +314,30 @@ func (si *SkillInstaller) InstallSelectedSkills(tempDir string, skills []Discove
continue continue
} }
if exists {
updated = append(updated, skill.Name)
} else {
installed = append(installed, skill.Name) installed = append(installed, skill.Name)
} }
}
// Clean up temp directory. // Clean up temp directory.
_ = os.RemoveAll(tempDir) _ = os.RemoveAll(tempDir)
if len(errors) > 0 && len(installed) == 0 { if len(errors) > 0 && len(installed) == 0 && len(updated) == 0 {
return nil, fmt.Errorf("failed to install any skills:\n%s", strings.Join(errors, "\n")) return nil, fmt.Errorf("failed to install any skills:\n%s", strings.Join(errors, "\n"))
} }
return installed, nil // Combine installed and updated for the return value.
result := make([]string, 0, len(installed)+len(updated))
for _, name := range installed {
result = append(result, name+" (new)")
}
for _, name := range updated {
result = append(result, name+" (updated)")
}
return result, nil
} }
// copyDir recursively copies a directory. // copyDir recursively copies a directory.