summaryrefslogtreecommitdiffhomepage
path: root/lib/dispatch/tool
diff options
context:
space:
mode:
Diffstat (limited to 'lib/dispatch/tool')
-rw-r--r--lib/dispatch/tool/files.rb15
-rw-r--r--lib/dispatch/tool/files/create_file.rb46
-rw-r--r--lib/dispatch/tool/files/edit_file.rb91
-rw-r--r--lib/dispatch/tool/files/list_files.rb60
-rw-r--r--lib/dispatch/tool/files/read_file.rb68
-rw-r--r--lib/dispatch/tool/files/register.rb21
-rw-r--r--lib/dispatch/tool/files/sandbox.rb69
-rw-r--r--lib/dispatch/tool/files/search_files.rb89
-rw-r--r--lib/dispatch/tool/files/write_file.rb43
9 files changed, 501 insertions, 1 deletions
diff --git a/lib/dispatch/tool/files.rb b/lib/dispatch/tool/files.rb
index b7418c3..50e1a58 100644
--- a/lib/dispatch/tool/files.rb
+++ b/lib/dispatch/tool/files.rb
@@ -1,12 +1,25 @@
# frozen_string_literal: true
+require "dispatch/tools/interface"
+require "fileutils"
require_relative "files/version"
module Dispatch
module Tool
module Files
class Error < StandardError; end
- # Your code goes here...
+ class SandboxError < Error; end
+ class FileNotFoundError < Error; end
+ class FileExistsError < Error; end
end
end
end
+
+require_relative "files/sandbox"
+require_relative "files/read_file"
+require_relative "files/write_file"
+require_relative "files/edit_file"
+require_relative "files/create_file"
+require_relative "files/list_files"
+require_relative "files/search_files"
+require_relative "files/register"
diff --git a/lib/dispatch/tool/files/create_file.rb b/lib/dispatch/tool/files/create_file.rb
new file mode 100644
index 0000000..5cc13e4
--- /dev/null
+++ b/lib/dispatch/tool/files/create_file.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+module Dispatch
+ module Tool
+ module Files
+ CREATE_FILE = Dispatch::Tools::Definition.new(
+ name: "create_file",
+ description: "Create a new file with the given content. Fails if the file already exists.",
+ parameters: {
+ type: "object",
+ properties: {
+ path: {
+ type: "string",
+ description: "Path to the file to create, relative to the worktree root."
+ },
+ content: {
+ type: "string",
+ description: "The content to write to the new file."
+ }
+ },
+ required: %w[path content],
+ additionalProperties: false
+ }
+ ) do |params, context|
+ worktree_path = context[:worktree_path]
+ path = params[:path]
+ content = params[:content]
+
+ resolved = begin
+ Sandbox.resolve_path(path, worktree_path:)
+ rescue SandboxError => e
+ next Dispatch::Tools::Result.failure(error: e.message)
+ end
+
+ if File.exist?(resolved)
+ next Dispatch::Tools::Result.failure(error: "File already exists: #{path}. Use write_file to overwrite.")
+ end
+
+ FileUtils.mkdir_p(File.dirname(resolved))
+ File.write(resolved, content)
+
+ Dispatch::Tools::Result.success(output: "Created #{path}")
+ end
+ end
+ end
+end
diff --git a/lib/dispatch/tool/files/edit_file.rb b/lib/dispatch/tool/files/edit_file.rb
new file mode 100644
index 0000000..b1bd159
--- /dev/null
+++ b/lib/dispatch/tool/files/edit_file.rb
@@ -0,0 +1,91 @@
+# frozen_string_literal: true
+
+module Dispatch
+ module Tool
+ module Files
+ EDIT_FILE = Dispatch::Tools::Definition.new(
+ name: "edit_file",
+ description: "Apply text edits to an existing file. Each edit replaces old_text with new_text sequentially.",
+ parameters: {
+ type: "object",
+ properties: {
+ path: {
+ type: "string",
+ description: "Path to the file to edit, relative to the worktree root."
+ },
+ edits: {
+ type: "array",
+ description: "Array of edit operations to apply sequentially.",
+ items: {
+ type: "object",
+ properties: {
+ old_text: {
+ type: "string",
+ description: "The exact text to find in the file."
+ },
+ new_text: {
+ type: "string",
+ description: "The text to replace old_text with."
+ }
+ },
+ required: %w[old_text new_text],
+ additionalProperties: false
+ }
+ }
+ },
+ required: %w[path edits],
+ additionalProperties: false
+ }
+ ) do |params, context|
+ worktree_path = context[:worktree_path]
+ path = params[:path]
+ edits = params[:edits]
+
+ resolved = begin
+ Sandbox.resolve_path(path, worktree_path:)
+ rescue SandboxError => e
+ next Dispatch::Tools::Result.failure(error: e.message)
+ end
+
+ unless File.exist?(resolved)
+ next Dispatch::Tools::Result.failure(error: "File not found: #{path}")
+ end
+
+ content = File.read(resolved)
+ edits_applied = 0
+ error_result = nil
+
+ edits.each do |edit|
+ old_text = edit[:old_text] || edit["old_text"]
+ new_text = edit[:new_text] || edit["new_text"]
+
+ escaped = Regexp.new(Regexp.escape(old_text))
+ occurrences = content.scan(escaped).length
+
+ if occurrences.zero?
+ error_result = Dispatch::Tools::Result.failure(
+ error: "Edit #{edits_applied + 1}: old_text not found in #{path}"
+ )
+ break
+ end
+
+ if occurrences > 1
+ error_result = Dispatch::Tools::Result.failure(
+ error: "Edit #{edits_applied + 1}: ambiguous match — old_text found #{occurrences} times in #{path}. " \
+ "Provide more context to make the match unique."
+ )
+ break
+ end
+
+ content = content.sub(old_text, new_text)
+ edits_applied += 1
+ end
+
+ next error_result if error_result
+
+ File.write(resolved, content)
+ Dispatch::Tools::Result.success(output: "Applied #{edits_applied} edit(s) to #{path}")
+ end
+ end
+ end
+end
diff --git a/lib/dispatch/tool/files/list_files.rb b/lib/dispatch/tool/files/list_files.rb
new file mode 100644
index 0000000..7e7510d
--- /dev/null
+++ b/lib/dispatch/tool/files/list_files.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+module Dispatch
+ module Tool
+ module Files
+ LIST_FILES = Dispatch::Tools::Definition.new(
+ name: "list_files",
+ description: "List files in a directory. Returns paths relative to the worktree root.",
+ parameters: {
+ type: "object",
+ properties: {
+ path: {
+ type: "string",
+ description: "Directory path relative to the worktree root. Defaults to the root."
+ },
+ pattern: {
+ type: "string",
+ description: "Glob pattern to filter files (e.g. '**/*.rb')."
+ },
+ recursive: {
+ type: "boolean",
+ description: "Whether to list files recursively. Defaults to true."
+ }
+ },
+ additionalProperties: false
+ }
+ ) do |params, context|
+ worktree_path = context[:worktree_path]
+ path = params.fetch(:path, ".")
+ pattern = params[:pattern]
+ recursive = params.fetch(:recursive, true)
+
+ resolved = begin
+ Sandbox.resolve_path(path, worktree_path:)
+ rescue SandboxError => e
+ next Dispatch::Tools::Result.failure(error: e.message)
+ end
+
+ unless File.directory?(resolved)
+ next Dispatch::Tools::Result.failure(error: "Directory not found: #{path}")
+ end
+
+ glob = if pattern
+ File.join(resolved, pattern)
+ elsif recursive
+ File.join(resolved, "**", "*")
+ else
+ File.join(resolved, "*")
+ end
+
+ entries = Dir.glob(glob).select { |f| File.file?(f) }
+
+ worktree_real = File.realpath(worktree_path)
+ relative_paths = entries.map { |f| f.delete_prefix("#{worktree_real}/") }.sort
+
+ Dispatch::Tools::Result.success(output: relative_paths.join("\n"))
+ end
+ end
+ end
+end
diff --git a/lib/dispatch/tool/files/read_file.rb b/lib/dispatch/tool/files/read_file.rb
new file mode 100644
index 0000000..c0c5edc
--- /dev/null
+++ b/lib/dispatch/tool/files/read_file.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+module Dispatch
+ module Tool
+ module Files
+ READ_FILE = Dispatch::Tools::Definition.new(
+ name: "read_file",
+ description: "Read the contents of a file. Returns file contents with line numbers.",
+ parameters: {
+ type: "object",
+ properties: {
+ path: {
+ type: "string",
+ description: "Path to the file to read, relative to the worktree root."
+ },
+ start_line: {
+ type: "integer",
+ description: "The line number to start reading from (0-based). Defaults to 0."
+ },
+ end_line: {
+ type: "integer",
+ description: "The inclusive line number to stop reading at (0-based). Use -1 for end of file."
+ }
+ },
+ required: %w[path],
+ additionalProperties: false
+ }
+ ) do |params, context|
+ worktree_path = context[:worktree_path]
+ path = params[:path]
+
+ resolved = begin
+ Sandbox.resolve_path(path, worktree_path:)
+ rescue SandboxError => e
+ next Dispatch::Tools::Result.failure(error: e.message)
+ end
+
+ unless File.exist?(resolved)
+ next Dispatch::Tools::Result.failure(error: "File not found: #{path}")
+ end
+
+ # Binary file detection: check first 8192 bytes for null bytes
+ sample = File.binread(resolved, 8192)
+ if sample.include?("\x00")
+ next Dispatch::Tools::Result.failure(error: "Cannot read binary file: #{path}")
+ end
+
+ lines = File.readlines(resolved)
+
+ start_line = params.fetch(:start_line, 0)
+ end_line = params.fetch(:end_line, -1)
+ end_line = lines.length - 1 if end_line == -1
+
+ selected = lines[start_line..end_line] || []
+
+ # Calculate padding width based on the highest line number
+ width = end_line.to_s.length
+
+ output = selected.each_with_index.map do |line, idx|
+ line_num = start_line + idx
+ "#{line_num.to_s.rjust(width)}: #{line}"
+ end.join
+
+ Dispatch::Tools::Result.success(output:)
+ end
+ end
+ end
+end
diff --git a/lib/dispatch/tool/files/register.rb b/lib/dispatch/tool/files/register.rb
new file mode 100644
index 0000000..6877c6c
--- /dev/null
+++ b/lib/dispatch/tool/files/register.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Dispatch
+ module Tool
+ module Files
+ TOOLS = [
+ READ_FILE,
+ WRITE_FILE,
+ EDIT_FILE,
+ CREATE_FILE,
+ LIST_FILES,
+ SEARCH_FILES
+ ].freeze
+
+ def self.register(registry)
+ TOOLS.each { |tool| registry.register(tool) }
+ registry
+ end
+ end
+ end
+end
diff --git a/lib/dispatch/tool/files/sandbox.rb b/lib/dispatch/tool/files/sandbox.rb
new file mode 100644
index 0000000..6ceccb1
--- /dev/null
+++ b/lib/dispatch/tool/files/sandbox.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+module Dispatch
+ module Tool
+ module Files
+ module Sandbox
+ module_function
+
+ def resolve_path(path, worktree_path:)
+ worktree_real = File.realpath(worktree_path)
+
+ # Join the path against the worktree root
+ joined = File.expand_path(path, worktree_real)
+
+ # Check the expanded (logical) path first — catches ../traversal and absolute paths
+ unless joined.start_with?("#{worktree_real}/") || joined == worktree_real
+ raise SandboxError, "Path '#{path}' resolves outside the worktree sandbox"
+ end
+
+ # For existing paths, resolve symlinks and verify the real path
+ if File.exist?(joined)
+ real = File.realpath(joined)
+
+ unless real.start_with?("#{worktree_real}/") || real == worktree_real
+ raise SandboxError, "Path '#{path}' resolves outside the worktree sandbox (symlink escape)"
+ end
+
+ real
+ else
+ # For non-existent paths, resolve as much of the existing prefix as possible
+ # to catch symlinked directory components pointing outside the worktree
+ parts = joined.delete_prefix("#{worktree_real}/").split("/")
+ current = worktree_real
+
+ parts.each_with_index do |part, i|
+ candidate = File.join(current, part)
+
+ if File.symlink?(candidate)
+ real_target = File.realpath(candidate)
+
+ unless real_target.start_with?("#{worktree_real}/") || real_target == worktree_real
+ raise SandboxError, "Path '#{path}' resolves outside the worktree sandbox (symlink escape)"
+ end
+
+ current = real_target
+ elsif File.exist?(candidate)
+ current = File.realpath(candidate)
+ else
+ # Remainder of the path doesn't exist on disk — that's fine, return the logical path
+ remaining = parts[(i + 1)..]
+
+ return remaining.empty? ? candidate : File.join(candidate, *remaining)
+ end
+ end
+
+ current
+ end
+ end
+
+ def within_worktree?(path, worktree_path:)
+ resolve_path(path, worktree_path:)
+ true
+ rescue SandboxError
+ false
+ end
+ end
+ end
+ end
+end
diff --git a/lib/dispatch/tool/files/search_files.rb b/lib/dispatch/tool/files/search_files.rb
new file mode 100644
index 0000000..b0ccc9e
--- /dev/null
+++ b/lib/dispatch/tool/files/search_files.rb
@@ -0,0 +1,89 @@
+# frozen_string_literal: true
+
+module Dispatch
+ module Tool
+ module Files
+ SEARCH_FILES = Dispatch::Tools::Definition.new(
+ name: "search_files",
+ description: "Search for text in files. Returns matching lines with file paths and line numbers.",
+ parameters: {
+ type: "object",
+ properties: {
+ query: {
+ type: "string",
+ description: "The text or regex pattern to search for."
+ },
+ path: {
+ type: "string",
+ description: "Directory to search within, relative to the worktree root. Defaults to root."
+ },
+ pattern: {
+ type: "string",
+ description: "Glob pattern to filter which files to search (e.g. '**/*.rb')."
+ },
+ is_regex: {
+ type: "boolean",
+ description: "Whether the query is a regular expression. Defaults to false."
+ }
+ },
+ required: %w[query],
+ additionalProperties: false
+ }
+ ) do |params, context|
+ worktree_path = context[:worktree_path]
+ query = params[:query]
+ path = params.fetch(:path, ".")
+ file_pattern = params[:pattern]
+ is_regex = params.fetch(:is_regex, false)
+
+ resolved = begin
+ Sandbox.resolve_path(path, worktree_path:)
+ rescue SandboxError => e
+ next Dispatch::Tools::Result.failure(error: e.message)
+ end
+
+ regex = if is_regex
+ begin
+ Regexp.new(query)
+ rescue RegexpError => e
+ next Dispatch::Tools::Result.failure(error: "Invalid regex: #{e.message}")
+ end
+ else
+ Regexp.new(Regexp.escape(query))
+ end
+
+ # Build file list
+ glob = if file_pattern
+ File.join(resolved, file_pattern)
+ else
+ File.join(resolved, "**", "*")
+ end
+
+ files = Dir.glob(glob).select { |f| File.file?(f) }.sort
+
+ worktree_real = File.realpath(worktree_path)
+ max_results = 100
+ matches = []
+
+ files.each do |file_path|
+ # Skip binary files
+ sample = File.binread(file_path, 8192)
+ next if sample&.include?("\x00")
+
+ relative = file_path.delete_prefix("#{worktree_real}/")
+
+ File.readlines(file_path).each_with_index do |line, idx|
+ if regex.match?(line)
+ matches << "#{relative}:#{idx + 1}: #{line.chomp}"
+ break if matches.length >= max_results
+ end
+ end
+
+ break if matches.length >= max_results
+ end
+
+ Dispatch::Tools::Result.success(output: matches.join("\n"))
+ end
+ end
+ end
+end
diff --git a/lib/dispatch/tool/files/write_file.rb b/lib/dispatch/tool/files/write_file.rb
new file mode 100644
index 0000000..218c310
--- /dev/null
+++ b/lib/dispatch/tool/files/write_file.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+module Dispatch
+ module Tool
+ module Files
+ WRITE_FILE = Dispatch::Tools::Definition.new(
+ name: "write_file",
+ description: "Write or overwrite a file with the given content. Creates parent directories if needed.",
+ parameters: {
+ type: "object",
+ properties: {
+ path: {
+ type: "string",
+ description: "Path to the file to write, relative to the worktree root."
+ },
+ content: {
+ type: "string",
+ description: "The content to write to the file."
+ }
+ },
+ required: %w[path content],
+ additionalProperties: false
+ }
+ ) do |params, context|
+ worktree_path = context[:worktree_path]
+ path = params[:path]
+ content = params[:content]
+
+ resolved = begin
+ Sandbox.resolve_path(path, worktree_path:)
+ rescue SandboxError => e
+ next Dispatch::Tools::Result.failure(error: e.message)
+ end
+
+ FileUtils.mkdir_p(File.dirname(resolved))
+ File.write(resolved, content)
+
+ byte_count = content.bytesize
+ Dispatch::Tools::Result.success(output: "Wrote #{byte_count} bytes to #{path}")
+ end
+ end
+ end
+end