Nothing
#' @include tool-result.R
NULL
#' Tool: List files
#'
#' @examples
#' withr::with_tempdir({
#' write.csv(mtcars, "mtcars.csv")
#'
#' btw_tool_files_list_files(type = "file")
#' })
#'
#' @param path Path to a directory or file for which to get information. The
#' `path` must be in the current working directory. If `path` is a directory,
#' we use [fs::dir_info()] to list information about files and directories in
#' `path` (use `type` to pick only one or the other). If `path` is a file, we
#' show information about that file.
#' @param type File type(s) to return, one of `"any"` or `"file"` or
#' `"directory"`.
#' @inheritParams fs::dir_ls
#' @inheritParams btw_tool_docs_package_news
#'
#' @return Returns a character table of file information.
#'
#' @family Tools
#' @export
btw_tool_files_list_files <- function(path, type, regexp, `_intent`) {}
btw_tool_files_list_files_impl <- function(
path = NULL,
type = c("any", "file", "directory"),
regexp = ""
) {
path <- path %||% getwd()
type <- type %||% "any"
check_string(path) # one path a time, please
type <- arg_match(type, multiple = TRUE)
if (identical(type, c("any", "file", "directory"))) {
type <- c("file", "directory", "symlink")
}
regexp <- if (nzchar(regexp)) regexp
# Disallow listing files outside of the project directory
check_path_within_current_wd(path)
info <-
if (fs::is_file(path)) {
if (!fs::file_exists(path)) {
cli::cli_abort(
"The path {.path {path}} does not exist. Did you use a relative path?"
)
}
fs::file_info(path)
} else {
fs::dir_info(path, type = type, regexp = regexp, recurse = TRUE)
}
info <- info[!is_common_ignorable_files(info$path), ]
if (nrow(info) == 0) {
return(sprintf("No %s found in %s", paste(type, collapse = "/"), path))
}
info$path <- fs::path_rel(info$path)
fields <- c("path", "type", "size", "modification_time")
md_res <- md_table(info[fields])
btw_tool_result(
md_res,
data = info[fields],
display = list(markdown = md_res)
)
}
.btw_add_to_tools(
name = "btw_tool_files_list_files",
group = "files",
tool = function() {
ellmer::tool(
btw_tool_files_list_files_impl,
name = "btw_tool_files_list_files",
description = r"---(List files or directories in the project.
WHEN TO USE:
* Use this tool to discover the file structure of a project.
* When you want to understand the project structure, use `type = "directory"` to list all directories.
* When you want to find a specific file, use `type = "file"` and `regexp` to filter files by name or extension.
CAUTION: Do not list all files in a project, instead prefer listing files in a specific directory with a `regexp` to filter to files of interest.
)---",
annotations = ellmer::tool_annotations(
title = "Project Files",
read_only_hint = TRUE,
open_world_hint = FALSE,
idempotent_hint = FALSE,
btw_can_register = function() TRUE
),
arguments = list(
path = ellmer::type_string(
paste(
"The relative path to a folder or file.",
"If `path` is a directory, all files or directories (see `type`) are listed.",
'Use `"."` to refer to the current working directory.',
"If `path` is a file, information for just the selected file is listed."
),
required = FALSE
),
type = ellmer::type_enum(
"Whether to list files, directories or any file type, default is `any`.",
values = c("any", "file", "directory"),
required = FALSE
),
regexp = ellmer::type_string(
paste(
'A regular expression to use to identify files, e.g. `regexp="[.]csv$"` to find files with a `.csv` extension.',
"Note that it's best to be as general as possible to find the file you want."
),
required = FALSE
)
)
)
}
)
#' Tool: Read a file
#'
#' @examples
#' withr::with_tempdir({
#' write.csv(mtcars, "mtcars.csv")
#'
#' btw_tool_files_read_text_file("mtcars.csv", line_end = 5)
#' })
#'
#' @param path Path to a file for which to get information. The `path` must be
#' in the current working directory.
#' @param line_start Starting line to read, defaults to 1 (starting from the
#' first line).
#' @param line_end Ending line to read, defaults to 1000. Change only this value
#' if you want to read more or fewer lines. Use in combination with
#' `line_start` to read a specific line range of the file.
#' @inheritParams btw_tool_docs_package_news
#'
#' @return Returns a character vector of lines from the file.
#'
#' @family Tools
#' @export
btw_tool_files_read_text_file <- function(
path,
line_start,
line_end,
`_intent`
) {}
btw_tool_files_read_text_file_impl <- function(
path,
line_start = 1,
line_end = 1000
) {
check_path_within_current_wd(path)
if (!fs::is_file(path) || !fs::file_exists(path)) {
cli::cli_abort(
"Path {.path {path}} is not a file or does not exist. Check the path and ensure that it is provided as a relative path."
)
}
if (!isTRUE(is_text_file(path))) {
cli::cli_abort(
"Path {.path {path}} is not a path to a text file."
)
}
contents <- readLines(path, warn = FALSE, n = line_end)
contents <- contents[seq(max(line_start, 1), min(line_end, length(contents)))]
value <- md_code_block(fs::path_ext(path), contents)
value <- paste(value, collapse = "\n")
BtwTextFileToolResult(
value,
extra = list(
path = fs::path_rel(path),
display = list(
markdown = value,
title = HTML(sprintf(
"Read <code>%s</code>",
fs::path_file(path)
))
)
)
)
}
BtwTextFileToolResult <- S7::new_class(
"BtwTextFileToolResult",
parent = BtwToolResult
)
.btw_add_to_tools(
name = "btw_tool_files_read_text_file",
group = "files",
tool = function() {
ellmer::tool(
btw_tool_files_read_text_file_impl,
name = "btw_tool_files_read_text_file",
description = "Read an entire text file.",
annotations = ellmer::tool_annotations(
title = "Read File",
read_only_hint = TRUE,
open_world_hint = FALSE,
idempotent_hint = FALSE,
btw_can_register = function() TRUE
),
arguments = list(
path = ellmer::type_string(
"The relative path to a file that can be read as text, such as a CSV, JSON, HTML, markdown file, etc.",
),
line_start = ellmer::type_number(
"Starting line to read, defaults to 1 (starting from the first line).",
required = FALSE
),
line_end = ellmer::type_number(
paste(
"Ending line to read, defaults to 1000.",
"Change only this value if you want to read more or fewer lines.",
"Use in combination with `line_start` to read a specific line range of the file."
),
required = FALSE
)
)
)
}
)
is_text_file <- function(file_path) {
# Note: this function was written by claude-3.7-sonnet.
# Try to read the first chunk of the file as binary
tryCatch(
{
# Read first 8KB of the file
con <- file(file_path, "rb")
bytes <- readBin(con, what = "raw", n = 8192)
close(con)
# If file is empty, consider it text
if (length(bytes) == 0) {
return(TRUE)
}
# Check for NULL bytes (common in binary files)
if (any(bytes == as.raw(0))) {
return(FALSE)
}
# Count control characters (excluding common text file control chars)
# Allow: tab (9), newline (10), carriage return (13)
allowed_control <- as.raw(c(9, 10, 13))
control_chars <- bytes[bytes < as.raw(32) & !(bytes %in% allowed_control)]
# If more than 10% of the first 8KB are control characters, likely binary
if (length(control_chars) / length(bytes) > 0.1) {
return(FALSE)
}
# Check for high proportion of extended ASCII or non-UTF8 characters
extended_chars <- bytes[bytes > as.raw(127)]
if (length(extended_chars) / length(bytes) > 0.3) {
# Try to interpret as UTF-8
text <- rawToChar(bytes)
if (Encoding(text) == "unknown" || !validUTF8(text)) {
return(FALSE)
}
}
# If we've made it this far, it's likely a text file
return(TRUE)
},
error = function(e) {
warning("Error reading file: ", e$message)
return(NA)
}
)
}
check_path_exists <- function(path) {
if (!fs::file_exists(path) && !fs::dir_exists(path)) {
cli::cli_abort("The path {.path {path}} does not exist.")
}
}
check_path_within_current_wd <- function(path) {
if (!fs::path_has_parent(path, getwd())) {
cli::cli_abort(
"You are not allowed to list or read files outside of the project directory. Make sure that `path` is relative to the current working directory."
)
}
}
is_common_ignorable_files <- function(paths) {
ignorable_files <- c(".DS_Store", "Thumbs.db")
ignorable_dir <- c(
# Version control
".git",
".svn",
".hg",
".bzr",
# Package management
"node_modules",
"bower_components",
"jspm_packages",
# Python
".venv",
"venv",
"__pycache__",
".pytest_cache",
"eggs",
".eggs",
".tox",
".nox",
"*.egg-info",
"*.egg",
# R specific
"renv/library",
".Rproj.user",
"packrat/lib",
"packrat/src",
# JavaScript/TypeScript
"out",
".next",
".nuxt",
".cache",
# Docker
".docker",
# Documentation builds
"_site",
"site",
"docs/_build",
"docs/build",
"public"
)
is_ignorable_file <- fs::path_file(paths) %in% ignorable_files
ignorable_dir_combo <- grep("/", ignorable_dir, fixed = TRUE, value = TRUE)
ignorable_dir_simple <- setdiff(ignorable_dir, ignorable_dir_combo)
is_in_ignorable_dir <- map_lgl(
fs::path_split(fs::path_dir(paths)),
function(path_parts) {
some(path_parts, function(part) part %in% ignorable_dir_simple) ||
# R Markdown built files
any(grepl("_files$", path_parts)) ||
some(
ignorable_dir_combo,
function(id) grepl(id, fs::path_join(path_parts), fixed = TRUE)
)
}
)
is_ignorable_file | is_in_ignorable_dir
}
#' Tool: Write a text file
#'
#' @examples
#' withr::with_tempdir({
#' btw_tool_files_write_text_file("example.txt", "Hello\nWorld!")
#' readLines("example.txt")
#' })
#'
#' @param path Path to the file to write. The `path` must be in the current
#' working directory.
#' @param content The text content to write to the file. This should be the
#' complete content as the file will be overwritten.
#' @inheritParams btw_tool_docs_package_news
#'
#' @return Returns a message confirming the file was written.
#'
#' @family Tools
#' @export
btw_tool_files_write_text_file <- function(path, content, `_intent`) {}
btw_tool_files_write_text_file_impl <- function(path, content) {
check_string(path)
check_string(content)
check_path_within_current_wd(path)
if (fs::is_dir(path)) {
cli::cli_abort(
"Path {.path {path}} is a directory, not a file. Please provide a file path."
)
}
# Ensure the directory exists
dir_path <- fs::path_dir(path)
if (dir_path != "." && !fs::dir_exists(dir_path)) {
fs::dir_create(dir_path, recurse = TRUE)
}
previous_content <- if (fs::file_exists(path)) {
paste(readLines(path, warn = FALSE), collapse = "\n")
}
writeLines(content, path)
BtwWriteFileToolResult(
"Success",
extra = list(
path = path,
content = content,
previous_content = previous_content,
display = list(
markdown = md_code_block(fs::path_ext(path), content),
title = HTML(sprintf("Write <code>%s</code>", path)),
show_request = FALSE,
icon = tool_icon("file-save")
)
)
)
}
BtwWriteFileToolResult <- S7::new_class(
"BtwWriteFileToolResult",
parent = BtwToolResult
)
.btw_add_to_tools(
name = "btw_tool_files_write_text_file",
group = "files",
tool = function() {
ellmer::tool(
btw_tool_files_write_text_file_impl,
name = "btw_tool_files_write_text_file",
description = 'Write content to a text file.
If the file doesn\'t exist, it will be created, along with any necessary parent directories.
WHEN TO USE:
Use this tool only when the user has explicitly asked you to write or create a file.
Do not use for temporary or one-off content; prefer direct responses for those cases.
Consider checking with the user to ensure that the file path is correct and that they want to write to a file before calling this tool.
CAUTION:
This completely overwrites any existing file content.
To modify an existing file, first read its content using `btw_tool_files_read_text_file`, make your changes to the text, then write back the complete modified content.
',
annotations = ellmer::tool_annotations(
title = "Write File",
read_only_hint = FALSE,
open_world_hint = FALSE,
idempotent_hint = TRUE,
btw_can_register = function() TRUE
),
arguments = list(
path = ellmer::type_string(
"The relative path to the file to write. The file will be created if it doesn't exist, or overwritten if it does."
),
content = ellmer::type_string(
"The complete text content to write to the file."
)
)
)
}
)
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.