/ internal / tools / applescript.go
applescript.go
  1  package tools
  2  
  3  import (
  4  	"context"
  5  	"encoding/json"
  6  	"fmt"
  7  	"os/exec"
  8  	"strings"
  9  	"time"
 10  
 11  	"github.com/Kocoro-lab/ShanClaw/internal/agent"
 12  )
 13  
 14  type AppleScriptTool struct{}
 15  
 16  type appleScriptArgs struct {
 17  	Script string `json:"script"`
 18  }
 19  
 20  func (t *AppleScriptTool) Info() agent.ToolInfo {
 21  	return agent.ToolInfo{
 22  		Name:        "applescript",
 23  		Description: "Execute an AppleScript script via osascript. Use for opening/activating apps, window management, calendar events, and macOS-specific operations. NOT for web page interaction — use browser tool for web content.",
 24  		Parameters: map[string]any{
 25  			"type": "object",
 26  			"properties": map[string]any{
 27  				"script": map[string]any{"type": "string", "description": "AppleScript code to execute"},
 28  			},
 29  		},
 30  		Required: []string{"script"},
 31  	}
 32  }
 33  
 34  func (t *AppleScriptTool) Run(ctx context.Context, argsJSON string) (agent.ToolResult, error) {
 35  	var args appleScriptArgs
 36  	if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
 37  		return agent.ToolResult{Content: fmt.Sprintf("invalid arguments: %v", err), IsError: true}, nil
 38  	}
 39  
 40  	// Apply a default timeout to prevent hangs (e.g., osascript waiting for user interaction).
 41  	execCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
 42  	defer cancel()
 43  
 44  	// Split multi-line scripts into separate -e arguments for osascript
 45  	cmdArgs := []string{}
 46  	for _, line := range splitScriptLines(args.Script) {
 47  		cmdArgs = append(cmdArgs, "-e", line)
 48  	}
 49  	cmd := exec.CommandContext(execCtx, "osascript", cmdArgs...)
 50  	output, err := cmd.CombinedOutput()
 51  
 52  	result := string(output)
 53  	if len(result) > 10240 {
 54  		result = result[:10240] + "\n... (truncated)"
 55  	}
 56  
 57  	if err != nil {
 58  		return agent.ToolResult{
 59  			Content: fmt.Sprintf("osascript error: %v\n%s", err, result),
 60  			IsError: true,
 61  		}, nil
 62  	}
 63  
 64  	var toolResult agent.ToolResult
 65  	if result == "" {
 66  		toolResult = agent.ToolResult{Content: "script executed successfully (no output)"}
 67  	} else {
 68  		toolResult = agent.ToolResult{Content: result}
 69  	}
 70  
 71  	// Auto-screenshot after GUI actions so the LLM can verify the outcome.
 72  	// Brief delay to let the UI settle.
 73  	time.Sleep(500 * time.Millisecond)
 74  	_, block, captureErr := CaptureAndEncode(DefaultAPIWidth)
 75  	if captureErr == nil {
 76  		toolResult.Images = []agent.ImageBlock{block}
 77  	}
 78  
 79  	return toolResult, nil
 80  }
 81  
 82  func (t *AppleScriptTool) RequiresApproval() bool { return true }
 83  
 84  func (t *AppleScriptTool) IsReadOnlyCall(string) bool { return false }
 85  
 86  // splitScriptLines splits an AppleScript into individual lines for -e args.
 87  // Preserves empty lines as they can be significant in AppleScript blocks.
 88  func splitScriptLines(script string) []string {
 89  	lines := strings.Split(script, "\n")
 90  	var result []string
 91  	for _, line := range lines {
 92  		trimmed := strings.TrimSpace(line)
 93  		if trimmed != "" {
 94  			result = append(result, line)
 95  		}
 96  	}
 97  	if len(result) == 0 {
 98  		return []string{script}
 99  	}
100  	return result
101  }
102  
103  // escapeAppleScript escapes a string for safe embedding in AppleScript string literals.
104  func escapeAppleScript(s string) string {
105  	s = strings.ReplaceAll(s, `\`, `\\`)
106  	s = strings.ReplaceAll(s, `"`, `\"`)
107  	s = strings.ReplaceAll(s, "\n", "\\n")
108  	s = strings.ReplaceAll(s, "\r", "\\r")
109  	return s
110  }