// This file implements the write tool (US-016): create or overwrite a file at a // given path, creating parent directories as needed. Overwrites are reported so // the caller/model knows an existing file was replaced (parity with pi's write // behavior). Paths resolve against a Root and are rejected if they escape it. package agenttool import ( "context" "encoding/json" "fmt" "os" "path/filepath" "github.com/smallnest/pigo/internal/agentcore" ) // WriteTool writes text files under Root, creating parent directories as needed. type WriteTool struct { // Root bounds all writes; a path resolving outside Root is rejected. Empty // Root defaults to the current working directory. Root string // ExtraRoots are additional trusted directories a write may target even though // they lie outside Root. It exists for the skills directory so the model can // author or update skills (create a new SKILL.md, edit an existing one) that // live outside the workspace. ExtraRoots []string // Snap, when non-nil, records the file's prior content before it is written so // the /rewind command can roll the change back. It is shared with the edit tool. Snap *FileSnapshotRecorder } // writeToolArgs is the decoded argument shape for WriteTool. type writeToolArgs struct { // Path is the file to write, relative to Root (or absolute within Root). Path string `json:"path"` // Content is the full file contents to write (overwrites any existing file). Content string `json:"content"` } // Name implements AgentTool. func (t *WriteTool) Name() string { return "write" } // Description implements AgentTool. func (t *WriteTool) Description() string { return "Create or overwrite a file at the given path, creating parent " + "directories as needed. Overwriting an existing file is reported." } // Schema implements AgentTool. func (t *WriteTool) Schema() json.RawMessage { return json.RawMessage(`{ "type": "object", "properties": { "path": {"type": "string", "description": "File path to write, relative to the workspace root."}, "content": {"type": "string", "description": "Full file contents to write."} }, "required": ["path", "content"], "additionalProperties": false }`) } // ExecutionMode implements AgentTool. Writes mutate the filesystem → sequential // so a batch does not race concurrent writes to the same tree. func (t *WriteTool) ExecutionMode() agentcore.ToolExecutionMode { return agentcore.ToolExecutionSequential } // resolvePath resolves p against Root (or any ExtraRoots) via the shared // resolveWithin boundary policy, so every file tool enforces the same // workspace-escape guard while writes can also reach trusted extra roots. func (t *WriteTool) resolvePath(p string) (string, error) { if len(t.ExtraRoots) == 0 { return resolveWithin(t.Root, p) } return resolveWithinAny(append([]string{t.Root}, t.ExtraRoots...), p) } // Execute implements AgentTool. Write failures are encoded as error results; // the returned Go error is reserved for nothing here (argument decode also // degrades to a result), matching the read tool's contract. func (t *WriteTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) { a, bad := decodeArgs[writeToolArgs](args, "write") if bad != nil { return *bad, nil } if a.Path == "" { return errorResult("write: path is required"), nil } full, err := t.resolvePath(a.Path) if err != nil { return errorResult("write: " + err.Error()), nil } // Detect overwrite before writing so the result can report it. A path that // points at a directory is an error, not an overwrite. overwrote := false if info, statErr := os.Stat(full); statErr == nil { if info.IsDir() { return errorResult(fmt.Sprintf("write: %q is a directory, not a file", a.Path)), nil } overwrote = true } // Create parent directories as needed. if dir := filepath.Dir(full); dir != "" { if err := os.MkdirAll(dir, dirPerm); err != nil { return errorResult(fmt.Sprintf("write: cannot create parent directories for %q: %v", a.Path, err)), nil } } // Snapshot the prior state before mutating so /rewind can restore it. t.Snap.Record(full) if err := os.WriteFile(full, []byte(a.Content), filePerm); err != nil { return errorResult(fmt.Sprintf("write: cannot write %q: %v", a.Path, err)), nil } verb := "Created" if overwrote { verb = "Overwrote" } msg := fmt.Sprintf("%s %s (%d bytes)", verb, a.Path, len(a.Content)) return agentcore.AgentToolResult{ Content: agentcore.ContentList{agentcore.NewTextContent(msg)}, Details: map[string]any{"path": a.Path, "bytes": len(a.Content), "overwrote": overwrote}, }, nil }