Class: Pikuri::Tool::Write

Inherits:
Pikuri::Tool show all
Defined in:
lib/pikuri/tool/write.rb

Overview

The write tool, expressed as a Pikuri::Tool subclass: instantiating Tool::Write.new(workspace: ws, confirmer: c) produces a tool whose #to_ruby_llm_tool wiring is identical to any bundled tool’s, so ruby_llm sees nothing special about it. Same shape as SubAgent and Read — workspace and confirmer are captured by the execute closure at construction.

Policy

Three branches based on the on-disk state of path:

  1. *New file* — write, no prompt.

  2. *Existing file, identical content* — return an “Error: …” no-op observation before invoking the confirmer; don’t ask the user to approve a write that wouldn’t change the file. Comparison is byte-strict, in BINARY encoding (trailing-newline-only differences trigger the confirm path; encoding tags can’t make equal bytes compare unequal).

  3. *Existing file, content differs* — confirm with “OK to overwrite <path>: <old> → <new> bytes?” via Confirmer; on yes, write. On no, return a decline-Error observation.

Why ask-on-overwrite (Edit doesn’t)

Edit’s old_string argument is an implicit read-check: the model can’t write a correct old_string without having read the file, so blast radius is bounded by what the model actually knows about file state. Write has no such check, so a hallucinated 500-line content could clobber unread work. The confirmation prompt guards exactly the gap Edit’s argument shape already covers.

Side effects

Parent directories are created (FileUtils.mkdir_p) before the write — matches the git add lib/new/dir/foo.rb mental model and mirrors opencode’s and pi’s behavior. Edge case: mkdir_p succeeds but the write fails; an empty directory is left behind. Accepted for v1 — users have version control.

No atomic temp-file+rename. Plain File.write, same as opencode and pi. The crash-safety story is “the user has git”.

Constant Summary collapse

DESCRIPTION =

Description shown to the LLM. Follows the opencode-shape (summary + Usage: bullets) prescribed by the project’s tool-description convention. Per-parameter constraints live in the parameter descriptions; the Usage: bullets are for “when do I pick this? how does it chain with other tools?”.

Returns:

  • (String)
<<~DESC
  Write a file to the workspace, creating parent directories as needed.

  Usage:
  - Use for new files or full-file rewrites; for partial changes use `edit` instead.
  - Overwriting an existing file requires user confirmation; identical content is rejected as a no-op error — if you see that error, re-read the file rather than trying again.
  - Parent directories are created automatically (mkdir -p).
  - Writes the exact bytes supplied: no trailing-newline normalization, no encoding conversion.
  - Paths outside the workspace are refused.
DESC

Constants inherited from Pikuri::Tool

CALCULATOR, FETCH, WEB_SCRAPE, WEB_SEARCH

Instance Attribute Summary

Attributes inherited from Pikuri::Tool

#description, #execute, #name, #parameters

Class Method Summary collapse

Instance Method Summary collapse

Methods inherited from Pikuri::Tool

#run, #to_ruby_llm_tool

Constructor Details

#initialize(workspace:, confirmer:) ⇒ Write

Parameters:

  • workspace (Tool::Workspace)

    captured for path resolution; all writes route through workspace.resolve_for_write.

  • confirmer (Tool::Confirmer)

    consulted before any overwrite of an existing file with non-identical content.



72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
# File 'lib/pikuri/tool/write.rb', line 72

def initialize(workspace:, confirmer:)
  super(
    name: 'write',
    description: DESCRIPTION,
    parameters: Parameters.build { |p|
      p.required_string :path,
                        'Path to the file to write. Relative paths ' \
                        'resolve against the workspace root, e.g. ' \
                        '"lib/foo.rb".'
      p.required_string :content,
                        'Full contents to write to the file, e.g. ' \
                        '"class Foo\nend\n".'
    },
    execute: ->(path:, content:) {
      Write.write(workspace: workspace, confirmer: confirmer, path: path, content: content)
    }
  )
end

Class Method Details

.write(workspace:, confirmer:, path:, content:) ⇒ String

Resolve path against workspace, apply the three-branch policy (new / identical / differs), and return either a success observation or an “Error: …” observation.

Parameters:

Returns:

  • (String)

    tool observation



100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
# File 'lib/pikuri/tool/write.rb', line 100

def self.write(workspace:, confirmer:, path:, content:)
  resolved = workspace.resolve_for_write(path)

  if resolved.exist?
    return "Error: #{path} is a directory" if resolved.directory?

    existing = read_for_compare(resolved, path)

    if existing == content.b
      return "Error: #{path} already contains exactly this content — " \
             'no write needed. If you intended a change, re-read the ' \
             'file and try again.'
    end

    prompt = "OK to overwrite #{path}: #{existing.bytesize}#{content.bytesize} bytes? (y/n)"
    return "Error: user declined the write to #{path}." unless confirmer.confirm?(prompt: prompt)

    write_bytes(resolved, content)
    "Updated #{path} (#{existing.bytesize}#{content.bytesize} bytes)"
  else
    write_bytes(resolved, content)
    "Created #{path} (#{content.bytesize} bytes)"
  end
rescue Tool::Workspace::Error, Error => e
  "Error: #{e.message}"
rescue Errno::EACCES => e
  "Error: cannot write #{path}: #{e.message}"
end