Why Every R Package Wrapping External Tools Needs a sitrep() Function


Stop playing “diagnostics ping-pong” with your users. This post explores why the _sitrep() (situation report) pattern — popularized by the usethis package — is a game-changer for R packages wrapping APIs or external software. Learn how to build structured validation functions that power both early error-handling and comprehensive system reports, featuring real-world implementation examples from the meetupr and freesurfer packages.

The usethis package has a function I find extremely useful: git_sitrep().
When Git authentication breaks or remotes get confused, it dumps everything relevant in one go.
No more hunting through config files or trying random fixes.

This pattern — the situation report function — should be standard in any R package wrapping an API or external program.
You’ll find similar functions in devtools (dev_sitrep()) and greta (greta_sitrep()), packages that also depend on external tooling.
I especially like the greta_sitrep(), because it allows for three flavours of sitrep “minimal”, “detailed” and “quiet”.
Super nice!
This gives the ability for a quick overview, and provide a more detailed report when needed.

Huge thanks Maëlle Salmon for both reviewing this post and for showing me this one.
I’ve built them into meetupr and freesurfer, and they help both users and developers when debugging issues.

Why This Matters

API wrappers and program interfaces fail predictably: external tool not installed (or installed incorrectly), missing credentials, expired tokens, wrong environment variables, version mismatches, network issues.

Users open issues: “why doesn’t it work?” and you end up playing 20 questions.
A sitrep() function surfaces everything at once, helping users diagnose and fix problems on their own.
They’re also invaluable in teaching contexts, where students encounter these setup issues constantly.

If you’re just starting, a sitrep doesn’t need to be 100 lines of code.
Here is a “Minimum Viable Sitrep” to get the pattern into your package:

pkg_sitrep  function() {
  cli::cli_h1("Quick Status Report")
  
  # Check for a specific environment variable
  has_key  nzchar(Sys.getenv("MY_API_KEY"))
  
  cli::cli_list(
    "API Key: {if(has_key) cli::col_green('Found') else cli::col_red('Missing')}",
    "Internet: {if(curl::has_internet()) cli::col_green('Online') else cli::col_red('Offline')}",
    "R Version: {utils::packageVersion('base')}"
  )
}

Reusing Checking Functions

A naive approach would be to write validation code twice: once scattered throughout your package functions, and again in sitrep.
Instead, extract your checks into small functions that return structured results.

For example, a function like has_auth() can:

  1. Be called inside api_query() to fail early with a helpful error
  2. Be called inside sitrep() to show the user their authentication status

Same logic, two contexts.
This keeps your validation consistent and means updating one function fixes both places.

meetupr Implementation

The package authenticates with Meetup’s GraphQL API via OAuth. Here’s the sitrep output:

meetupr_sitrep()
#> ── meetupr Situation Report ─────────────────────
#> 
#> ── Active Authentication Method ──
#> ✔ OAuth - Active
#> 
#> ── OAuth Configuration ──
#> Client ID: ae743e...
#> Client Secret: Set
#> ✔ Cached Token: Available
#> 
#> ── Package Settings ──
#> Debug Mode: Disabled
#> API endpoint: https://api.meetup.com/gql
#> 
#> ── API Connectivity Test ──
#> ✔ API Connection: Working
#> ℹ Authenticated as: Mo Mowinckel (ID: 123456)

The Checking Functions

In meetupr, we implemented a function that goes through the possible validation options and checking what is available.
It is quite extensive, but I wanted to show the entire function, because I quite honestly think it’s pretty neat (not so humble brag).

The function checks if a JWT token is set up and available, then if the httr2 cache has a valid API token stored, and returns a list with all the information (which we can then use later).

