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}