feat: extract project dir from file tool paths too, not just exec cd

Refactored extractProjectDir to accept any tool call. For read_file,
edit_file etc., the file path is used to detect the project directory.
Extracted shared logic into projectDirFromPath.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dj-oyu 2026-02-22 06:37:36 +09:00
parent d6733de91e
commit 6297d3679e
2 changed files with 99 additions and 45 deletions

View file

@ -956,37 +956,35 @@ var cdPrefixPattern = regexp.MustCompile(`^cd\s+(\S+)\s*&&\s*`)
// argument (e.g. "-A 20") are kept because removing them would lose context.
var optFlagPattern = regexp.MustCompile(`\s+--?\w[\w-]*(=\S*)?`)
// extractProjectDir extracts the project directory name from an exec command's
// "cd <path> && ..." prefix by stripping the workspace prefix and taking the
// first remaining path component.
// Returns "" if no cd prefix or no deeper directory exists.
func extractProjectDir(cmd, workspace string) string {
m := cdPrefixPattern.FindStringSubmatch(cmd)
if len(m) < 2 {
// projectDirFromPath extracts the project directory name from an absolute path
// by stripping the workspace prefix and returning the first meaningful component.
// Generic directory names like "projects" or "repos" are skipped.
// Returns "" if the path doesn't extend beyond workspace.
func projectDirFromPath(absPath, workspace string) string {
absPath = strings.TrimRight(absPath, "/\\")
if absPath == "" {
return ""
}
cdPath := strings.TrimRight(m[1], "/\\")
if workspace == "" {
if idx := strings.LastIndex(cdPath, "/"); idx >= 0 {
return cdPath[idx+1:]
if idx := strings.LastIndex(absPath, "/"); idx >= 0 {
return absPath[idx+1:]
}
return cdPath
return absPath
}
ws := strings.TrimRight(workspace, "/\\")
rest := strings.TrimPrefix(cdPath, ws)
if rest == cdPath {
rest := strings.TrimPrefix(absPath, ws)
if rest == absPath {
// workspace not a prefix — fall back to last component
if idx := strings.LastIndex(cdPath, "/"); idx >= 0 {
return cdPath[idx+1:]
if idx := strings.LastIndex(absPath, "/"); idx >= 0 {
return absPath[idx+1:]
}
return cdPath
return absPath
}
rest = strings.TrimLeft(rest, "/\\")
if rest == "" {
return ""
}
// Take first path component (e.g. "projects/my-app" → "projects")
// But if it looks like a generic dir (projects, src, workspace), go deeper
// Take first path component, skipping generic directory names
parts := strings.SplitN(rest, "/", 3)
if len(parts) >= 2 {
first := strings.ToLower(parts[0])
@ -997,6 +995,33 @@ func extractProjectDir(cmd, workspace string) string {
return parts[0]
}
// extractProjectDir extracts the project directory name from a tool call.
// For exec: parses the "cd <path> && ..." prefix.
// For file tools: uses the file path directly.
// Returns "" if no project directory can be determined.
func extractProjectDir(toolName string, args map[string]interface{}, workspace string) string {
switch toolName {
case "exec":
cmd, _ := args["command"].(string)
if cmd == "" {
return ""
}
m := cdPrefixPattern.FindStringSubmatch(cmd)
if len(m) < 2 {
return ""
}
return projectDirFromPath(m[1], workspace)
case "read_file", "write_file", "edit_file", "append_file", "list_dir":
path, _ := args["path"].(string)
if path == "" {
return ""
}
return projectDirFromPath(path, workspace)
}
return ""
}
// buildArgsSnippet produces a human-friendly snippet for the tool log.
// For exec: extracts the command and strips the leading "cd <workspace> && ".
// For file tools: extracts the path and strips the workspace prefix.
@ -1534,11 +1559,9 @@ func (al *AgentLoop) runLLMIteration(
ArgsSnip: buildArgsSnippet(tc.Name, tc.Arguments, agent.Workspace),
Result: "\u23F3",
})
// Detect project directory from exec cd prefix (once)
if task.projectDir == "" && tc.Name == "exec" {
if cmd, _ := tc.Arguments["command"].(string); cmd != "" {
task.projectDir = extractProjectDir(cmd, agent.Workspace)
}
// Detect project directory from tool call args (once)
if task.projectDir == "" {
task.projectDir = extractProjectDir(tc.Name, tc.Arguments, agent.Workspace)
}
}
task.mu.Unlock()

View file

@ -1604,54 +1604,85 @@ func TestBuildRichStatus_ProjectDir(t *testing.T) {
}
func TestExtractProjectDir(t *testing.T) {
ws := "/home/user/.picoclaw/workspace"
tests := []struct {
name string
cmd string
toolName string
args map[string]interface{}
workspace string
want string
}{
// exec: cd prefix
{
name: "cd into projects subdir",
cmd: "cd /home/user/.picoclaw/workspace/projects/terra-py-form && pytest",
workspace: "/home/user/.picoclaw/workspace",
name: "exec cd into projects subdir",
toolName: "exec",
args: map[string]interface{}{"command": "cd /home/user/.picoclaw/workspace/projects/terra-py-form && pytest"},
workspace: ws,
want: "terra-py-form",
},
{
name: "cd into projects subdir with trailing slash",
cmd: "cd /home/user/.picoclaw/workspace/projects/terra-py-form && ls",
workspace: "/home/user/.picoclaw/workspace/",
name: "exec cd with trailing slash workspace",
toolName: "exec",
args: map[string]interface{}{"command": "cd /home/user/.picoclaw/workspace/projects/terra-py-form && ls"},
workspace: ws + "/",
want: "terra-py-form",
},
{
name: "cd into direct subdir",
cmd: "cd /home/user/.picoclaw/workspace/my-app && make build",
workspace: "/home/user/.picoclaw/workspace",
name: "exec cd into direct subdir",
toolName: "exec",
args: map[string]interface{}{"command": "cd /home/user/.picoclaw/workspace/my-app && make build"},
workspace: ws,
want: "my-app",
},
{
name: "cd to workspace itself",
cmd: "cd /home/user/.picoclaw/workspace && ls",
workspace: "/home/user/.picoclaw/workspace",
name: "exec cd to workspace itself",
toolName: "exec",
args: map[string]interface{}{"command": "cd /home/user/.picoclaw/workspace && ls"},
workspace: ws,
want: "",
},
{
name: "no cd prefix",
cmd: "pytest tests/",
workspace: "/home/user/.picoclaw/workspace",
name: "exec no cd prefix",
toolName: "exec",
args: map[string]interface{}{"command": "pytest tests/"},
workspace: ws,
want: "",
},
// file tools
{
name: "cd to unrelated path",
cmd: "cd /tmp/build && make",
workspace: "/home/user/.picoclaw/workspace",
want: "build",
name: "read_file in projects subdir",
toolName: "read_file",
args: map[string]interface{}{"path": "/home/user/.picoclaw/workspace/projects/terra-py-form/src/main.py"},
workspace: ws,
want: "terra-py-form",
},
{
name: "edit_file in direct subdir",
toolName: "edit_file",
args: map[string]interface{}{"path": "/home/user/.picoclaw/workspace/my-app/README.md"},
workspace: ws,
want: "my-app",
},
{
name: "write_file at workspace root",
toolName: "write_file",
args: map[string]interface{}{"path": "/home/user/.picoclaw/workspace/notes.txt"},
workspace: ws,
want: "notes.txt",
},
{
name: "unknown tool returns empty",
toolName: "web_search",
args: map[string]interface{}{"query": "test"},
workspace: ws,
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractProjectDir(tt.cmd, tt.workspace)
got := extractProjectDir(tt.toolName, tt.args, tt.workspace)
if got != tt.want {
t.Errorf("extractProjectDir(%q, %q) = %q, want %q", tt.cmd, tt.workspace, got, tt.want)
t.Errorf("extractProjectDir(%q, args, %q) = %q, want %q", tt.toolName, tt.workspace, got, tt.want)
}
})
}