meetupr_auth_status  function(
  client_name = get_client_name(),
  silent = FALSE
) {
  # JWT token
  jwt  tryCatch(
    get_jwt_token(client_name = client_name),
    error = function(e) NULL
  )

  jwt_issuer  meetupr_key_get(
    "jwt_issuer",
    client_name = client_name,
    error = FALSE
  )

  client_key  meetupr_key_get(
    "client_key",
    client_name = client_name,
    error = FALSE
  )

  jwt_valid  !is_empty(jwt) &&
    !is_empty(jwt_issuer) &&
    !is_empty(client_key)

  if (jwt_valid) {
    if (!silent) {
      cli::cli_alert_success("JWT setup found and is valid")
    }
  } else {
    if (!silent) {
      cli::cli_alert_warning("JWT setup not found or invalid")
    }
  }

  # httr2 OAuth cache
  cache_path  get_cache_path(client_name)

  if (!dir.exists(cache_path)) {
    if (!silent) {
      cli::cli_alert_danger("Not authenticated: No token cache found")
    }
  }

  cache_files  list_token_files(cache_path)
  cache_valid  length(cache_files) > 0

  if (cache_valid) {
    if (!silent) {
      cli::cli_alert_success("Token found in cache")
      if (length(cache_files) > 1) {
        cli::cli_alert_info("Multiple token files found in cache:")
        for (f in cache_files) {
          cli::cli_text(" - {.file {f}}")
        }
      }
    }
  } else {
    if (!silent) {
      cli::cli_alert_danger("Not authenticated: No token found in cache")
    }
  }

  type  if (jwt_valid) {
    "jwt"
  } else if (cache_valid) {
    "cache"
  } else {
    "none"
  }

  # Return detailed status
  list(
    auth = list(
      any = jwt_valid || cache_valid,
      client_name = client_name,
      method = type
    ),
    jwt = list(
      available = jwt_valid,
      value = jwt %||% NULL,
      issuer = jwt_issuer %||% NA_character_,
      client_key = client_key %||% NULL
    ),
    cache = list(
      available = cache_valid,
      files = cache_files %||% NA_character_
    )
  ) |>
    invisible()
}

Since the function returns all this information, we can set up convenience functions around this one that can help us evaluate the state of auth for the functions.

has_auth  function(
  client_name = get_client_name()
) {
  meetupr_auth_status(
    client_name,
    silent = TRUE
  )$auth$any
}

Using Them in Functions

Internal functions call these for early validation:

meetup_query  function(query) {
  if (!has_auth()) {
    cli::cli_abort("Not authenticated. Run {.code meetup_auth()} first.")
  }
  
  # Proceed with API call
}

Using Them in Sitrep

Since we have this meetupr_auth_status with all the information on the state of authentication, we can use it in the sitrep.
We built a convenience function, that takes the result of that function and displays the information in an orderly fashion and gives users aid if they need to fix something.

meetupr_sitrep  function() {
  cli::cli_h1("meetupr Situation Report")

  auth_status  meetupr_auth_status(silent = TRUE)

  display_auth_status(auth_status)

  test_api_connectivity()

  invisible(auth_status)
}

Lastly, we created a simple API connectivity test, that calls the API and checks who is authenticated.
With this last bit, we can tell the users whether the setup actually works.

The key: meetupr_auth_status() doesn’t duplicate logic — it calls the same validation functions used throughout the package.

freesurfer Implementation

FreeSurfer is a neuroimaging toolkit with command-line tools.
John Muschelli has a wrapper package from R that calls the CLI functions from R and optionally imports the data to R for further processing.

My ggsegExtra-package, which contains pipelines for creating new ggseg-atlases, calls Freesurfer in several stages of the process, so I have contributed to the package several times, with functionality I need to my own package which make better sense to exist in Freesurfer than in my own package.
Last time I was working on Freesurfer, I had some issues getting R and my Freesurfer to talk to each other, and I got frustrated figuring out why.
So I thought, this package needs a sitrep function.

