2026-02-20 10:55:04 +00:00
package tools
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
2026-04-14 07:14:16 +00:00
"strings"
2026-02-20 10:55:04 +00:00
"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"
)
2026-04-14 07:14:16 +00:00
const defaultSkillRegistryName = "github"
var persistInstalledSkillOriginMeta = writeOriginMeta
2026-02-20 10:55:04 +00:00
// 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 {
2026-04-14 07:14:16 +00:00
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."
2026-02-20 10:55:04 +00:00
}
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" ,
2026-04-14 07:14:16 +00:00
"description" : "Registry to install from (optional, defaults to 'github')" ,
2026-02-20 10:55:04 +00:00
} ,
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)" ,
} ,
} ,
2026-04-14 07:14:16 +00:00
"required" : [ ] string { "slug" } ,
2026-02-20 10:55:04 +00:00
}
}
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 ( )
slug , _ := args [ "slug" ] . ( string )
2026-04-14 07:14:16 +00:00
if strings . TrimSpace ( slug ) == "" {
return ErrorResult ( "identifier is required and must be a non-empty string" )
2026-02-20 10:55:04 +00:00
}
// Validate registry
registryName , _ := args [ "registry" ] . ( string )
2026-04-14 07:14:16 +00:00
if registryName == "" {
registryName = defaultSkillRegistryName
}
2026-02-20 10:55:04 +00:00
if err := utils . ValidateSkillIdentifier ( registryName ) ; err != nil {
return ErrorResult ( fmt . Sprintf ( "invalid registry %q: error: %s" , registryName , err . Error ( ) ) )
}
2026-04-14 07:14:16 +00:00
// 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 ( ) ) )
}
2026-02-20 10:55:04 +00:00
version , _ := args [ "version" ] . ( string )
force , _ := args [ "force" ] . ( bool )
// Check if already installed.
skillsDir := filepath . Join ( t . workspace , "skills" )
2026-04-14 07:14:16 +00:00
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 = ""
}
2026-02-20 10:55:04 +00:00
if ! force {
2026-04-14 07:14:16 +00:00
if _ , statErr := os . Stat ( targetDir ) ; statErr == 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 {
2026-04-14 07:14:16 +00:00
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 ) )
}
2026-02-20 10:55:04 +00:00
}
// Ensure skills directory exists.
2026-04-14 07:14:16 +00:00
if mkdirErr := os . MkdirAll ( skillsDir , 0 o755 ) ; mkdirErr != nil {
restorePreviousInstall ( )
return ErrorResult ( fmt . Sprintf ( "failed to create skills directory: %v" , mkdirErr ) )
2026-02-20 10:55:04 +00:00
}
// 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 ( ) ,
} )
}
2026-04-14 07:14:16 +00:00
restorePreviousInstall ( )
2026-02-20 10:55:04 +00:00
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 ( ) ,
} )
}
2026-04-14 07:14:16 +00:00
restorePreviousInstall ( )
2026-02-20 10:55:04 +00:00
return ErrorResult ( fmt . Sprintf ( "skill %q is flagged as malicious and cannot be installed" , slug ) )
}
2026-04-14 07:14:16 +00:00
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 ) )
}
2026-02-20 10:55:04 +00:00
// Write origin metadata.
2026-04-14 07:14:16 +00:00
if err := persistInstalledSkillOriginMeta ( targetDir , registry , slug , result . Version ) ; err != nil {
2026-02-20 10:55:04 +00:00
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 ,
} )
2026-04-14 07:14:16 +00:00
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 ( ) ,
} )
}
2026-02-20 10:55:04 +00:00
}
// 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" `
2026-04-14 07:14:16 +00:00
OriginKind string ` json:"origin_kind,omitempty" `
2026-02-20 10:55:04 +00:00
Registry string ` json:"registry" `
Slug string ` json:"slug" `
2026-04-14 07:14:16 +00:00
RegistryURL string ` json:"registry_url,omitempty" `
2026-02-20 10:55:04 +00:00
InstalledVersion string ` json:"installed_version" `
InstalledAt int64 ` json:"installed_at" `
}
2026-04-14 07:14:16 +00:00
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 ( )
}
2026-02-20 10:55:04 +00:00
meta := originMeta {
Version : 1 ,
2026-04-14 07:14:16 +00:00
OriginKind : "third_party" ,
2026-02-20 10:55:04 +00:00
Registry : registryName ,
2026-04-14 07:14:16 +00:00
Slug : normalizedSlug ,
RegistryURL : registryURL ,
2026-02-20 10:55:04 +00:00
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
}
2026-04-14 07:14:16 +00:00
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
}