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

View file

@ -1604,54 +1604,85 @@ func TestBuildRichStatus_ProjectDir(t *testing.T) {
} }
func TestExtractProjectDir(t *testing.T) { func TestExtractProjectDir(t *testing.T) {
ws := "/home/user/.picoclaw/workspace"
tests := []struct { tests := []struct {
name string name string
cmd string toolName string
args map[string]interface{}
workspace string workspace string
want string want string
}{ }{
// exec: cd prefix
{ {
name: "cd into projects subdir", name: "exec cd into projects subdir",
cmd: "cd /home/user/.picoclaw/workspace/projects/terra-py-form && pytest", toolName: "exec",
workspace: "/home/user/.picoclaw/workspace", args: map[string]interface{}{"command": "cd /home/user/.picoclaw/workspace/projects/terra-py-form && pytest"},
workspace: ws,
want: "terra-py-form", want: "terra-py-form",
}, },
{ {
name: "cd into projects subdir with trailing slash", name: "exec cd with trailing slash workspace",
cmd: "cd /home/user/.picoclaw/workspace/projects/terra-py-form && ls", toolName: "exec",
workspace: "/home/user/.picoclaw/workspace/", args: map[string]interface{}{"command": "cd /home/user/.picoclaw/workspace/projects/terra-py-form && ls"},
workspace: ws + "/",
want: "terra-py-form", want: "terra-py-form",
}, },
{ {
name: "cd into direct subdir", name: "exec cd into direct subdir",
cmd: "cd /home/user/.picoclaw/workspace/my-app && make build", toolName: "exec",
workspace: "/home/user/.picoclaw/workspace", args: map[string]interface{}{"command": "cd /home/user/.picoclaw/workspace/my-app && make build"},
workspace: ws,
want: "my-app", want: "my-app",
}, },
{ {
name: "cd to workspace itself", name: "exec cd to workspace itself",
cmd: "cd /home/user/.picoclaw/workspace && ls", toolName: "exec",
workspace: "/home/user/.picoclaw/workspace", args: map[string]interface{}{"command": "cd /home/user/.picoclaw/workspace && ls"},
workspace: ws,
want: "", want: "",
}, },
{ {
name: "no cd prefix", name: "exec no cd prefix",
cmd: "pytest tests/", toolName: "exec",
workspace: "/home/user/.picoclaw/workspace", args: map[string]interface{}{"command": "pytest tests/"},
workspace: ws,
want: "", want: "",
}, },
// file tools
{ {
name: "cd to unrelated path", name: "read_file in projects subdir",
cmd: "cd /tmp/build && make", toolName: "read_file",
workspace: "/home/user/.picoclaw/workspace", args: map[string]interface{}{"path": "/home/user/.picoclaw/workspace/projects/terra-py-form/src/main.py"},
want: "build", 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 { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { 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 { 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)
} }
}) })
} }