diff --git a/DESCRIPTION b/DESCRIPTION index 3178bfb5..9ad97e57 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -52,6 +52,7 @@ Suggests: bslib (>= 0.7.0), chromote, DBI, + devtools, duckdb, evaluate, fansi, @@ -60,6 +61,7 @@ Suggests: htmltools, pandoc, ragg, + roxygen2, shiny, shinychat (>= 0.2.0), testthat (>= 3.0.0), @@ -95,6 +97,7 @@ Collate: 'tool-files.R' 'tool-git.R' 'tool-github.R' + 'tool-pkg.R' 'tool-rstudioapi.R' 'tool-run.R' 'tool-search-packages.R' diff --git a/NAMESPACE b/NAMESPACE index e9999b2b..9d9fd144 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -47,6 +47,9 @@ export(btw_tool_git_log) export(btw_tool_git_status) export(btw_tool_github) export(btw_tool_ide_read_current_editor) +export(btw_tool_pkg_check) +export(btw_tool_pkg_document) +export(btw_tool_pkg_test) export(btw_tool_run_r) export(btw_tool_search_package_info) export(btw_tool_search_packages) diff --git a/NEWS.md b/NEWS.md index cfaace6d..02c69f86 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,7 @@ # btw (development version) +* New "pkg" tool group with package development tools: `btw_tool_pkg_document()`, `btw_tool_pkg_check()`, and `btw_tool_pkg_test()` provide LLMs with the ability to document, check, and test R packages during development (#133). + * New `btw_tool_run_r()` tool allows LLMs to run R code and to see the output, including of plots. Because this tool lets LLMs run R arbitrary R code in the global environment (which can be great but can also have security implications), it is opt-in and disabled by default. See `?btw_tool_run_r` for more details (#126). * `btw_tool_docs_help_page()` now uses markdown headings and sections for argument descriptions, rather than a table. This is considerably more token efficient when the argument descriptions have more than one paragraph and can't be converted into a markdown table (@jeanchristophe13v, #123). diff --git a/R/btw_client_app.R b/R/btw_client_app.R index 32b45ebc..fc883308 100644 --- a/R/btw_client_app.R +++ b/R/btw_client_app.R @@ -617,6 +617,7 @@ app_tool_group_choice_input <- function( "git" = shiny::span(label_icon, "Git"), "github" = shiny::span(label_icon, "GitHub"), "ide" = shiny::span(label_icon, "IDE"), + "pkg" = shiny::span(label_icon, "Package Tools"), "run" = shiny::span(label_icon, "Run Code"), "search" = shiny::span(label_icon, "Search"), "session" = shiny::span(label_icon, "Session Info"), diff --git a/R/btw_this.R b/R/btw_this.R index a7d056cb..319423fa 100644 --- a/R/btw_this.R +++ b/R/btw_this.R @@ -650,7 +650,9 @@ btw_this_file_path <- function(x) { path <- "." } if (fs::is_file(path)) { - return(btw_tool_files_read_text_file_impl(path, check_within_wd = FALSE)@value) + return( + btw_tool_files_read_text_file_impl(path, check_within_wd = FALSE)@value + ) } else { return(btw_tool_files_list_files_impl(path, check_within_wd = FALSE)@value) } diff --git a/R/tool-pkg.R b/R/tool-pkg.R new file mode 100644 index 00000000..8f1c09bc --- /dev/null +++ b/R/tool-pkg.R @@ -0,0 +1,223 @@ +#' @include tool-result.R +NULL + +has_devtools <- function() { + is_installed("devtools") +} + +has_roxygen2 <- function() { + is_installed("roxygen2") +} + +# btw_tool_pkg_document -------------------------------------------------------- + +#' Tool: Generate package documentation +#' +#' Generate package documentation using [devtools::document()]. This runs +#' \pkg{roxygen2} on the package to create/update man pages and `NAMESPACE`. +#' +#' @param pkg Path to package directory. Defaults to `"."`. Must be within +#' current working directory. +#' @inheritParams btw_tool_docs_package_news +#' +#' @returns The output from [devtools::document()]. +#' +#' @seealso [btw_tools()] +#' @family Tools +#' @export +btw_tool_pkg_document <- function(pkg = ".", `_intent`) {} + +btw_tool_pkg_document_impl <- function(pkg = ".") { + check_string(pkg) + check_path_within_current_wd(pkg) + + code <- sprintf( + 'devtools::document(pkg = "%s", roclets = NULL, quiet = FALSE)', + pkg + ) + + btw_tool_run_r_impl(code) +} + +.btw_add_to_tools( + name = "btw_tool_pkg_document", + group = "pkg", + tool = function() { + ellmer::tool( + btw_tool_pkg_document_impl, + name = "btw_tool_pkg_document", + description = "Generate package documentation. + +Runs `devtools::document()` which processes roxygen2 tags to: +- Create/update .Rd help files in man/ directory +- Update NAMESPACE with exports and imports +- Update Collate field in DESCRIPTION if needed + +Use this after adding or modifying roxygen2 comments in your R code. The tool modifies files but changes are safe and can be committed to version control. Returns a summary of files created or updated.", + annotations = ellmer::tool_annotations( + title = "Package Document", + read_only_hint = FALSE, + idempotent_hint = TRUE, + btw_can_register = function() has_devtools() && has_roxygen2() + ), + arguments = list( + pkg = ellmer::type_string( + "Path to package directory. Defaults to '.'. Must be within current working directory.", + required = FALSE + ) + ) + ) + } +) + +# btw_tool_pkg_check ----------------------------------------------------------- + +#' Tool: Run R CMD check on a package +#' +#' Run R CMD check on a package using [devtools::check()]. This performs +#' comprehensive checks on the package structure, code, and documentation. +#' +#' The check runs with `remote = TRUE`, `cran = TRUE`, `manual = FALSE`, and +#' `error_on = "never"` to provide comprehensive feedback without failing. +#' +#' @param pkg Path to package directory. Defaults to '.'. Must be within +#' current working directory. +#' @inheritParams btw_tool_docs_package_news +#' +#' @returns The output from [devtools::check()]. +#' +#' @seealso [btw_tools()] +#' @family Tools +#' @export +btw_tool_pkg_check <- function(pkg = ".", `_intent`) {} + +btw_tool_pkg_check_impl <- function(pkg = ".") { + check_string(pkg) + check_path_within_current_wd(pkg) + + code <- sprintf( + 'devtools::check(pkg = "%s", remote = TRUE, cran = TRUE, manual = FALSE, quiet = FALSE, error_on = "never")', + pkg + ) + + btw_tool_run_r_impl(code) +} + +.btw_add_to_tools( + name = "btw_tool_pkg_check", + group = "pkg", + tool = function() { + ellmer::tool( + btw_tool_pkg_check_impl, + name = "btw_tool_pkg_check", + description = "Run comprehensive package checks. + +Use this tool to verify the package is ready for release or CRAN submission. + +Runs devtools::check() which builds the package and performs ~50 checks including: +- Documentation completeness and validity +- Code syntax and best practices +- Example code execution +- Test suite execution +- Vignette building (if present) +- CRAN policy compliance + +This tool runs with CRAN-like settings and takes several minutes to complete. It always completes and reports findings even if errors are found. + +For iterative development, use the `btw_tool_pkg_test` if available or `devtools::test()` to run only the test suite for faster feedback.", + annotations = ellmer::tool_annotations( + title = "Package Check", + read_only_hint = FALSE, + idempotent_hint = TRUE, + btw_can_register = function() has_devtools() + ), + arguments = list( + pkg = ellmer::type_string( + "Path to package directory. Defaults to '.'. Must be within current working directory.", + required = FALSE + ) + ) + ) + } +) + +# btw_tool_pkg_test ------------------------------------------------------------ + +#' Tool: Run package tests +#' +#' Run package tests using [devtools::test()]. Optionally filter tests by name +#' pattern. +#' +#' @param pkg Path to package directory. Defaults to '.'. Must be within +#' current working directory. +#' @param filter Optional regex to filter test files. Example: 'helper' matches +#' 'test-helper.R'. +#' @inheritParams btw_tool_docs_package_news +#' +#' @returns The output from [devtools::test()]. +#' +#' @seealso [btw_tools()] +#' @family Tools +#' @export +btw_tool_pkg_test <- function(pkg = ".", filter = NULL, `_intent`) {} + +btw_tool_pkg_test_impl <- function(pkg = ".", filter = NULL) { + check_string(pkg) + check_path_within_current_wd(pkg) + + filter_arg <- if (!is.null(filter)) { + check_string(filter) + sprintf(', filter = "%s"', filter) + } else { + "" + } + + code <- sprintf( + 'devtools::test(pkg = "%s"%s, stop_on_failure = FALSE, export_all = TRUE, reporter = "check")', + pkg, + filter_arg + ) + + btw_tool_run_r_impl(code) +} + +.btw_add_to_tools( + name = "btw_tool_pkg_test", + group = "pkg", + tool = function() { + ellmer::tool( + btw_tool_pkg_test_impl, + name = "btw_tool_pkg_test", + description = "Run testthat tests for an R package. + +Runs `devtools::test()` which executes the test suite in tests/testthat/ and reports: +- Number of tests passed, failed, warned, and skipped +- Detailed failure messages with file locations +- Test execution time + +The filter parameter accepts a regular expression matched against test file names after stripping the 'test-' prefix and '.R' extension. For example: +- filter = 'helper' runs test-helper.R +- filter = 'tool-.*' runs test-tool-docs.R, test-tool-files.R, etc. +- No filter runs all tests +- It is common to pair `test-{name}.R` with a source `{name}.R` file. To test this file, you can generally use filter = '{name}'. + +Use `filter` when working on specific functionality to get faster feedback. The tool always runs all matching tests to completion regardless of failures.", + annotations = ellmer::tool_annotations( + title = "Package Test", + read_only_hint = FALSE, + idempotent_hint = TRUE, + btw_can_register = function() has_devtools() + ), + arguments = list( + pkg = ellmer::type_string( + "Path to package directory. Defaults to '.'. Must be within current working directory.", + required = FALSE + ), + filter = ellmer::type_string( + "Optional regex to filter test files. Example: 'helper' matches 'test-helper.R'.", + required = FALSE + ) + ) + ) + } +) diff --git a/R/tool-run.R b/R/tool-run.R index 01ed9fc6..5688ee81 100644 --- a/R/tool-run.R +++ b/R/tool-run.R @@ -157,13 +157,13 @@ btw_tool_run_r_impl <- function(code, .envir = global_env()) { last_plot <<- NULL } - local_reproducible_output(disable_ansi_features = !is_installed("fansi")) - # Ensure working directory, options, envvar are restored after execution withr::local_dir(getwd()) withr::local_options() withr::local_envvar() + local_reproducible_output(disable_ansi_features = !is_installed("fansi")) + # Create output handler that converts to Content types as outputs are generated handler <- evaluate::new_output_handler( source = function(src, expr) { @@ -520,13 +520,17 @@ S7::method(contents_shinychat, BtwRunToolResult) <- function(content) { request_id <- NULL tool_title <- NULL + display <- content@extra$display %||% list() + annotations <- list() + if (!is.null(content@request)) { request_id <- content@request@id tool_title <- NULL tool <- content@request@tool + annotations <- tool@annotations if (!is.null(tool)) { - tool_title <- tool@annotations$title + tool_title <- annotations$title } } @@ -537,6 +541,7 @@ S7::method(contents_shinychat, BtwRunToolResult) <- function(content) { code = code, status = status, `tool-title` = tool_title, + icon = display$icon %||% annotations$icon, htmltools::HTML(output_html), btw_run_tool_card_dep() ) diff --git a/R/tools.R b/R/tools.R index 1b6f088e..b4598960 100644 --- a/R/tools.R +++ b/R/tools.R @@ -142,6 +142,7 @@ tool_group_icon <- function(group, default = NULL) { "git" = tool_icon("git"), "github" = tool_icon("github"), "ide" = tool_icon("code-blocks"), + "pkg" = tool_icon("package"), "search" = tool_icon("search"), "session" = tool_icon("screen-search-desktop"), "web" = tool_icon("globe-book"), diff --git a/R/utils.R b/R/utils.R index ba8a8cb4..9a99f161 100644 --- a/R/utils.R +++ b/R/utils.R @@ -194,13 +194,16 @@ local_reproducible_output <- function( ) { # Replicating testthat::local_reproducible_output() withr::local_options(width = width, cli.width = width, .local_envir = .env) - withr::local_envvar(RSTUDIO_CONSOLE_WIDTH = width, .local_envir = .env) + withr::local_envvar( + RSTUDIO_CONSOLE_WIDTH = width, + R_CLI_DYNAMIC = "false", + .local_envir = .env + ) if (disable_ansi_features) { - withr::local_envvar(list(NO_COLOR = "true"), .local_envir = .env) + withr::local_envvar(NO_COLOR = "true", .local_envir = .env) withr::local_options( crayon.enabled = FALSE, - cli.dynamic = FALSE, cli.unicode = FALSE, cli.condition_width = Inf, cli.num_colors = 1L, @@ -219,7 +222,7 @@ local_reproducible_output <- function( } withr::local_options( - cil.dynamic = FALSE, + cli.dynamic = FALSE, cli.spinner = FALSE, cli.hyperlink = FALSE, cli.hyperlink_run = FALSE, diff --git a/inst/icons/package.svg b/inst/icons/package.svg new file mode 100644 index 00000000..f1067534 --- /dev/null +++ b/inst/icons/package.svg @@ -0,0 +1 @@ + diff --git a/inst/js/run-r/btw-run-r.js b/inst/js/run-r/btw-run-r.js index 6c0dc5fe..a7f846b5 100644 --- a/inst/js/run-r/btw-run-r.js +++ b/inst/js/run-r/btw-run-r.js @@ -46,6 +46,7 @@ class BtwRunRResult extends HTMLElement { super() this.toolTitle = this.getAttribute("tool-title") || "Run R Code" + this.icon = this.getAttribute("icon") || ICONS.playCircle } connectedCallback() { @@ -57,7 +58,6 @@ class BtwRunRResult extends HTMLElement { this.titleTemplate = "{title} failed" } else { this.classStatus = "" - this.icon = ICONS.playCircle this.titleTemplate = "{title}" } diff --git a/man/btw_tool_docs_package_news.Rd b/man/btw_tool_docs_package_news.Rd index 4035ff34..78297c71 100644 --- a/man/btw_tool_docs_package_news.Rd +++ b/man/btw_tool_docs_package_news.Rd @@ -57,6 +57,9 @@ Other Tools: \code{\link{btw_tool_files_write_text_file}()}, \code{\link{btw_tool_ide_read_current_editor}()}, \code{\link{btw_tool_package_docs}}, +\code{\link{btw_tool_pkg_check}()}, +\code{\link{btw_tool_pkg_document}()}, +\code{\link{btw_tool_pkg_test}()}, \code{\link{btw_tool_run_r}()}, \code{\link{btw_tool_search_packages}()}, \code{\link{btw_tool_session_package_info}()}, diff --git a/man/btw_tool_env_describe_data_frame.Rd b/man/btw_tool_env_describe_data_frame.Rd index 3f529c65..34d0947a 100644 --- a/man/btw_tool_env_describe_data_frame.Rd +++ b/man/btw_tool_env_describe_data_frame.Rd @@ -68,6 +68,9 @@ Other Tools: \code{\link{btw_tool_files_write_text_file}()}, \code{\link{btw_tool_ide_read_current_editor}()}, \code{\link{btw_tool_package_docs}}, +\code{\link{btw_tool_pkg_check}()}, +\code{\link{btw_tool_pkg_document}()}, +\code{\link{btw_tool_pkg_test}()}, \code{\link{btw_tool_run_r}()}, \code{\link{btw_tool_search_packages}()}, \code{\link{btw_tool_session_package_info}()}, diff --git a/man/btw_tool_env_describe_environment.Rd b/man/btw_tool_env_describe_environment.Rd index 00660f75..0733d61d 100644 --- a/man/btw_tool_env_describe_environment.Rd +++ b/man/btw_tool_env_describe_environment.Rd @@ -42,6 +42,9 @@ Other Tools: \code{\link{btw_tool_files_write_text_file}()}, \code{\link{btw_tool_ide_read_current_editor}()}, \code{\link{btw_tool_package_docs}}, +\code{\link{btw_tool_pkg_check}()}, +\code{\link{btw_tool_pkg_document}()}, +\code{\link{btw_tool_pkg_test}()}, \code{\link{btw_tool_run_r}()}, \code{\link{btw_tool_search_packages}()}, \code{\link{btw_tool_session_package_info}()}, diff --git a/man/btw_tool_files_code_search.Rd b/man/btw_tool_files_code_search.Rd index 9e44cc48..f1b2d6e4 100644 --- a/man/btw_tool_files_code_search.Rd +++ b/man/btw_tool_files_code_search.Rd @@ -101,6 +101,9 @@ Other Tools: \code{\link{btw_tool_files_write_text_file}()}, \code{\link{btw_tool_ide_read_current_editor}()}, \code{\link{btw_tool_package_docs}}, +\code{\link{btw_tool_pkg_check}()}, +\code{\link{btw_tool_pkg_document}()}, +\code{\link{btw_tool_pkg_test}()}, \code{\link{btw_tool_run_r}()}, \code{\link{btw_tool_search_packages}()}, \code{\link{btw_tool_session_package_info}()}, diff --git a/man/btw_tool_files_list_files.Rd b/man/btw_tool_files_list_files.Rd index 14dd050c..8c901fbd 100644 --- a/man/btw_tool_files_list_files.Rd +++ b/man/btw_tool_files_list_files.Rd @@ -51,6 +51,9 @@ Other Tools: \code{\link{btw_tool_files_write_text_file}()}, \code{\link{btw_tool_ide_read_current_editor}()}, \code{\link{btw_tool_package_docs}}, +\code{\link{btw_tool_pkg_check}()}, +\code{\link{btw_tool_pkg_document}()}, +\code{\link{btw_tool_pkg_test}()}, \code{\link{btw_tool_run_r}()}, \code{\link{btw_tool_search_packages}()}, \code{\link{btw_tool_session_package_info}()}, diff --git a/man/btw_tool_files_read_text_file.Rd b/man/btw_tool_files_read_text_file.Rd index d65c994b..1fdc43a9 100644 --- a/man/btw_tool_files_read_text_file.Rd +++ b/man/btw_tool_files_read_text_file.Rd @@ -50,6 +50,9 @@ Other Tools: \code{\link{btw_tool_files_write_text_file}()}, \code{\link{btw_tool_ide_read_current_editor}()}, \code{\link{btw_tool_package_docs}}, +\code{\link{btw_tool_pkg_check}()}, +\code{\link{btw_tool_pkg_document}()}, +\code{\link{btw_tool_pkg_test}()}, \code{\link{btw_tool_run_r}()}, \code{\link{btw_tool_search_packages}()}, \code{\link{btw_tool_session_package_info}()}, diff --git a/man/btw_tool_files_write_text_file.Rd b/man/btw_tool_files_write_text_file.Rd index 082e05c0..262d2bcd 100644 --- a/man/btw_tool_files_write_text_file.Rd +++ b/man/btw_tool_files_write_text_file.Rd @@ -40,6 +40,9 @@ Other Tools: \code{\link{btw_tool_files_read_text_file}()}, \code{\link{btw_tool_ide_read_current_editor}()}, \code{\link{btw_tool_package_docs}}, +\code{\link{btw_tool_pkg_check}()}, +\code{\link{btw_tool_pkg_document}()}, +\code{\link{btw_tool_pkg_test}()}, \code{\link{btw_tool_run_r}()}, \code{\link{btw_tool_search_packages}()}, \code{\link{btw_tool_session_package_info}()}, diff --git a/man/btw_tool_ide_read_current_editor.Rd b/man/btw_tool_ide_read_current_editor.Rd index a13b5b7e..e3f89c0c 100644 --- a/man/btw_tool_ide_read_current_editor.Rd +++ b/man/btw_tool_ide_read_current_editor.Rd @@ -46,6 +46,9 @@ Other Tools: \code{\link{btw_tool_files_read_text_file}()}, \code{\link{btw_tool_files_write_text_file}()}, \code{\link{btw_tool_package_docs}}, +\code{\link{btw_tool_pkg_check}()}, +\code{\link{btw_tool_pkg_document}()}, +\code{\link{btw_tool_pkg_test}()}, \code{\link{btw_tool_run_r}()}, \code{\link{btw_tool_search_packages}()}, \code{\link{btw_tool_session_package_info}()}, diff --git a/man/btw_tool_package_docs.Rd b/man/btw_tool_package_docs.Rd index e60900aa..0536e3a5 100644 --- a/man/btw_tool_package_docs.Rd +++ b/man/btw_tool_package_docs.Rd @@ -80,6 +80,9 @@ Other Tools: \code{\link{btw_tool_files_read_text_file}()}, \code{\link{btw_tool_files_write_text_file}()}, \code{\link{btw_tool_ide_read_current_editor}()}, +\code{\link{btw_tool_pkg_check}()}, +\code{\link{btw_tool_pkg_document}()}, +\code{\link{btw_tool_pkg_test}()}, \code{\link{btw_tool_run_r}()}, \code{\link{btw_tool_search_packages}()}, \code{\link{btw_tool_session_package_info}()}, diff --git a/man/btw_tool_pkg_check.Rd b/man/btw_tool_pkg_check.Rd new file mode 100644 index 00000000..93553fb7 --- /dev/null +++ b/man/btw_tool_pkg_check.Rd @@ -0,0 +1,50 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/tool-pkg.R +\name{btw_tool_pkg_check} +\alias{btw_tool_pkg_check} +\title{Tool: Run R CMD check on a package} +\usage{ +btw_tool_pkg_check(pkg = ".", `_intent` = "") +} +\arguments{ +\item{pkg}{Path to package directory. Defaults to '.'. Must be within +current working directory.} + +\item{_intent}{An optional string describing the intent of the tool use. +When the tool is used by an LLM, the model will use this argument to +explain why it called the tool.} +} +\value{ +The output from \code{\link[devtools:check]{devtools::check()}}. +} +\description{ +Run R CMD check on a package using \code{\link[devtools:check]{devtools::check()}}. This performs +comprehensive checks on the package structure, code, and documentation. +} +\details{ +The check runs with \code{remote = TRUE}, \code{cran = TRUE}, \code{manual = FALSE}, and +\code{error_on = "never"} to provide comprehensive feedback without failing. +} +\seealso{ +\code{\link[=btw_tools]{btw_tools()}} + +Other Tools: +\code{\link{btw_tool_docs_package_news}()}, +\code{\link{btw_tool_env_describe_data_frame}()}, +\code{\link{btw_tool_env_describe_environment}()}, +\code{\link{btw_tool_files_code_search}()}, +\code{\link{btw_tool_files_list_files}()}, +\code{\link{btw_tool_files_read_text_file}()}, +\code{\link{btw_tool_files_write_text_file}()}, +\code{\link{btw_tool_ide_read_current_editor}()}, +\code{\link{btw_tool_package_docs}}, +\code{\link{btw_tool_pkg_document}()}, +\code{\link{btw_tool_pkg_test}()}, +\code{\link{btw_tool_run_r}()}, +\code{\link{btw_tool_search_packages}()}, +\code{\link{btw_tool_session_package_info}()}, +\code{\link{btw_tool_session_platform_info}()}, +\code{\link{btw_tool_web_read_url}()}, +\code{\link{btw_tools}()} +} +\concept{Tools} diff --git a/man/btw_tool_pkg_document.Rd b/man/btw_tool_pkg_document.Rd new file mode 100644 index 00000000..dbe3c33b --- /dev/null +++ b/man/btw_tool_pkg_document.Rd @@ -0,0 +1,46 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/tool-pkg.R +\name{btw_tool_pkg_document} +\alias{btw_tool_pkg_document} +\title{Tool: Generate package documentation} +\usage{ +btw_tool_pkg_document(pkg = ".", `_intent` = "") +} +\arguments{ +\item{pkg}{Path to package directory. Defaults to \code{"."}. Must be within +current working directory.} + +\item{_intent}{An optional string describing the intent of the tool use. +When the tool is used by an LLM, the model will use this argument to +explain why it called the tool.} +} +\value{ +The output from \code{\link[devtools:document]{devtools::document()}}. +} +\description{ +Generate package documentation using \code{\link[devtools:document]{devtools::document()}}. This runs +\pkg{roxygen2} on the package to create/update man pages and \code{NAMESPACE}. +} +\seealso{ +\code{\link[=btw_tools]{btw_tools()}} + +Other Tools: +\code{\link{btw_tool_docs_package_news}()}, +\code{\link{btw_tool_env_describe_data_frame}()}, +\code{\link{btw_tool_env_describe_environment}()}, +\code{\link{btw_tool_files_code_search}()}, +\code{\link{btw_tool_files_list_files}()}, +\code{\link{btw_tool_files_read_text_file}()}, +\code{\link{btw_tool_files_write_text_file}()}, +\code{\link{btw_tool_ide_read_current_editor}()}, +\code{\link{btw_tool_package_docs}}, +\code{\link{btw_tool_pkg_check}()}, +\code{\link{btw_tool_pkg_test}()}, +\code{\link{btw_tool_run_r}()}, +\code{\link{btw_tool_search_packages}()}, +\code{\link{btw_tool_session_package_info}()}, +\code{\link{btw_tool_session_platform_info}()}, +\code{\link{btw_tool_web_read_url}()}, +\code{\link{btw_tools}()} +} +\concept{Tools} diff --git a/man/btw_tool_pkg_test.Rd b/man/btw_tool_pkg_test.Rd new file mode 100644 index 00000000..10b83fe9 --- /dev/null +++ b/man/btw_tool_pkg_test.Rd @@ -0,0 +1,49 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/tool-pkg.R +\name{btw_tool_pkg_test} +\alias{btw_tool_pkg_test} +\title{Tool: Run package tests} +\usage{ +btw_tool_pkg_test(pkg = ".", filter = NULL, `_intent` = "") +} +\arguments{ +\item{pkg}{Path to package directory. Defaults to '.'. Must be within +current working directory.} + +\item{filter}{Optional regex to filter test files. Example: 'helper' matches +'test-helper.R'.} + +\item{_intent}{An optional string describing the intent of the tool use. +When the tool is used by an LLM, the model will use this argument to +explain why it called the tool.} +} +\value{ +The output from \code{\link[devtools:test]{devtools::test()}}. +} +\description{ +Run package tests using \code{\link[devtools:test]{devtools::test()}}. Optionally filter tests by name +pattern. +} +\seealso{ +\code{\link[=btw_tools]{btw_tools()}} + +Other Tools: +\code{\link{btw_tool_docs_package_news}()}, +\code{\link{btw_tool_env_describe_data_frame}()}, +\code{\link{btw_tool_env_describe_environment}()}, +\code{\link{btw_tool_files_code_search}()}, +\code{\link{btw_tool_files_list_files}()}, +\code{\link{btw_tool_files_read_text_file}()}, +\code{\link{btw_tool_files_write_text_file}()}, +\code{\link{btw_tool_ide_read_current_editor}()}, +\code{\link{btw_tool_package_docs}}, +\code{\link{btw_tool_pkg_check}()}, +\code{\link{btw_tool_pkg_document}()}, +\code{\link{btw_tool_run_r}()}, +\code{\link{btw_tool_search_packages}()}, +\code{\link{btw_tool_session_package_info}()}, +\code{\link{btw_tool_session_platform_info}()}, +\code{\link{btw_tool_web_read_url}()}, +\code{\link{btw_tools}()} +} +\concept{Tools} diff --git a/man/btw_tool_run_r.Rd b/man/btw_tool_run_r.Rd index 96a4c65e..8ce4c782 100644 --- a/man/btw_tool_run_r.Rd +++ b/man/btw_tool_run_r.Rd @@ -141,6 +141,9 @@ Other Tools: \code{\link{btw_tool_files_write_text_file}()}, \code{\link{btw_tool_ide_read_current_editor}()}, \code{\link{btw_tool_package_docs}}, +\code{\link{btw_tool_pkg_check}()}, +\code{\link{btw_tool_pkg_document}()}, +\code{\link{btw_tool_pkg_test}()}, \code{\link{btw_tool_search_packages}()}, \code{\link{btw_tool_session_package_info}()}, \code{\link{btw_tool_session_platform_info}()}, diff --git a/man/btw_tool_search_packages.Rd b/man/btw_tool_search_packages.Rd index 13ace3c6..040165a5 100644 --- a/man/btw_tool_search_packages.Rd +++ b/man/btw_tool_search_packages.Rd @@ -64,6 +64,9 @@ Other Tools: \code{\link{btw_tool_files_write_text_file}()}, \code{\link{btw_tool_ide_read_current_editor}()}, \code{\link{btw_tool_package_docs}}, +\code{\link{btw_tool_pkg_check}()}, +\code{\link{btw_tool_pkg_document}()}, +\code{\link{btw_tool_pkg_test}()}, \code{\link{btw_tool_run_r}()}, \code{\link{btw_tool_session_package_info}()}, \code{\link{btw_tool_session_platform_info}()}, diff --git a/man/btw_tool_session_package_info.Rd b/man/btw_tool_session_package_info.Rd index f27d91a4..77813e05 100644 --- a/man/btw_tool_session_package_info.Rd +++ b/man/btw_tool_session_package_info.Rd @@ -48,6 +48,9 @@ Other Tools: \code{\link{btw_tool_files_write_text_file}()}, \code{\link{btw_tool_ide_read_current_editor}()}, \code{\link{btw_tool_package_docs}}, +\code{\link{btw_tool_pkg_check}()}, +\code{\link{btw_tool_pkg_document}()}, +\code{\link{btw_tool_pkg_test}()}, \code{\link{btw_tool_run_r}()}, \code{\link{btw_tool_search_packages}()}, \code{\link{btw_tool_session_platform_info}()}, diff --git a/man/btw_tool_session_platform_info.Rd b/man/btw_tool_session_platform_info.Rd index dd02853f..b7b44d4e 100644 --- a/man/btw_tool_session_platform_info.Rd +++ b/man/btw_tool_session_platform_info.Rd @@ -36,6 +36,9 @@ Other Tools: \code{\link{btw_tool_files_write_text_file}()}, \code{\link{btw_tool_ide_read_current_editor}()}, \code{\link{btw_tool_package_docs}}, +\code{\link{btw_tool_pkg_check}()}, +\code{\link{btw_tool_pkg_document}()}, +\code{\link{btw_tool_pkg_test}()}, \code{\link{btw_tool_run_r}()}, \code{\link{btw_tool_search_packages}()}, \code{\link{btw_tool_session_package_info}()}, diff --git a/man/btw_tool_web_read_url.Rd b/man/btw_tool_web_read_url.Rd index be98bb00..db3d3b0b 100644 --- a/man/btw_tool_web_read_url.Rd +++ b/man/btw_tool_web_read_url.Rd @@ -44,6 +44,9 @@ Other Tools: \code{\link{btw_tool_files_write_text_file}()}, \code{\link{btw_tool_ide_read_current_editor}()}, \code{\link{btw_tool_package_docs}}, +\code{\link{btw_tool_pkg_check}()}, +\code{\link{btw_tool_pkg_document}()}, +\code{\link{btw_tool_pkg_test}()}, \code{\link{btw_tool_run_r}()}, \code{\link{btw_tool_search_packages}()}, \code{\link{btw_tool_session_package_info}()}, diff --git a/man/btw_tools.Rd b/man/btw_tools.Rd index 6d154d1c..528ce03c 100644 --- a/man/btw_tools.Rd +++ b/man/btw_tools.Rd @@ -86,6 +86,15 @@ this function have access to the tools: } +\subsection{Group: pkg}{\tabular{ll}{ + Name \tab Description \cr + \code{\link[=btw_tool_pkg_check]{btw_tool_pkg_check()}} \tab Run comprehensive package checks. \cr + \code{\link[=btw_tool_pkg_document]{btw_tool_pkg_document()}} \tab Generate package documentation. \cr + \code{\link[=btw_tool_pkg_test]{btw_tool_pkg_test()}} \tab Run testthat tests for an R package. \cr +} + +} + \subsection{Group: run}{\tabular{ll}{ Name \tab Description \cr \code{\link[=btw_tool_run_r]{btw_tool_run_r()}} \tab Run R code. \cr @@ -143,6 +152,9 @@ Other Tools: \code{\link{btw_tool_files_write_text_file}()}, \code{\link{btw_tool_ide_read_current_editor}()}, \code{\link{btw_tool_package_docs}}, +\code{\link{btw_tool_pkg_check}()}, +\code{\link{btw_tool_pkg_document}()}, +\code{\link{btw_tool_pkg_test}()}, \code{\link{btw_tool_run_r}()}, \code{\link{btw_tool_search_packages}()}, \code{\link{btw_tool_session_package_info}()}, diff --git a/tests/testthat/helpers.R b/tests/testthat/helpers.R index 60e31a73..4d0b7704 100644 --- a/tests/testthat/helpers.R +++ b/tests/testthat/helpers.R @@ -87,6 +87,8 @@ with_mocked_platform <- function( # Helper to enable tools that are conditionally registered local_enable_tools <- function( has_chromote = TRUE, + has_devtools = TRUE, + has_roxygen2 = TRUE, rstudioapi_has_source_editor_context = TRUE, btw_can_register_git_tool = TRUE, btw_can_register_gh_tool = TRUE, @@ -95,6 +97,8 @@ local_enable_tools <- function( ) { local_mocked_bindings( has_chromote = function() has_chromote, + has_devtools = function() has_devtools, + has_roxygen2 = function() has_roxygen2, rstudioapi_has_source_editor_context = function() { rstudioapi_has_source_editor_context }, diff --git a/tests/testthat/test-tool-pkg.R b/tests/testthat/test-tool-pkg.R new file mode 100644 index 00000000..507cefa8 --- /dev/null +++ b/tests/testthat/test-tool-pkg.R @@ -0,0 +1,400 @@ +# Test btw_tool_pkg_document -------------------------------------------------- + +test_that("btw_tool_pkg_document constructs correct code", { + local_mocked_bindings( + btw_tool_run_r_impl = function(code) { + BtwRunToolResult( + value = list(ContentOutput(text = "Documentation updated")), + extra = list( + code = code, + status = "success", + data = NULL, + contents = list() + ) + ) + } + ) + + result <- btw_tool_pkg_document_impl(".") + expect_s7_class(result, BtwRunToolResult) + expect_match(result@extra$code, "devtools::document") + expect_match(result@extra$code, 'pkg = "."') + expect_match(result@extra$code, "roclets = NULL") + expect_match(result@extra$code, "quiet = FALSE") +}) + +test_that("btw_tool_pkg_document handles subdirectory paths", { + local_mocked_bindings( + btw_tool_run_r_impl = function(code) { + BtwRunToolResult( + value = list(ContentOutput(text = "Documentation updated")), + extra = list( + code = code, + status = "success", + data = NULL, + contents = list() + ) + ) + } + ) + + result <- btw_tool_pkg_document_impl("subdir/pkg") + expect_s7_class(result, BtwRunToolResult) + expect_match(result@extra$code, 'pkg = "subdir/pkg"') +}) + +test_that("btw_tool_pkg_document rejects paths outside current directory", { + expect_error( + btw_tool_pkg_document_impl("/tmp/outside"), + "not allowed to list or read" + ) + expect_error( + btw_tool_pkg_document_impl("../outside"), + "not allowed to list or read" + ) + expect_error( + btw_tool_pkg_document_impl("/etc/passwd"), + "not allowed to list or read" + ) +}) + +test_that("btw_tool_pkg_document validates pkg argument", { + expect_error(btw_tool_pkg_document_impl(123)) + expect_error(btw_tool_pkg_document_impl(c("a", "b"))) + expect_error(btw_tool_pkg_document_impl(NULL)) +}) + +# Test btw_tool_pkg_check ------------------------------------------------------ + +test_that("btw_tool_pkg_check constructs correct code", { + local_mocked_bindings( + btw_tool_run_r_impl = function(code) { + BtwRunToolResult( + value = list(ContentOutput(text = "R CMD check output")), + extra = list( + code = code, + status = "success", + data = NULL, + contents = list() + ) + ) + } + ) + + result <- btw_tool_pkg_check_impl(".") + expect_s7_class(result, BtwRunToolResult) + expect_match(result@extra$code, "devtools::check") + expect_match(result@extra$code, 'pkg = "."') + expect_match(result@extra$code, "remote = TRUE") + expect_match(result@extra$code, "cran = TRUE") + expect_match(result@extra$code, "manual = FALSE") + expect_match(result@extra$code, "quiet = FALSE") + expect_match(result@extra$code, 'error_on = "never"') +}) + +test_that("btw_tool_pkg_check handles subdirectory paths", { + local_mocked_bindings( + btw_tool_run_r_impl = function(code) { + BtwRunToolResult( + value = list(ContentOutput(text = "R CMD check output")), + extra = list( + code = code, + status = "success", + data = NULL, + contents = list() + ) + ) + } + ) + + result <- btw_tool_pkg_check_impl("subdir/pkg") + expect_s7_class(result, BtwRunToolResult) + expect_match(result@extra$code, 'pkg = "subdir/pkg"') +}) + +test_that("btw_tool_pkg_check rejects paths outside current directory", { + expect_error( + btw_tool_pkg_check_impl("/tmp/outside"), + "not allowed to list or read" + ) + expect_error( + btw_tool_pkg_check_impl("../outside"), + "not allowed to list or read" + ) + expect_error( + btw_tool_pkg_check_impl("/etc/passwd"), + "not allowed to list or read" + ) +}) + +test_that("btw_tool_pkg_check validates pkg argument", { + expect_error(btw_tool_pkg_check_impl(123)) + expect_error(btw_tool_pkg_check_impl(c("a", "b"))) + expect_error(btw_tool_pkg_check_impl(NULL)) +}) + +# Test btw_tool_pkg_test ------------------------------------------------------- + +test_that("btw_tool_pkg_test constructs correct code without filter", { + local_mocked_bindings( + btw_tool_run_r_impl = function(code) { + BtwRunToolResult( + value = list(ContentOutput(text = "Test output")), + extra = list( + code = code, + status = "success", + data = NULL, + contents = list() + ) + ) + } + ) + + result <- btw_tool_pkg_test_impl(".") + expect_s7_class(result, BtwRunToolResult) + expect_match(result@extra$code, "devtools::test") + expect_match(result@extra$code, 'pkg = "."') + expect_match(result@extra$code, "stop_on_failure = FALSE") + expect_match(result@extra$code, "export_all = TRUE") + # Should NOT have filter argument + expect_false(grepl("filter", result@extra$code)) +}) + +test_that("btw_tool_pkg_test constructs correct code with filter", { + local_mocked_bindings( + btw_tool_run_r_impl = function(code) { + BtwRunToolResult( + value = list(ContentOutput(text = "Test output")), + extra = list( + code = code, + status = "success", + data = NULL, + contents = list() + ) + ) + } + ) + + result <- btw_tool_pkg_test_impl(".", filter = "helper") + expect_s7_class(result, BtwRunToolResult) + expect_match(result@extra$code, "devtools::test") + expect_match(result@extra$code, 'pkg = "."') + expect_match(result@extra$code, 'filter = "helper"') + expect_match(result@extra$code, "stop_on_failure = FALSE") + expect_match(result@extra$code, "export_all = TRUE") +}) + +test_that("btw_tool_pkg_test handles different filter patterns", { + local_mocked_bindings( + btw_tool_run_r_impl = function(code) { + BtwRunToolResult( + value = list(ContentOutput(text = "Test output")), + extra = list( + code = code, + status = "success", + data = NULL, + contents = list() + ) + ) + } + ) + + # Test with regex pattern + result1 <- btw_tool_pkg_test_impl(".", filter = "tool-.*") + expect_match(result1@extra$code, 'filter = "tool-.*"') + + # Test with simple string + result2 <- btw_tool_pkg_test_impl(".", filter = "pkg") + expect_match(result2@extra$code, 'filter = "pkg"') +}) + +test_that("btw_tool_pkg_test handles subdirectory paths", { + local_mocked_bindings( + btw_tool_run_r_impl = function(code) { + BtwRunToolResult( + value = list(ContentOutput(text = "Test output")), + extra = list( + code = code, + status = "success", + data = NULL, + contents = list() + ) + ) + } + ) + + result <- btw_tool_pkg_test_impl("subdir/pkg") + expect_s7_class(result, BtwRunToolResult) + expect_match(result@extra$code, 'pkg = "subdir/pkg"') +}) + +test_that("btw_tool_pkg_test handles subdirectory paths with filter", { + local_mocked_bindings( + btw_tool_run_r_impl = function(code) { + BtwRunToolResult( + value = list(ContentOutput(text = "Test output")), + extra = list( + code = code, + status = "success", + data = NULL, + contents = list() + ) + ) + } + ) + + result <- btw_tool_pkg_test_impl("subdir/pkg", filter = "helper") + expect_s7_class(result, BtwRunToolResult) + expect_match(result@extra$code, 'pkg = "subdir/pkg"') + expect_match(result@extra$code, 'filter = "helper"') +}) + +test_that("btw_tool_pkg_test rejects paths outside current directory", { + expect_error( + btw_tool_pkg_test_impl("/tmp/outside"), + "not allowed to list or read" + ) + expect_error( + btw_tool_pkg_test_impl("../outside"), + "not allowed to list or read" + ) + expect_error( + btw_tool_pkg_test_impl("/etc/passwd"), + "not allowed to list or read" + ) +}) + +test_that("btw_tool_pkg_test validates pkg argument", { + expect_error(btw_tool_pkg_test_impl(123)) + expect_error(btw_tool_pkg_test_impl(c("a", "b"))) + expect_error(btw_tool_pkg_test_impl(NULL)) +}) + +test_that("btw_tool_pkg_test validates filter argument", { + expect_error(btw_tool_pkg_test_impl(".", filter = 123)) + expect_error(btw_tool_pkg_test_impl(".", filter = c("a", "b"))) + # NULL should be allowed (no filter) + expect_no_error({ + local_mocked_bindings( + btw_tool_run_r_impl = function(code) { + BtwRunToolResult( + value = list(ContentOutput(text = "Test output")), + extra = list( + code = code, + status = "success", + data = NULL, + contents = list() + ) + ) + } + ) + btw_tool_pkg_test_impl(".", filter = NULL) + }) +}) + +# Test code construction with special characters ------------------------------- + +test_that("btw_tool_pkg_check handles paths with spaces correctly", { + local_mocked_bindings( + btw_tool_run_r_impl = function(code) { + BtwRunToolResult( + value = list(ContentOutput(text = "output")), + extra = list( + code = code, + status = "success", + data = NULL, + contents = list() + ) + ) + }, + check_path_within_current_wd = function(path) invisible(path) + ) + + result <- btw_tool_pkg_check_impl("path with spaces") + expect_s7_class(result, BtwRunToolResult) + # deparse() should properly quote the path + expect_match(result@extra$code, 'pkg = "path with spaces"') +}) + +test_that("btw_tool_pkg_test handles filter with special regex chars", { + local_mocked_bindings( + btw_tool_run_r_impl = function(code) { + BtwRunToolResult( + value = list(ContentOutput(text = "Test output")), + extra = list( + code = code, + status = "success", + data = NULL, + contents = list() + ) + ) + } + ) + + result <- btw_tool_pkg_test_impl(".", filter = "test-.*\\.R$") + expect_s7_class(result, BtwRunToolResult) + # deparse() should properly quote the regex + expect_true(grepl( + 'filter = \"test-.*\\.R$\"', + result@extra$code, + fixed = TRUE + )) +}) + +# Test return values ----------------------------------------------------------- + +test_that("all pkg tools return BtwRunToolResult objects", { + local_mocked_bindings( + btw_tool_run_r_impl = function(code) { + BtwRunToolResult( + value = list(ContentOutput(text = "output")), + extra = list( + code = code, + status = "success", + data = NULL, + contents = list() + ) + ) + } + ) + + result1 <- btw_tool_pkg_document_impl(".") + expect_s7_class(result1, BtwRunToolResult) + expect_type(result1@value, "list") + expect_equal(result1@extra$status, "success") + + result2 <- btw_tool_pkg_check_impl(".") + expect_s7_class(result2, BtwRunToolResult) + expect_type(result2@value, "list") + expect_equal(result2@extra$status, "success") + + result3 <- btw_tool_pkg_test_impl(".") + expect_s7_class(result3, BtwRunToolResult) + expect_type(result3@value, "list") + expect_equal(result3@extra$status, "success") +}) + +test_that("pkg tools pass through btw_tool_run_r_impl status", { + local_mocked_bindings( + btw_tool_run_r_impl = function(code) { + BtwRunToolResult( + value = list(ContentError(text = "Error message")), + extra = list( + code = code, + status = "error", + data = NULL, + contents = list() + ) + ) + } + ) + + result1 <- btw_tool_pkg_document_impl(".") + expect_equal(result1@extra$status, "error") + + result2 <- btw_tool_pkg_check_impl(".") + expect_equal(result2@extra$status, "error") + + result3 <- btw_tool_pkg_test_impl(".") + expect_equal(result3@extra$status, "error") +}) diff --git a/tests/testthat/test-tool-run.R b/tests/testthat/test-tool-run.R index aa7315f5..4b79d852 100644 --- a/tests/testthat/test-tool-run.R +++ b/tests/testthat/test-tool-run.R @@ -223,6 +223,17 @@ test_that("btw_tool_run_r() is included in btw_tools() when requested", { expect_true("btw_tool_run_r" %in% tool_names) }) +test_that("btw_tool_run_r() runs code without a dynamic tty", { + withr::local_options(cli.dynamic = TRUE) + withr::local_envvar(R_CLI_DYNAMIC = "TRUE") + + expect_true(cli::is_dynamic_tty()) + expect_equal( + btw_tool_run_r_impl("cli::is_dynamic_tty()")@extra$contents[[2]]@text, + "[1] FALSE" + ) +}) + describe("btw_tool_run_r() in btw_tools()", { local_mocked_bindings(is_installed = function(...) TRUE)