For freesurfer, I needed similar, but still different approach.
Since it relies on software being installed on your system, I needed a way to get information on user settings (environment or options) and whether the paths specified actually exists or not,
and I needed to have a good overview over how Freesurfer deals with all this itself (I kind of already knew this last bit, you can’t work with Freesurfer CLI unless you have a fairly thorough understanding of where it’s installed and how to work with system paths).

However, there are quite a lot of possible settings, so the first step was to set up a convenience function that would help evaluate whether settings were available using the heuristic options > environment > default guesswork.

When building these, I follow a specific hierarchy of “truth” to find settings. This ensures the user has maximum flexibility:

  • R Options: getOption(“pkg.path”) — Highest priority, set per session.

  • Environment Variables: Sys.getenv(“PKG_PATH”) — Standard for CI/CD and shell users.

  • Default Guesswork: Known installation paths or standard values — The “just work” fallback.

get_fs_setting  function(
  env_var,
  opt_var,
  defaults = NULL,
  is_path = TRUE
) {
  # Check R option first
  original_opt  getOption(opt_var)
  if (!is.null(original_opt) && nzchar(as.character(original_opt))) {
    return(return_setting(
      as.character(original_opt),
      paste("R option:", opt_var),
      is_path
    ))
  }

  # Check environment variable
  original_env  Sys.getenv(env_var)
  if (nzchar(original_env)) {
    return(return_setting(
      original_env,
      paste("Environment variable:", env_var),
      is_path
    ))
  }

  # Try defaults
  if (!is.null(defaults)) {
    if (is_path) {
      # Find first existing default
      existing_defaults  batch_file_exists(
        defaults,
        error = FALSE,
        warn = FALSE
      )
      valid_defaults  defaults[existing_defaults]

      if (length(valid_defaults) > 0) {
        return(return_setting(
          valid_defaults[1],
          "Default path",
          TRUE
        ))
      }
    } else {
      # For non-paths, just return first default
      return(return_setting(
        defaults[1],
        "Default value",
        FALSE
      ))
    }
  }
  # Nothing found
  return_setting(
    NA,
    "Not found",
    is_path
  )
}

Again, my function here returns the information and context needed for further evaluation, but also calls a return_setting function, since I wanted to make sure this ALWAYS returns the same information, and evaluates whether a path exists or not.

return_setting  function(value, source, is_path = TRUE) {
  exists  FALSE

  if (!is_path) {
    exists  NA
  }

  if (is_path && !all(is.na(value))) {
    value  normalizePath(value, mustWork = FALSE)
    if (length(value) == 1) {
      exists  file.exists(value)
    } else {
      exists  batch_file_exists(
        value,
        error = FALSE,
        warn = FALSE
      )
    }
  }

  list(
    value = value,
    source = source,
    exists = exists
  )
}

Now that we have these conveniences, I could start setting up custom functions that would look for specific pieces needed for the communication between Freesurfer and R, like the very crucial get_fs_home() function, which finds the path to where Freesurfer is installed.
(Not to be confused with fs::path_home() from the fs package, which returns the user’s home directory!)

get_fs_home  function(simplify = TRUE) {
  ret  get_fs_setting(
    env_var = "FREESURFER_HOME",
    opt_var = "freesurfer.home",
    defaults = c(
      "/usr/freesurfer",
      "/usr/bin/freesurfer",
      "/usr/local/freesurfer",
      "/Applications/freesurfer"
    ),
    is_path = TRUE
  )
  if (simplify) {
    return(ret$value)
  }
  ret
}

This function checks first for whether the FREESURFER_HOME environment variable is set (which is necessary to set when using Freesurfer from the terminal, and thus any R called from a terminal on such a system will already have this set), it checks for the freesurfer.home option() setting in R, and then looks in the known default paths it may exist on a system.

And since we also added the simplify argument to the function, we can return a simple logical statement is Freesurfer home is set and exists on the system

have_fs  function() {
  get_fs_home(simplify = FALSE)$exists
}

