mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
d692cc0cc6
* Add Find Skills and Install Skills * Improvements * fix file name * Update pkg/skills/clawhub_registry.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix * Comments addressed * Resolve comments * fix tests * fixes * Comments resolved * Update pkg/skills/search_cache_repro_test.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * minor fix * fix test * fixes --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
200 lines
6.2 KiB
Go
200 lines
6.2 KiB
Go
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]interface{} {
|
|
return map[string]interface{}{
|
|
"type": "object",
|
|
"properties": map[string]interface{}{
|
|
"slug": map[string]interface{}{
|
|
"type": "string",
|
|
"description": "The unique slug of the skill to install (e.g., 'github', 'docker-compose')",
|
|
},
|
|
"version": map[string]interface{}{
|
|
"type": "string",
|
|
"description": "Specific version to install (optional, defaults to latest)",
|
|
},
|
|
"registry": map[string]interface{}{
|
|
"type": "string",
|
|
"description": "Registry to install from (required, e.g., 'clawhub')",
|
|
},
|
|
"force": map[string]interface{}{
|
|
"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]interface{}) *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, 0755); 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]interface{}{
|
|
"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]interface{}{
|
|
"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]interface{}{
|
|
"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, 0644)
|
|
}
|