changedir.go

  1package claudetool
  2
  3import (
  4	"context"
  5	"encoding/json"
  6	"fmt"
  7	"os"
  8	"path/filepath"
  9	"strings"
 10
 11	"shelley.exe.dev/gitstate"
 12	"shelley.exe.dev/llm"
 13)
 14
 15// tildeReplace replaces the home directory prefix with ~ for display.
 16func tildeReplace(path string) string {
 17	if home, err := os.UserHomeDir(); err == nil && strings.HasPrefix(path, home) {
 18		return "~" + path[len(home):]
 19	}
 20	return path
 21}
 22
 23// ChangeDirTool changes the working directory for bash commands.
 24type ChangeDirTool struct {
 25	// WorkingDir is the shared mutable working directory.
 26	WorkingDir *MutableWorkingDir
 27	// OnChange is called after the working directory changes successfully.
 28	// This can be used to persist the change to a database.
 29	OnChange func(newDir string)
 30}
 31
 32const (
 33	changeDirName        = "change_dir"
 34	changeDirDescription = `Change the working directory for subsequent bash commands.
 35
 36This affects the working directory used by the bash tool. The directory must exist.
 37Relative paths are resolved against the current working directory.
 38
 39Use this to navigate the filesystem persistently across bash commands,
 40rather than using 'cd' within each bash command (which doesn't persist).
 41`
 42	changeDirInputSchema = `{
 43  "type": "object",
 44  "required": ["path"],
 45  "properties": {
 46    "path": {
 47      "type": "string",
 48      "description": "The directory path to change to (absolute or relative)"
 49    }
 50  }
 51}`
 52)
 53
 54type changeDirInput struct {
 55	Path string `json:"path"`
 56}
 57
 58// Tool returns an llm.Tool for changing directories.
 59func (c *ChangeDirTool) Tool() *llm.Tool {
 60	return &llm.Tool{
 61		Name:        changeDirName,
 62		Description: changeDirDescription,
 63		InputSchema: llm.MustSchema(changeDirInputSchema),
 64		Run:         c.Run,
 65	}
 66}
 67
 68// Run executes the change_dir tool.
 69func (c *ChangeDirTool) Run(ctx context.Context, m json.RawMessage) llm.ToolOut {
 70	var req changeDirInput
 71	if err := json.Unmarshal(m, &req); err != nil {
 72		return llm.ErrorfToolOut("failed to parse change_dir input: %w", err)
 73	}
 74
 75	if req.Path == "" {
 76		return llm.ErrorfToolOut("path is required")
 77	}
 78
 79	// Get current working directory
 80	currentWD := c.WorkingDir.Get()
 81
 82	// Resolve the path
 83	targetPath := req.Path
 84	if !filepath.IsAbs(targetPath) {
 85		targetPath = filepath.Join(currentWD, targetPath)
 86	}
 87	targetPath = filepath.Clean(targetPath)
 88
 89	// Validate the directory exists
 90	info, err := os.Stat(targetPath)
 91	if err != nil {
 92		if os.IsNotExist(err) {
 93			return llm.ErrorfToolOut("directory does not exist: %s", targetPath)
 94		}
 95		return llm.ErrorfToolOut("failed to stat path: %w", err)
 96	}
 97	if !info.IsDir() {
 98		return llm.ErrorfToolOut("path is not a directory: %s", targetPath)
 99	}
100
101	// Update the working directory
102	c.WorkingDir.Set(targetPath)
103
104	// Notify callback if set
105	if c.OnChange != nil {
106		c.OnChange(targetPath)
107	}
108
109	// Check git status for the new directory
110	state := gitstate.GetGitState(targetPath)
111	var resultText string
112	if state.IsRepo {
113		resultText = fmt.Sprintf("Changed working directory to: %s\n\nGit repository detected (root: %s, branch: %s)", targetPath, tildeReplace(state.Worktree), state.Branch)
114		if state.Branch == "" {
115			resultText = fmt.Sprintf("Changed working directory to: %s\n\nGit repository detected (root: %s, detached HEAD)", targetPath, tildeReplace(state.Worktree))
116		}
117	} else {
118		resultText = fmt.Sprintf("Changed working directory to: %s\n\nNot in a git repository.", targetPath)
119	}
120
121	return llm.ToolOut{
122		LLMContent: llm.TextContent(resultText),
123	}
124}