In addition, since we have the convenience get_fs_setting() function, we can create other check which we know Freesurfer relies on to work properly, like checking whether its license file is set up correctly (its free to use, but you need to register with them to get a license file).

get_fs_license  function(
  fs_home = get_fs_home(),
  simplify = TRUE
) {
  if (is.na(fs_home)) {
    ret  return_setting(
      NA, 
      "FreeSurfer home not found", 
      TRUE
    )
    if (simplify) {
      return(ret$value)
    }
    return(ret)
  }

  ret  get_fs_setting(
    "FS_LICENSE",
    "freesurfer.license",
    c(
      file.path(fs_home, ".license"),
      file.path(fs_home, "license.txt")
    )
  )

  if (simplify) {
    return(ret$value)
  }
  ret
}

The license file check, first checks if fs_home() returns correctly, no reason to check for license with the program isn’t there.
Then it moves on to check for environment settings, options, and default paths again, like before.

Using Them in Sitrep

To finally create a good fs_sitrep() function, we created a convenience function that took the output from get_fs_setting(), which is a list of three things, and made sure it could output good cli-style information to the console.

alert_info  function(settings, header) {
  cli::cli_h3(header)

  if (is.null(settings) || all(is.na(settings$value))) {
    cli::cli_li("Unable to detect")
    return()
  }

  if (length(settings$value) > 1) {
    cli::cli_alert_warning("Multiple possible values found")
    for (val in settings$value) {
      cli::cli_li("{.val {val}}")
    }
    cli::cli_alert_info("Consider setting preferred value with {.code options}")
    settings  return_single(settings)
  }

  cli::cli_li("{.val {settings$value}}")

  # Source information
  if (!is.na(settings$source)) {
    if (grepl("Default|Not found", settings$source, ignore.case = TRUE)) {
      cli::cli_alert_warning("Determined from: {.code {settings$source}}")
    } else {
      cli::cli_alert_info("Determined from: {.code {settings$source}}")
    }
  }

  # Path existence
  if (!is.na(settings$exists)) {
    if (settings$exists) {
      cli::cli_alert_success("Path exists")
    } else {
      cli::cli_alert_danger("Path does not exist")
    }
  }
}

With that in place, we have a rather large sitrep for fs, that shows lots of different things, including whether something has been set from what source (env or option) or if it’s just the default behaviour.

And just like in meetupr, we finish off with a test to whether the communication between R and Freesurfer is working, in this case by just asking for the help file of a core Freesurfer function, and making sure that outputs expected help information.

