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 }