Files
codecraft/server/src/tools/mod.rs
T
Richard ZinkandClaude Opus 5.5 6dffcb501a Add terminal client, memory, model fallback, version and install improvements
- codecraft: full-screen terminal client (ratatui) – local tools and files,
  models via the CodeCraft server gateway or own providers (standalone with a
  guided setup), slash commands, @files, permission dialogs, resume, update
- Static Linux CLI builds (x86_64/aarch64, cargo-zigbuild) served by the
  server: /cli/install.sh and /cli/download/<target>
- Project memory (remember tool, .codecraft/memory.md) and personal memory
- Model fallback when a host is unreachable or a budget is used up
- Version/commit in UI and health, daily release check, "What's new" dialog
- PWA: install button, maskable icon, manifest id and screenshots
- Case-insensitive detection of connection errors for retries

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 00:13:06 +01:00

214 lines
6.9 KiB
Rust

//! Built-in tools. Each tool declares a JSON schema, whether it is read-only
//! (allowed in plan mode) and how to run it.
pub mod agent;
pub mod bash;
pub mod craft;
pub mod cad;
pub mod fs;
pub mod web;
use crate::app::App;
use crate::provider::ToolDef;
use crate::types::ImageData;
use anyhow::{Result, bail};
use async_trait::async_trait;
use serde_json::Value;
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use tokio_util::sync::CancellationToken;
#[derive(Debug, Default)]
pub struct ToolOutput {
pub content: String,
pub images: Vec<ImageData>,
pub meta: Option<Value>,
pub is_error: bool,
}
impl ToolOutput {
pub fn text(s: impl Into<String>) -> Self {
Self { content: s.into(), ..Default::default() }
}
pub fn error(s: impl Into<String>) -> Self {
Self { content: s.into(), is_error: true, ..Default::default() }
}
pub fn with_meta(mut self, meta: Value) -> Self {
self.meta = Some(meta);
self
}
}
#[derive(Clone)]
pub struct ToolCtx {
pub app: Arc<App>,
pub session_id: String,
pub call_id: String,
pub cwd: PathBuf,
pub cancel: CancellationToken,
/// Agent mode running this tool (subagents inherit restrictions).
pub agent: String,
/// Directories this session may access (the owner's roots).
pub roots: Vec<PathBuf>,
/// Shell sandbox of the session owner (None = run unsandboxed).
pub sandbox: Option<crate::sandbox::SandboxSpec>,
/// Craft clean room active for this session (no page sources, no binaries).
pub clean_room: bool,
/// User the call runs for when there is no session (CLI requests).
pub owner_id: Option<String>,
}
impl ToolCtx {
/// Resolve a user/model supplied path against the session cwd and make sure
/// it stays inside the workspace.
pub fn resolve(&self, p: &str) -> Result<PathBuf> {
let p = p.trim();
let raw = if Path::new(p).is_absolute() { PathBuf::from(p) } else { self.cwd.join(p) };
let norm = normalize(&raw);
if norm.starts_with("/tmp") || self.roots.iter().any(|r| norm.starts_with(normalize(r))) {
// Symlinks must not lead out of the allowed directories either.
if let Ok(real) = std::fs::canonicalize(&norm) {
if !real.starts_with("/tmp") && !self.roots.iter().any(|r| real.starts_with(r)) {
bail!("path {} resolves outside your directories", norm.display());
}
}
return Ok(norm);
}
let allowed: Vec<String> = self.roots.iter().map(|r| r.display().to_string()).collect();
bail!("path {} is outside your directories ({})", norm.display(), allowed.join(", "))
}
/// Like `resolve`, but also allows the read-only built-in resources (skill templates).
pub fn resolve_read(&self, p: &str) -> Result<PathBuf> {
let builtin = normalize(&crate::prompts::builtin_resources());
let raw = if Path::new(p.trim()).is_absolute() { PathBuf::from(p.trim()) } else { self.cwd.join(p.trim()) };
let norm = normalize(&raw);
if norm.starts_with(&builtin) {
return Ok(norm);
}
self.resolve(p)
}
/// The root directory containing the working directory.
pub fn root(&self) -> PathBuf {
let mut roots = self.roots.clone();
roots.sort_by_key(|r| std::cmp::Reverse(r.as_os_str().len()));
roots.iter().find(|r| self.cwd.starts_with(r)).cloned().unwrap_or_else(|| self.cwd.clone())
}
pub fn progress(&self, output: impl Into<String>) {
self.app.emit(crate::types::Event::ToolProgress {
session_id: self.session_id.clone(),
call_id: self.call_id.clone(),
output: output.into(),
});
}
}
/// Lexical normalization (no symlink resolution, works for non-existing files).
pub fn normalize(p: &Path) -> PathBuf {
let mut out = PathBuf::new();
for c in p.components() {
match c {
Component::ParentDir => {
out.pop();
}
Component::CurDir => {}
other => out.push(other.as_os_str()),
}
}
out
}
#[async_trait]
pub trait Tool: Send + Sync {
fn name(&self) -> &str;
fn description(&self) -> String;
fn schema(&self) -> Value;
/// Read-only tools are available in plan mode and never modify state.
fn read_only(&self) -> bool {
false
}
/// Category used by the "accept_edits" permission mode.
fn is_edit(&self) -> bool {
false
}
/// Short human title for permission prompts.
fn title(&self, input: &Value) -> String {
let _ = input;
self.name().to_string()
}
/// Optional unified diff shown in permission prompts.
async fn preview(&self, _input: &Value, _ctx: &ToolCtx) -> Option<String> {
None
}
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput>;
fn def(&self) -> ToolDef {
ToolDef { name: self.name().into(), description: self.description(), input_schema: self.schema() }
}
}
pub fn builtin_tools() -> Vec<Arc<dyn Tool>> {
vec![
Arc::new(fs::Read),
Arc::new(fs::Write),
Arc::new(fs::Edit),
Arc::new(fs::List),
Arc::new(fs::Glob),
Arc::new(fs::Grep),
Arc::new(bash::Bash),
Arc::new(crate::shells::BashOutput),
Arc::new(crate::shells::KillShell),
Arc::new(web::WebFetch),
Arc::new(web::WebSearch),
Arc::new(agent::CraftPhase),
Arc::new(agent::ChangeDirectory),
Arc::new(agent::Remember),
Arc::new(cad::OpenScad),
Arc::new(crate::jobs::Schedule),
Arc::new(crate::lsp::Diagnostics),
Arc::new(agent::TodoWrite),
Arc::new(agent::AskUser),
Arc::new(agent::Task),
Arc::new(craft::BrowserSnapshot),
Arc::new(craft::ImageCompare),
Arc::new(crate::prompts::SkillTool),
Arc::new(crate::images::GenerateImage),
Arc::new(crate::reverse::BinaryInfo),
Arc::new(crate::reverse::Decompile),
]
}
/// Helper for reading typed arguments.
pub fn arg_str<'a>(input: &'a Value, key: &str) -> Result<&'a str> {
input
.get(key)
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("missing required string argument '{key}'"))
}
pub fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
return s.to_string();
}
let mut cut = max;
while !s.is_char_boundary(cut) {
cut -= 1;
}
format!("{}\n\n[... output truncated: {} of {} bytes shown ...]", &s[..cut], cut, s.len())
}
/// Server tools answer the permission rules' questions about themselves.
impl codecraft_core::permission::ToolInfo for dyn Tool {
fn name(&self) -> &str {
Tool::name(self)
}
fn read_only(&self) -> bool {
Tool::read_only(self)
}
fn is_edit(&self) -> bool {
Tool::is_edit(self)
}
}