diff --git a/NEWS.md b/NEWS.md index ae2261c7..13f29aeb 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,6 +1,10 @@ # btw (development version) -* `path_find_in_project()` now stops its upward search when it reaches the home directory (either `fs::path_home()` or `fs::path_home_r()`, which differ on Windows) and never treats files there as project files, so `~/AGENTS.md` or `~/CLAUDE.md` are no longer picked up as project context when the working directory is under `~` but outside a project. Home-level files are handled only by the user-level lookup. This also fixes `~/btw.md` instructions being included twice — once as the project file and once as the user-level config (#225). +* `btw_client()` and `btw_app()` now fall back to a user-level `AGENTS.md` in the cross-tool `~/.agents/` directory when no user-level `btw.md` exists, mirroring the project-level precedence `btw.md` > `AGENTS.md`. `edit_btw_md()` and `use_btw_md()` are aware of the new fallback, e.g. `use_btw_md("user")` asks before creating a `btw.md` that would take priority over an existing `AGENTS.md` (#226). + +* The `path_btw` argument of `btw_client()` and `btw_app()` now also accepts a named list with `project` and/or `user` fields, each `TRUE` (search the default locations), `FALSE` (skip that scope), or a path to a specific file; a missing field keeps the default for that scope. For example, `path_btw = list(user = FALSE)` skips user-level context for a single chat. A scalar path now uses only that file, no longer combining it with user-level context; pass `list(project = "path")` to keep the user-level context. The new `btw.client.path_btw_project` and `btw.client.path_btw_user` options set the global defaults for each scope (#226). + +* `path_find_in_project()` now stops its upward search at the home directory (`fs::path_home()` or `fs::path_home_r()`, which differ on Windows), so files like `~/AGENTS.md` or `~/CLAUDE.md` are no longer picked up as project context when the working directory is under `~` but outside a project. Home-level files are used only by the user-level lookup, which also fixes `~/btw.md` instructions being included twice — once as the project file and once as the user-level config (#225). * `btw_client(path_btw = ...)` no longer duplicates instructions when `path_btw` is set explicitly to the same file as the user-level `btw.md` (#225). diff --git a/R/btw-config.R b/R/btw-config.R index 911319d6..a885c6a1 100644 --- a/R/btw-config.R +++ b/R/btw-config.R @@ -35,6 +35,19 @@ #' user-level `btw.md` exists, the highest-priority one is used and btw warns #' once per session. #' +#' If no user-level `btw.md` exists anywhere, [btw_client()] and [btw_app()] +#' fall back to a user-level `AGENTS.md` in the cross-tool `~/.agents/` +#' directory, a convention shared with other coding agents. This mirrors the +#' project-level precedence `btw.md` > `AGENTS.md` > `CLAUDE.md`, except a +#' user-level `CLAUDE.md` is never used. +#' +#' User-level context is combined with the project-level context file by +#' default; use `path_btw = list(user = FALSE)` in [btw_client()] or +#' [btw_app()] to skip it for a single chat, or set the +#' `btw.client.path_btw_user` option to `FALSE` to change the default. The +#' `project` scope of `path_btw` has a matching +#' `btw.client.path_btw_project` option. +#' #' On Windows, R's notion of your home directory (`fs::path_home_r()`, #' typically your `Documents` folder) can differ from your user profile #' directory (`fs::path_home()`); btw searches both. diff --git a/R/btw_chat_history_store.R b/R/btw_chat_history_store.R index bc6481e9..286c5569 100644 --- a/R/btw_chat_history_store.R +++ b/R/btw_chat_history_store.R @@ -221,8 +221,12 @@ btw_conversation_store_sqlite <- function() { } btw_app_history_project_dir <- function(path_btw = NULL) { - if (!is.null(path_btw) && !identical(path_btw, FALSE)) { - path <- fs::path_abs(fs::path_expand(path_btw)) + # Only an explicit project path scopes the chat history; TRUE/FALSE fall + # through to the marker-based detection below. + project <- normalize_path_btw(path_btw)$project + + if (!isTRUE(project) && !isFALSE(project)) { + path <- fs::path_abs(fs::path_expand(project)) if (fs::dir_exists(path)) { return(as.character(fs::path_norm(path))) } diff --git a/R/btw_client.R b/R/btw_client.R index b15568b3..0db666eb 100644 --- a/R/btw_client.R +++ b/R/btw_client.R @@ -26,8 +26,11 @@ #' `AGENTS.md` file take precedence. If a project file doesn't specify a #' setting, btw will fall back to settings in a user-level `btw.md` file. This #' is `~/btw.md` if present, otherwise `~/.btw/btw.md`, `~/.config/btw/btw.md`, -#' or `tools::R_user_dir("btw")`, in that order. See `?btw-config` for the -#' complete list of user-level locations. Project-level btw tool options under +#' or `tools::R_user_dir("btw")`, in that order. If no user-level `btw.md` +#' exists anywhere, btw falls back to a user-level `AGENTS.md` in the +#' cross-tool `~/.agents/` directory. See `?btw-config` for the complete list +#' of user-level locations. +#' Project-level btw tool options under #' the `options` key are merged with user-level options, with project-level #' options taking precedence. #' @@ -46,6 +49,15 @@ #' * `btw.tools`: The btw tools to include by default when starting a new #' btw chat, see [btw_tools()] for details.` #' +#' Two additional options are consulted when resolving context files: +#' +#' * `btw.client.path_btw_project`: `TRUE` (default) to search the default +#' project locations, `FALSE` to skip them, or a path to a specific project +#' context file. This is the default for the `project` field of `path_btw`. +#' * `btw.client.path_btw_user`: `TRUE` (default) to include user-level context +#' in new chats, `FALSE` to skip it, or a path to a specific user-level +#' context file. This is the default for the `user` field of `path_btw`. +#' #' ## Multiple Providers and Models #' #' You can configure multiple client options in your `btw.md` file. When @@ -103,13 +115,25 @@ #' alternatively in the shorter form `tools = "docs_help_page"`. Finally, #' set `tools = FALSE` to skip registering \pkg{btw} tools with the chat #' client. -#' @param path_btw A path to a `btw.md`, `AGENTS.md`, or `CLAUDE.md` project -#' context file. If `NULL`, btw will find a project-specific context file by -#' walking up the parents of the current working directory, preferring -#' `btw.md`, then `AGENTS.md`, then `CLAUDE.md`, with fallback to a -#' user-level `btw.md` file if no project file is found. See `?btw-config` -#' for the complete list of locations. Set `path_btw = FALSE` to create a -#' chat client without using a `btw.md` file. +#' @param path_btw Path to a `btw.md`, `AGENTS.md`, or `CLAUDE.md` project +#' context file, or a named list with `project` and/or `user` fields to +#' control project-level and user-level context independently. Each field +#' can be `TRUE` (search the default locations), `FALSE` (skip that scope), +#' or a path to a specific file; a missing (or `NULL`) field keeps the +#' default for that scope, set by the `btw.client.path_btw_project` and +#' `btw.client.path_btw_user` options described below. +#' If `NULL`, btw will find a +#' project-specific context file by walking up the parents of the current +#' working directory, preferring `btw.md`, then `AGENTS.md`, then +#' `CLAUDE.md`, and always combines it with the user-level context file: +#' the highest-priority user-level `btw.md`, or if none exists, a +#' user-level `AGENTS.md`. The defaults for each scope can be changed globally +#' with the `btw.client.path_btw_project` and `btw.client.path_btw_user` +#' options, e.g. `options(btw.client.path_btw_user = FALSE)`. A scalar path +#' uses only that file, skipping user-level context +#' (equivalent to `list(project = path, user = FALSE)`), and a scalar `FALSE` +#' skips all context files. See `?btw-config` for the complete list of +#' locations. #' @param path_llms_txt A path to an `llms.txt` file containing context about #' the current project. By default, btw will look for an `llms.txt` file in #' the your current working directory or its parents. Set `path_llms_txt = @@ -558,6 +582,85 @@ maybe_find_in_project <- function(path, file_name, arg = "path") { path } +# Default for a scope of `path_btw` when it isn't specified: an R option so +# users can opt out of (or pin) a scope globally, e.g. +# `options(btw.client.path_btw_user = FALSE)` in ~/.Rprofile. +btw_path_scope_default <- function(scope) { + option <- paste0("btw.client.path_btw_", scope) + value <- getOption(option, TRUE) + if (isTRUE(value) || isFALSE(value)) { + return(value) + } + check_string(value, arg = option) + value +} + +# Normalize the `path_btw` argument of btw_client()/btw_app() into a list with +# `project` and `user` fields, each TRUE (search the default locations), FALSE +# (skip that scope), or a path to a specific file. +# +# Scalar values: NULL/TRUE search project and user locations (each scope's +# default comes from its `btw.client.path_btw_*` option), FALSE skips context +# entirely, and a string uses only that file as the project context, skipping +# user-level context -- a shortcut for list(project = path, user = FALSE). +# +# A named list controls each scope independently: a missing (or NULL) field +# keeps the default for that scope, from the corresponding +# `btw.client.path_btw_*` option. +normalize_path_btw <- function(path_btw, arg = "path_btw") { + if (is.list(path_btw)) { + nms <- names(path_btw) + if ( + length(path_btw) == 0 || + is.null(nms) || + any(!nzchar(nms)) || + !all(nms %in% c("project", "user")) + ) { + cli::cli_abort(c( + "{.arg {arg}} must be a named list with {.code project} and/or {.code user} fields.", + "i" = "Each field can be {.code TRUE} (search the default locations), {.code FALSE} (skip), or a path to a file." + )) + } + return(list( + project = normalize_path_btw_field( + path_btw$project, + btw_path_scope_default("project"), + paste0(arg, "$project") + ), + user = normalize_path_btw_field( + path_btw$user, + btw_path_scope_default("user"), + paste0(arg, "$user") + ) + )) + } + + if (is.null(path_btw) || isTRUE(path_btw)) { + return(list( + project = btw_path_scope_default("project"), + user = btw_path_scope_default("user") + )) + } + + if (isFALSE(path_btw)) { + return(list(project = FALSE, user = FALSE)) + } + + check_string(path_btw, arg = arg) + list(project = path_btw, user = FALSE) +} + +normalize_path_btw_field <- function(value, default, arg) { + if (is.null(value)) { + default + } else if (isTRUE(value) || isFALSE(value)) { + value + } else { + check_string(value, arg = arg) + value + } +} + find_btw_context_file <- function(path = NULL, search_user = TRUE) { # 1. Local closest btw.md file path <- maybe_find_in_project(path, "btw.md", "path_btw") @@ -572,9 +675,9 @@ find_btw_context_file <- function(path = NULL, search_user = TRUE) { path <- maybe_find_in_project(NULL, "CLAUDE.md", "path_btw") } - # 4. User btw.md file + # 4. User-level btw.md file, falling back to a user-level AGENTS.md if (search_user && is.null(path)) { - path <- path_find_user("btw.md") + path <- path_find_user_context() } path @@ -602,12 +705,31 @@ read_single_btw_file <- function(path) { } read_btw_file <- function(path = NULL) { - if (isFALSE(path)) { - return(list()) + paths <- normalize_path_btw(path) + + # Locate the project-level context file: search, skip, or an explicit path + project_path <- if (isTRUE(paths$project)) { + find_btw_context_file(NULL, search_user = FALSE) + } else if (isFALSE(paths$project)) { + NULL + } else { + maybe_find_in_project(paths$project, "btw.md", "path_btw") } - project_path <- find_btw_context_file(path, search_user = FALSE) - user_path <- path_find_user("btw.md") + user_path <- if (isTRUE(paths$user)) { + path_find_user_context() + } else if (isFALSE(paths$user)) { + NULL + } else { + user_path <- fs::path_norm(fs::path_expand(paths$user)) + if (!fs::file_exists(user_path)) { + cli::cli_abort(c( + "x" = "Invalid {.arg path_btw$user}: {.path {user_path}} does not exist.", + "i" = "Point {.code path_btw$user} at an existing file, or set {.code user = FALSE} to skip user-level context." + )) + } + user_path + } # If the project path was set explicitly to the same file as the user-level # config, don't read it twice. diff --git a/R/edit_btw_md.R b/R/edit_btw_md.R index f0c27e4f..15c08e62 100644 --- a/R/edit_btw_md.R +++ b/R/edit_btw_md.R @@ -172,11 +172,16 @@ #' directly in your home directory (`~/btw.md`) takes precedence; otherwise btw #' looks in `~/.btw/btw.md`, `~/.config/btw/btw.md`, and #' `tools::R_user_dir("btw")`, in that order (see [btw-config] for the full -#' picture, including skills and agents). `use_btw_md("user")` creates new -#' configuration in the recommended `~/.btw/` directory, and offers to migrate -#' an existing user-level configuration found elsewhere. When more than one -#' user-level `btw.md` file exists, `edit_btw_md("user")` asks which one to open. -#' Note that \pkg{btw} does not look for `AGENTS.md` in your home directory. +#' picture, including skills and agents). If no user-level `btw.md` exists +#' anywhere, btw falls back to a user-level `AGENTS.md` in the cross-tool +#' `~/.agents/` directory. `use_btw_md("user")` creates new configuration in +#' the recommended `~/.btw/` directory, offers to migrate an existing user-level +#' configuration found elsewhere, and asks for confirmation when creating a +#' `btw.md` would take priority over an existing user-level `AGENTS.md`. When +#' more than one user-level context file exists, `edit_btw_md("user")` asks +#' which one to open. To skip user-level context in a specific chat, use +#' `path_btw = list(user = FALSE)` in [btw_client()] or [btw_app()], or change +#' the default globally with the `btw.client.path_btw_user` option. #' #' The user-level `btw.md` file is used alongside your project's context file, #' not only when a project file is missing. When both are present, @@ -212,18 +217,21 @@ #' @param scope The scope of the context file. Can be: #' - `"project"` (default): Creates/opens `btw.md` (by default) or `AGENTS.md` #' in the project root -#' - `"user"`: Opens your user-level `btw.md`. When multiple user-level -#' configuration files exist, `edit_btw_md("user")` asks which one to open, -#' indicating the file that [btw_client()] and [btw_app()] load. When -#' creating, `use_btw_md("user")` uses the recommended `~/.btw/` location and -#' offers to migrate an existing user-level configuration found elsewhere. +#' - `"user"`: Opens your user-level `btw.md`, or if only a user-level +#' `AGENTS.md` exists, that file. When multiple user-level context files +#' exist, `edit_btw_md("user")` asks which one to open, indicating the file +#' that [btw_client()] and [btw_app()] load. When creating, +#' `use_btw_md("user")` uses the recommended `~/.btw/` location, offers to +#' migrate an existing user-level configuration found elsewhere, and asks +#' for confirmation when creating a `btw.md` would take priority over an +#' existing user-level `AGENTS.md`. #' - A directory path: Creates/opens `btw.md` in that directory #' - A file path: Creates/opens that specific file #' #' For `edit_btw_md()`, `scope = NULL` (default) will find and open the #' context file that [btw_client()] would use, searching first for `btw.md`, -#' then `AGENTS.md`, then `CLAUDE.md` in the project directory and then for a -#' user-level `btw.md`. +#' then `AGENTS.md`, then `CLAUDE.md` in the project directory and then for +#' a user-level `btw.md` (or, if none exists, a user-level `AGENTS.md`). #' #' @return `use_btw_md()` returns the path to the context file, invisibly. #' `edit_btw_md()` is called for its side effect of opening the file. @@ -315,12 +323,33 @@ existing_user_btw_md <- function() { fs::path(paths[fs::file_exists(paths)]) } +# Existing user-level AGENTS.md files, in decreasing priority order (empty if +# none). These are fallbacks for the user-level btw.md: loaded by +# path_find_user_context() only when no user-level btw.md exists. +existing_user_agents_md <- function() { + paths <- user_agents_md_paths() + fs::path(paths[fs::file_exists(paths)]) +} + use_btw_md_user <- function() { canonical <- fs::path(btw_user_dir_preferred(), "btw.md") existing <- existing_user_btw_md() - # No user-level config anywhere: create it in the recommended ~/.btw/ location. + # No user-level btw.md anywhere: check for a user-level AGENTS.md, which a + # new ~/.btw/btw.md would take priority over. if (length(existing) == 0) { + existing_agents <- existing_user_agents_md() + if (length(existing_agents) > 0) { + active_agents <- existing_agents[[1]] + if (!confirm_btw_md_over_agents_md(active_agents)) { + cli::cli_inform(c( + "i" = "Keeping {.path {path_home_display(active_agents)}}." + )) + return(invisible(active_agents)) + } + } + + # Create new configuration in the recommended ~/.btw/ location. fs::dir_create(fs::path_dir(canonical)) fs::file_copy(btw_md_template(canonical), canonical) cli::cli_inform(c("v" = "Created {.file {path_display(canonical)}}")) @@ -349,6 +378,35 @@ use_btw_md_user <- function() { invisible(path) } +# Called by use_btw_md("user") when a user-level AGENTS.md exists but no +# user-level btw.md does: creating a ~/.btw/btw.md would take priority over +# the AGENTS.md, so warn (or confirm interactively) and point at +# edit_btw_md("user") to edit the AGENTS.md instead. Returns TRUE if the +# btw.md should be created. +confirm_btw_md_over_agents_md <- function(agents_md) { + cli::cli_inform(c( + "!" = "Found a user-level {.file AGENTS.md} at {.path {path_home_display(agents_md)}}.", + "i" = "A new {.file btw.md} in {.path {path_home_display(btw_user_dir_preferred())}} would take priority over it.", + "i" = "Call {.run btw::edit_btw_md(\"user\")} to edit the existing {.file AGENTS.md} instead." + )) + + if (!is_interactive()) { + return(TRUE) + } + + choice <- utils::menu( + c("Create user-level btw.md", "Cancel"), + graphics = FALSE, + title = paste( + "Create a btw.md that takes priority over", + path_home_display(agents_md), + "?" + ) + ) + + identical(choice, 1L) +} + # Offer to move an existing user config to the recommended ~/.btw/ location. # Returns the path in effect afterward (canonical if migrated, otherwise the # original `source`). @@ -386,11 +444,16 @@ maybe_migrate_user_btw_md <- function(source, canonical) { canonical } -# Choose which user-level btw.md edit_btw_md("user") opens. When several exist, -# prompt interactively, making clear which one btw_client()/btw_app() actually -# read and nudging toward consolidating in ~/.btw/. +# Choose which user-level context file edit_btw_md("user") opens: the +# user-level btw.md files, or if none exist, the user-level AGENTS.md files, +# in decreasing priority order. When several exist, prompt interactively, +# making clear which one btw_client()/btw_app() actually read and nudging +# toward consolidating in ~/.btw/. resolve_user_btw_md_for_edit <- function() { - existing <- existing_user_btw_md() + existing <- fs::path(c( + as.character(existing_user_btw_md()), + as.character(existing_user_agents_md()) + )) recommended <- fs::path(btw_user_dir_preferred(), "btw.md") # Nothing exists yet: point at the recommended location so edit_btw_md() @@ -409,7 +472,7 @@ resolve_user_btw_md_for_edit <- function() { labels[[1]] <- paste0(labels[[1]], " (read by btw_client()/btw_app())") cli::cli_inform(c( - "!" = "You have more than one user-level {.file btw.md} config file.", + "!" = "You have more than one user-level context file.", "i" = "{.run btw::btw_client()} and {.run btw::btw_app()} read the highest-priority one, marked below.", "i" = "Consider consolidating your config into {.path {path_home_display(recommended)}}." )) @@ -417,7 +480,7 @@ resolve_user_btw_md_for_edit <- function() { choice <- utils::menu( labels, graphics = FALSE, - title = "Which user-level btw.md do you want to edit?" + title = "Which user-level context file do you want to edit?" ) # Selection cancelled (0): default to the file btw actually reads. diff --git a/R/utils.R b/R/utils.R index 2e01bf68..2242c0e6 100644 --- a/R/utils.R +++ b/R/utils.R @@ -224,13 +224,13 @@ user_config_paths <- function(filename) { ))) } -path_find_user <- function(filename) { +path_find_user <- function(filename, paths = user_config_paths(filename)) { if (identical(Sys.getenv("TESTTHAT"), "true")) { # In testthat, we don't want to use the home directory return(NULL) } - possibilities <- user_config_paths(filename) + possibilities <- paths existing <- possibilities[fs::file_exists(possibilities)] if (length(existing) == 0) { @@ -244,14 +244,40 @@ path_find_user <- function(filename) { fs::path_norm(existing[[1]]) } +# Ordered user-level candidate paths for AGENTS.md, in decreasing priority. +# Unlike btw.md, which is searched in btw's own config directories, a +# user-level AGENTS.md lives in the cross-tool ~/.agents/ convention shared +# with other coding agents. A loose AGENTS.md in the home root is not +# searched: no tool reads one there, and btw's project search no longer walks +# through the home directory, so a loose ~/AGENTS.md is never picked up by +# accident either. +user_agents_md_paths <- function() { + homes <- unique(c(fs::path_home(), fs::path_home_r())) + unique(as.character(fs::path(homes, ".agents", "AGENTS.md"))) +} + +# The active user-level context file, mirroring the project-level precedence +# btw.md > AGENTS.md > CLAUDE.md, except CLAUDE.md is never considered at user +# level: the highest-priority existing user-level btw.md, or if none exists +# anywhere, a user-level AGENTS.md in ~/.agents/. +path_find_user_context <- function() { + path_find_user("btw.md") %||% + path_find_user("AGENTS.md", paths = user_agents_md_paths()) +} + # Warn once per session when more than one user-level config file exists, so a # user editing a now-shadowed file has a hint about which one btw reads. -warn_multiple_user_config <- function(paths) { +warn_multiple_user_config <- function(paths, filename = basename(paths[[1]])) { shown <- path_home_display(paths) - target <- path_home_display(fs::path(btw_user_dir_preferred(), "btw.md")) + recommended <- if (identical(filename, "AGENTS.md")) { + user_agents_md_paths()[[1]] + } else { + fs::path(btw_user_dir_preferred(), filename) + } + target <- path_home_display(recommended) cli::cli_warn( c( - "!" = "Found more than one user-level {.file btw.md} config file.", + "!" = "Found more than one user-level {.file {filename}} config file.", "i" = "Using {.path {shown[[1]]}}.", "i" = "Ignoring lower-priority: {.path {shown[-1]}}.", "i" = "Consider consolidating your btw configuration into {.path {target}}." diff --git a/man/btw-config.Rd b/man/btw-config.Rd index 14c3895b..93676703 100644 --- a/man/btw-config.Rd +++ b/man/btw-config.Rd @@ -40,6 +40,19 @@ configuration in the recommended \verb{~/.btw/} directory. If more than one user-level \code{btw.md} exists, the highest-priority one is used and btw warns once per session. +If no user-level \code{btw.md} exists anywhere, \code{\link[=btw_client]{btw_client()}} and \code{\link[=btw_app]{btw_app()}} +fall back to a user-level \code{AGENTS.md} in the cross-tool \verb{~/.agents/} +directory, a convention shared with other coding agents. This mirrors the +project-level precedence \code{btw.md} > \code{AGENTS.md} > \code{CLAUDE.md}, except a +user-level \code{CLAUDE.md} is never used. + +User-level context is combined with the project-level context file by +default; use \code{path_btw = list(user = FALSE)} in \code{\link[=btw_client]{btw_client()}} or +\code{\link[=btw_app]{btw_app()}} to skip it for a single chat, or set the +\code{btw.client.path_btw_user} option to \code{FALSE} to change the default. The +\code{project} scope of \code{path_btw} has a matching +\code{btw.client.path_btw_project} option. + On Windows, R's notion of your home directory (\code{fs::path_home_r()}, typically your \code{Documents} folder) can differ from your user profile directory (\code{fs::path_home()}); btw searches both. diff --git a/man/btw_client.Rd b/man/btw_client.Rd index dd43bf47..e4d2ce91 100644 --- a/man/btw_client.Rd +++ b/man/btw_client.Rd @@ -48,13 +48,25 @@ alternatively in the shorter form \code{tools = "docs_help_page"}. Finally, set \code{tools = FALSE} to skip registering \pkg{btw} tools with the chat client.} -\item{path_btw}{A path to a \code{btw.md}, \code{AGENTS.md}, or \code{CLAUDE.md} project -context file. If \code{NULL}, btw will find a project-specific context file by -walking up the parents of the current working directory, preferring -\code{btw.md}, then \code{AGENTS.md}, then \code{CLAUDE.md}, with fallback to a -user-level \code{btw.md} file if no project file is found. See \code{?btw-config} -for the complete list of locations. Set \code{path_btw = FALSE} to create a -chat client without using a \code{btw.md} file.} +\item{path_btw}{Path to a \code{btw.md}, \code{AGENTS.md}, or \code{CLAUDE.md} project +context file, or a named list with \code{project} and/or \code{user} fields to +control project-level and user-level context independently. Each field +can be \code{TRUE} (search the default locations), \code{FALSE} (skip that scope), +or a path to a specific file; a missing (or \code{NULL}) field keeps the +default for that scope, set by the \code{btw.client.path_btw_project} and +\code{btw.client.path_btw_user} options described below. +If \code{NULL}, btw will find a +project-specific context file by walking up the parents of the current +working directory, preferring \code{btw.md}, then \code{AGENTS.md}, then +\code{CLAUDE.md}, and always combines it with the user-level context file: +the highest-priority user-level \code{btw.md}, or if none exists, a +user-level \code{AGENTS.md}. The defaults for each scope can be changed globally +with the \code{btw.client.path_btw_project} and \code{btw.client.path_btw_user} +options, e.g. \code{options(btw.client.path_btw_user = FALSE)}. A scalar path +uses only that file, skipping user-level context +(equivalent to \code{list(project = path, user = FALSE)}), and a scalar \code{FALSE} +skips all context files. See \code{?btw-config} for the complete list of +locations.} \item{path_llms_txt}{A path to an \code{llms.txt} file containing context about the current project. By default, btw will look for an \code{llms.txt} file in @@ -100,8 +112,11 @@ Client settings in \code{client} and \code{tools} from a project-level \code{btw \code{AGENTS.md} file take precedence. If a project file doesn't specify a setting, btw will fall back to settings in a user-level \code{btw.md} file. This is \verb{~/btw.md} if present, otherwise \verb{~/.btw/btw.md}, \verb{~/.config/btw/btw.md}, -or \code{tools::R_user_dir("btw")}, in that order. See \code{?btw-config} for the -complete list of user-level locations. Project-level btw tool options under +or \code{tools::R_user_dir("btw")}, in that order. If no user-level \code{btw.md} +exists anywhere, btw falls back to a user-level \code{AGENTS.md} in the +cross-tool \verb{~/.agents/} directory. See \code{?btw-config} for the complete list +of user-level locations. +Project-level btw tool options under the \code{options} key are merged with user-level options, with project-level options taking precedence. @@ -121,6 +136,16 @@ take precedence over settings in a \code{btw.md} file: \item \code{btw.tools}: The btw tools to include by default when starting a new btw chat, see \code{\link[=btw_tools]{btw_tools()}} for details.` } + +Two additional options are consulted when resolving context files: +\itemize{ +\item \code{btw.client.path_btw_project}: \code{TRUE} (default) to search the default +project locations, \code{FALSE} to skip them, or a path to a specific project +context file. This is the default for the \code{project} field of \code{path_btw}. +\item \code{btw.client.path_btw_user}: \code{TRUE} (default) to include user-level context +in new chats, \code{FALSE} to skip it, or a path to a specific user-level +context file. This is the default for the \code{user} field of \code{path_btw}. +} } \subsection{Multiple Providers and Models}{ @@ -201,6 +226,19 @@ configuration in the recommended \verb{~/.btw/} directory. If more than one user-level \code{btw.md} exists, the highest-priority one is used and btw warns once per session. +If no user-level \code{btw.md} exists anywhere, \code{\link[=btw_client]{btw_client()}} and \code{\link[=btw_app]{btw_app()}} +fall back to a user-level \code{AGENTS.md} in the cross-tool \verb{~/.agents/} +directory, a convention shared with other coding agents. This mirrors the +project-level precedence \code{btw.md} > \code{AGENTS.md} > \code{CLAUDE.md}, except a +user-level \code{CLAUDE.md} is never used. + +User-level context is combined with the project-level context file by +default; use \code{path_btw = list(user = FALSE)} in \code{\link[=btw_client]{btw_client()}} or +\code{\link[=btw_app]{btw_app()}} to skip it for a single chat, or set the +\code{btw.client.path_btw_user} option to \code{FALSE} to change the default. The +\code{project} scope of \code{path_btw} has a matching +\code{btw.client.path_btw_project} option. + On Windows, R's notion of your home directory (\code{fs::path_home_r()}, typically your \code{Documents} folder) can differ from your user profile directory (\code{fs::path_home()}); btw searches both. diff --git a/man/use_btw_md.Rd b/man/use_btw_md.Rd index da06410b..830524b0 100644 --- a/man/use_btw_md.Rd +++ b/man/use_btw_md.Rd @@ -14,19 +14,22 @@ edit_btw_md(scope = NULL) \itemize{ \item \code{"project"} (default): Creates/opens \code{btw.md} (by default) or \code{AGENTS.md} in the project root -\item \code{"user"}: Opens your user-level \code{btw.md}. When multiple user-level -configuration files exist, \code{edit_btw_md("user")} asks which one to open, -indicating the file that \code{\link[=btw_client]{btw_client()}} and \code{\link[=btw_app]{btw_app()}} load. When -creating, \code{use_btw_md("user")} uses the recommended \verb{~/.btw/} location and -offers to migrate an existing user-level configuration found elsewhere. +\item \code{"user"}: Opens your user-level \code{btw.md}, or if only a user-level +\code{AGENTS.md} exists, that file. When multiple user-level context files +exist, \code{edit_btw_md("user")} asks which one to open, indicating the file +that \code{\link[=btw_client]{btw_client()}} and \code{\link[=btw_app]{btw_app()}} load. When creating, +\code{use_btw_md("user")} uses the recommended \verb{~/.btw/} location, offers to +migrate an existing user-level configuration found elsewhere, and asks +for confirmation when creating a \code{btw.md} would take priority over an +existing user-level \code{AGENTS.md}. \item A directory path: Creates/opens \code{btw.md} in that directory \item A file path: Creates/opens that specific file } For \code{edit_btw_md()}, \code{scope = NULL} (default) will find and open the context file that \code{\link[=btw_client]{btw_client()}} would use, searching first for \code{btw.md}, -then \code{AGENTS.md}, then \code{CLAUDE.md} in the project directory and then for a -user-level \code{btw.md}.} +then \code{AGENTS.md}, then \code{CLAUDE.md} in the project directory and then for +a user-level \code{btw.md} (or, if none exists, a user-level \code{AGENTS.md}).} } \value{ \code{use_btw_md()} returns the path to the context file, invisibly. @@ -214,11 +217,16 @@ For global configuration, btw uses a user-level \code{btw.md} file. A \code{btw. directly in your home directory (\verb{~/btw.md}) takes precedence; otherwise btw looks in \verb{~/.btw/btw.md}, \verb{~/.config/btw/btw.md}, and \code{tools::R_user_dir("btw")}, in that order (see \link{btw-config} for the full -picture, including skills and agents). \code{use_btw_md("user")} creates new -configuration in the recommended \verb{~/.btw/} directory, and offers to migrate -an existing user-level configuration found elsewhere. When more than one -user-level \code{btw.md} file exists, \code{edit_btw_md("user")} asks which one to open. -Note that \pkg{btw} does not look for \code{AGENTS.md} in your home directory. +picture, including skills and agents). If no user-level \code{btw.md} exists +anywhere, btw falls back to a user-level \code{AGENTS.md} in the cross-tool +\verb{~/.agents/} directory. \code{use_btw_md("user")} creates new configuration in +the recommended \verb{~/.btw/} directory, offers to migrate an existing user-level +configuration found elsewhere, and asks for confirmation when creating a +\code{btw.md} would take priority over an existing user-level \code{AGENTS.md}. When +more than one user-level context file exists, \code{edit_btw_md("user")} asks +which one to open. To skip user-level context in a specific chat, use +\code{path_btw = list(user = FALSE)} in \code{\link[=btw_client]{btw_client()}} or \code{\link[=btw_app]{btw_app()}}, or change +the default globally with the \code{btw.client.path_btw_user} option. The user-level \code{btw.md} file is used alongside your project's context file, not only when a project file is missing. When both are present, diff --git a/tests/testthat/_snaps/btw_client.md b/tests/testthat/_snaps/btw_client.md index ff36ace4..48dda5d6 100644 --- a/tests/testthat/_snaps/btw_client.md +++ b/tests/testthat/_snaps/btw_client.md @@ -222,3 +222,63 @@ +# normalize_path_btw() / rejects malformed values + + Code + normalize_path_btw(list()) + Condition + Error in `normalize_path_btw()`: + ! `path_btw` must be a named list with `project` and/or `user` fields. + i Each field can be `TRUE` (search the default locations), `FALSE` (skip), or a path to a file. + +--- + + Code + normalize_path_btw(list("x.md")) + Condition + Error in `normalize_path_btw()`: + ! `path_btw` must be a named list with `project` and/or `user` fields. + i Each field can be `TRUE` (search the default locations), `FALSE` (skip), or a path to a file. + +--- + + Code + normalize_path_btw(list(other = TRUE)) + Condition + Error in `normalize_path_btw()`: + ! `path_btw` must be a named list with `project` and/or `user` fields. + i Each field can be `TRUE` (search the default locations), `FALSE` (skip), or a path to a file. + +--- + + Code + normalize_path_btw(list(project = 1)) + Condition + Error in `normalize_path_btw_field()`: + ! `path_btw$project` must be a single string, not the number 1. + +--- + + Code + normalize_path_btw(1) + Condition + Error in `normalize_path_btw()`: + ! `path_btw` must be a single string, not the number 1. + +--- + + Code + withr::with_options(list(btw.client.path_btw_user = 1), normalize_path_btw(NULL)) + Condition + Error in `btw_path_scope_default()`: + ! `btw.client.path_btw_user` must be a single string, not the number 1. + +--- + + Code + withr::with_options(list(btw.client.path_btw_project = 1), normalize_path_btw( + NULL)) + Condition + Error in `btw_path_scope_default()`: + ! `btw.client.path_btw_project` must be a single string, not the number 1. + diff --git a/tests/testthat/_snaps/edit_btw_md.md b/tests/testthat/_snaps/edit_btw_md.md index 3e4bc64a..27cb5f7c 100644 --- a/tests/testthat/_snaps/edit_btw_md.md +++ b/tests/testthat/_snaps/edit_btw_md.md @@ -112,7 +112,7 @@ Code path <- edit_btw_md("user") Message - ! You have more than one user-level 'btw.md' config file. + ! You have more than one user-level context file. i `btw::btw_client()` and `btw::btw_app()` read the highest-priority one, marked below. i Consider consolidating your config into '~/.btw/btw.md'. v Opening '~/.btw/btw.md' @@ -122,8 +122,44 @@ Code path <- edit_btw_md("user") Message - ! You have more than one user-level 'btw.md' config file. + ! You have more than one user-level context file. i `btw::btw_client()` and `btw::btw_app()` read the highest-priority one, marked below. i Consider consolidating your config into '~/.btw/btw.md'. v Opening '~/btw.md' +# use_btw_md('user') warns and creates btw.md when an AGENTS.md exists + + Code + path <- use_btw_md("user") + Message + ! Found a user-level 'AGENTS.md' at '~/.agents/AGENTS.md'. + i A new 'btw.md' in '~/.btw' would take priority over it. + i Call `btw::edit_btw_md("user")` to edit the existing 'AGENTS.md' instead. + v Created '~/.btw/btw.md' + i See `?btw::btw_client()` for format details + i See `?btw::btw_tools()` for available tools + i Call `btw::btw_task_create_btw_md()` to use an LLM to help you initialize the project context. + +# use_btw_md('user') creates btw.md over an AGENTS.md when confirmed + + Code + path <- use_btw_md("user") + Message + ! Found a user-level 'AGENTS.md' at '~/.agents/AGENTS.md'. + i A new 'btw.md' in '~/.btw' would take priority over it. + i Call `btw::edit_btw_md("user")` to edit the existing 'AGENTS.md' instead. + v Created '~/.btw/btw.md' + i See `?btw::btw_client()` for format details + i See `?btw::btw_tools()` for available tools + i Call `btw::btw_task_create_btw_md()` to use an LLM to help you initialize the project context. + +# use_btw_md('user') keeps an existing AGENTS.md when creation is declined + + Code + path <- use_btw_md("user") + Message + ! Found a user-level 'AGENTS.md' at '~/.agents/AGENTS.md'. + i A new 'btw.md' in '~/.btw' would take priority over it. + i Call `btw::edit_btw_md("user")` to edit the existing 'AGENTS.md' instead. + i Keeping '~/.agents/AGENTS.md'. + diff --git a/tests/testthat/_snaps/utils.md b/tests/testthat/_snaps/utils.md index 7d98c4b6..3e310958 100644 --- a/tests/testthat/_snaps/utils.md +++ b/tests/testthat/_snaps/utils.md @@ -19,3 +19,15 @@ i Consider consolidating your btw configuration into '~/.btw/btw.md'. This warning is displayed once per session. +# path_find_user() warns for multiple user-level AGENTS.md files + + Code + result <- path_find_user("AGENTS.md", paths = user_agents_md_paths()) + Condition + Warning: + ! Found more than one user-level 'AGENTS.md' config file. + i Using '/.agents/AGENTS.md'. + i Ignoring lower-priority: '~/.agents/AGENTS.md'. + i Consider consolidating your btw configuration into '/.agents/AGENTS.md'. + This warning is displayed once per session. + diff --git a/tests/testthat/test-btw_chat_history_store.R b/tests/testthat/test-btw_chat_history_store.R index a1d026c0..c497a672 100644 --- a/tests/testthat/test-btw_chat_history_store.R +++ b/tests/testthat/test-btw_chat_history_store.R @@ -256,3 +256,18 @@ test_that("project pointer lifecycle restores once and follows local changes", { } ) }) + +test_that("btw_app_history_project_dir() resolves the project field of a list path_btw", { + path <- withr::local_tempfile(lines = "# btw") + + expect_identical( + btw:::btw_app_history_project_dir(list(project = path, user = FALSE)), + as.character(fs::path_norm(fs::path_dir(fs::path_abs(path)))) + ) + + # A list with only user fields falls back to marker-based detection + expect_identical( + btw:::btw_app_history_project_dir(list(user = path)), + btw:::btw_app_history_project_dir() + ) +}) diff --git a/tests/testthat/test-btw_client.R b/tests/testthat/test-btw_client.R index 502c9c10..2cba6e7c 100644 --- a/tests/testthat/test-btw_client.R +++ b/tests/testthat/test-btw_client.R @@ -308,7 +308,7 @@ describe("btw_client() project vs user settings", { path_user_btw <- withr::local_tempfile(fileext = ".md") local_mocked_bindings( - path_find_user = function(filename) { + path_find_user = function(filename, ...) { if (filename == "btw.md") path_user_btw else NULL } ) @@ -480,7 +480,7 @@ describe("btw_client() project vs user settings", { ) withr::defer(unlink(path_user_btw)) - config <- read_btw_file(path_user_btw) + config <- read_btw_file(list(project = path_user_btw)) expect_equal(config$btw_system_prompt, "home btw instructions") }) @@ -559,6 +559,178 @@ describe("btw_client() project vs user settings", { }) }) +describe("normalize_path_btw()", { + it("keeps the meaning of scalar values", { + expect_equal(normalize_path_btw(NULL), list(project = TRUE, user = TRUE)) + expect_equal(normalize_path_btw(TRUE), list(project = TRUE, user = TRUE)) + expect_equal(normalize_path_btw(FALSE), list(project = FALSE, user = FALSE)) + # A scalar path uses only that file, skipping user-level context + expect_equal(normalize_path_btw("x.md"), list(project = "x.md", user = FALSE)) + }) + + it("controls each scope via a named list", { + expect_equal( + normalize_path_btw(list(project = "x.md")), + list(project = "x.md", user = TRUE) + ) + expect_equal( + normalize_path_btw(list(user = "u.md")), + list(project = TRUE, user = "u.md") + ) + expect_equal( + normalize_path_btw(list(project = FALSE, user = TRUE)), + list(project = FALSE, user = TRUE) + ) + }) + + it("reads the btw.client.path_btw_user option as the user default", { + withr::local_options(btw.client.path_btw_user = FALSE) + expect_equal(normalize_path_btw(NULL), list(project = TRUE, user = FALSE)) + expect_equal( + normalize_path_btw(list(project = "x.md")), + list(project = "x.md", user = FALSE) + ) + + withr::local_options(btw.client.path_btw_user = "u.md") + expect_equal(normalize_path_btw(NULL), list(project = TRUE, user = "u.md")) + expect_equal( + normalize_path_btw(list(project = "x.md")), + list(project = "x.md", user = "u.md") + ) + + # An explicit user field wins over the option + expect_equal( + normalize_path_btw(list(user = TRUE)), + list(project = TRUE, user = TRUE) + ) + + # A scalar path never consults the option: it uses only that file + expect_equal(normalize_path_btw("x.md"), list(project = "x.md", user = FALSE)) + }) + + it("reads the btw.client.path_btw_project option as the project default", { + withr::local_options(btw.client.path_btw_project = FALSE) + expect_equal(normalize_path_btw(NULL), list(project = FALSE, user = TRUE)) + expect_equal( + normalize_path_btw(list(user = FALSE)), + list(project = FALSE, user = FALSE) + ) + + withr::local_options(btw.client.path_btw_project = "p.md") + expect_equal(normalize_path_btw(NULL), list(project = "p.md", user = TRUE)) + expect_equal( + normalize_path_btw(list(user = FALSE)), + list(project = "p.md", user = FALSE) + ) + + # An explicit project field wins over the option + expect_equal( + normalize_path_btw(list(project = TRUE)), + list(project = TRUE, user = TRUE) + ) + + # A scalar path never consults the option: it uses only that file + expect_equal(normalize_path_btw("x.md"), list(project = "x.md", user = FALSE)) + }) + + it("rejects malformed values", { + expect_snapshot(error = TRUE, normalize_path_btw(list())) + expect_snapshot(error = TRUE, normalize_path_btw(list("x.md"))) + expect_snapshot(error = TRUE, normalize_path_btw(list(other = TRUE))) + expect_snapshot(error = TRUE, normalize_path_btw(list(project = 1))) + expect_snapshot(error = TRUE, normalize_path_btw(1)) + expect_snapshot( + error = TRUE, + withr::with_options(list(btw.client.path_btw_user = 1), normalize_path_btw(NULL)) + ) + expect_snapshot( + error = TRUE, + withr::with_options(list(btw.client.path_btw_project = 1), normalize_path_btw(NULL)) + ) + }) +}) + +describe("read_btw_file() with scoped path_btw", { + project_dir <- withr::local_tempdir("btw-test-project-") + path_project <- withr::local_tempfile( + lines = c("---", "client: openai", "---", "PROJECT CONTEXT") + ) + path_user <- withr::local_tempfile( + lines = c("---", "tools: docs", "---", "USER CONTEXT") + ) + path_agents <- withr::local_tempfile( + lines = c("---", "client: anthropic", "---", "USER AGENTS CONTEXT") + ) + + it("combines project and user files by default", { + local_btw_md(project = path_project, user = path_user) + + config <- read_btw_file() + expect_equal(config$btw_system_prompt, "USER CONTEXT\n\n---\n\nPROJECT CONTEXT") + expect_equal(config$client, "openai") + expect_equal(config$tools, "docs") + }) + + it("skips user context with list(user = FALSE)", { + local_btw_md(project = path_project, user = path_user) + + config <- read_btw_file(list(user = FALSE)) + expect_equal(config$btw_system_prompt, "PROJECT CONTEXT") + expect_equal(config$client, "openai") + expect_null(config$tools) + }) + + it("keeps user context when the list names only project", { + local_btw_md(project = path_project, user = path_user) + + config <- read_btw_file(list(project = path_project)) + expect_equal(config$btw_system_prompt, "USER CONTEXT\n\n---\n\nPROJECT CONTEXT") + expect_equal(config$client, "openai") + expect_equal(config$tools, "docs") + }) + + it("uses only an explicit user file with list(project = FALSE, user = path)", { + config <- read_btw_file(list(project = FALSE, user = path_user)) + expect_equal(config$btw_system_prompt, "USER CONTEXT") + expect_equal(config$tools, "docs") + }) + + it("falls back to a user-level AGENTS.md when no user-level btw.md exists", { + local_mocked_bindings( + find_btw_context_file = function(...) NULL, + path_find_user = function(filename, ...) { + if (filename == "btw.md") NULL else path_agents + } + ) + + config <- read_btw_file(list(project = FALSE, user = TRUE)) + expect_equal(config$btw_system_prompt, "USER AGENTS CONTEXT") + expect_equal(config$client, "anthropic") + }) + + it("a scalar path uses only that file, without user-level context", { + local_btw_md(project = path_project, user = path_user) + + config <- read_btw_file(path_project) + expect_equal(config$btw_system_prompt, "PROJECT CONTEXT") + expect_equal(config$client, "openai") + expect_null(config$tools) + }) + + it("throws for a user path that does not exist", { + expect_error( + read_btw_file(list(project = FALSE, user = tempfile("missing-"))), + "does not exist" + ) + }) + + it("returns an empty config when both scopes are skipped", { + local_btw_md(project = path_project, user = path_user) + config <- read_btw_file(list(project = FALSE, user = FALSE)) + expect_equal(config, list()) + }) +}) + test_that("btw_client() throws for deprecated `model` and `provider` fields in btw.md", { withr::local_envvar(list(OPENAI_API_KEY = "beep")) diff --git a/tests/testthat/test-edit_btw_md.R b/tests/testthat/test-edit_btw_md.R index f271d775..3db3cee5 100644 --- a/tests/testthat/test-edit_btw_md.R +++ b/tests/testthat/test-edit_btw_md.R @@ -452,3 +452,106 @@ test_that("btw_md_template() selects correct template", { template <- btw_md_template("custom.md") expect_match(basename(template), "^btw.*\\.md$") }) + +test_that("edit_btw_md('user') opens a user-level AGENTS.md when no btw.md exists", { + wd <- withr::local_tempdir() + local_user_home(wd) + local_mocked_bindings(is_interactive = function() TRUE) + + fs::dir_create(fs::path(wd, ".agents")) + fs::file_create(fs::path(wd, ".agents", "AGENTS.md")) + + local_mocked_bindings( + menu = function(...) stop("menu should not be called"), + .package = "utils" + ) + local_mocked_bindings( + is_installed = function(pkg) pkg != "rstudioapi", + .package = "rlang" + ) + + edited <- NULL + with_mocked_bindings( + file.edit = function(file) edited <<- file, + .package = "utils", + suppressMessages(path <- edit_btw_md("user")) + ) + + expect_equal(edited, fs::path(wd, ".agents", "AGENTS.md")) +}) + +test_that("edit_btw_md('user') prefers btw.md files over AGENTS.md files", { + wd <- withr::local_tempdir() + local_user_home(wd) + local_mocked_bindings(is_interactive = function() FALSE) + + fs::dir_create(fs::path(wd, ".btw")) + fs::file_create(fs::path(wd, ".btw", "AGENTS.md")) + fs::file_create(fs::path(wd, "btw.md")) + + local_mocked_bindings( + is_installed = function(pkg) pkg != "rstudioapi", + .package = "rlang" + ) + + edited <- NULL + with_mocked_bindings( + file.edit = function(file) edited <<- file, + .package = "utils", + suppressMessages(path <- edit_btw_md("user")) + ) + + expect_equal(edited, fs::path(wd, "btw.md")) +}) + +test_that("use_btw_md('user') warns and creates btw.md when an AGENTS.md exists", { + wd <- withr::local_tempdir() + local_user_home(wd) + local_mocked_bindings(is_interactive = function() FALSE) + + fs::dir_create(fs::path(wd, ".agents")) + fs::file_create(fs::path(wd, ".agents", "AGENTS.md")) + + expect_snapshot( + path <- use_btw_md("user") + ) + + expect_equal(path, fs::path(wd, ".btw", "btw.md")) + expect_true(fs::file_exists(fs::path(wd, ".btw", "btw.md"))) + expect_true(fs::file_exists(fs::path(wd, ".agents", "AGENTS.md"))) +}) + +test_that("use_btw_md('user') creates btw.md over an AGENTS.md when confirmed", { + wd <- withr::local_tempdir() + local_user_home(wd) + local_mocked_bindings(is_interactive = function() TRUE) + local_mocked_bindings(menu = function(...) 1L, .package = "utils") + + fs::dir_create(fs::path(wd, ".agents")) + fs::file_create(fs::path(wd, ".agents", "AGENTS.md")) + + expect_snapshot( + path <- use_btw_md("user") + ) + + expect_equal(path, fs::path(wd, ".btw", "btw.md")) + expect_true(fs::file_exists(fs::path(wd, ".btw", "btw.md"))) +}) + +test_that("use_btw_md('user') keeps an existing AGENTS.md when creation is declined", { + wd <- withr::local_tempdir() + local_user_home(wd) + local_mocked_bindings(is_interactive = function() TRUE) + local_mocked_bindings(menu = function(...) 2L, .package = "utils") + + fs::dir_create(fs::path(wd, ".agents")) + fs::file_create(fs::path(wd, ".agents", "AGENTS.md")) + + expect_snapshot( + path <- use_btw_md("user") + ) + + expect_equal(path, fs::path(wd, ".agents", "AGENTS.md")) + expect_false(fs::file_exists(fs::path(wd, ".btw", "btw.md"))) + expect_true(fs::file_exists(fs::path(wd, ".agents", "AGENTS.md"))) +}) diff --git a/tests/testthat/test-utils.R b/tests/testthat/test-utils.R index 8dcd76ff..a6b15fe7 100644 --- a/tests/testthat/test-utils.R +++ b/tests/testthat/test-utils.R @@ -486,3 +486,81 @@ test_that("path_find_user() prefers ~/btw.md and warns on multiple configs", { # The loose ~/btw.md takes precedence over ~/.btw/btw.md. expect_equal(result, fs::path_norm(fs::path(user_dir, "btw.md"))) }) + +test_that("user_agents_md_paths() covers only the cross-tool ~/.agents/", { + user_dir <- withr::local_tempdir() + local_user_home(user_dir) + + paths <- user_agents_md_paths() + expect_equal( + paths, + unique(as.character(fs::path(user_dir, ".agents", "AGENTS.md"))) + ) + # No btw config directory and no loose home-root AGENTS.md candidates + expect_false(any(file.path(btw_user_dirs(), "AGENTS.md") %in% paths)) + expect_false(fs::path(user_dir, "AGENTS.md") %in% paths) +}) + +test_that("path_find_user_context() prefers any btw.md over any AGENTS.md", { + user_dir <- withr::local_tempdir() + local_user_home(user_dir) + withr::local_envvar(TESTTHAT = NA) + + expect_null(path_find_user_context()) + + # An AGENTS.md in a btw config directory is not a user-level candidate + fs::dir_create(fs::path(user_dir, ".btw")) + fs::file_create(fs::path(user_dir, ".btw", "AGENTS.md")) + expect_null(path_find_user_context()) + + # Only one AGENTS.md: it's used + fs::dir_create(fs::path(user_dir, ".agents")) + fs::file_create(fs::path(user_dir, ".agents", "AGENTS.md")) + expect_equal( + path_find_user_context(), + fs::path_norm(fs::path(user_dir, ".agents", "AGENTS.md")) + ) + + # A btw.md anywhere beats an AGENTS.md anywhere + fs::dir_create(fs::path(user_dir, ".config", "btw")) + fs::file_create(fs::path(user_dir, ".config", "btw", "btw.md")) + expect_equal( + path_find_user_context(), + fs::path_norm(fs::path(user_dir, ".config", "btw", "btw.md")) + ) +}) + +test_that("path_find_user_context() never uses a user-level CLAUDE.md", { + user_dir <- withr::local_tempdir() + local_user_home(user_dir) + withr::local_envvar(TESTTHAT = NA) + + fs::file_create(fs::path(user_dir, "CLAUDE.md")) + fs::dir_create(fs::path(user_dir, ".btw")) + fs::file_create(fs::path(user_dir, ".btw", "CLAUDE.md")) + expect_null(path_find_user_context()) +}) + +test_that("path_find_user() warns for multiple user-level AGENTS.md files", { + user_dir <- withr::local_tempdir() + local_user_home(user_dir) + withr::local_envvar(TESTTHAT = NA) + withr::local_options(rlib_warning_verbosity = "verbose") + + # Windows-style: the two notions of home differ, so both are searched + local_mocked_bindings( + path_home_r = function(...) fs::path(user_dir, "home_r", ...), + .package = "fs" + ) + + fs::dir_create(fs::path(user_dir, ".agents")) + fs::file_create(fs::path(user_dir, ".agents", "AGENTS.md")) + fs::dir_create(fs::path(user_dir, "home_r", ".agents")) + fs::file_create(fs::path(user_dir, "home_r", ".agents", "AGENTS.md")) + + expect_snapshot( + result <- path_find_user("AGENTS.md", paths = user_agents_md_paths()), + transform = function(lines) gsub(as.character(fs::path_norm(user_dir)), "", lines, fixed = TRUE) + ) + expect_equal(result, fs::path_norm(fs::path(user_dir, ".agents", "AGENTS.md"))) +}) diff --git a/vignettes/btw-md.Rmd b/vignettes/btw-md.Rmd index 6f798105..54f50bb1 100644 --- a/vignettes/btw-md.Rmd +++ b/vignettes/btw-md.Rmd @@ -29,7 +29,7 @@ A `btw.md` file plays the same role for btw, and btw falls back to those files w Using `btw.md` gives you a few things those files don't: - **It configures the chat, not just the instructions.** The front matter chooses the model, tools, and options. btw ignores front matter in `CLAUDE.md`, and the other tools that read `AGENTS.md` don't expect front matter there. -- **It has a user-level version.** btw combines a `btw.md` in your home directory, such as `~/.btw/btw.md`, with each project's file. btw doesn't look for `AGENTS.md` or `CLAUDE.md` in your home directory. +- **It has a user-level version.** btw combines a `btw.md` in your home directory, such as `~/.btw/btw.md`, with each project's file. If there's no user-level `btw.md`, btw falls back to a user-level `AGENTS.md` in the cross-tool `~/.agents/` directory. - **It keeps btw-specific instructions separate.** Instructions about btw's tools or your R workflow don't end up in the files that other coding agents read. In this vignette, we'll set up a chat by hand, save that setup in a `btw.md` file, and then learn how btw decides which file to use. @@ -232,6 +232,7 @@ btw reads the front matter in `AGENTS.md` like `btw.md`, but ignores the front m btw reads only one of these files, so if your project has both `btw.md` and `AGENTS.md`, btw uses `btw.md` and ignores `AGENTS.md`. btw also reads a **user-level** `btw.md` that applies to all of your projects, such as `~/.btw/btw.md`. +If there's no user-level `btw.md`, btw falls back to a user-level `AGENTS.md` in the cross-tool `~/.agents/` directory. The two files are combined: - The project's `client` and `tools` fields are used when they're present; otherwise the user-level fields are used. @@ -242,12 +243,15 @@ This lets you keep your general preferences, such as a default model or coding s See `?btw-config` for every location btw checks. To use a particular file instead of searching, pass its path to `path_btw`. -To start a chat without any `btw.md` file, use `path_btw = FALSE`: +To start a chat without any `btw.md` file, use `path_btw = FALSE`. +Or pass a named list to control project and user context separately, e.g. `path_btw = list(user = FALSE)`; the `btw.client.path_btw_user` option sets the default for the user scope. ```{r, eval = FALSE} btw_app(path_btw = "analysis/btw.md") btw_client(path_btw = FALSE) + +btw_client(path_btw = list(user = FALSE)) ``` ## Next: the same format for agents