From b8b81fe6aaa1e9b06cd5acaff6c93e8c1b853fb2 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 13 May 2026 11:04:46 -0400 Subject: [PATCH 1/7] Add custom skill dirs via envvar/option Add `BTW_SKILLS_DIRS_USER` / `btw.skills.dirs_user` and `BTW_SKILLS_DIRS_PROJECT` / `btw.skills.dirs_project` envvar/option pairs to replace the default user-level and project-level skill directories. - Add `skill_dirs_from_option_or_envvar()`: reads the R option first, falls back to the envvar, splits on OS-native path separator, normalizes paths, returns `NULL` when neither is set. - Add `default_user_skill_dirs()`: encapsulates the legacy + current user-level defaults previously inlined in `btw_skills_directories()`. - Add `default_project_skill_dirs()`: encapsulates the project-level defaults previously inlined in `btw_skills_directories()`. - Update `btw_skills_directories()` to call the new helpers; resolution happens fresh on every call (tool-call time, not registration time). Tests: add 22 tests covering option > envvar > NULL precedence, path splitting/normalization, replace semantics, silent skipping of non-existent paths, and fallback to defaults. --- R/tool-skills.R | 94 +++++++++++++--- tests/testthat/test-tool_skills.R | 180 +++++++++++++++++++++++++++++- 2 files changed, 257 insertions(+), 17 deletions(-) diff --git a/R/tool-skills.R b/R/tool-skills.R index d9620589..e4f495cb 100644 --- a/R/tool-skills.R +++ b/R/tool-skills.R @@ -159,32 +159,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) - } + user_dirs <- skill_dirs_from_option_or_envvar( + "btw.skills.dirs_user", "BTW_SKILLS_DIRS_USER" + ) %||% default_user_skill_dirs() - # 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) + for (user_dir in user_dirs) { + if (dir.exists(user_dir) && !user_dir %in% dirs) { + dirs <- c(dirs, user_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) + project_dirs <- skill_dirs_from_option_or_envvar( + "btw.skills.dirs_project", "BTW_SKILLS_DIRS_PROJECT" + ) %||% default_project_skill_dirs(project_dir) + + for (project_dir_path in project_dirs) { + if (dir.exists(project_dir_path)) { + dirs <- c(dirs, project_dir_path) } } 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 + } + paths <- strsplit(raw, .Platform$path.sep, fixed = TRUE)[[1]] + paths <- paths[nzchar(paths)] + normalizePath(paths, mustWork = FALSE) +} + +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. + # Duplicates are filtered out at resolution time (dir.exists + %in% check), + # but filter obvious duplication here to be safe. + 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() { pkgs <- setdiff(.packages(), "btw") dirs <- character() @@ -1094,3 +1131,28 @@ install_skill_from_dir <- function( invisible(target_dir) } +resolve_skill_dirs_envvar_or_option <- function(option_name, envvar_name) { + value <- getOption(option_name) + if (is.null(value)) { + value <- Sys.getenv(envvar_name, NA_character_) + if (identical(value, NA_character_)) value <- NULL + } + value +} + +parse_skill_dirs <- function(value, scope) { + sep <- if (.Platform$OS.type == "unix") ":" else ";" + paths <- trimws(strsplit(value, sep, fixed = TRUE)[[1]]) + paths <- paths[nzchar(paths)] + normalizePath(paths, mustWork = FALSE) +} + +default_user_skill_dirs <- function() { + legacy <- file.path(tools::R_user_dir("btw", "config"), "skills") + current <- file.path(btw_user_dirs(), "skills") + c(legacy, current) +} + +default_project_skill_dirs <- function(project_dir) { + file.path(project_dir, project_skill_subdirs()) +} \ No newline at end of file diff --git a/tests/testthat/test-tool_skills.R b/tests/testthat/test-tool_skills.R index 0d67ef47..8cd8d8a2 100644 --- a/tests/testthat/test-tool_skills.R +++ b/tests/testthat/test-tool_skills.R @@ -303,6 +303,184 @@ 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)) +}) + +# btw_skills_directories() with custom user/project dirs --------------------- + +test_that("btw_skills_directories() uses BTW_SKILLS_DIRS_USER envvar when set", { + custom_user <- withr::local_tempdir() + dir.create(file.path(custom_user, "my-skill"), recursive = TRUE) + writeLines( + "---\nname: my-skill\ndescription: A skill.\n---\n", + file.path(custom_user, "my-skill", "SKILL.md") + ) + withr::local_envvar("BTW_SKILLS_DIRS_USER" = custom_user) + withr::local_options("btw.skills.dirs_user" = NULL) + + # Use a temp dir as project_dir so no project skills are found + project <- withr::local_tempdir() + dirs <- btw_skills_directories(project_dir = project) + expect_true(normalizePath(custom_user, mustWork = FALSE) %in% dirs) +}) + +test_that("btw_skills_directories() uses btw.skills.dirs_user option when set", { + custom_user <- withr::local_tempdir() + withr::local_options("btw.skills.dirs_user" = custom_user) + + project <- withr::local_tempdir() + dirs <- btw_skills_directories(project_dir = project) + expect_true(normalizePath(custom_user, mustWork = FALSE) %in% dirs) +}) + +test_that("btw_skills_directories() option overrides envvar for user dirs", { + opt_dir <- withr::local_tempdir() + env_dir <- withr::local_tempdir() + withr::local_options("btw.skills.dirs_user" = opt_dir) + withr::local_envvar("BTW_SKILLS_DIRS_USER" = 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() uses BTW_SKILLS_DIRS_PROJECT envvar when set", { + custom_proj <- withr::local_tempdir() + withr::local_envvar("BTW_SKILLS_DIRS_PROJECT" = custom_proj) + withr::local_options("btw.skills.dirs_project" = NULL) + + project <- withr::local_tempdir() + dirs <- btw_skills_directories(project_dir = project) + expect_true(normalizePath(custom_proj, mustWork = FALSE) %in% dirs) +}) + +test_that("btw_skills_directories() uses btw.skills.dirs_project option when set", { + custom_proj <- withr::local_tempdir() + withr::local_options("btw.skills.dirs_project" = custom_proj) + + project <- withr::local_tempdir() + dirs <- btw_skills_directories(project_dir = project) + expect_true(normalizePath(custom_proj, mustWork = FALSE) %in% dirs) +}) + +test_that("btw_skills_directories() option overrides envvar for project dirs", { + opt_dir <- withr::local_tempdir() + env_dir <- withr::local_tempdir() + withr::local_options("btw.skills.dirs_project" = opt_dir) + withr::local_envvar("BTW_SKILLS_DIRS_PROJECT" = 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.dirs_user" = NULL, + "btw.skills.dirs_project" = NULL + ) + withr::local_envvar( + "BTW_SKILLS_DIRS_USER" = NA, + "BTW_SKILLS_DIRS_PROJECT" = 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 user dirs replace (not append to) defaults", { + custom_user <- withr::local_tempdir() + withr::local_options("btw.skills.dirs_user" = custom_user) + withr::local_envvar("BTW_SKILLS_DIRS_USER" = NA) + + # Mock btw_user_dirs so we can detect if defaults 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) + + project <- withr::local_tempdir() + dirs <- btw_skills_directories(project_dir = project) + + expect_true(normalizePath(custom_user, mustWork = FALSE) %in% dirs) + expect_false(normalizePath(default_skills, mustWork = FALSE) %in% dirs) +}) + +test_that("btw_skills_directories() custom project dirs replace (not append to) defaults", { + custom_proj <- withr::local_tempdir() + withr::local_options("btw.skills.dirs_project" = custom_proj) + withr::local_envvar("BTW_SKILLS_DIRS_PROJECT" = NA) + + # 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_proj, mustWork = FALSE) %in% dirs) + expect_false(normalizePath(default_proj_dir, mustWork = FALSE) %in% dirs) +}) + +test_that("btw_skills_directories() non-existent custom dirs are silently skipped", { + nonexistent <- file.path(withr::local_tempdir(), "does-not-exist") + withr::local_options("btw.skills.dirs_user" = nonexistent) + + project <- withr::local_tempdir() + dirs <- btw_skills_directories(project_dir = project) + expect_false(normalizePath(nonexistent, mustWork = FALSE) %in% dirs) +}) + test_that("resolve_project_skill_dir() defaults to .btw/skills when none exist", { project <- withr::local_tempdir() withr::local_dir(project) @@ -1287,4 +1465,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 From d942f14dc3f80d07991b726c7c3da01fb5bc39f8 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 13 May 2026 11:05:23 -0400 Subject: [PATCH 2/7] Document new skill dir envvar/option pairs in btw_tool_skill Update the `btw_tool_skill` roxygen block (and its generated .Rd) to document: - The new `btw.skills.dirs_user`/`BTW_SKILLS_DIRS_USER` and `btw.skills.dirs_project`/`BTW_SKILLS_DIRS_PROJECT` pairs via a table - Replace semantics (new value replaces defaults, not appends) - Option > envvar precedence - OS-native path separator format - Resolution timing (at tool-call time, not registration time) --- R/tool-skills.R | 16 ++++++++++++++++ man/btw_tool_skill.Rd | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/R/tool-skills.R b/R/tool-skills.R index e4f495cb..431f81c0 100644 --- a/R/tool-skills.R +++ b/R/tool-skills.R @@ -29,6 +29,22 @@ 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 an R option or environment variable: +#' +#' | Scope | R Option | Environment Variable | +#' |---|---|---| +#' | User-level | `btw.skills.dirs_user` | `BTW_SKILLS_DIRS_USER` | +#' | Project-level | `btw.skills.dirs_project` | `BTW_SKILLS_DIRS_PROJECT` | +#' +#' When set, the value **replaces** (not appends to) the defaults for that +#' scope. The R option takes precedence over the environment variable. Multiple +#' paths can be provided using the OS-native path separator (`:` on Unix/Mac, +#' `;` on Windows). Non-existent paths are silently skipped. Values are +#' resolved fresh on every call (at tool-call time, not at registration time), +#' so they do not need to be set before the tool is registered with the chat +#' client. +#' #' @param name The name of the skill to load, or `""` to list all available #' skills. #' @inheritParams btw_tool_docs_package_news diff --git a/man/btw_tool_skill.Rd b/man/btw_tool_skill.Rd index 62d90100..7949906b 100644 --- a/man/btw_tool_skill.Rd +++ b/man/btw_tool_skill.Rd @@ -44,6 +44,22 @@ 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 an R option or environment variable:\tabular{lll}{ + Scope \tab R Option \tab Environment Variable \cr + User-level \tab \code{btw.skills.dirs_user} \tab \code{BTW_SKILLS_DIRS_USER} \cr + Project-level \tab \code{btw.skills.dirs_project} \tab \code{BTW_SKILLS_DIRS_PROJECT} \cr +} + + +When set, the value \strong{replaces} (not appends to) the defaults for that +scope. The R option takes precedence over the environment variable. Multiple +paths can be provided using the OS-native path separator (\code{:} on Unix/Mac, +\verb{;} on Windows). Non-existent paths are silently skipped. Values are +resolved fresh on every call (at tool-call time, not at registration time), +so they do not need to be set before the tool is registered with the chat +client. } \seealso{ Other skills: From 1303a3da89ce71f64a780c10b65ac4b349fa2287 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 13 May 2026 11:21:02 -0400 Subject: [PATCH 3/7] fix(skills): resolve code review issues from plan_skill-dir-options - Delete stale dead-code block at bottom of tool-skills.R (duplicate definitions of default_user_skill_dirs, default_project_skill_dirs, and two unused helpers resolve_skill_dirs_envvar_or_option / parse_skill_dirs). The bottom copies were silently clobbering the correct implementations above. - Fix skill_dirs_from_option_or_envvar() to accept a character vector option value (e.g. c('/a', '/b')) in addition to a path-separator- delimited string. Character vectors are now the primary documented idiom; the path-separator form remains available and is still required for environment variables. - Add missing %in% duplicate guard to the project-dir loop in btw_skills_directories(), matching the existing guard on the user-dir loop. - Fix tool closing-over bug: the .btw_add_to_tools factory for btw_tool_skill now captures the resolved option/envvar values at registration time via local(). Previously, options set transiently by btw_client() (via withr::local_options) were gone by the time the tool was invoked, so custom dirs from btw.md were silently ignored. - Fix misleading deduplication comment in default_user_skill_dirs(). - Update docs to reflect character-vector option support and registration-time (not call-time) resolution semantics. - Add 4 new tests (173 total, up from 169): - char-vector option accepted by skill_dirs_from_option_or_envvar() - package-bundled skills survive when custom user dirs are set - no duplicate entry when user and project dirs point at same path (the project-dir %in% guard is now exercised) Fixes noted in _dev/agents/plan_skill-dir-options.md review section. --- R/tool-skills.R | 79 +++++++++++++++++-------------- man/btw_tool_skill.Rd | 17 +++++-- tests/testthat/test-tool_skills.R | 47 ++++++++++++++++++ 3 files changed, 103 insertions(+), 40 deletions(-) diff --git a/R/tool-skills.R b/R/tool-skills.R index 431f81c0..4e019acb 100644 --- a/R/tool-skills.R +++ b/R/tool-skills.R @@ -39,11 +39,18 @@ NULL #' #' When set, the value **replaces** (not appends to) the defaults for that #' scope. The R option takes precedence over the environment variable. Multiple -#' paths can be provided using the OS-native path separator (`:` on Unix/Mac, -#' `;` on Windows). Non-existent paths are silently skipped. Values are -#' resolved fresh on every call (at tool-call time, not at registration time), -#' so they do not need to be set before the tool is registered with the chat -#' client. +#' paths can be provided as a character vector (e.g. +#' `options(btw.skills.dirs_user = 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. @@ -133,8 +140,28 @@ 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_user_dirs <- skill_dirs_from_option_or_envvar("btw.skills.dirs_user", "BTW_SKILLS_DIRS_USER") + captured_project_dirs <- skill_dirs_from_option_or_envvar("btw.skills.dirs_project", "BTW_SKILLS_DIRS_PROJECT") + + impl <- local({ + u <- captured_user_dirs + p <- captured_project_dirs + function(name) { + withr::with_options( + list( + btw.skills.dirs_user = u %||% getOption("btw.skills.dirs_user"), + btw.skills.dirs_project = p %||% getOption("btw.skills.dirs_project") + ), + 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", @@ -190,7 +217,7 @@ btw_skills_directories <- function(project_dir = getwd()) { ) %||% default_project_skill_dirs(project_dir) for (project_dir_path in project_dirs) { - if (dir.exists(project_dir_path)) { + if (dir.exists(project_dir_path) && !project_dir_path %in% dirs) { dirs <- c(dirs, project_dir_path) } } @@ -207,7 +234,13 @@ skill_dirs_from_option_or_envvar <- function(option_name, envvar_name) { } raw <- env_val } - paths <- strsplit(raw, .Platform$path.sep, fixed = TRUE)[[1]] + # 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[nzchar(paths)] normalizePath(paths, mustWork = FALSE) } @@ -225,8 +258,9 @@ default_user_skill_dirs <- function() { )) # Combine: legacy first, then current dirs in increasing priority order. - # Duplicates are filtered out at resolution time (dir.exists + %in% check), - # but filter obvious duplication here to be safe. + # 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)) } @@ -1146,29 +1180,4 @@ install_skill_from_dir <- function( maybe_use_build_ignore(target_parent) invisible(target_dir) -} -resolve_skill_dirs_envvar_or_option <- function(option_name, envvar_name) { - value <- getOption(option_name) - if (is.null(value)) { - value <- Sys.getenv(envvar_name, NA_character_) - if (identical(value, NA_character_)) value <- NULL - } - value -} - -parse_skill_dirs <- function(value, scope) { - sep <- if (.Platform$OS.type == "unix") ":" else ";" - paths <- trimws(strsplit(value, sep, fixed = TRUE)[[1]]) - paths <- paths[nzchar(paths)] - normalizePath(paths, mustWork = FALSE) -} - -default_user_skill_dirs <- function() { - legacy <- file.path(tools::R_user_dir("btw", "config"), "skills") - current <- file.path(btw_user_dirs(), "skills") - c(legacy, current) -} - -default_project_skill_dirs <- function(project_dir) { - file.path(project_dir, project_skill_subdirs()) } \ No newline at end of file diff --git a/man/btw_tool_skill.Rd b/man/btw_tool_skill.Rd index 7949906b..751cd37f 100644 --- a/man/btw_tool_skill.Rd +++ b/man/btw_tool_skill.Rd @@ -55,11 +55,18 @@ setting an R option or environment variable:\tabular{lll}{ When set, the value \strong{replaces} (not appends to) the defaults for that scope. The R option takes precedence over the environment variable. Multiple -paths can be provided using the OS-native path separator (\code{:} on Unix/Mac, -\verb{;} on Windows). Non-existent paths are silently skipped. Values are -resolved fresh on every call (at tool-call time, not at registration time), -so they do not need to be set before the tool is registered with the chat -client. +paths can be provided as a character vector (e.g. +\code{options(btw.skills.dirs_user = 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 8cd8d8a2..1449dd84 100644 --- a/tests/testthat/test-tool_skills.R +++ b/tests/testthat/test-tool_skills.R @@ -481,6 +481,53 @@ test_that("btw_skills_directories() non-existent custom dirs are silently skippe 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 user dirs are set", { + custom_user <- withr::local_tempdir() + withr::local_options("btw.skills.dirs_user" = custom_user) + withr::local_envvar("BTW_SKILLS_DIRS_USER" = 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 custom project dir matches an existing user dir", { + 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.dirs_user" = shared_dir, + "btw.skills.dirs_project" = shared_dir + ) + withr::local_envvar( + "BTW_SKILLS_DIRS_USER" = NA, + "BTW_SKILLS_DIRS_PROJECT" = 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) +}) + test_that("resolve_project_skill_dir() defaults to .btw/skills when none exist", { project <- withr::local_tempdir() withr::local_dir(project) From 73e32fbc17db425c4a722dba85d0a76eb2c90407 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 13 May 2026 11:36:03 -0400 Subject: [PATCH 4/7] fix(skills): address round-2 code review issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R1: Remove %||% getOption() fallback in tool impl closure. Only non-NULL captured values are now replayed via withr::with_options(); untouched slots resolve naturally from the live environment. This enforces the documented contract (registration-time capture wins when set; defers to live options otherwise). S1: Add two closure tests via btw_tools() to cover the registration- time capture behavior — one asserting frozen values survive option changes, one asserting deferred resolution when nothing was captured. S2: Strengthen NA filter in skill_dirs_from_option_or_envvar() from `nzchar()` to `!is.na() & nzchar()`. Add test for NA in char-vector. S3: Remove redundant local() IIFE wrapper around impl. The function closes correctly over captured_user_dirs / captured_project_dirs without it. --- R/tool-skills.R | 24 ++++++------- tests/testthat/test-tool_skills.R | 56 +++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 14 deletions(-) diff --git a/R/tool-skills.R b/R/tool-skills.R index 4e019acb..998417fe 100644 --- a/R/tool-skills.R +++ b/R/tool-skills.R @@ -146,19 +146,15 @@ btw_tool_skill_impl <- function(name) { captured_user_dirs <- skill_dirs_from_option_or_envvar("btw.skills.dirs_user", "BTW_SKILLS_DIRS_USER") captured_project_dirs <- skill_dirs_from_option_or_envvar("btw.skills.dirs_project", "BTW_SKILLS_DIRS_PROJECT") - impl <- local({ - u <- captured_user_dirs - p <- captured_project_dirs - function(name) { - withr::with_options( - list( - btw.skills.dirs_user = u %||% getOption("btw.skills.dirs_user"), - btw.skills.dirs_project = p %||% getOption("btw.skills.dirs_project") - ), - btw_tool_skill_impl(name) - ) - } - }) + # 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_user_dirs)) opts[["btw.skills.dirs_user"]] <- captured_user_dirs + if (!is.null(captured_project_dirs)) opts[["btw.skills.dirs_project"]] <- captured_project_dirs + withr::with_options(opts, btw_tool_skill_impl(name)) + } ellmer::tool( impl, @@ -241,7 +237,7 @@ skill_dirs_from_option_or_envvar <- function(option_name, envvar_name) { } else { paths <- strsplit(as.character(raw), .Platform$path.sep, fixed = TRUE)[[1]] } - paths <- paths[nzchar(paths)] + paths <- paths[!is.na(paths) & nzchar(paths)] normalizePath(paths, mustWork = FALSE) } diff --git a/tests/testthat/test-tool_skills.R b/tests/testthat/test-tool_skills.R index 1449dd84..5bfabf66 100644 --- a/tests/testthat/test-tool_skills.R +++ b/tests/testthat/test-tool_skills.R @@ -350,6 +350,14 @@ test_that("skill_dirs_from_option_or_envvar() handles single path (no separator) 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 custom user/project dirs --------------------- test_that("btw_skills_directories() uses BTW_SKILLS_DIRS_USER envvar when set", { @@ -528,6 +536,54 @@ test_that("btw_skills_directories() no duplicate when custom project dir matches expect_equal(sum(dirs == norm_shared), 1L) }) +# btw_tool_skill tool factory (closure / registration-time capture) ----------- + +test_that("btw_tool_skill tool freezes dirs 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.dirs_user" = reg_dir, + "btw.skills.dirs_project" = NULL + ) + withr::local_envvar( + "BTW_SKILLS_DIRS_USER" = NA, + "BTW_SKILLS_DIRS_PROJECT" = NA + ) + + # Instantiate the tool while reg_dir is the active user-skills 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.dirs_user" = 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 dirs set. + withr::local_options( + "btw.skills.dirs_user" = NULL, + "btw.skills.dirs_project" = NULL + ) + withr::local_envvar( + "BTW_SKILLS_DIRS_USER" = NA, + "BTW_SKILLS_DIRS_PROJECT" = NA + ) + tool_obj <- btw_tools("btw_tool_skill")[[1]] + + # Now set a user-skills dir *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.dirs_user" = 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) From 293930100c08f675300ba9d423239272f3ab44b8 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 13 May 2026 11:53:50 -0400 Subject: [PATCH 5/7] feat: replace two-option skill dirs with single btw.skills.paths / BTW_SKILLS_PATHS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the separate user-dir and project-dir option/envvar pairs (btw.skills.dirs_user, BTW_SKILLS_DIRS_USER, btw.skills.dirs_project, BTW_SKILLS_DIRS_PROJECT) with a single btw.skills.paths / BTW_SKILLS_PATHS setting. When set, the new value entirely replaces all user-level and project-level skill directories. Package-bundled skills and skills from attached packages are always preserved regardless of the setting. The registration-time capture closure is simplified accordingly — only one captured_paths variable instead of two. Tests and docs updated to match. --- R/tool-skills.R | 48 +++++----- man/btw_tool_skill.Rd | 17 ++-- tests/testthat/test-tool_skills.R | 151 +++++++++--------------------- 3 files changed, 73 insertions(+), 143 deletions(-) diff --git a/R/tool-skills.R b/R/tool-skills.R index 998417fe..2e8a7079 100644 --- a/R/tool-skills.R +++ b/R/tool-skills.R @@ -32,15 +32,16 @@ NULL #' The default user-level and project-level directories can be replaced by #' setting an R option or environment variable: #' -#' | Scope | R Option | Environment Variable | -#' |---|---|---| -#' | User-level | `btw.skills.dirs_user` | `BTW_SKILLS_DIRS_USER` | -#' | Project-level | `btw.skills.dirs_project` | `BTW_SKILLS_DIRS_PROJECT` | +#' | R Option | Environment Variable | +#' |---|---| +#' | `btw.skills.paths` | `BTW_SKILLS_PATHS` | #' -#' When set, the value **replaces** (not appends to) the defaults for that -#' scope. The R option takes precedence over the environment variable. Multiple -#' paths can be provided as a character vector (e.g. -#' `options(btw.skills.dirs_user = c("/path/a", "/path/b"))`) or as a single +#' 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. @@ -143,16 +144,14 @@ btw_tool_skill_impl <- function(name) { # 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_user_dirs <- skill_dirs_from_option_or_envvar("btw.skills.dirs_user", "BTW_SKILLS_DIRS_USER") - captured_project_dirs <- skill_dirs_from_option_or_envvar("btw.skills.dirs_project", "BTW_SKILLS_DIRS_PROJECT") + 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_user_dirs)) opts[["btw.skills.dirs_user"]] <- captured_user_dirs - if (!is.null(captured_project_dirs)) opts[["btw.skills.dirs_project"]] <- captured_project_dirs + if (!is.null(captured_paths)) opts[["btw.skills.paths"]] <- captured_paths withr::with_options(opts, btw_tool_skill_impl(name)) } @@ -198,23 +197,18 @@ btw_skills_directories <- function(project_dir = getwd()) { # Skills from attached packages dirs <- c(dirs, attached_package_skill_dirs()) - user_dirs <- skill_dirs_from_option_or_envvar( - "btw.skills.dirs_user", "BTW_SKILLS_DIRS_USER" - ) %||% default_user_skill_dirs() + # 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") - for (user_dir in user_dirs) { - if (dir.exists(user_dir) && !user_dir %in% dirs) { - dirs <- c(dirs, user_dir) - } - } - - project_dirs <- skill_dirs_from_option_or_envvar( - "btw.skills.dirs_project", "BTW_SKILLS_DIRS_PROJECT" - ) %||% default_project_skill_dirs(project_dir) + search_dirs <- custom_paths %||% c( + default_user_skill_dirs(), + default_project_skill_dirs(project_dir) + ) - for (project_dir_path in project_dirs) { - if (dir.exists(project_dir_path) && !project_dir_path %in% dirs) { - dirs <- c(dirs, project_dir_path) + for (search_dir in search_dirs) { + if (dir.exists(search_dir) && !search_dir %in% dirs) { + dirs <- c(dirs, search_dir) } } diff --git a/man/btw_tool_skill.Rd b/man/btw_tool_skill.Rd index 751cd37f..bec56aa4 100644 --- a/man/btw_tool_skill.Rd +++ b/man/btw_tool_skill.Rd @@ -46,17 +46,18 @@ by btw 1.2.0 is also included at lower priority. } The default user-level and project-level directories can be replaced by -setting an R option or environment variable:\tabular{lll}{ - Scope \tab R Option \tab Environment Variable \cr - User-level \tab \code{btw.skills.dirs_user} \tab \code{BTW_SKILLS_DIRS_USER} \cr - Project-level \tab \code{btw.skills.dirs_project} \tab \code{BTW_SKILLS_DIRS_PROJECT} \cr +setting an R option or environment variable:\tabular{ll}{ + R Option \tab Environment Variable \cr + \code{btw.skills.paths} \tab \code{BTW_SKILLS_PATHS} \cr } -When set, the value \strong{replaces} (not appends to) the defaults for that -scope. The R option takes precedence over the environment variable. Multiple -paths can be provided as a character vector (e.g. -\code{options(btw.skills.dirs_user = c("/path/a", "/path/b"))}) or as a single +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. diff --git a/tests/testthat/test-tool_skills.R b/tests/testthat/test-tool_skills.R index 5bfabf66..fe2429d9 100644 --- a/tests/testthat/test-tool_skills.R +++ b/tests/testthat/test-tool_skills.R @@ -358,69 +358,37 @@ test_that("skill_dirs_from_option_or_envvar() ignores NA entries in character-ve expect_equal(result, normalizePath(dir1, mustWork = FALSE)) }) -# btw_skills_directories() with custom user/project dirs --------------------- +# btw_skills_directories() with BTW_SKILLS_PATHS / btw.skills.paths ----------- -test_that("btw_skills_directories() uses BTW_SKILLS_DIRS_USER envvar when set", { - custom_user <- withr::local_tempdir() - dir.create(file.path(custom_user, "my-skill"), recursive = TRUE) +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_user, "my-skill", "SKILL.md") + file.path(custom, "my-skill", "SKILL.md") ) - withr::local_envvar("BTW_SKILLS_DIRS_USER" = custom_user) - withr::local_options("btw.skills.dirs_user" = NULL) + withr::local_envvar("BTW_SKILLS_PATHS" = custom) + withr::local_options("btw.skills.paths" = NULL) - # Use a temp dir as project_dir so no project skills are found project <- withr::local_tempdir() dirs <- btw_skills_directories(project_dir = project) - expect_true(normalizePath(custom_user, mustWork = FALSE) %in% dirs) + expect_true(normalizePath(custom, mustWork = FALSE) %in% dirs) }) -test_that("btw_skills_directories() uses btw.skills.dirs_user option when set", { - custom_user <- withr::local_tempdir() - withr::local_options("btw.skills.dirs_user" = custom_user) +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_user, mustWork = FALSE) %in% dirs) + expect_true(normalizePath(custom, mustWork = FALSE) %in% dirs) }) -test_that("btw_skills_directories() option overrides envvar for user dirs", { +test_that("btw_skills_directories() option overrides envvar", { opt_dir <- withr::local_tempdir() env_dir <- withr::local_tempdir() - withr::local_options("btw.skills.dirs_user" = opt_dir) - withr::local_envvar("BTW_SKILLS_DIRS_USER" = 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() uses BTW_SKILLS_DIRS_PROJECT envvar when set", { - custom_proj <- withr::local_tempdir() - withr::local_envvar("BTW_SKILLS_DIRS_PROJECT" = custom_proj) - withr::local_options("btw.skills.dirs_project" = NULL) - - project <- withr::local_tempdir() - dirs <- btw_skills_directories(project_dir = project) - expect_true(normalizePath(custom_proj, mustWork = FALSE) %in% dirs) -}) - -test_that("btw_skills_directories() uses btw.skills.dirs_project option when set", { - custom_proj <- withr::local_tempdir() - withr::local_options("btw.skills.dirs_project" = custom_proj) - - project <- withr::local_tempdir() - dirs <- btw_skills_directories(project_dir = project) - expect_true(normalizePath(custom_proj, mustWork = FALSE) %in% dirs) -}) - -test_that("btw_skills_directories() option overrides envvar for project dirs", { - opt_dir <- withr::local_tempdir() - env_dir <- withr::local_tempdir() - withr::local_options("btw.skills.dirs_project" = opt_dir) - withr::local_envvar("BTW_SKILLS_DIRS_PROJECT" = env_dir) + 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) @@ -429,14 +397,8 @@ test_that("btw_skills_directories() option overrides envvar for project dirs", { }) test_that("btw_skills_directories() falls back to defaults when no option/envvar set", { - withr::local_options( - "btw.skills.dirs_user" = NULL, - "btw.skills.dirs_project" = NULL - ) - withr::local_envvar( - "BTW_SKILLS_DIRS_USER" = NA, - "BTW_SKILLS_DIRS_PROJECT" = NA - ) + 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") @@ -446,29 +408,17 @@ test_that("btw_skills_directories() falls back to defaults when no option/envvar expect_true(btw_dir %in% dirs) }) -test_that("btw_skills_directories() custom user dirs replace (not append to) defaults", { - custom_user <- withr::local_tempdir() - withr::local_options("btw.skills.dirs_user" = custom_user) - withr::local_envvar("BTW_SKILLS_DIRS_USER" = NA) +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 detect if defaults sneak through + # 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) - project <- withr::local_tempdir() - dirs <- btw_skills_directories(project_dir = project) - - expect_true(normalizePath(custom_user, mustWork = FALSE) %in% dirs) - expect_false(normalizePath(default_skills, mustWork = FALSE) %in% dirs) -}) - -test_that("btw_skills_directories() custom project dirs replace (not append to) defaults", { - custom_proj <- withr::local_tempdir() - withr::local_options("btw.skills.dirs_project" = custom_proj) - withr::local_envvar("BTW_SKILLS_DIRS_PROJECT" = NA) - # 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") @@ -476,13 +426,14 @@ test_that("btw_skills_directories() custom project dirs replace (not append to) dirs <- btw_skills_directories(project_dir = project) - expect_true(normalizePath(custom_proj, mustWork = FALSE) %in% dirs) + 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 dirs are silently skipped", { +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.dirs_user" = nonexistent) + withr::local_options("btw.skills.paths" = nonexistent) project <- withr::local_tempdir() dirs <- btw_skills_directories(project_dir = project) @@ -498,10 +449,10 @@ test_that("skill_dirs_from_option_or_envvar() accepts a character vector option" expect_equal(result, normalizePath(c(dir1, dir2), mustWork = FALSE)) }) -test_that("btw_skills_directories() package-bundled skills are present even when custom user dirs are set", { - custom_user <- withr::local_tempdir() - withr::local_options("btw.skills.dirs_user" = custom_user) - withr::local_envvar("BTW_SKILLS_DIRS_USER" = NA) +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) @@ -515,20 +466,16 @@ test_that("btw_skills_directories() package-bundled skills are present even when } }) -test_that("btw_skills_directories() no duplicate when custom project dir matches an existing user dir", { +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.dirs_user" = shared_dir, - "btw.skills.dirs_project" = shared_dir - ) - withr::local_envvar( - "BTW_SKILLS_DIRS_USER" = NA, - "BTW_SKILLS_DIRS_PROJECT" = NA + "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) @@ -538,24 +485,18 @@ test_that("btw_skills_directories() no duplicate when custom project dir matches # btw_tool_skill tool factory (closure / registration-time capture) ----------- -test_that("btw_tool_skill tool freezes dirs captured at registration, ignores later option changes", { +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.dirs_user" = reg_dir, - "btw.skills.dirs_project" = NULL - ) - withr::local_envvar( - "BTW_SKILLS_DIRS_USER" = NA, - "BTW_SKILLS_DIRS_PROJECT" = NA - ) + withr::local_options("btw.skills.paths" = reg_dir) + withr::local_envvar("BTW_SKILLS_PATHS" = NA) - # Instantiate the tool while reg_dir is the active user-skills option. + # 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.dirs_user" = post_dir) + 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. @@ -564,21 +505,15 @@ test_that("btw_tool_skill tool freezes dirs captured at registration, ignores la }) test_that("btw_tool_skill tool defers to live options when nothing was captured at registration", { - # Register with no custom dirs set. - withr::local_options( - "btw.skills.dirs_user" = NULL, - "btw.skills.dirs_project" = NULL - ) - withr::local_envvar( - "BTW_SKILLS_DIRS_USER" = NA, - "BTW_SKILLS_DIRS_PROJECT" = NA - ) + # 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 user-skills dir *after* registration — the tool should pick it up. + # 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.dirs_user" = post_dir) + withr::local_options("btw.skills.paths" = post_dir) result <- tool_obj(name = "", `_intent` = "test") expect_match(result@value, "post-skill") From 69bfb90f608c030ba5381c2f7e820ca08602f86b Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 13 May 2026 13:38:25 -0400 Subject: [PATCH 6/7] docs(skills): replace single-row table with inline prose for option/envvar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also fix btw.md options key: skills.dirs_user → skills.paths --- R/tool-skills.R | 9 +-- btw.md | 136 ++++++++++++++++++++++++++++++++++++++++++ man/btw_tool_skill.Rd | 9 +-- 3 files changed, 140 insertions(+), 14 deletions(-) create mode 100644 btw.md diff --git a/R/tool-skills.R b/R/tool-skills.R index 2e8a7079..8776ff0d 100644 --- a/R/tool-skills.R +++ b/R/tool-skills.R @@ -30,13 +30,8 @@ NULL #' 4. Project-level skills (`.btw/skills/` or `.agents/skills/`) #' #' The default user-level and project-level directories can be replaced by -#' setting an R option or environment variable: -#' -#' | R Option | Environment Variable | -#' |---|---| -#' | `btw.skills.paths` | `BTW_SKILLS_PATHS` | -#' -#' When set, the value **entirely replaces** all user-level and project-level +#' 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. 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 bec56aa4..6714e5c5 100644 --- a/man/btw_tool_skill.Rd +++ b/man/btw_tool_skill.Rd @@ -46,13 +46,8 @@ by btw 1.2.0 is also included at lower priority. } The default user-level and project-level directories can be replaced by -setting an R option or environment variable:\tabular{ll}{ - R Option \tab Environment Variable \cr - \code{btw.skills.paths} \tab \code{BTW_SKILLS_PATHS} \cr -} - - -When set, the value \strong{entirely replaces} all user-level and project-level +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. From 05de5376119cbb6e0db1c3ab34e9260671d2b073 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 13 May 2026 13:48:01 -0400 Subject: [PATCH 7/7] docs(news): add bullet for btw.skills.paths / BTW_SKILLS_PATHS (#193) --- NEWS.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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