aboutsummaryrefslogtreecommitdiffstats
path: root/crates/atuin-ai/src/tools
diff options
context:
space:
mode:
authorMichelle Tilley <michelle@michelletilley.net>2026-04-23 13:43:01 -0700
committerGitHub <noreply@github.com>2026-04-23 13:43:01 -0700
commit461ef4c43589c6ca68176c180fd04f2755c9f036 (patch)
treec646ea272d6016533c4941592f9a22baa2a54488 /crates/atuin-ai/src/tools
parentfeat: Send user-defined context with `TERMINAL.md` (#3443) (diff)
downloadatuin-461ef4c43589c6ca68176c180fd04f2755c9f036.zip
feat: Add skill discovery, loading, and invocation (#3444)
Adds a skills system that lets users define reusable LLM instructions as `SKILL.md` files with YAML frontmatter.
Diffstat (limited to 'crates/atuin-ai/src/tools')
-rw-r--r--crates/atuin-ai/src/tools/descriptor.rs10
-rw-r--r--crates/atuin-ai/src/tools/mod.rs54
2 files changed, 63 insertions, 1 deletions
diff --git a/crates/atuin-ai/src/tools/descriptor.rs b/crates/atuin-ai/src/tools/descriptor.rs
index 6ccb595f..06858bf8 100644
--- a/crates/atuin-ai/src/tools/descriptor.rs
+++ b/crates/atuin-ai/src/tools/descriptor.rs
@@ -67,6 +67,15 @@ pub(crate) const ATUIN_HISTORY: &ToolDescriptor = &ToolDescriptor {
is_client: true,
};
+pub(crate) const LOAD_SKILL: &ToolDescriptor = &ToolDescriptor {
+ canonical_names: &["load_skill"],
+ capability: Some("client_v1_load_skill"),
+ display_verb: "load skill",
+ progressive_verb: "Loading skill...",
+ past_verb: "Loaded skill",
+ is_client: true,
+};
+
// ── Server-side tool descriptors ──
// These appear in tool summaries but aren't client-side tools.
@@ -95,6 +104,7 @@ const ALL_DESCRIPTORS: &[&ToolDescriptor] = &[
WRITE,
SHELL,
ATUIN_HISTORY,
+ LOAD_SKILL,
SERVER_SEARCH,
SERVER_SCRAPE,
];
diff --git a/crates/atuin-ai/src/tools/mod.rs b/crates/atuin-ai/src/tools/mod.rs
index e66d64b8..fdda10a4 100644
--- a/crates/atuin-ai/src/tools/mod.rs
+++ b/crates/atuin-ai/src/tools/mod.rs
@@ -158,6 +158,7 @@ pub(crate) enum ClientToolCall {
Write(WriteToolCall),
Shell(ShellToolCall),
AtuinHistory(AtuinHistoryToolCall),
+ LoadSkill(LoadSkillToolCall),
}
impl TryFrom<(&str, &serde_json::Value)> for ClientToolCall {
@@ -172,6 +173,9 @@ impl TryFrom<(&str, &serde_json::Value)> for ClientToolCall {
"atuin_history" => Ok(ClientToolCall::AtuinHistory(
AtuinHistoryToolCall::try_from(input)?,
)),
+ "load_skill" => Ok(ClientToolCall::LoadSkill(LoadSkillToolCall::try_from(
+ input,
+ )?)),
_ => Err(eyre::eyre!("Unknown tool call: {name}")),
}
}
@@ -185,6 +189,7 @@ impl ClientToolCall {
ClientToolCall::Write(_) => descriptor::WRITE,
ClientToolCall::Shell(_) => descriptor::SHELL,
ClientToolCall::AtuinHistory(_) => descriptor::ATUIN_HISTORY,
+ ClientToolCall::LoadSkill(_) => descriptor::LOAD_SKILL,
}
}
@@ -200,6 +205,7 @@ impl ClientToolCall {
ClientToolCall::Write(_) => "Write",
ClientToolCall::Shell(_) => "Shell",
ClientToolCall::AtuinHistory(_) => "AtuinHistory",
+ ClientToolCall::LoadSkill(_) => "LoadSkill",
}
}
@@ -210,7 +216,9 @@ impl ClientToolCall {
ClientToolCall::Read(tool) => Some(tool.resolved_path()),
ClientToolCall::Edit(tool) => Some(tool.resolved_path()),
ClientToolCall::Write(tool) => Some(tool.resolved_path()),
- _ => None,
+ ClientToolCall::Shell(_)
+ | ClientToolCall::AtuinHistory(_)
+ | ClientToolCall::LoadSkill(_) => None,
}
}
@@ -221,6 +229,7 @@ impl ClientToolCall {
ClientToolCall::Write(tool) => tool.matches_rule(rule),
ClientToolCall::Shell(tool) => tool.matches_rule(rule),
ClientToolCall::AtuinHistory(tool) => tool.matches_rule(rule),
+ ClientToolCall::LoadSkill(tool) => tool.matches_rule(rule),
}
}
@@ -231,6 +240,7 @@ impl ClientToolCall {
ClientToolCall::Write(tool) => tool.target_dir(),
ClientToolCall::Shell(tool) => tool.target_dir(),
ClientToolCall::AtuinHistory(tool) => tool.target_dir(),
+ ClientToolCall::LoadSkill(tool) => tool.target_dir(),
}
}
@@ -239,6 +249,10 @@ impl ClientToolCall {
match self {
ClientToolCall::Read(tool) => tool.execute(),
ClientToolCall::AtuinHistory(tool) => tool.execute(db).await,
+ // LoadSkill is handled separately by the driver (needs registry access)
+ ClientToolCall::LoadSkill(_) => {
+ ToolOutcome::Error("LoadSkill must be executed via the driver".to_string())
+ }
_ => ToolOutcome::Error("Client-side tool execution not yet implemented".to_string()),
}
}
@@ -271,6 +285,7 @@ impl PermissableToolCall for ClientToolCall {
fn all_covered_by(&self, rules: &[Rule]) -> bool {
match self {
ClientToolCall::Shell(tool) => tool.all_covered_by(rules),
+ // LoadSkill is always auto-approved, but support rules for completeness
_ => rules.iter().any(|r| self.matches_rule(r)),
}
}
@@ -280,6 +295,13 @@ impl PermissableToolCall for ClientToolCall {
}
}
+/// Returns true if this tool call should bypass the permission system entirely.
+impl ClientToolCall {
+ pub(crate) fn is_auto_approved(&self) -> bool {
+ matches!(self, ClientToolCall::LoadSkill(_))
+ }
+}
+
/// Expand shell constructs (`~`, `$HOME`, etc.) in a path string.
///
/// Tool call paths arrive as raw strings from the API without shell
@@ -1197,6 +1219,36 @@ impl AtuinHistoryToolCall {
}
}
+#[derive(Debug, Clone)]
+pub(crate) struct LoadSkillToolCall {
+ pub name: String,
+}
+
+impl TryFrom<&serde_json::Value> for LoadSkillToolCall {
+ type Error = eyre::Error;
+
+ fn try_from(value: &serde_json::Value) -> Result<Self, Self::Error> {
+ let name = value
+ .get("name")
+ .and_then(|v| v.as_str())
+ .ok_or(eyre::eyre!("Missing skill name"))?;
+
+ Ok(LoadSkillToolCall {
+ name: name.to_string(),
+ })
+ }
+}
+
+impl PermissableToolCall for LoadSkillToolCall {
+ fn target_dir(&self) -> Option<&Path> {
+ None
+ }
+
+ fn matches_rule(&self, rule: &Rule) -> bool {
+ rule.tool == "LoadSkill"
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;