Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion DESCRIPTION
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ Suggests:
shiny,
shinychat (>= 0.3.0),
testthat (>= 3.0.0),
usethis
usethis,
yaml
Config/Needs/website: tidyverse/tidytemplate
Config/testthat/edition: 3
Config/testthat/parallel: true
Expand Down Expand Up @@ -109,6 +110,7 @@ Collate:
'tool-search-packages.R'
'tool-session-info.R'
'tool-session-package-installed.R'
'tool-skills.R'
'tool-web.R'
'tools.R'
'utils-ellmer.R'
Expand Down
1 change: 1 addition & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export(btw_tool_docs_package_news)
export(btw_tool_docs_vignette)
export(btw_tool_env_describe_data_frame)
export(btw_tool_env_describe_environment)
export(btw_tool_fetch_skill)
export(btw_tool_files_code_search)
export(btw_tool_files_list_files)
export(btw_tool_files_read_text_file)
Expand Down
4 changes: 4 additions & 0 deletions R/btw_client.R
Original file line number Diff line number Diff line change
Expand Up @@ -110,12 +110,16 @@ btw_client <- function(
llms_txt <- read_llms_txt(path_llms_txt)
project_context <- c(llms_txt, config$btw_system_prompt)
project_context <- paste(project_context, collapse = "\n\n")
skills_prompt <- btw_skills_system_prompt()

sys_prompt <- c(
btw_prompt("btw-system_session.md"),
if (!skip_tools) {
btw_prompt("btw-system_tools.md")
},
if (nzchar(skills_prompt)) {
skills_prompt
},
if (nzchar(project_context)) {
btw_prompt("btw-system_project.md")
},
Expand Down
289 changes: 289 additions & 0 deletions R/tool-skills.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,289 @@
#' @include tool-result.R
NULL

#' Tool: Fetch a skill
#'
#' @description
#' Fetch a skill's instructions and list its bundled resources.
#'
#' Skills are modular capabilities that extend Claude's functionality with
#' specialized knowledge, workflows, and tools. Each skill is a directory
#' containing a `SKILL.md` file with instructions and optional bundled
#' resources (scripts, references, assets).
#'
#' @param skill_name The name of the skill to fetch.
#' @inheritParams btw_tool_docs_package_news
#'
#' @return A `btw_tool_result` containing the skill instructions and a listing
#' of bundled resources with their paths.
#'
#' @family skills
#' @export
btw_tool_fetch_skill <- function(skill_name, `_intent`) {}

btw_tool_fetch_skill_impl <- function(skill_name) {

check_string(skill_name)

skill_info <- find_skill(skill_name)

if (is.null(skill_info)) {
available <- btw_list_skills()
skill_names <- vapply(available, function(x) x$name, character(1))
cli::cli_abort(
c(
"Skill {.val {skill_name}} not found.",
"i" = "Available skills: {.val {skill_names}}"
)
)
}

skill_content <- readLines(skill_info$path, warn = FALSE)

content_start <- 1
if (length(skill_content) > 0 && skill_content[1] == "---") {
yaml_end <- which(skill_content == "---")
if (length(yaml_end) >= 2) {
content_start <- yaml_end[2] + 1
}
}

skill_text <- paste(
skill_content[content_start:length(skill_content)],
collapse = "\n"
)

resources <- list_skill_resources(skill_info$base_dir)
resources_listing <- format_resources_listing(resources, skill_info$base_dir)

full_content <- paste0(skill_text, resources_listing)

btw_tool_result(
value = full_content,
data = list(
name = skill_name,
path = skill_info$path,
base_dir = skill_info$base_dir,
resources = resources
),
display = list(
title = sprintf("Skill: %s", skill_name),
markdown = full_content
)
)
}

.btw_add_to_tools(
name = "btw_tool_fetch_skill",
group = "skills",
tool = function() {
ellmer::tool(
btw_tool_fetch_skill_impl,
name = "btw_tool_fetch_skill",
description = paste(
"Fetch a skill's instructions and list its bundled resources.",
"Skills provide specialized guidance for specific tasks.",
"After fetching, use file read tools to access references,",
"or bash/code tools to run scripts."
),
annotations = ellmer::tool_annotations(
title = "Fetch Skill",
read_only_hint = TRUE,
open_world_hint = FALSE,
btw_can_register = function() length(btw_list_skills()) > 0
),
arguments = list(
skill_name = ellmer::type_string(
"The name of the skill to fetch"
)
)
)
}
)

# Skill Discovery ----------------------------------------------------------

btw_skill_directories <- function() {

dirs <- character()


package_skills <- system.file("skills", package = "btw")
if (nzchar(package_skills) && dir.exists(package_skills)) {
dirs <- c(dirs, package_skills)
}


user_skills_dir <- file.path(
tools::R_user_dir("btw", "config"),
"skills"
)
Comment on lines +117 to +120
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some existing tools have tools::R_user_dir("btw", which = "cache"). Is this a reasonable directory to look in? I've used ~/.config/{pkg}/ in a few packages, but don't know if this is a Simon-ism. Whatever directory we land on, it should ofc be documented. :)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a helper that wraps tools::R_user_dir() -- path_btw_cache(). My take is: this is for cached files used and written by btw. I'm currently only using it for some code related to Shiny bookmarks, but I have plans to have btw_app() remember conversation history and we'd use it there.

Separately, there's path_find_user() that considers these locations:

  possibilities <- c(
    fs::path_home(filename),
    fs::path_home_r(filename),
    fs::path_home(".config", "btw", filename)
  )

So ~/.config/btw/skills would be a natural place to put this that fits in with currently places we look for btw.md. (This is only briefly mentioned in ?btw_client and ?edit_btw_md.)

On the other hand, I could also see ~/.btw/skills making sense.

What do you think about inheriting installed Claude skills too from ~/.claude/skills?

Also: I like using inst/skills just like inst/prompts! I wonder how hard it is to look through installed packages to auto-discover these skills. Or maybe we'd need an option that helps bring in skills from packages.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if (dir.exists(user_skills_dir)) {
dirs <- c(dirs, user_skills_dir)
}

project_skills_dir <- file.path(getwd(), ".btw", "skills")
if (dir.exists(project_skills_dir)) {
dirs <- c(dirs, project_skills_dir)
}

dirs
}

