✨ 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:
parent
538e7a521a
commit
29de0f02d3
3 changed files with 109 additions and 16 deletions
|
|
@ -53,7 +53,7 @@ func skillsInstallCmd(installer *skills.SkillInstaller, repo string) error {
|
|||
}
|
||||
|
||||
// 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)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
|
|
@ -86,7 +86,7 @@ func skillsInstallFromGitCmd(installer *skills.SkillInstaller, gitURL string) er
|
|||
|
||||
// Install selected skills.
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
|
||||
func newInstallCommand(installerFn func() (*skills.SkillInstaller, error)) *cobra.Command {
|
||||
var registry string
|
||||
var force bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "install",
|
||||
|
|
@ -19,6 +20,7 @@ func newInstallCommand(installerFn func() (*skills.SkillInstaller, error)) *cobr
|
|||
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 --force git@gitlab.com:user/my-skill.git
|
||||
picoclaw skills install --registry clawhub github
|
||||
`,
|
||||
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.
|
||||
if skills.IsGitURL(args[0]) {
|
||||
return skillsInstallFromGitCmd(installer, args[0])
|
||||
return skillsInstallFromGitCmd(installer, args[0], force)
|
||||
}
|
||||
|
||||
return skillsInstallCmd(installer, args[0])
|
||||
|
|
@ -60,6 +62,7 @@ picoclaw skills install --registry clawhub github
|
|||
}
|
||||
|
||||
cmd.Flags().StringVar(®istry, "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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,9 +114,10 @@ func (si *SkillInstaller) InstallFromGit(ctx context.Context, gitURL string) err
|
|||
|
||||
// DiscoveredSkill represents a skill found in a Git repository.
|
||||
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
|
||||
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.
|
||||
|
|
@ -176,13 +177,23 @@ func (si *SkillInstaller) discoverSkillsInDir(dir string) ([]DiscoveredSkill, er
|
|||
|
||||
if _, err := os.Stat(skillFile); err == nil {
|
||||
skill := DiscoveredSkill{
|
||||
Name: entry.Name(),
|
||||
Path: skillDir,
|
||||
Name: entry.Name(), // Default to directory name.
|
||||
DirName: entry.Name(),
|
||||
Path: skillDir,
|
||||
}
|
||||
|
||||
// Try to extract description from SKILL.md.
|
||||
// Try to extract metadata from SKILL.md.
|
||||
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)
|
||||
|
|
@ -192,6 +203,60 @@ func (si *SkillInstaller) discoverSkillsInDir(dir string) ([]DiscoveredSkill, er
|
|||
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.
|
||||
func extractFirstLine(content string) string {
|
||||
lines := strings.Split(content, "\n")
|
||||
|
|
@ -213,22 +278,34 @@ func extractFirstLine(content string) string {
|
|||
|
||||
// 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) {
|
||||
// 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")
|
||||
if err := os.MkdirAll(skillsDir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create skills directory: %w", err)
|
||||
}
|
||||
|
||||
var installed []string
|
||||
var updated []string
|
||||
var errors []string
|
||||
|
||||
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.
|
||||
exists := false
|
||||
if _, err := os.Stat(targetDir); err == nil {
|
||||
errors = append(errors, fmt.Sprintf("'%s' already exists, skipped", skill.Name))
|
||||
continue
|
||||
if !force {
|
||||
errors = append(errors, fmt.Sprintf("'%s' already exists, use --force to overwrite", skill.Name))
|
||||
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.
|
||||
|
|
@ -237,17 +314,30 @@ func (si *SkillInstaller) InstallSelectedSkills(tempDir string, skills []Discove
|
|||
continue
|
||||
}
|
||||
|
||||
installed = append(installed, skill.Name)
|
||||
if exists {
|
||||
updated = append(updated, skill.Name)
|
||||
} else {
|
||||
installed = append(installed, skill.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up temp directory.
|
||||
_ = 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 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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue