✨ 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.
|
// 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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(®istry, "registry", "", "Install from registry: --registry <name> <slug>")
|
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
|
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.
|
// 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.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue