package tools import ( "context" "encoding/json" "fmt" "os" "path/filepath" "sync" "time" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/skills" "github.com/sipeed/picoclaw/pkg/utils" ) // 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. 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 (required, e.g., 'clawhub')", }, "force": map[string]any{ "type": "boolean", "description": "Force reinstall if skill already exists (default false)", }, }, "required": []string{"slug", "registry"}, } } 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() // Validate slug slug, _ := args["slug"].(string) if err := utils.ValidateSkillIdentifier(slug); err != nil { return ErrorResult(fmt.Sprintf("invalid slug %q: error: %s", slug, err.Error())) } // Validate registry registryName, _ := args["registry"].(string) if err := utils.ValidateSkillIdentifier(registryName); err != nil { return ErrorResult(fmt.Sprintf("invalid registry %q: error: %s", registryName, err.Error())) } version, _ := args["version"].(string) force, _ := args["force"].(bool) // Check if already installed. skillsDir := filepath.Join(t.workspace, "skills") targetDir := filepath.Join(skillsDir, slug) if !force { if _, err := os.Stat(targetDir); err == nil { return ErrorResult( fmt.Sprintf("skill %q already installed at %s. Use force=true to reinstall.", slug, targetDir), ) } } else { // Force: remove existing if present. os.RemoveAll(targetDir) } // Resolve which registry to use. registry := t.registryMgr.GetRegistry(registryName) if registry == nil { return ErrorResult(fmt.Sprintf("registry %q not found", registryName)) } // Ensure skills directory exists. if err := os.MkdirAll(skillsDir, 0o755); err != nil { return ErrorResult(fmt.Sprintf("failed to create skills directory: %v", err)) } // 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(), }) } 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(), }) } return ErrorResult(fmt.Sprintf("skill %q is flagged as malicious and cannot be installed", slug)) } // Write origin metadata. if err := writeOriginMeta(targetDir, registry.Name(), 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, }) _ = err } // 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"` Registry string `json:"registry"` Slug string `json:"slug"` InstalledVersion string `json:"installed_version"` InstalledAt int64 `json:"installed_at"` } func writeOriginMeta(targetDir, registryName, slug, version string) error { meta := originMeta{ Version: 1, Registry: registryName, Slug: slug, InstalledVersion: version, InstalledAt: time.Now().UnixMilli(), } data, err := json.MarshalIndent(meta, "", " ") if err != nil { return err } return os.WriteFile(filepath.Join(targetDir, ".skill-origin.json"), data, 0o644) }