diff --git a/NEWS.md b/NEWS.md index 3f0e34fc..f727fddd 100644 --- a/NEWS.md +++ b/NEWS.md @@ -6,6 +6,8 @@ * 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). +* The `btw info` CLI command group has been replaced by three focused top-level commands: `btw system-info` (platform and R session info), `btw check-installed ` (check if packages are installed, exits 0 by default with `--fail` for non-zero exit on missing packages), and `btw installed-packages ` (show installed package versions). `btw info` is retained as a deprecated stub that prints migration guidance. All three commands support `--json` output with documented field shapes (#195). + * 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. * Added `btw docs topics ` to the `btw` CLI for discovering a package's help topics and vignettes. Use `--only help` or `--only vignettes` to limit output to one section, or `--json` for machine-readable output (#195). diff --git a/exec/btw.R b/exec/btw.R index 87a03aa4..422cfddf 100755 --- a/exec/btw.R +++ b/exec/btw.R @@ -22,7 +22,16 @@ if (version) { has_value <- function(x) !is.na(x) && nzchar(x) btw_json_output <- function(x) { - cat(jsonlite::toJSON(x, auto_unbox = TRUE, pretty = TRUE, na = "null"), "\n") + cat( + jsonlite::toJSON( + x, + auto_unbox = TRUE, + pretty = TRUE, + na = "null", + null = "null" + ), + "\n" + ) } btw_output <- function(x) { @@ -227,7 +236,7 @@ btw_pkg_coverage <- function(path, file, json = FALSE) { } } -btw_info_platform <- function(json = FALSE) { +btw_system_info <- function(json = FALSE) { result <- btw:::btw_tool_sessioninfo_platform_impl() if (json) { btw_json_output(S7::prop(result, "extra")) @@ -236,31 +245,51 @@ btw_info_platform <- function(json = FALSE) { } } -btw_info_packages <- function(packages, deps, check, json = FALSE) { - pkgs <- packages - if (check && length(pkgs) > 0) { - if (json) { - results <- lapply(pkgs, function(pkg) { - result <- btw:::btw_tool_sessioninfo_is_package_installed_impl(pkg) - S7::prop(result, "extra") - }) - btw_json_output(results) - } else { - for (pkg in pkgs) { - btw_output(btw:::btw_tool_sessioninfo_is_package_installed_impl(pkg)) +btw_check_installed <- function(packages, fail = FALSE, json = FALSE) { + results <- lapply(packages, function(pkg) { + tryCatch( + btw:::btw_tool_sessioninfo_is_package_installed_impl(pkg), + error = function(e) e + ) + }) + + errors <- vapply(results, inherits, logical(1), "error") + + if (json) { + data <- unname(Map( + function(pkg, r) { + if (inherits(r, "error")) { + list(package = pkg, version = NULL, installed = FALSE) + } else { + extra <- S7::prop(r, "extra") + extra[["installed"]] <- TRUE + extra + } + }, + packages, + results + )) + btw_json_output(data) + } else { + for (r in results) { + if (inherits(r, "error")) { + cat(cli::ansi_strip(conditionMessage(r)), "\n", file = stderr()) + } else { + btw_output(r) } } + } + + if (fail && any(errors)) quit(status = 1) +} + +btw_installed_packages <- function(packages, deps, json = FALSE) { + deps_val <- if (has_value(deps)) deps else "" + result <- btw:::btw_tool_sessioninfo_package_impl(packages, deps_val) + if (json) { + btw_json_output(S7::prop(result, "extra")$data) } else { - if (length(pkgs) == 0) { - pkgs <- "attached" - } - deps_val <- if (has_value(deps)) deps else "" - result <- btw:::btw_tool_sessioninfo_package_impl(pkgs, deps_val) - if (json) { - btw_json_output(S7::prop(result, "extra")$data) - } else { - btw_output(result) - } + btw_output(result) } } @@ -420,36 +449,62 @@ switch( if (pkg_cmd == "") btw_self_help("pkg") }, - #| title: Inspect the R session and environment + #| title: "[Deprecated] Inspect the R session and environment" info = { - #| description: Output as JSON. - json <- FALSE - switch( info_cmd <- "", - - #| title: Show platform and session info - platform = { - tryCatch(btw_info_platform(json), error = btw_error) - }, - - #| title: Show installed package information + platform = {}, packages = { - #| description: Package names to query. `packages...` <- c() - #| description: Dependency types to include. deps <- "" - #| description: Check if packages are installed. - #| short: 'c' check <- FALSE - - tryCatch( - btw_info_packages(`packages...`, deps, check, json), - error = btw_error - ) } ) - if (info_cmd == "") btw_self_help("info") + cat( + "btw info is deprecated. Use:\n", + " btw system-info (was: btw info platform)\n", + " btw check-installed (was: btw info packages --check)\n", + " btw installed-packages (was: btw info packages)\n", + file = stderr() + ) + quit(status = 1) + }, + + #| title: Show platform and R session info + system_info = { + #| description: "Output as JSON object with fields: r_version, os, system, ui, language, locale, encoding, timezone, date." + json <- FALSE + + tryCatch(btw_system_info(json), error = btw_error) + }, + + #| title: Check if packages are installed + check_installed = { + #| description: Package names to check. + #| required: true + `packages...` <- c() + #| description: Exit with a non-zero status if any package is not installed. + fail <- FALSE + #| description: "Output as JSON array of objects with fields: package (string), version (string or null if not installed), installed (bool)." + json <- FALSE + + tryCatch(btw_check_installed(`packages...`, fail, json), error = btw_error) + }, + + #| title: Show installed package versions + installed_packages = { + #| description: Package names to query. + #| required: true + `packages...` <- c() + #| description: "Dependency types to include. Use TRUE for all types, FALSE for none, or a comma-separated list of types: Depends, Imports, Suggests, LinkingTo, Enhances." + deps <- "" + #| description: "Output as JSON array of objects with fields: package, ondiskversion, loadedversion, path, loadedpath, attached, is_base, date, source, md5ok, library." + json <- FALSE + + tryCatch( + btw_installed_packages(`packages...`, deps, json), + error = btw_error + ) }, #| title: Query CRAN package metadata diff --git a/tests/testthat/_snaps/cli.md b/tests/testthat/_snaps/cli.md index 11831955..e4c67222 100644 --- a/tests/testthat/_snaps/cli.md +++ b/tests/testthat/_snaps/cli.md @@ -9,13 +9,16 @@ Wraps btw package tools for docs, pkg, info, and cran operations. Commands: - docs Access R documentation - pkg Work with an R package under development - info Inspect the R session and environment - cran Query CRAN package metadata - skills Manage btw skills - help Show btw CLI usage guide for AI agents - app Run btw_app() in the current directory + docs Access R documentation + pkg Work with an R package under development + info [Deprecated] Inspect the R session and environment + system-info Show platform and R session info + check-installed Check if packages are installed + installed-packages Show installed package information + cran Query CRAN package metadata + skills Manage btw skills + help Show btw CLI usage guide for AI agents + app Run btw_app() in the current directory Options: --version / --no-version Print btw version and exit. [default: false] @@ -89,28 +92,6 @@ For help with a specific command, run: `btw pkg --help`. -# btw info --help shows info group help - - Code - run_btw("info", "--help") - Output - Inspect the R session and environment - - Usage: btw info [OPTIONS] - - Commands: - platform Show platform and session info - packages Show installed package information - - Options: - --json / --no-json Output as JSON. [default: false] Enable with `--json`. - - Global options: - --version / --no-version Print btw version and exit. [default: false] - Enable with `--version`. - - For help with a specific command, run: `btw info --help`. - # btw cran --help shows cran group help Code diff --git a/tests/testthat/test-cli.R b/tests/testthat/test-cli.R index f1e1930b..0f408bfc 100644 --- a/tests/testthat/test-cli.R +++ b/tests/testthat/test-cli.R @@ -55,10 +55,6 @@ test_that("btw pkg --help shows pkg group help", { expect_snapshot(run_btw("pkg", "--help")) }) -test_that("btw info --help shows info group help", { - expect_snapshot(run_btw("info", "--help")) -}) - test_that("btw cran --help shows cran group help", { expect_snapshot(run_btw("cran", "--help")) }) @@ -300,78 +296,66 @@ test_that("btw pkg coverage --file passes filename", { expect_equal(mock_filename, "utils.R") }) -# info group ------------------------------------------------------------- +# btw info deprecated ---------------------------------------------------- + +test_that("btw info exits 1 with deprecation message", { + result <- run_btw_subprocess("info", "platform") + expect_equal(result$status, 1) + expect_match(result$stderr, "deprecated", ignore.case = TRUE) + expect_match(result$stderr, "system-info") + expect_match(result$stderr, "check-installed") + expect_match(result$stderr, "installed-packages") +}) + +# btw system-info -------------------------------------------------------- -test_that("btw info platform shows platform info", { +test_that("btw system-info shows platform info", { local_sessioninfo_quarto_version() - env <- run_btw_quietly("info", "platform") + env <- run_btw_quietly("system-info") expect_true(is.environment(env)) }) -test_that("btw info packages with no args defaults to attached", { - mock_pkgs <- NULL - local_mocked_bindings( - btw_tool_sessioninfo_package_impl = function(packages, dependencies) { - mock_pkgs <<- packages - "package info" - } - ) - run_btw_quietly("info", "packages") - expect_equal(mock_pkgs, "attached") +test_that("btw system-info --json outputs valid JSON", { + local_sessioninfo_quarto_version() + env <- run_btw_quietly("system-info", "--json") + parsed <- jsonlite::fromJSON(paste(env$.output, collapse = "\n")) + expect_type(parsed, "list") + expect_true("os:" %in% names(parsed)) }) -test_that("btw info packages with package names", { - mock_pkgs <- NULL - local_mocked_bindings( - btw_tool_sessioninfo_package_impl = function(packages, dependencies) { - mock_pkgs <<- packages - "package info" - } - ) - run_btw_quietly("info", "packages", "dplyr", "ggplot2") - expect_equal(mock_pkgs, c("dplyr", "ggplot2")) -}) +# btw check-installed ---------------------------------------------------- -test_that("btw info packages --check calls is_package_installed", { +test_that("btw check-installed calls is_package_installed for each package", { checked_pkgs <- character() local_mocked_bindings( btw_tool_sessioninfo_is_package_installed_impl = function(package_name) { checked_pkgs <<- c(checked_pkgs, package_name) - paste(package_name, "is installed") + btw:::BtwToolResult( + value = paste(package_name, "is installed"), + extra = list(package = package_name, version = "1.0.0") + ) } ) - run_btw_quietly("info", "packages", "dplyr", "ggplot2", "--check") + run_btw_quietly("check-installed", "dplyr", "ggplot2") expect_equal(checked_pkgs, c("dplyr", "ggplot2")) }) -test_that("btw info platform --json outputs valid JSON", { - local_sessioninfo_quarto_version() - env <- run_btw_quietly("info", "platform", "--json") - parsed <- jsonlite::fromJSON(paste(env$.output, collapse = "\n")) - expect_type(parsed, "list") - expect_true("os:" %in% names(parsed)) +test_that("btw check-installed exits 0 when package is not installed", { + result <- run_btw_subprocess("check-installed", "completely_nonexistent_xyz_pkg") + expect_equal(result$status, 0) }) -test_that("btw info packages --json outputs valid JSON", { - mock_df <- data.frame( - package = c("dplyr", "ggplot2"), - version = c("1.1.4", "3.5.0"), - stringsAsFactors = FALSE - ) - local_mocked_bindings( - btw_tool_sessioninfo_package_impl = function(packages, dependencies) { - btw:::BtwPackageInfoToolResult( - value = "pkg info", - extra = list(data = mock_df) - ) - } - ) - env <- run_btw_quietly("info", "packages", "--json") - parsed <- jsonlite::fromJSON(paste(env$.output, collapse = "\n")) - expect_equal(parsed$package, c("dplyr", "ggplot2")) +test_that("btw check-installed --fail exits 1 when package is not installed", { + result <- run_btw_subprocess("check-installed", "completely_nonexistent_xyz_pkg", "--fail") + expect_equal(result$status, 1) }) -test_that("btw info packages --check --json outputs valid JSON", { +test_that("btw check-installed --fail exits 0 when all packages are installed", { + result <- run_btw_subprocess("check-installed", "base", "--fail") + expect_equal(result$status, 0) +}) + +test_that("btw check-installed --json outputs array with installed field", { local_mocked_bindings( btw_tool_sessioninfo_is_package_installed_impl = function(package_name) { btw:::BtwToolResult( @@ -380,13 +364,43 @@ test_that("btw info packages --check --json outputs valid JSON", { ) } ) - env <- run_btw_quietly("info", "packages", "dplyr", "ggplot2", "--check", "--json") + env <- run_btw_quietly("check-installed", "dplyr", "ggplot2", "--json") parsed <- jsonlite::fromJSON(paste(env$.output, collapse = "\n")) expect_equal(nrow(parsed), 2) expect_equal(parsed$package, c("dplyr", "ggplot2")) + expect_true(all(parsed$installed)) +}) + + +test_that("btw check-installed --json has null version for not-installed package", { + local_mocked_bindings( + btw_tool_sessioninfo_is_package_installed_impl = function(package_name) { + stop(paste("Package", package_name, "is not installed.")) + } + ) + env <- run_btw_quietly("check-installed", "notapkg", "--json") + parsed <- jsonlite::fromJSON(paste(env$.output, collapse = "\n"), simplifyVector = FALSE) + expect_length(parsed, 1) + expect_equal(parsed[[1]]$package, "notapkg") + expect_null(parsed[[1]]$version) + expect_false(parsed[[1]]$installed) +}) + +# btw installed-packages ------------------------------------------------- + +test_that("btw installed-packages passes package names", { + mock_pkgs <- NULL + local_mocked_bindings( + btw_tool_sessioninfo_package_impl = function(packages, dependencies) { + mock_pkgs <<- packages + "package info" + } + ) + run_btw_quietly("installed-packages", "dplyr", "ggplot2") + expect_equal(mock_pkgs, c("dplyr", "ggplot2")) }) -test_that("btw info packages --deps passes dependency types", { +test_that("btw installed-packages --deps passes dependency types", { mock_deps <- NULL local_mocked_bindings( btw_tool_sessioninfo_package_impl = function(packages, dependencies) { @@ -394,10 +408,29 @@ test_that("btw info packages --deps passes dependency types", { "package info" } ) - run_btw_quietly("info", "packages", "dplyr", "--deps", "Imports,Suggests") + run_btw_quietly("installed-packages", "dplyr", "--deps", "Imports,Suggests") expect_equal(mock_deps, "Imports,Suggests") }) +test_that("btw installed-packages --json outputs valid JSON", { + mock_df <- data.frame( + package = c("dplyr", "ggplot2"), + version = c("1.1.4", "3.5.0"), + stringsAsFactors = FALSE + ) + local_mocked_bindings( + btw_tool_sessioninfo_package_impl = function(packages, dependencies) { + btw:::BtwPackageInfoToolResult( + value = "pkg info", + extra = list(data = mock_df) + ) + } + ) + env <- run_btw_quietly("installed-packages", "dplyr", "ggplot2", "--json") + parsed <- jsonlite::fromJSON(paste(env$.output, collapse = "\n")) + expect_equal(parsed$package, c("dplyr", "ggplot2")) +}) + # cran group ------------------------------------------------------------- test_that("btw cran search calls pkgsearch and btw_this", {