2026-02-20 10:55:04 +00:00
package tools
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
2026-02-24 15:57:13 +00:00
"github.com/sipeed/picoclaw/pkg/fileutil"
2026-02-20 10:55:04 +00:00
"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."
}
2026-02-20 18:03:11 +00:00
func ( t * InstallSkillTool ) Parameters ( ) map [ string ] any {
return map [ string ] any {
2026-02-20 10:55:04 +00:00
"type" : "object" ,
2026-02-20 18:03:11 +00:00
"properties" : map [ string ] any {
"slug" : map [ string ] any {
2026-02-20 10:55:04 +00:00
"type" : "string" ,
"description" : "The unique slug of the skill to install (e.g., 'github', 'docker-compose')" ,
} ,
2026-02-20 18:03:11 +00:00
"version" : map [ string ] any {
2026-02-20 10:55:04 +00:00
"type" : "string" ,
"description" : "Specific version to install (optional, defaults to latest)" ,
} ,
2026-02-20 18:03:11 +00:00
"registry" : map [ string ] any {
2026-02-20 10:55:04 +00:00
"type" : "string" ,
"description" : "Registry to install from (required, e.g., 'clawhub')" ,
} ,
2026-02-20 18:03:11 +00:00
"force" : map [ string ] any {
2026-02-20 10:55:04 +00:00
"type" : "boolean" ,
"description" : "Force reinstall if skill already exists (default false)" ,
} ,
} ,
"required" : [ ] string { "slug" , "registry" } ,
}
}
2026-02-20 18:03:11 +00:00
func ( t * InstallSkillTool ) Execute ( ctx context . Context , args map [ string ] any ) * ToolResult {
2026-02-20 10:55:04 +00:00
// 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 {
2026-02-20 18:03:11 +00:00
return ErrorResult (
fmt . Sprintf ( "skill %q already installed at %s. Use force=true to reinstall." , slug , targetDir ) ,
)
2026-02-20 10:55:04 +00:00
}
} 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.
2026-02-20 18:03:11 +00:00
if err := os . MkdirAll ( skillsDir , 0 o755 ) ; err != nil {
2026-02-20 10:55:04 +00:00
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" ,
2026-02-20 18:03:11 +00:00
map [ string ] any {
2026-02-20 10:55:04 +00:00
"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" ,
2026-02-20 18:03:11 +00:00
map [ string ] any {
2026-02-20 10:55:04 +00:00
"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" ,
2026-02-20 18:03:11 +00:00
map [ string ] any {
2026-02-20 10:55:04 +00:00
"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
}
2026-02-24 05:22:52 +00:00
// Use unified atomic write utility with explicit sync for flash storage reliability.
2026-02-24 15:57:13 +00:00
return fileutil . WriteFileAtomic ( filepath . Join ( targetDir , ".skill-origin.json" ) , data , 0 o600 )
2026-02-20 10:55:04 +00:00
}