diff --git a/NEWS.md b/NEWS.md index 7ef7c764..332141e0 100644 --- a/NEWS.md +++ b/NEWS.md @@ -2,6 +2,8 @@ ## New features +* The `btw_tool_skill` tool now supports custom skill search directories via the `btw.skills.paths` R option or `BTW_SKILLS_PATHS` environment variable. When set, the value entirely replaces all user-level and project-level skill directories; package-bundled skills are always preserved. Multiple paths can be provided as a character vector (R option) or OS-native path-separated string (env var). Values are captured at tool-registration time, so custom directories set in `btw.md` survive after `btw_client()` returns (#193). + * Added `btw_tool_files_patch()`, a new files tool that applies a structured diff-style patch envelope to make coordinated changes across multiple files in a single call. One patch can add, update, delete, and rename files atomically: all operations are validated before any file is written, so a partial failure leaves the working tree untouched (#190). * Added two new commands to the `btw` CLI: `btw app` to launch a `btw_app()` session in the current working directory and `btw skills install` to install skills from the terminal. @@ -174,4 +176,4 @@ # btw 1.0.0 -* Initial CRAN submission. +* Initial CRAN submission. \ No newline at end of file diff --git a/R/tool-skills.R b/R/tool-skills.R index d9620589..8776ff0d 100644 --- a/R/tool-skills.R +++ b/R/tool-skills.R @@ -29,6 +29,25 @@ NULL #' by btw 1.2.0 is also included at lower priority. #' 4. Project-level skills (`.btw/skills/` or `.agents/skills/`) #' +#' The default user-level and project-level directories can be replaced by +#' setting the `btw.skills.paths` R option or the `BTW_SKILLS_PATHS` environment +#' variable. When set, the value **entirely replaces** all user-level and project-level +#' directories (items 3 and 4 above). Package-bundled skills and skills from +#' attached packages (items 1 and 2) are always included regardless of this +#' setting. The R option takes precedence over the environment variable. +#' Multiple paths can be provided as a character vector (e.g. +#' `options(btw.skills.paths = c("/path/a", "/path/b"))`) or as a single +#' path-separator-delimited string (`:` on Unix/Mac, `;` on Windows, which is +#' the only form supported by environment variables). Non-existent paths are +#' silently skipped. +#' +#' **Resolution timing:** options and environment variables are read at +#' **tool-registration time** (i.e. when [btw_tools()] or [btw_client()] is +#' called). The resolved paths are captured in the tool's closure so that they +#' remain correct even if the options are later modified or go out of scope +#' (for example, when `btw_client()` restores options after returning). If you +#' need different directories for a new session, create a new client. +#' #' @param name The name of the skill to load, or `""` to list all available #' skills. #' @inheritParams btw_tool_docs_package_news @@ -117,8 +136,22 @@ btw_tool_skill_impl <- function(name) { group = "skills", tool = function() { + # Capture the resolved skill dir overrides at registration time so the + # tool closes over the correct paths even after options set transiently by + # btw_client() / btw_app() have been restored to their prior values. + captured_paths <- skill_dirs_from_option_or_envvar("btw.skills.paths", "BTW_SKILLS_PATHS") + + # Only replay the options that were actually captured. When a captured + # value is NULL (nothing was set at registration time), leave the live + # option untouched so btw_skills_directories() sees the real environment. + impl <- function(name) { + opts <- list() + if (!is.null(captured_paths)) opts[["btw.skills.paths"]] <- captured_paths + withr::with_options(opts, btw_tool_skill_impl(name)) + } + ellmer::tool( - btw_tool_skill_impl, + impl, name = "btw_tool_skill", description = paste( "Load a skill's specialized instructions and list its bundled", @@ -159,30 +192,69 @@ btw_skills_directories <- function(project_dir = getwd()) { # Skills from attached packages dirs <- c(dirs, attached_package_skill_dirs()) - # Legacy: btw <= 1.2.0 install target — kept for backwards compatibility only, - # never written to by newer versions - legacy_skills_dir <- file.path(tools::R_user_dir("btw", "config"), "skills") - if (dir.exists(legacy_skills_dir)) { - dirs <- c(dirs, legacy_skills_dir) - } + # Custom paths entirely replace all user-level and project-level defaults. + # When not set, fall back to the standard user-level + project-level dirs. + custom_paths <- skill_dirs_from_option_or_envvar("btw.skills.paths", "BTW_SKILLS_PATHS") - # User-level skills from btw_user_dirs() in increasing priority order - for (user_dir in rev(btw_user_dirs())) { - user_skills_dir <- file.path(user_dir, "skills") - if (dir.exists(user_skills_dir) && !user_skills_dir %in% dirs) { - dirs <- c(dirs, user_skills_dir) + search_dirs <- custom_paths %||% c( + default_user_skill_dirs(), + default_project_skill_dirs(project_dir) + ) + + for (search_dir in search_dirs) { + if (dir.exists(search_dir) && !search_dir %in% dirs) { + dirs <- c(dirs, search_dir) } } - # Project-level skills from multiple conventions - for (project_subdir in project_skill_subdirs()) { - project_skills_dir <- file.path(project_dir, project_subdir) - if (dir.exists(project_skills_dir)) { - dirs <- c(dirs, project_skills_dir) + dirs +} + +skill_dirs_from_option_or_envvar <- function(option_name, envvar_name) { + raw <- getOption(option_name, default = NULL) + if (is.null(raw)) { + env_val <- Sys.getenv(envvar_name, unset = NA_character_) + if (is.na(env_val)) { + return(NULL) } + raw <- env_val } + # Accept either a character vector (idiomatic R / YAML array) or a + # path-separator-delimited string (useful from env vars). + if (length(raw) > 1) { + paths <- as.character(raw) + } else { + paths <- strsplit(as.character(raw), .Platform$path.sep, fixed = TRUE)[[1]] + } + paths <- paths[!is.na(paths) & nzchar(paths)] + normalizePath(paths, mustWork = FALSE) +} - dirs +default_user_skill_dirs <- function() { + # Legacy: btw <= 1.2.0 install target — kept for backwards compatibility only, + # never written to by newer versions. Listed first (lowest priority). + legacy_skills_dir <- file.path(tools::R_user_dir("btw", "config"), "skills") + + # Current user-level skill dirs in increasing priority order + current_dirs <- rev(vapply( + btw_user_dirs(), + function(d) file.path(d, "skills"), + character(1) + )) + + # Combine: legacy first, then current dirs in increasing priority order. + # The `%in%` guard in the calling loop prevents re-adding dirs already + # present from earlier sources (e.g. attached packages). `unique()` removes + # any duplicates within this vector itself before the loop sees them. + unique(c(legacy_skills_dir, current_dirs)) +} + +default_project_skill_dirs <- function(project_dir) { + vapply( + project_skill_subdirs(), + function(s) file.path(project_dir, s), + character(1) + ) } attached_package_skill_dirs <- function() { @@ -1093,4 +1165,4 @@ install_skill_from_dir <- function( maybe_use_build_ignore(target_parent) invisible(target_dir) -} +} \ No newline at end of file diff --git a/btw.md b/btw.md new file mode 100644 index 00000000..6439220c --- /dev/null +++ b/btw.md @@ -0,0 +1,136 @@ +--- +client: + sonnet: + provider: aws_bedrock + model: us.anthropic.claude-sonnet-4-6 + api_args: + additionalModelRequestFields: + thinking: + type: enabled + budget_tokens: 4000 + haiku: + provider: aws_bedrock + model: us.anthropic.claude-haiku-4-5-20251001-v1:0 + api_args: + additionalModelRequestFields: + thinking: + type: enabled + budget_tokens: 4000 + opus: + provider: aws_bedrock + model: us.anthropic.claude-opus-4-5-20251101-v1:0 + api_args: + additionalModelRequestFields: + thinking: + type: enabled + budget_tokens: 4000 + + sonnet-4.5: + provider: aws_bedrock + model: us.anthropic.claude-sonnet-4-5-20250929-v1:0 + sonnet-4: + provider: aws_bedrock + model: us.anthropic.claude-sonnet-4-20250514-v1:0 + opus-4.5: + provider: aws_bedrock + model: us.anthropic.claude-opus-4-5-20251101-v1:0 + + gemma4: + provider: openai_compatible + model: google/gemma-4-26b-a4b + # base_url: http://127.0.0.1:1234/v1 + base_url: http://remy.local:1234/v1 + name: "LM Studio" + + qwen3.6: + provider: openai_compatible + model: qwen/qwen3.6-35b-a3b + base_url: http://remy.local:1234/v1 + name: "LM Studio" + preserve_thinking: true + + qwen-3-5-35b: + provider: openai_compatible + model: qwen/qwen3.5-35b-a3b + base_url: http://127.0.0.1:1234/v1 + name: "LM Studio" + + qwen-3-5-9b: + provider: openai_compatible + model: qwen/qwen3.5-9b + base_url: http://127.0.0.1:1234/v1 + name: "LM Studio" + params: + reasoning_effort: medium + + glm-4-7-flash: + provider: openai_compatible + model: zai-org/glm-4.7-flash + base_url: http://127.0.0.1:1234/v1 + name: "LM Studio" + + nemotron-3-nano-4b: + provider: openai_compatible + model: nvidia/nemotron-3-nano-4b + base_url: http://127.0.0.1:1234/v1 + name: "LM Studio" + + lm-glm4v: + provider: openai_compatible + model: zai-org/glm-4.6v-flash + base_url: http://127.0.0.1:1234/v1 + name: "LM Studio" + qwen3: + provider: openai_compatible + model: qwen/qwen3-vl-30b + base_url: http://127.0.0.1:1234/v1 + name: "LM Studio" + gpt-oss-20b: + provider: openai_compatible + model: openai/gpt-oss-20b + base_url: http://127.0.0.1:1234/v1 + name: "LM Studio" + +tools: + # - agent + - skills + - files_list + - files_read + - files_write + # - files_edit + - files_replace + - docs_help_page + - docs_package_help_topics + +options: + skills: + paths: ["~/.agents/skills"] + subagent: + # client: openai/gpt-5.4-mini + tools_allowed: [docs, files_search, files_list] +--- + +## Overview + +btw is an R package that helps humans and LLMs work together with R by providing utilities to describe R objects, package documentation, and workspace state in LLM-friendly plain text. The package offers a flexible collection of tools that can be used interactively (copy-paste workflows), programmatically (direct function calls), or as enhanced chat clients (via ellmer or MCP servers). + +The primary goal is creating a collection of tools useful to both LLMs and humans when working together with R, with an emphasis on flexibility of usage across different workflows and platforms. + +## Quick Reference + +- **Project type:** R Package +- **Language:** R (≥ 4.1.0) +- **Key frameworks:** ellmer (LLM chat integration), mcptools (Model Context Protocol), shiny and shinychat (chat app) + +## Purpose and Design Philosophy + +btw prioritizes flexibility of usage through multiple entry points: + +- **`btw()`** - Interactive copy-paste workflow: gather context from R and paste into any chat interface +- **`btw_tools()`** - Register tools with ellmer chat clients for custom applications +- **`btw_client()` / `btw_app()`** - Batteries-included chat clients with your preferred LLM provider, model, and project context +- **MCP server** - Expose tools to third-party coding agents like Claude Desktop or Continue via `btw_mcp_server()` + +Project configuration via `btw.md` files provides conversation stability across sessions by defining default provider, model, tools, and project-specific instructions. These files are treated as instructions for coding assistants and help avoid repeating context. + +btw also serves as a laboratory for discovering best practices in LLM tool design - output formats and approaches evolve based on experimentation with what works best across different models. \ No newline at end of file diff --git a/man/btw_tool_skill.Rd b/man/btw_tool_skill.Rd index 62d90100..6714e5c5 100644 --- a/man/btw_tool_skill.Rd +++ b/man/btw_tool_skill.Rd @@ -44,6 +44,25 @@ legacy \code{tools::R_user_dir("btw", "config")/skills} path used by briefly by btw 1.2.0 is also included at lower priority. \item Project-level skills (\verb{.btw/skills/} or \verb{.agents/skills/}) } + +The default user-level and project-level directories can be replaced by +setting the \code{btw.skills.paths} R option or the \code{BTW_SKILLS_PATHS} environment +variable. When set, the value \strong{entirely replaces} all user-level and project-level +directories (items 3 and 4 above). Package-bundled skills and skills from +attached packages (items 1 and 2) are always included regardless of this +setting. The R option takes precedence over the environment variable. +Multiple paths can be provided as a character vector (e.g. +\code{options(btw.skills.paths = c("/path/a", "/path/b"))}) or as a single +path-separator-delimited string (\code{:} on Unix/Mac, \verb{;} on Windows, which is +the only form supported by environment variables). Non-existent paths are +silently skipped. + +\strong{Resolution timing:} options and environment variables are read at +\strong{tool-registration time} (i.e. when \code{\link[=btw_tools]{btw_tools()}} or \code{\link[=btw_client]{btw_client()}} is +called). The resolved paths are captured in the tool's closure so that they +remain correct even if the options are later modified or go out of scope +(for example, when \code{btw_client()} restores options after returning). If you +need different directories for a new session, create a new client. } \seealso{ Other skills: diff --git a/tests/testthat/test-tool_skills.R b/tests/testthat/test-tool_skills.R index 0d67ef47..fe2429d9 100644 --- a/tests/testthat/test-tool_skills.R +++ b/tests/testthat/test-tool_skills.R @@ -303,6 +303,222 @@ test_that("btw_skills_directories() discovers .agents/skills", { expect_true(agents_dir %in% dirs) }) +# skill_dirs_from_option_or_envvar() ---------------------------------------- + +test_that("skill_dirs_from_option_or_envvar() returns NULL when neither option nor envvar is set", { + withr::local_options("btw.test.option" = NULL) + withr::local_envvar("BTW_TEST_ENVVAR" = NA) + result <- skill_dirs_from_option_or_envvar("btw.test.option", "BTW_TEST_ENVVAR") + expect_null(result) +}) + +test_that("skill_dirs_from_option_or_envvar() reads from envvar when option is not set", { + dir1 <- withr::local_tempdir() + dir2 <- withr::local_tempdir() + raw <- paste(dir1, dir2, sep = .Platform$path.sep) + withr::local_options("btw.test.option" = NULL) + withr::local_envvar("BTW_TEST_ENVVAR" = raw) + result <- skill_dirs_from_option_or_envvar("btw.test.option", "BTW_TEST_ENVVAR") + expect_equal(result, normalizePath(c(dir1, dir2), mustWork = FALSE)) +}) + +test_that("skill_dirs_from_option_or_envvar() option takes precedence over envvar", { + dir_opt <- withr::local_tempdir() + dir_env <- withr::local_tempdir() + withr::local_options("btw.test.option" = dir_opt) + withr::local_envvar("BTW_TEST_ENVVAR" = dir_env) + result <- skill_dirs_from_option_or_envvar("btw.test.option", "BTW_TEST_ENVVAR") + expect_equal(result, normalizePath(dir_opt, mustWork = FALSE)) + expect_false(normalizePath(dir_env, mustWork = FALSE) %in% result) +}) + +test_that("skill_dirs_from_option_or_envvar() splits on OS path separator and normalizes", { + dir1 <- withr::local_tempdir() + dir2 <- withr::local_tempdir() + raw <- paste(dir1, dir2, sep = .Platform$path.sep) + withr::local_options("btw.test.option" = raw) + result <- skill_dirs_from_option_or_envvar("btw.test.option", "BTW_TEST_ENVVAR") + expect_length(result, 2) + expect_equal(result, normalizePath(c(dir1, dir2), mustWork = FALSE)) +}) + +test_that("skill_dirs_from_option_or_envvar() handles single path (no separator)", { + dir1 <- withr::local_tempdir() + withr::local_options("btw.test.option" = dir1) + result <- skill_dirs_from_option_or_envvar("btw.test.option", "BTW_TEST_ENVVAR") + expect_length(result, 1) + expect_equal(result, normalizePath(dir1, mustWork = FALSE)) +}) + +test_that("skill_dirs_from_option_or_envvar() ignores NA entries in character-vector options", { + dir1 <- withr::local_tempdir() + withr::local_options("btw.test.option" = c(dir1, NA_character_)) + result <- skill_dirs_from_option_or_envvar("btw.test.option", "BTW_TEST_ENVVAR") + expect_length(result, 1) + expect_equal(result, normalizePath(dir1, mustWork = FALSE)) +}) + +# btw_skills_directories() with BTW_SKILLS_PATHS / btw.skills.paths ----------- + +test_that("btw_skills_directories() uses BTW_SKILLS_PATHS envvar when set", { + custom <- withr::local_tempdir() + dir.create(file.path(custom, "my-skill"), recursive = TRUE) + writeLines( + "---\nname: my-skill\ndescription: A skill.\n---\n", + file.path(custom, "my-skill", "SKILL.md") + ) + withr::local_envvar("BTW_SKILLS_PATHS" = custom) + withr::local_options("btw.skills.paths" = NULL) + + project <- withr::local_tempdir() + dirs <- btw_skills_directories(project_dir = project) + expect_true(normalizePath(custom, mustWork = FALSE) %in% dirs) +}) + +test_that("btw_skills_directories() uses btw.skills.paths option when set", { + custom <- withr::local_tempdir() + withr::local_options("btw.skills.paths" = custom) + + project <- withr::local_tempdir() + dirs <- btw_skills_directories(project_dir = project) + expect_true(normalizePath(custom, mustWork = FALSE) %in% dirs) +}) + +test_that("btw_skills_directories() option overrides envvar", { + opt_dir <- withr::local_tempdir() + env_dir <- withr::local_tempdir() + withr::local_options("btw.skills.paths" = opt_dir) + withr::local_envvar("BTW_SKILLS_PATHS" = env_dir) + + project <- withr::local_tempdir() + dirs <- btw_skills_directories(project_dir = project) + expect_true(normalizePath(opt_dir, mustWork = FALSE) %in% dirs) + expect_false(normalizePath(env_dir, mustWork = FALSE) %in% dirs) +}) + +test_that("btw_skills_directories() falls back to defaults when no option/envvar set", { + withr::local_options("btw.skills.paths" = NULL) + withr::local_envvar("BTW_SKILLS_PATHS" = NA) + + project <- withr::local_tempdir() + btw_dir <- file.path(project, ".btw", "skills") + dir.create(btw_dir, recursive = TRUE) + + dirs <- btw_skills_directories(project_dir = project) + expect_true(btw_dir %in% dirs) +}) + +test_that("btw_skills_directories() custom paths replace (not append to) all user/project defaults", { + custom <- withr::local_tempdir() + withr::local_options("btw.skills.paths" = custom) + withr::local_envvar("BTW_SKILLS_PATHS" = NA) + + # Mock btw_user_dirs so we can confirm user defaults don't sneak through + default_user <- withr::local_tempdir() + default_skills <- file.path(default_user, "skills") + dir.create(default_skills, recursive = TRUE) + local_mocked_bindings(btw_user_dirs = function() default_user) + + # Create the default project skills dir so we can confirm it's NOT included + project <- withr::local_tempdir() + default_proj_dir <- file.path(project, ".btw", "skills") + dir.create(default_proj_dir, recursive = TRUE) + + dirs <- btw_skills_directories(project_dir = project) + + expect_true(normalizePath(custom, mustWork = FALSE) %in% dirs) + expect_false(normalizePath(default_skills, mustWork = FALSE) %in% dirs) + expect_false(normalizePath(default_proj_dir, mustWork = FALSE) %in% dirs) +}) + +test_that("btw_skills_directories() non-existent custom paths are silently skipped", { + nonexistent <- file.path(withr::local_tempdir(), "does-not-exist") + withr::local_options("btw.skills.paths" = nonexistent) + + project <- withr::local_tempdir() + dirs <- btw_skills_directories(project_dir = project) + expect_false(normalizePath(nonexistent, mustWork = FALSE) %in% dirs) +}) + +test_that("skill_dirs_from_option_or_envvar() accepts a character vector option", { + dir1 <- withr::local_tempdir() + dir2 <- withr::local_tempdir() + withr::local_options("btw.test.option" = c(dir1, dir2)) + result <- skill_dirs_from_option_or_envvar("btw.test.option", "BTW_TEST_ENVVAR") + expect_length(result, 2) + expect_equal(result, normalizePath(c(dir1, dir2), mustWork = FALSE)) +}) + +test_that("btw_skills_directories() package-bundled skills are present even when custom paths are set", { + custom <- withr::local_tempdir() + withr::local_options("btw.skills.paths" = custom) + withr::local_envvar("BTW_SKILLS_PATHS" = NA) + + project <- withr::local_tempdir() + dirs <- btw_skills_directories(project_dir = project) + + # btw's own bundled skills directory must always be present + bundled <- system.file("skills", package = "btw") + if (nzchar(bundled) && dir.exists(bundled)) { + expect_true(bundled %in% dirs) + } else { + skip("btw bundled skills directory not found (dev environment without installed skills)") + } +}) + +test_that("btw_skills_directories() no duplicate when the same dir is listed twice in BTW_SKILLS_PATHS", { + shared_dir <- withr::local_tempdir() + dir.create(file.path(shared_dir, "a-skill"), recursive = TRUE) + writeLines("---\nname: a-skill\ndescription: A skill.\n---\n", + file.path(shared_dir, "a-skill", "SKILL.md")) + + withr::local_options( + "btw.skills.paths" = c(shared_dir, shared_dir) + ) + withr::local_envvar("BTW_SKILLS_PATHS" = NA) + + project <- withr::local_tempdir() + dirs <- btw_skills_directories(project_dir = project) + norm_shared <- normalizePath(shared_dir, mustWork = FALSE) + expect_equal(sum(dirs == norm_shared), 1L) +}) + +# btw_tool_skill tool factory (closure / registration-time capture) ----------- + +test_that("btw_tool_skill tool freezes paths captured at registration, ignores later option changes", { + reg_dir <- withr::local_tempdir() + create_temp_skill(name = "reg-skill", dir = reg_dir) + withr::local_options("btw.skills.paths" = reg_dir) + withr::local_envvar("BTW_SKILLS_PATHS" = NA) + + # Instantiate the tool while reg_dir is the active option. + tool_obj <- btw_tools("btw_tool_skill")[[1]] + + # Change the option *after* registration to a different (empty) directory. + post_dir <- withr::local_tempdir() + withr::local_options("btw.skills.paths" = post_dir) + + # The tool should still find reg-skill because it froze reg_dir at + # registration time. It should NOT see post_dir's empty directory. + result <- tool_obj(name = "", `_intent` = "test") + expect_match(result@value, "reg-skill") +}) + +test_that("btw_tool_skill tool defers to live options when nothing was captured at registration", { + # Register with no custom paths set. + withr::local_options("btw.skills.paths" = NULL) + withr::local_envvar("BTW_SKILLS_PATHS" = NA) + tool_obj <- btw_tools("btw_tool_skill")[[1]] + + # Now set a paths option *after* registration — the tool should pick it up. + post_dir <- withr::local_tempdir() + create_temp_skill(name = "post-skill", dir = post_dir) + withr::local_options("btw.skills.paths" = post_dir) + + result <- tool_obj(name = "", `_intent` = "test") + expect_match(result@value, "post-skill") +}) + test_that("resolve_project_skill_dir() defaults to .btw/skills when none exist", { project <- withr::local_tempdir() withr::local_dir(project) @@ -1287,4 +1503,4 @@ test_that("skills prompt is included in btw_client() system prompt", { expect_match(system_prompt, "## Skills", fixed = TRUE) expect_match(system_prompt, "skill-creator", fixed = TRUE) -}) +}) \ No newline at end of file