- 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>
214 lines
6.9 KiB
Rust
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)
|
|
}
|
|
}
|