fs_sitrep  function(test_commands = TRUE) {
  fs_home  get_fs_home(simplify = FALSE)
  license_info  get_fs_license(simplify = FALSE)
  verbosity  get_fs_verbosity(simplify = FALSE)

  cli::cli_h2("FreeSurfer Setup Report")

  # Core settings - simple and clean
  alert_info(fs_home, "FreeSurfer Directory")
  alert_info(
    get_fs_source(
      fs_home = fs_home$value,
      simplify = FALSE
    ),
    "Source script"
  )
  alert_info(license_info, "License File")
  alert_info(
    get_fs_subdir(fs_home = fs_home$value, simplify = FALSE),
    "Subjects Directory"
  )
  alert_info(verbosity, "Verbose mode")
  alert_info(
    get_mni_bin(fs_home = fs_home$value, simplify = FALSE),
    "MNI functionality"
  )
  alert_info(
    get_fs_output(simplify = FALSE),
    "Output Format"
  )

  # System information
  sysinfo  sys_info()
  cli::cli_h3("System Information")
  cli::cli_li("Operating System: {.val {sysinfo$platform}}")
  cli::cli_li("R Version: {.val {sysinfo$r_version}}")
  cli::cli_li("Shell: {.val {sysinfo$shell}}")

  # Testing installation
  if (test_commands) {
    cli::cli_h3("Testing R and FreeSurfer Communication")

    # Test basic availability
    if (!have_fs()) {
      cli::cli_alert_danger("FreeSurfer installation not detected")
      cli::cli_li(
        "Use {.code options(freesurfer.home = '/path/to/freesurfer')} to set location"
      )
      return(invisible())
    }

    # Test version
    version_info  fs_version()
    cli::cli_li("Version: {.val {version_info}}")

    # Test command execution - simple approach
    cli::cli_li("Testing command execution with {.code mri_info --help}")

    mri_info_result  suppressMessages(mri_info.help())

    if (is.character(mri_info_result) && length(mri_info_result) > 0) {
      if (any(grepl("USAGE:|mri_info", mri_info_result, ignore.case = TRUE))) {
        cli::cli_alert_success("R and FreeSurfer are working together")
        cli::cli_li("Command test successful")
      } else {
        cli::cli_alert_warning(
          "FreeSurfer command executed but output format unexpected"
        )
        if (verbosity$value) {
          cli::cli_li(
            "Output preview: {.val {paste(head(mri_info_result, 2), collapse = ' | ')}}"
          )
        }
      }
    } else {
      cli::cli_alert_danger("FreeSurfer and R are not working together")
      cli::cli_li("Command execution failed or returned no output")
    }
  }

  # Simple recommendations
  cli::cli_h3("Recommendations")

  if (is.na(fs_home$value)) {
    cli::cli_li(
      "Set FreeSurfer home: {.code options(freesurfer.home = '/path/to/freesurfer')}"
    )
  } else if (is.na(license_info$value) || !license_info$exists) {
    cli::cli_li("Install FreeSurfer license file in {fs_home$value}")
  } else if (!verbosity$value) {
    cli::cli_li(
      "Enable verbose mode for better debugging: {.code options(freesurfer.verbose = TRUE)}"
    )
  } else {
    cli::cli_alert_success("FreeSurfer setup looks good!")
  }

  invisible()
}

With all that information, both the user and us should get enough context to figure out what is going wrong if they need help.

── FreeSurfer Setup Report ──

── FreeSurfer Directory 
• "/Applications/freesurfer"
! Determined from: `Default path`
✔ Path exists

── Source script 
• Unable to detect

── License File 
• Unable to detect

── Subjects Directory 
• Unable to detect

── Verbose mode 
• TRUE
! Determined from: `Default value`

── MNI functionality 
• Unable to detect

── Output Format 
• "nii.gz"
! Determined from: `Default value`

── System Information 
• Operating System:
"aarch64-apple-darwin20"
• R Version: "4.5.2 (2025-10-31)"
• Shell: "/bin/zsh"

── Testing R and FreeSurfer Communication 
✖ FreeSurfer installation not detected
• Use `options(freesurfer.home="/path/to/freesurfer")` to set location

Design Principles

Write checking functions, not checking code. Extract validation logic into small, testable functions that return structured results.

Structure over booleans. Return lists with valid, value, and message instead of just TRUE/FALSE.
This gives both functions and sitrep the context they need.

No side effects in checks. Functions named check_*() or has_*() should only inspect, never modify or trigger auth flows.

Consistent returns. All checking functions should return the same structure so they can be used interchangeably.

Use cli semantics. Headers, alerts, and colors make sitrep output scannable.

The Maintenance Win

When auth logic changes, you update one checking function.
Both error messages and sitrep automatically stay in sync.
No hunting for duplicate validation code scattered across your package.

When debugging CI failures, you can add a package_sitrep() call to the workflow and get comprehensive diagnostics in the logs.

When someone reports “it’s not working”, you say: “Run package_sitrep() and show me the output.”
You get structured information instead of playing diagnostics ping-pong.

The usethis team built this pattern because they knew users (and them) needed help in diagnosing setup issues.
If it works for them, it’ll work for your API wrapper too.



Source link

Leave a Reply

Your email address will not be published. Required fields are marked *