btw_list_skills <- function() {
skill_dirs <- btw_skill_directories()
all_skills <- list()

for (dir in skill_dirs) {
if (!dir.exists(dir)) {
next
}

subdirs <- list.dirs(dir, full.names = TRUE, recursive = FALSE)

for (subdir in subdirs) {
skill_md_path <- file.path(subdir, "SKILL.md")
if (file.exists(skill_md_path)) {
metadata <- extract_skill_metadata(skill_md_path)
skill_name <- basename(subdir)

all_skills[[skill_name]] <- list(
name = skill_name,
description = metadata$description %||% "No description available",
path = skill_md_path
)
}
}
}

all_skills
}

find_skill <- function(skill_name) {
skill_dirs <- btw_skill_directories()

for (dir in skill_dirs) {
skill_dir <- file.path(dir, skill_name)
skill_md_path <- file.path(skill_dir, "SKILL.md")
if (dir.exists(skill_dir) && file.exists(skill_md_path)) {
return(list(
path = skill_md_path,
base_dir = skill_dir
))
}
}

NULL
}

extract_skill_metadata <- function(skill_path) {
lines <- readLines(skill_path, warn = FALSE)

if (length(lines) == 0 || lines[1] != "---") {
return(list())
}

yaml_end_indices <- which(lines == "---")
if (length(yaml_end_indices) < 2) {
return(list())
}

yaml_lines <- lines[2:(yaml_end_indices[2] - 1)]
yaml_text <- paste(yaml_lines, collapse = "\n")

check_installed("yaml")
tryCatch(
yaml::yaml.load(yaml_text),
error = function(e) list()
)
}

# Skill Resources ----------------------------------------------------------

list_skill_resources <- function(skill_dir) {
list(
scripts = list_files_in_subdir(skill_dir, "scripts"),
references = list_files_in_subdir(skill_dir, "references"),
assets = list_files_in_subdir(skill_dir, "assets")
)
}

list_files_in_subdir <- function(base_dir, subdir) {
full_path <- file.path(base_dir, subdir)
if (!dir.exists(full_path)) {
return(character(0))
}
list.files(full_path, full.names = FALSE)
}

has_skill_resources <- function(resources) {
length(resources$scripts) > 0 ||
length(resources$references) > 0 ||
length(resources$assets) > 0
}

format_resources_listing <- function(resources, base_dir) {
if (!has_skill_resources(resources)) {
return("")
}

parts <- character()
parts <- c(parts, "\n\n---\n\n## Bundled Resources\n")

if (length(resources$scripts) > 0) {
parts <- c(parts, "\n**Scripts:**\n")
script_paths <- file.path(base_dir, "scripts", resources$scripts)
parts <- c(parts, paste0("- ", script_paths, collapse = "\n"))
}

if (length(resources$references) > 0) {
parts <- c(parts, "\n\n**References:**\n")
ref_paths <- file.path(base_dir, "references", resources$references)
parts <- c(parts, paste0("- ", ref_paths, collapse = "\n"))
}

if (length(resources$assets) > 0) {
parts <- c(parts, "\n\n**Assets:**\n")
asset_paths <- file.path(base_dir, "assets", resources$assets)
parts <- c(parts, paste0("- ", asset_paths, collapse = "\n"))
}

paste(parts, collapse = "")
}

# System Prompt ------------------------------------------------------------

btw_skills_system_prompt <- function() {
skills <- btw_list_skills()

if (length(skills) == 0) {
return("")
}


skills_prompt_path <- system.file("prompts", "skills.md", package = "btw")
explanation <- if (file.exists(skills_prompt_path)) {
paste(readLines(skills_prompt_path, warn = FALSE), collapse = "\n")
} else {
"## Skills\n\nYou have access to specialized skills that provide detailed guidance for specific tasks."
}

skill_items <- vapply(
skills,
function(skill) {
sprintf(
"<skill>\n<name>%s</name>\n<description>%s</description>\n</skill>",
skill$name,
skill$description
)
},
character(1)
)

paste0(
explanation,
"\n\n<available_skills>\n",
paste(skill_items, collapse = "\n"),
"\n</available_skills>"
)
}
14 changes: 14 additions & 0 deletions inst/prompts/skills.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
## Skills

You have access to specialized skills that provide detailed guidance for specific tasks. Skills are loaded on-demand to provide domain-specific expertise without consuming context until needed.

### Using Skills

1. **Check available skills**: Review the `<available_skills>` listing below
2. **Fetch when relevant**: Call `btw_tool_fetch_skill(skill_name)` when a task matches a skill's description
3. **Access resources**: After fetching, use file read tools to access references or bash/code tools to run bundled scripts

Skills may include bundled resources:
- **Scripts**: Executable code (R, Python, bash) for automated tasks
- **References**: Additional documentation to consult as needed
- **Assets**: Templates and files for use in outputs
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is auto-injected into btw_client() (and thus btw_app())'s context, along with a btw_list_skills() summary so that the agent knows what it has access to via a fetch_skill() tool call. This is Claude Code's design.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would be open to some sort of preferential treatment given to https://github.com/posit-dev/skills as well

Loading