Files
picoclaw/pkg/tools/skills_install.go
T
lxowalle 0425cd4d77 refactor skills registries and add GitHub-backed skill discovery (#2442)
* refactor skills registries and add GitHub-backed skill discovery

* fix ci

* fix command error

* fix default skills install registry behavior

* fix github registry URL parsing and versioned skill links

* fix skills registry config compatibility and URL installs

* * fix lint

* fix deprecated github base url compatibility

* fix skills registry yaml and github default branch handling

* fix github skills registry fallback and install metadata

* fix cli skills install origin metadata

* fix clawhub registry env compatibility

* fix skills registry config merge compatibility

* fix skill install metadata consistency and onboard template copy

* fix yaml overrides for default skills registries

* fix install_skill registry metadata normalization

* fix github skill URL parsing for slash branch names

* fix skills registry install/search validation and github URLs

* fix github skill URL host validation

* fix install_skill validation for invalid registry archives

* fix redundant skills registry names in saved config

* fix github blob skill URL installs and metadata links

* fix github registry URL scheme validation

* fix v0 skills migration preserving github registry defaults

* fix github blob skill install directory resolution

* fix install_skill rollback on origin metadata write failure

* fix github skill URL validation and registry JSON merging

* fix github registry target resolution and metadata links

* fix install_skill force reinstall rollback

* fix skills config compatibility and legacy security overlays

* fix ci
2026-04-14 15:14:16 +08:00

309 lines
9.6 KiB
Go

package tools
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/sipeed/picoclaw/pkg/fileutil"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/skills"
"github.com/sipeed/picoclaw/pkg/utils"
)
const defaultSkillRegistryName = "github"
var persistInstalledSkillOriginMeta = writeOriginMeta
// InstallSkillTool allows the LLM agent to install skills from registries.
// It shares the same RegistryManager that FindSkillsTool uses,
// so all registries configured in config are available for installation.
type InstallSkillTool struct {
registryMgr *skills.RegistryManager
workspace string
mu sync.Mutex
}
// NewInstallSkillTool creates a new InstallSkillTool.
// registryMgr is the shared registry manager (same instance as FindSkillsTool).
// workspace is the root workspace directory; skills install to {workspace}/skills/{slug}/.
func NewInstallSkillTool(registryMgr *skills.RegistryManager, workspace string) *InstallSkillTool {
return &InstallSkillTool{
registryMgr: registryMgr,
workspace: workspace,
mu: sync.Mutex{},
}
}
func (t *InstallSkillTool) Name() string {
return "install_skill"
}
func (t *InstallSkillTool) Description() string {
return "Install a skill from a registry by slug. Defaults to GitHub when registry is omitted. Downloads and extracts the skill into the workspace. Use find_skills first to discover available skills."
}
func (t *InstallSkillTool) Parameters() map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{
"slug": map[string]any{
"type": "string",
"description": "The unique slug of the skill to install (e.g., 'github', 'docker-compose')",
},
"version": map[string]any{
"type": "string",
"description": "Specific version to install (optional, defaults to latest)",
},
"registry": map[string]any{
"type": "string",
"description": "Registry to install from (optional, defaults to 'github')",
},
"force": map[string]any{
"type": "boolean",
"description": "Force reinstall if skill already exists (default false)",
},
},
"required": []string{"slug"},
}
}
func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
// Install lock to prevent concurrent directory operations.
// Ideally this should be done at a `slug` level, currently, its at a `workspace` level.
t.mu.Lock()
defer t.mu.Unlock()
slug, _ := args["slug"].(string)
if strings.TrimSpace(slug) == "" {
return ErrorResult("identifier is required and must be a non-empty string")
}
// Validate registry
registryName, _ := args["registry"].(string)
if registryName == "" {
registryName = defaultSkillRegistryName
}
if err := utils.ValidateSkillIdentifier(registryName); err != nil {
return ErrorResult(fmt.Sprintf("invalid registry %q: error: %s", registryName, err.Error()))
}
// Resolve which registry to use.
registry := t.registryMgr.GetRegistry(registryName)
if registry == nil {
return ErrorResult(fmt.Sprintf("registry %q not found", registryName))
}
// Validate target and resolve install directory.
dirName, err := registry.ResolveInstallDirName(slug)
if err != nil {
return ErrorResult(fmt.Sprintf("invalid slug %q: error: %s", slug, err.Error()))
}
version, _ := args["version"].(string)
force, _ := args["force"].(bool)
// Check if already installed.
skillsDir := filepath.Join(t.workspace, "skills")
targetDir := filepath.Join(skillsDir, dirName)
backupDir := ""
restorePreviousInstall := func() {
if backupDir == "" {
return
}
if rmErr := os.RemoveAll(targetDir); rmErr != nil {
logger.ErrorCF("tool", "Failed to remove failed install before restore",
map[string]any{
"tool": "install_skill",
"target_dir": targetDir,
"error": rmErr.Error(),
})
return
}
if restoreErr := os.Rename(backupDir, targetDir); restoreErr != nil {
logger.ErrorCF("tool", "Failed to restore previous install after failed reinstall",
map[string]any{
"tool": "install_skill",
"backup_dir": backupDir,
"target_dir": targetDir,
"error": restoreErr.Error(),
})
return
}
backupDir = ""
}
if !force {
if _, statErr := os.Stat(targetDir); statErr == nil {
return ErrorResult(
fmt.Sprintf("skill %q already installed at %s. Use force=true to reinstall.", slug, targetDir),
)
}
} else {
if _, statErr := os.Stat(targetDir); statErr == nil {
backupDir = filepath.Join(skillsDir, fmt.Sprintf(".%s.picoclaw-backup-%d", dirName, time.Now().UnixNano()))
if renameErr := os.Rename(targetDir, backupDir); renameErr != nil {
return ErrorResult(fmt.Sprintf("failed to prepare reinstall for %q: %v", slug, renameErr))
}
} else if !os.IsNotExist(statErr) {
return ErrorResult(fmt.Sprintf("failed to inspect existing install for %q: %v", slug, statErr))
}
}
// Ensure skills directory exists.
if mkdirErr := os.MkdirAll(skillsDir, 0o755); mkdirErr != nil {
restorePreviousInstall()
return ErrorResult(fmt.Sprintf("failed to create skills directory: %v", mkdirErr))
}
// Download and install (handles metadata, version resolution, extraction).
result, err := registry.DownloadAndInstall(ctx, slug, version, targetDir)
if err != nil {
// Clean up partial install.
rmErr := os.RemoveAll(targetDir)
if rmErr != nil {
logger.ErrorCF("tool", "Failed to remove partial install",
map[string]any{
"tool": "install_skill",
"target_dir": targetDir,
"error": rmErr.Error(),
})
}
restorePreviousInstall()
return ErrorResult(fmt.Sprintf("failed to install %q: %v", slug, err))
}
// Moderation: block malware.
if result.IsMalwareBlocked {
rmErr := os.RemoveAll(targetDir)
if rmErr != nil {
logger.ErrorCF("tool", "Failed to remove partial install",
map[string]any{
"tool": "install_skill",
"target_dir": targetDir,
"error": rmErr.Error(),
})
}
restorePreviousInstall()
return ErrorResult(fmt.Sprintf("skill %q is flagged as malicious and cannot be installed", slug))
}
if !workspaceHasValidInstalledSkill(t.workspace, dirName) {
rmErr := os.RemoveAll(targetDir)
if rmErr != nil {
logger.ErrorCF("tool", "Failed to remove invalid installed skill",
map[string]any{
"tool": "install_skill",
"target_dir": targetDir,
"error": rmErr.Error(),
})
}
restorePreviousInstall()
return ErrorResult(fmt.Sprintf("failed to install %q: registry archive is not a valid skill", slug))
}
// Write origin metadata.
if err := persistInstalledSkillOriginMeta(targetDir, registry, slug, result.Version); err != nil {
logger.ErrorCF("tool", "Failed to write origin metadata",
map[string]any{
"tool": "install_skill",
"error": err.Error(),
"target": targetDir,
"registry": registry.Name(),
"slug": slug,
"version": result.Version,
})
rmErr := os.RemoveAll(targetDir)
if rmErr != nil {
logger.ErrorCF("tool", "Failed to roll back install after metadata write failure",
map[string]any{
"tool": "install_skill",
"target_dir": targetDir,
"error": rmErr.Error(),
})
}
restorePreviousInstall()
return ErrorResult(fmt.Sprintf("failed to persist skill metadata for %q: %v", slug, err))
}
if backupDir != "" {
if rmErr := os.RemoveAll(backupDir); rmErr != nil {
logger.ErrorCF("tool", "Failed to remove previous install backup after successful reinstall",
map[string]any{
"tool": "install_skill",
"backup_dir": backupDir,
"error": rmErr.Error(),
})
}
}
// Build result with moderation warning if suspicious.
var output string
if result.IsSuspicious {
output = fmt.Sprintf("⚠️ Warning: skill %q is flagged as suspicious (may contain risky patterns).\n\n", slug)
}
output += fmt.Sprintf("Successfully installed skill %q v%s from %s registry.\nLocation: %s\n",
slug, result.Version, registry.Name(), targetDir)
if result.Summary != "" {
output += fmt.Sprintf("Description: %s\n", result.Summary)
}
output += "\nThe skill is now available and can be loaded in the current session."
return SilentResult(output)
}
// originMeta tracks which registry a skill was installed from.
type originMeta struct {
Version int `json:"version"`
OriginKind string `json:"origin_kind,omitempty"`
Registry string `json:"registry"`
Slug string `json:"slug"`
RegistryURL string `json:"registry_url,omitempty"`
InstalledVersion string `json:"installed_version"`
InstalledAt int64 `json:"installed_at"`
}
func writeOriginMeta(targetDir string, registry skills.SkillRegistry, slug, version string) error {
normalizedSlug, registryURL := skills.BuildInstallMetadataForRegistryInstance(registry, slug, version)
registryName := ""
if registry != nil {
registryName = registry.Name()
}
meta := originMeta{
Version: 1,
OriginKind: "third_party",
Registry: registryName,
Slug: normalizedSlug,
RegistryURL: registryURL,
InstalledVersion: version,
InstalledAt: time.Now().UnixMilli(),
}
data, err := json.MarshalIndent(meta, "", " ")
if err != nil {
return err
}
// Use unified atomic write utility with explicit sync for flash storage reliability.
return fileutil.WriteFileAtomic(filepath.Join(targetDir, ".skill-origin.json"), data, 0o600)
}
func workspaceHasValidInstalledSkill(workspace, directory string) bool {
loader := skills.NewSkillsLoader(workspace, "", "")
for _, skill := range loader.ListSkills() {
if skill.Source != "workspace" {
continue
}
if filepath.Base(filepath.Dir(skill.Path)) == directory {
return true
}
}
return false
}