1package claudetool
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
8 "io"
9 "log/slog"
10 "math"
11 "os"
12 "os/exec"
13 "path/filepath"
14 "slices"
15 "strings"
16 "sync"
17 "syscall"
18 "time"
19
20 "shelley.exe.dev/claudetool/bashkit"
21 "shelley.exe.dev/llm"
22)
23
24// PermissionCallback is a function type for checking if a command is allowed to run
25type PermissionCallback func(command string) error
26
27// BashTool specifies an llm.Tool for executing shell commands.
28type BashTool struct {
29 // CheckPermission is called before running any command, if set
30 CheckPermission PermissionCallback
31 // EnableJITInstall enables just-in-time tool installation for missing commands
32 EnableJITInstall bool
33 // Timeouts holds the configurable timeout values (uses defaults if nil)
34 Timeouts *Timeouts
35 // WorkingDir is the shared mutable working directory.
36 WorkingDir *MutableWorkingDir
37 // LLMProvider provides access to LLM services for tool validation
38 LLMProvider LLMServiceProvider
39 // ConversationID is the ID of the conversation this tool belongs to.
40 // It is exposed to invoked commands via SHELLEY_CONVERSATION_ID.
41 ConversationID string
42}
43
44const (
45 EnableBashToolJITInstall = true
46 NoBashToolJITInstall = false
47
48 DefaultFastTimeout = 30 * time.Second
49 DefaultSlowTimeout = 15 * time.Minute
50 DefaultBackgroundTimeout = 24 * time.Hour
51)
52
53// Timeouts holds the configurable timeout values for bash commands.
54type Timeouts struct {
55 Fast time.Duration // regular commands (e.g., ls, echo, simple scripts)
56 Slow time.Duration // commands that may reasonably take longer (e.g., downloads, builds, tests)
57 Background time.Duration // background commands (e.g., servers, long-running processes)
58}
59
60// Fast returns t's fast timeout, or DefaultFastTimeout if t is nil.
61func (t *Timeouts) fast() time.Duration {
62 if t == nil {
63 return DefaultFastTimeout
64 }
65 return t.Fast
66}
67
68// Slow returns t's slow timeout, or DefaultSlowTimeout if t is nil.
69func (t *Timeouts) slow() time.Duration {
70 if t == nil {
71 return DefaultSlowTimeout
72 }
73 return t.Slow
74}
75
76// Background returns t's background timeout, or DefaultBackgroundTimeout if t is nil.
77func (t *Timeouts) background() time.Duration {
78 if t == nil {
79 return DefaultBackgroundTimeout
80 }
81 return t.Background
82}
83
84// Tool returns an llm.Tool based on b.
85func (b *BashTool) Tool() *llm.Tool {
86 return &llm.Tool{
87 Name: bashName,
88 Description: strings.TrimSpace(bashDescription),
89 InputSchema: llm.MustSchema(bashInputSchema),
90 Run: b.Run,
91 }
92}
93
94// getWorkingDir returns the current working directory.
95func (b *BashTool) getWorkingDir() string {
96 return b.WorkingDir.Get()
97}
98
99// isNoTrailerSet checks if user has disabled co-author trailer via git config.
100func (b *BashTool) isNoTrailerSet() bool {
101 out, err := exec.Command("git", "config", "--get", "shelley.no-trailer").Output()
102 if err != nil {
103 return false
104 }
105 return strings.TrimSpace(string(out)) == "true"
106}
107
108const (
109 bashName = "bash"
110 bashDescription = `Executes shell commands via bash --login -c, returning combined stdout/stderr.
111Bash state changes (working dir, variables, aliases) don't persist between calls.
112
113With background=true, returns immediately, with output redirected to a file.
114Use background for servers/demos that need to stay running.
115
116MUST set slow_ok=true for potentially slow commands: builds, downloads,
117installs, tests, or any other substantive operation.
118
119Avoid overly destructive cleanup commands. Commands that could delete .git
120directories, home directories, or use broad wildcards require explicit paths.
121Confirm with the user before running destructive operations.
122
123To change the working directory persistently, use the change_dir tool.
124
125IMPORTANT: Keep commands concise. The command input must be less than 60k tokens.
126For complex scripts, write them to a file first and then execute the file.
127`
128 // If you modify this, update the termui template for prettier rendering.
129 bashInputSchema = `
130{
131 "type": "object",
132 "required": ["command"],
133 "properties": {
134 "command": {
135 "type": "string",
136 "description": "Shell to execute"
137 },
138 "slow_ok": {
139 "type": "boolean",
140 "description": "Use extended timeout"
141 },
142 "background": {
143 "type": "boolean",
144 "description": "Execute in background"
145 }
146 }
147}
148`
149)
150
151type bashInput struct {
152 Command string `json:"command"`
153 SlowOK bool `json:"slow_ok,omitempty"`
154 Background bool `json:"background,omitempty"`
155}
156
157// BashDisplayData is the display data sent to the UI for bash tool results.
158type BashDisplayData struct {
159 WorkingDir string `json:"workingDir"`
160}
161
162type BackgroundResult struct {
163 PID int
164 OutFile string
165}
166
167func (r *BackgroundResult) XMLish() string {
168 return fmt.Sprintf("<pid>%d</pid>\n<output_file>%s</output_file>\n<reminder>To stop the process: `kill -9 -%d`</reminder>\n",
169 r.PID, r.OutFile, r.PID)
170}
171
172func (i *bashInput) timeout(t *Timeouts) time.Duration {
173 switch {
174 case i.Background:
175 return t.background()
176 case i.SlowOK:
177 return t.slow()
178 default:
179 return t.fast()
180 }
181}
182
183func (b *BashTool) Run(ctx context.Context, m json.RawMessage) llm.ToolOut {
184 var req bashInput
185 if err := json.Unmarshal(m, &req); err != nil {
186 return llm.ErrorfToolOut("failed to unmarshal bash command input: %w", err)
187 }
188
189 // Check that the working directory exists
190 wd := b.getWorkingDir()
191 if _, err := os.Stat(wd); err != nil {
192 if os.IsNotExist(err) {
193 return llm.ErrorfToolOut("working directory does not exist: %s (use change_dir to switch to a valid directory)", wd)
194 }
195 return llm.ErrorfToolOut("cannot access working directory %s: %w", wd, err)
196 }
197
198 // do a quick permissions check (NOT a security barrier)
199 err := bashkit.Check(req.Command)
200 if err != nil {
201 return llm.ErrorToolOut(err)
202 }
203
204 // Custom permission callback if set
205 if b.CheckPermission != nil {
206 if err := b.CheckPermission(req.Command); err != nil {
207 return llm.ErrorToolOut(err)
208 }
209 }
210
211 // Check for missing tools and try to install them if needed, best effort only
212 if b.EnableJITInstall {
213 err := b.checkAndInstallMissingTools(ctx, req.Command)
214 if err != nil {
215 slog.DebugContext(ctx, "failed to auto-install missing tools", "error", err)
216 }
217 }
218
219 // Add co-author trailer to git commits unless user has disabled it
220 if !b.isNoTrailerSet() {
221 req.Command = bashkit.AddCoauthorTrailer(req.Command, "Co-authored-by: Shelley <shelley@exe.dev>")
222 }
223
224 timeout := req.timeout(b.Timeouts)
225
226 display := BashDisplayData{WorkingDir: wd}
227
228 // If Background is set to true, use executeBackgroundBash
229 if req.Background {
230 result, err := b.executeBackgroundBash(ctx, req, timeout)
231 if err != nil {
232 return llm.ErrorToolOut(err)
233 }
234 return llm.ToolOut{LLMContent: llm.TextContent(result.XMLish()), Display: display}
235 }
236
237 // For foreground commands, use executeBash
238 out, execErr := b.executeBash(ctx, req, timeout)
239 if execErr != nil {
240 return llm.ErrorToolOut(execErr)
241 }
242 return llm.ToolOut{LLMContent: llm.TextContent(out), Display: display}
243}
244
245const (
246 largeOutputThreshold = 50 * 1024 // 50KB - threshold for saving to file
247 firstLinesCount = 2
248 lastLinesCount = 5
249 maxLineLength = 200 // truncate displayed lines to this length
250)
251
252func (b *BashTool) makeBashCommand(ctx context.Context, command string, out io.Writer) *exec.Cmd {
253 cmd := exec.CommandContext(ctx, "bash", "--login", "-c", command)
254 // Use shared WorkingDir if available, then context, then Pwd fallback
255 cmd.Dir = b.getWorkingDir()
256 cmd.Stdin = nil
257 cmd.Stdout = out
258 cmd.Stderr = out
259 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} // set up for killing the process group
260 cmd.Cancel = func() error {
261 if cmd.Process == nil {
262 // Process hasn't started yet.
263 // Not sure whether this is possible in practice,
264 // but it is possible in theory, and it doesn't hurt to handle it gracefully.
265 return nil
266 }
267 return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) // kill entire process group
268 }
269 cmd.WaitDelay = 15 * time.Second // prevent indefinite hangs when child processes keep pipes open
270 // Remove SHELLEY_CONVERSATION_ID so we control it explicitly below.
271 env := slices.DeleteFunc(os.Environ(), func(s string) bool {
272 return strings.HasPrefix(s, "SHELLEY_CONVERSATION_ID=")
273 })
274 env = append(env, "SKETCH=1") // signal that this has been run by Sketch, sometimes useful for scripts
275 env = append(env, "EDITOR=/bin/false") // interactive editors won't work
276 if b.ConversationID != "" {
277 env = append(env, "SHELLEY_CONVERSATION_ID="+b.ConversationID)
278 }
279 cmd.Env = env
280 return cmd
281}
282
283func cmdWait(cmd *exec.Cmd) error {
284 err := cmd.Wait()
285 // We used to kill the process group here, but it's not clear that
286 // this is correct in the case of self-daemonizing processes,
287 // and I encountered issues where daemons that I tried to run
288 // as background tasks would mysteriously exit.
289 return err
290}
291
292func (b *BashTool) executeBash(ctx context.Context, req bashInput, timeout time.Duration) (string, error) {
293 execCtx, cancel := context.WithTimeout(ctx, timeout)
294 defer cancel()
295
296 output := new(bytes.Buffer)
297 cmd := b.makeBashCommand(execCtx, req.Command, output)
298 // TODO: maybe detect simple interactive git rebase commands and auto-background them?
299 // Would need to hint to the agent what is happening.
300 // We might also be able to do this for other simple interactive commands that use EDITOR.
301 cmd.Env = append(cmd.Env, `GIT_SEQUENCE_EDITOR=echo "To do an interactive rebase, run it as a background task and check the output file." && exit 1`)
302 if err := cmd.Start(); err != nil {
303 return "", fmt.Errorf("command failed: %w", err)
304 }
305
306 err := cmdWait(cmd)
307
308 out, formatErr := formatForegroundBashOutput(output.String())
309 if formatErr != nil {
310 return "", formatErr
311 }
312
313 if execCtx.Err() == context.DeadlineExceeded {
314 return "", fmt.Errorf("[command timed out after %s, showing output until timeout]\n%s", timeout, out)
315 }
316 if err != nil {
317 return "", fmt.Errorf("[command failed: %w]\n%s", err, out)
318 }
319
320 return out, nil
321}
322
323// formatForegroundBashOutput formats the output of a foreground bash command for display to the agent.
324// If output exceeds largeOutputThreshold, it saves to a file and returns a summary.
325func formatForegroundBashOutput(out string) (string, error) {
326 if len(out) <= largeOutputThreshold {
327 return out, nil
328 }
329
330 // Save full output to a temp file
331 tmpDir, err := os.MkdirTemp("", "shelley-output-")
332 if err != nil {
333 return "", fmt.Errorf("failed to create temp dir for large output: %w", err)
334 }
335
336 outFile := filepath.Join(tmpDir, "output")
337 if err := os.WriteFile(outFile, []byte(out), 0o644); err != nil {
338 os.RemoveAll(tmpDir)
339 return "", fmt.Errorf("failed to write large output to file: %w", err)
340 }
341
342 // Split into lines
343 lines := strings.Split(out, "\n")
344
345 // If fewer than 3 lines total, likely binary or single-line output
346 if len(lines) < 3 {
347 return fmt.Sprintf("[output too large (%s, %d lines), saved to: %s]",
348 humanizeBytes(len(out)), len(lines), outFile), nil
349 }
350
351 var result strings.Builder
352 result.WriteString(fmt.Sprintf("[output too large (%s, %d lines), saved to: %s]\n\n",
353 humanizeBytes(len(out)), len(lines), outFile))
354
355 // First N lines
356 result.WriteString("First lines:\n")
357 firstN := min(firstLinesCount, len(lines))
358 for i := 0; i < firstN; i++ {
359 result.WriteString(fmt.Sprintf("%5d: %s\n", i+1, truncateLine(lines[i])))
360 }
361
362 // Last N lines
363 result.WriteString("\n...\n\nLast lines:\n")
364 startIdx := max(0, len(lines)-lastLinesCount)
365 for i := startIdx; i < len(lines); i++ {
366 result.WriteString(fmt.Sprintf("%5d: %s\n", i+1, truncateLine(lines[i])))
367 }
368
369 return result.String(), nil
370}
371
372// truncateLine truncates a line to maxLineLength characters, appending "..." if truncated.
373func truncateLine(line string) string {
374 if len(line) <= maxLineLength {
375 return line
376 }
377 return line[:maxLineLength] + "..."
378}
379
380func humanizeBytes(bytes int) string {
381 switch {
382 case bytes < 4*1024:
383 return fmt.Sprintf("%dB", bytes)
384 case bytes < 1024*1024:
385 kb := int(math.Round(float64(bytes) / 1024.0))
386 return fmt.Sprintf("%dkB", kb)
387 case bytes < 1024*1024*1024:
388 mb := int(math.Round(float64(bytes) / (1024.0 * 1024.0)))
389 return fmt.Sprintf("%dMB", mb)
390 }
391 return "more than 1GB"
392}
393
394// executeBackgroundBash executes a command in the background and returns the pid and output file locations
395func (b *BashTool) executeBackgroundBash(ctx context.Context, req bashInput, timeout time.Duration) (*BackgroundResult, error) {
396 // Create temp output files
397 tmpDir, err := os.MkdirTemp("", "sketch-bg-")
398 if err != nil {
399 return nil, fmt.Errorf("failed to create temp directory: %w", err)
400 }
401 // We can't really clean up tempDir, because we have no idea
402 // how far into the future the agent might want to read the output.
403
404 outFile := filepath.Join(tmpDir, "output")
405 out, err := os.Create(outFile)
406 if err != nil {
407 return nil, fmt.Errorf("failed to create output file: %w", err)
408 }
409
410 execCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), timeout) // detach from tool use context
411 cmd := b.makeBashCommand(execCtx, req.Command, out)
412 cmd.Env = append(cmd.Env, `GIT_SEQUENCE_EDITOR=python3 -c "import os, sys, signal, threading; print(f\"Send USR1 to pid {os.getpid()} after editing {sys.argv[1]}\", flush=True); signal.signal(signal.SIGUSR1, lambda *_: sys.exit(0)); threading.Event().wait()"`)
413
414 if err := cmd.Start(); err != nil {
415 cancel()
416 out.Close()
417 os.RemoveAll(tmpDir) // clean up temp dir -- didn't start means we don't need the output
418 return nil, fmt.Errorf("failed to start background command: %w", err)
419 }
420
421 // Wait for completion in the background, then do cleanup.
422 go func() {
423 err := cmdWait(cmd)
424 // Leave a note to the agent so that it knows that the process has finished.
425 if err != nil {
426 fmt.Fprintf(out, "\n\n[background process failed: %v]\n", err)
427 } else {
428 fmt.Fprintf(out, "\n\n[background process completed]\n")
429 }
430 out.Close()
431 cancel()
432 }()
433
434 return &BackgroundResult{
435 PID: cmd.Process.Pid,
436 OutFile: outFile,
437 }, nil
438}
439
440// checkAndInstallMissingTools analyzes a bash command and attempts to automatically install any missing tools.
441func (b *BashTool) checkAndInstallMissingTools(ctx context.Context, command string) error {
442 commands, err := bashkit.ExtractCommands(command)
443 if err != nil {
444 return err
445 }
446
447 autoInstallMu.Lock()
448 defer autoInstallMu.Unlock()
449
450 var missing []string
451 for _, cmd := range commands {
452 if doNotAttemptToolInstall[cmd] {
453 continue
454 }
455 _, err := exec.LookPath(cmd)
456 if err == nil {
457 doNotAttemptToolInstall[cmd] = true // spare future LookPath calls
458 continue
459 }
460 missing = append(missing, cmd)
461 }
462
463 if len(missing) == 0 {
464 return nil
465 }
466
467 for _, cmd := range missing {
468 err := b.installTool(ctx, cmd)
469 if err != nil {
470 slog.WarnContext(ctx, "failed to install tool", "tool", cmd, "error", err)
471 }
472 doNotAttemptToolInstall[cmd] = true // either it's installed or it's not--either way, we're done with it
473 }
474 return nil
475}
476
477// Command safety check cache to avoid repeated LLM calls
478var (
479 autoInstallMu sync.Mutex
480 doNotAttemptToolInstall = make(map[string]bool) // set to true if the tool should not be auto-installed
481)
482
483// autodetectPackageManager returns the first packageāmanager binary
484// found in PATH, or an empty string if none are present.
485func autodetectPackageManager() string {
486 // TODO: cache this result with a sync.OnceValue
487
488 managers := []string{
489 "apt", "apt-get", // Debian/Ubuntu
490 "brew", "port", // macOS (Homebrew / MacPorts)
491 "apk", // Alpine
492 "yum", "dnf", // RHEL/Fedora
493 "pacman", // Arch
494 "zypper", // openSUSE
495 "xbps-install", // Void
496 "emerge", // Gentoo
497 "nix-env", "guix", // NixOS / Guix
498 "pkg", // FreeBSD
499 "slackpkg", // Slackware
500 }
501
502 for _, m := range managers {
503 if _, err := exec.LookPath(m); err == nil {
504 return m
505 }
506 }
507 return ""
508}
509
510// installTool attempts to install a single missing tool using LLM validation and system package manager.
511func (b *BashTool) installTool(ctx context.Context, cmd string) error {
512 slog.InfoContext(ctx, "attempting to install tool", "tool", cmd)
513
514 packageManager := autodetectPackageManager()
515 if packageManager == "" {
516 return fmt.Errorf("no known package manager found in PATH")
517 }
518 // Use LLM to validate and get package name
519 if b.LLMProvider == nil {
520 return fmt.Errorf("no LLM provider available for tool validation")
521 }
522 llmService, err := b.selectBestLLM()
523 if err != nil {
524 return fmt.Errorf("failed to get LLM service for tool validation: %w", err)
525 }
526
527 query := fmt.Sprintf(`Do you know this command/package/tool? Is it legitimate, clearly non-harmful, and commonly used? Can it be installed with package manager %s?
528
529Command: %s
530
531- YES: Respond ONLY with the package name used to install it
532- NO or UNSURE: Respond ONLY with the word NO`, packageManager, cmd)
533
534 req := &llm.Request{
535 Messages: []llm.Message{{
536 Role: llm.MessageRoleUser,
537 Content: []llm.Content{llm.StringContent(query)},
538 }},
539 System: []llm.SystemContent{{
540 Type: "text",
541 Text: "You are an expert in software developer tools.",
542 }},
543 }
544
545 resp, err := llmService.Do(ctx, req)
546 if err != nil {
547 return fmt.Errorf("failed to validate tool with LLM: %w", err)
548 }
549
550 if len(resp.Content) == 0 {
551 return fmt.Errorf("empty response from LLM for tool validation")
552 }
553
554 response := strings.TrimSpace(resp.Content[0].Text)
555 if response == "NO" || response == "UNSURE" {
556 slog.InfoContext(ctx, "tool installation declined by LLM", "tool", cmd, "response", response)
557 return fmt.Errorf("tool %s not approved for installation", cmd)
558 }
559
560 packageName := strings.TrimSpace(response)
561 if packageName == "" {
562 return fmt.Errorf("no package name provided for tool %s", cmd)
563 }
564
565 return b.installPackage(ctx, cmd, packageName, packageManager)
566}
567
568// installPackage handles the actual package installation
569func (b *BashTool) installPackage(ctx context.Context, cmd, packageName, packageManager string) error {
570 // Install the package (with update command first if needed)
571 // TODO: these invocations create zombies when we are PID 1.
572 // We should give them the same zombie-reaping treatment as above,
573 // if/when we care enough to put in the effort. Not today.
574 var updateCmd, installCmd string
575 switch packageManager {
576 case "apt", "apt-get":
577 updateCmd = fmt.Sprintf("sudo %s update", packageManager)
578 installCmd = fmt.Sprintf("sudo %s install -y %s", packageManager, packageName)
579 case "brew":
580 // brew handles updates automatically, no explicit update needed
581 installCmd = fmt.Sprintf("brew install %s", packageName)
582 case "apk":
583 updateCmd = "sudo apk update"
584 installCmd = fmt.Sprintf("sudo apk add %s", packageName)
585 case "yum", "dnf":
586 // For yum/dnf, we don't need a separate update command as the package cache is usually fresh enough
587 // and install will fetch the latest available packages
588 installCmd = fmt.Sprintf("sudo %s install -y %s", packageManager, packageName)
589 case "pacman":
590 updateCmd = "sudo pacman -Sy"
591 installCmd = fmt.Sprintf("sudo pacman -S --noconfirm %s", packageName)
592 case "zypper":
593 updateCmd = "sudo zypper refresh"
594 installCmd = fmt.Sprintf("sudo zypper install -y %s", packageName)
595 case "xbps-install":
596 updateCmd = "sudo xbps-install -S"
597 installCmd = fmt.Sprintf("sudo xbps-install -y %s", packageName)
598 case "emerge":
599 // Note: emerge --sync is expensive, so we skip it for JIT installs
600 // Users should manually sync if needed
601 installCmd = fmt.Sprintf("sudo emerge %s", packageName)
602 case "nix-env":
603 // nix-env doesn't require explicit updates for JIT installs
604 installCmd = fmt.Sprintf("nix-env -i %s", packageName)
605 case "guix":
606 // guix doesn't require explicit updates for JIT installs
607 installCmd = fmt.Sprintf("guix install %s", packageName)
608 case "pkg":
609 updateCmd = "sudo pkg update"
610 installCmd = fmt.Sprintf("sudo pkg install -y %s", packageName)
611 case "slackpkg":
612 updateCmd = "sudo slackpkg update"
613 installCmd = fmt.Sprintf("sudo slackpkg install %s", packageName)
614 default:
615 return fmt.Errorf("unsupported package manager: %s", packageManager)
616 }
617
618 slog.InfoContext(ctx, "installing tool", "tool", cmd, "package", packageName, "update_command", updateCmd, "install_command", installCmd)
619
620 // Execute the update command first if needed
621 if updateCmd != "" {
622 slog.InfoContext(ctx, "updating package cache", "command", updateCmd)
623 updateCmdExec := exec.CommandContext(ctx, "sh", "-c", updateCmd)
624 updateOutput, err := updateCmdExec.CombinedOutput()
625 if err != nil {
626 slog.WarnContext(ctx, "package cache update failed, proceeding with install anyway", "error", err, "output", string(updateOutput))
627 }
628 }
629
630 // Execute the install command
631 cmdExec := exec.CommandContext(ctx, "sh", "-c", installCmd)
632 output, err := cmdExec.CombinedOutput()
633 if err != nil {
634 return fmt.Errorf("failed to install %s: %w\nOutput: %s", packageName, err, string(output))
635 }
636
637 slog.InfoContext(ctx, "tool installation successful", "tool", cmd, "package", packageName)
638 return nil
639}
640
641// selectBestLLM selects the best available LLM service for bash tool validation
642func (b *BashTool) selectBestLLM() (llm.Service, error) {
643 if b.LLMProvider == nil {
644 return nil, fmt.Errorf("no LLM provider available")
645 }
646
647 // Preferred models in order of preference for tool validation (fast, cheap models preferred)
648 preferredModels := []string{"qwen3-coder-fireworks", "gpt-5-thinking-mini", "gpt5-mini", "claude-sonnet-4.5", "predictable"}
649
650 for _, model := range preferredModels {
651 svc, err := b.LLMProvider.GetService(model)
652 if err == nil {
653 return svc, nil
654 }
655 }
656
657 // If no preferred model is available, try any available model
658 available := b.LLMProvider.GetAvailableModels()
659 if len(available) > 0 {
660 return b.LLMProvider.GetService(available[0])
661 }
662
663 return nil, fmt.Errorf("no LLM services available")
664}