#[cfg(not(unix))]
compile_error!(
"coding_agent_tools only supports Unix-like platforms (Linux/macOS). Windows is not supported."
);
pub mod agent;
pub mod glob;
pub mod grep;
pub mod just;
mod logging;
pub mod pagination;
pub mod paths;
pub mod tools;
pub mod types;
pub mod walker;
pub use tools::build_registry;
use agentic_config::types::CliToolsConfig;
use agentic_config::types::SubagentsConfig;
use agentic_tools_core::ToolError;
use std::sync::Arc;
use types::AgentOutput;
use types::Depth;
use types::GlobOutput;
use types::GrepOutput;
use types::LsOutput;
use types::OutputMode;
use types::Show;
use types::SortOrder;
fn pick_non_empty_text(result: &claudecode::types::Result) -> Option<String> {
result
.result
.as_ref()
.filter(|s| !s.trim().is_empty())
.cloned()
.or_else(|| {
result
.content
.as_ref()
.filter(|s| !s.trim().is_empty())
.cloned()
})
}
#[derive(Clone)]
pub struct CodingAgentTools {
subagents: SubagentsConfig,
cli_tools: CliToolsConfig,
pager: Arc<pagination::PaginationCache>,
just_registry: Arc<just::JustRegistry>,
just_pager: Arc<just::pager::PaginationCache>,
}
impl Default for CodingAgentTools {
fn default() -> Self {
Self::new()
}
}
impl CodingAgentTools {
pub fn new() -> Self {
Self::with_config(SubagentsConfig::default(), CliToolsConfig::default())
}
pub fn with_config(subagents: SubagentsConfig, cli_tools: CliToolsConfig) -> Self {
Self {
subagents,
cli_tools,
pager: Arc::new(pagination::PaginationCache::new()),
just_registry: Arc::new(just::JustRegistry::new()),
just_pager: Arc::new(just::pager::PaginationCache::new()),
}
}
}
impl CodingAgentTools {
#[expect(
clippy::unused_async,
reason = "Must remain async for Tool trait BoxFuture dispatch pattern. \
Called via Box::pin(async move { tools.ls(...).await }) in tools.rs."
)]
pub async fn ls(
&self,
path: Option<String>,
depth: Option<Depth>,
show: Option<Show>,
ignore: Option<Vec<String>>,
hidden: Option<bool>,
) -> Result<LsOutput, ToolError> {
use std::path::Path;
let log_ctx = logging::ToolLogCtx::start("cli_ls");
let req_json = serde_json::json!({
"path": &path,
"depth": depth.map(types::Depth::as_u8),
"show": show.map(|s| format!("{s:?}").to_lowercase()),
"ignore": &ignore,
"hidden": hidden,
});
let path_str = path.unwrap_or_else(|| ".".into());
let abs_root = match paths::to_abs_string(&path_str) {
Ok(s) => s,
Err(msg) => {
log_ctx.finish(req_json, None, false, Some(msg.clone()), None, None, None);
return Err(ToolError::InvalidInput(msg));
}
};
let root_path = Path::new(&abs_root);
if !root_path.exists() {
let error_msg = format!("Path does not exist: {abs_root}");
log_ctx.finish(
req_json,
None,
false,
Some(error_msg.clone()),
None,
None,
None,
);
return Err(ToolError::InvalidInput(error_msg));
}
if root_path.is_file() {
let output = LsOutput {
root: abs_root,
entries: vec![],
has_more: false,
warnings: vec![
"Path is a file, not a directory. Use the 'read' tool to view file contents."
.into(),
],
};
let summary = serde_json::json!({
"entries": 0,
"has_more": false,
"is_file": true,
});
log_ctx.finish(req_json, None, true, None, Some(summary), None, None);
return Ok(output);
}
let depth_val = depth.map_or(1, types::Depth::as_u8);
let show_val = show.unwrap_or_default();
let include_hidden = hidden.unwrap_or(false);
let mut combined_ignores = ignore.unwrap_or_default();
combined_ignores.extend(self.cli_tools.extra_ignore_patterns.iter().cloned());
let cfg = walker::WalkConfig {
root: root_path,
depth: depth_val,
show: show_val,
user_ignores: &combined_ignores,
include_hidden,
};
self.pager.sweep_expired();
let page_size = pagination::page_size_for(show_val, depth_val);
let query_key = pagination::make_key(
&abs_root,
depth_val,
show_val,
include_hidden,
&combined_ignores,
);
let qlock = self.pager.get_or_create(&query_key);
let (entries, has_more, warnings, shown, total) = {
let mut st = qlock.lock_state();
if st.is_empty() || st.is_expired() {
match walker::list(&cfg) {
Ok(result) => st.reset(result.entries, result.warnings, page_size),
Err(e) => {
drop(st);
log_ctx.finish(
req_json,
None,
false,
Some(e.to_string()),
None,
None,
None,
);
return Err(e);
}
}
}
let offset = st.next_offset;
let (page, has_more) = pagination::paginate_slice(&st.results, offset, st.page_size);
st.next_offset = st.next_offset.saturating_add(st.page_size);
let shown = (offset + page.len()).min(st.results.len());
let total = st.results.len();
(page, has_more, st.meta.clone(), shown, total)
};
let mut all_warnings = warnings;
if has_more {
let encoded = types::encode_truncation_info(shown, total, page_size);
all_warnings.insert(0, encoded);
}
if !has_more {
self.pager.remove_if_same(&query_key, &qlock);
}
let output = LsOutput {
root: abs_root,
entries,
has_more,
warnings: all_warnings,
};
let summary = serde_json::json!({
"entries": output.entries.len(),
"has_more": output.has_more,
"shown": shown,
"total": total,
});
log_ctx.finish(req_json, None, true, None, Some(summary), None, None);
Ok(output)
}
pub async fn ask_agent(
&self,
agent_type: Option<types::AgentType>,
location: Option<types::AgentLocation>,
query: String,
) -> Result<AgentOutput, ToolError> {
use claudecode::client::Client;
use claudecode::config::SessionConfig;
use claudecode::mcp::validate::ValidateOptions;
use claudecode::mcp::validate::ensure_valid_mcp_config;
use claudecode::types::OutputFormat;
use claudecode::types::PermissionMode;
let log_ctx = logging::ToolLogCtx::start("ask_agent");
let agent_type = agent_type.unwrap_or_default();
let location = location.unwrap_or_default();
let req_json = serde_json::json!({
"agent_type": format!("{agent_type:?}").to_lowercase(),
"location": format!("{location:?}").to_lowercase(),
"query": &query,
});
if query.trim().is_empty() {
log_ctx.finish(
req_json,
None,
false,
Some("Query cannot be empty".into()),
None,
None,
None,
);
return Err(ToolError::InvalidInput("Query cannot be empty".into()));
}
let model = agent::model_for(agent_type, &self.subagents);
let system_prompt = agent::compose_prompt(agent_type, location);
let enabled_tools = agent::enabled_tools_for(agent_type, location);
let (builtin_tools, _mcp_tools): (Vec<String>, Vec<String>) = enabled_tools
.iter()
.cloned()
.partition(|t| !t.starts_with("mcp__"));
let mcp_config = agent::build_mcp_config(location, &enabled_tools);
let opts = ValidateOptions::default();
if let Err(e) = ensure_valid_mcp_config(&mcp_config, &opts).await {
use std::fmt::Write;
let mut details = String::new();
for (name, err) in &e.errors {
let _ = writeln!(details, " {name}: {err}");
}
let error_msg =
format!("ask_agent unavailable: MCP config validation failed.\n{details}");
log_ctx.finish(
req_json,
None,
false,
Some(error_msg.clone()),
None,
Some(model.to_string()),
None,
);
return Err(ToolError::Internal(error_msg));
}
let builder = SessionConfig::builder(query)
.model(model)
.output_format(OutputFormat::Text)
.permission_mode(PermissionMode::DontAsk)
.system_prompt(system_prompt)
.tools(builtin_tools) .allowed_tools(enabled_tools.clone()) .mcp_config(mcp_config)
.strict_mcp_config(true);
let config = match builder.build() {
Ok(c) => c,
Err(e) => {
let error_msg = format!("Failed to build session config: {e}");
log_ctx.finish(
req_json,
None,
false,
Some(error_msg.clone()),
None,
Some(model.to_string()),
None,
);
return Err(ToolError::Internal(error_msg));
}
};
let client = match Client::new().await {
Ok(c) => c,
Err(e) => {
let error_msg = format!(
"Claude CLI not found or not runnable: {e}. Ensure 'claude' is installed and available in PATH."
);
log_ctx.finish(
req_json,
None,
false,
Some(error_msg.clone()),
None,
Some(model.to_string()),
None,
);
return Err(ToolError::Internal(error_msg));
}
};
let result = match client.launch_and_wait(config).await {
Ok(r) => r,
Err(e) => {
let error_msg = format!("Failed to run Claude session: {e}");
log_ctx.finish(
req_json,
None,
false,
Some(error_msg.clone()),
None,
Some(model.to_string()),
None,
);
return Err(ToolError::Internal(error_msg));
}
};
if result.is_error {
let error_msg = result
.error
.unwrap_or_else(|| "Claude session returned an error".into());
log_ctx.finish(
req_json,
None,
false,
Some(error_msg.clone()),
None,
Some(model.to_string()),
None,
);
return Err(ToolError::Internal(error_msg));
}
if let Some(text) = pick_non_empty_text(&result) {
let (response_file, completed_at) = log_ctx
.write_markdown_response(&text)
.map_or((None, None), |(f, ts)| (Some(f), Some(ts)));
log_ctx.finish(
req_json,
response_file,
true,
None,
None,
Some(model.to_string()),
completed_at,
);
return Ok(AgentOutput::new(text));
}
let error_msg = "Claude session produced no text output (empty or whitespace-only)";
log_ctx.finish(
req_json,
None,
false,
Some(error_msg.into()),
None,
Some(model.to_string()),
None,
);
Err(ToolError::Internal(error_msg.to_string()))
}
#[expect(
clippy::unused_async,
reason = "Must remain async for Tool trait BoxFuture dispatch pattern."
)]
pub async fn search_grep(
&self,
pattern: String,
path: Option<String>,
mode: Option<OutputMode>,
globs: Option<Vec<String>>,
ignore: Option<Vec<String>>,
include_hidden: Option<bool>,
case_insensitive: Option<bool>,
multiline: Option<bool>,
line_numbers: Option<bool>,
context: Option<u32>,
context_before: Option<u32>,
context_after: Option<u32>,
include_binary: Option<bool>,
head_limit: Option<usize>,
offset: Option<usize>,
) -> Result<GrepOutput, ToolError> {
let log_ctx = logging::ToolLogCtx::start("cli_grep");
let req_json = serde_json::json!({
"pattern": &pattern,
"path": &path,
"mode": mode.map(|m| format!("{m:?}").to_lowercase()),
"globs": &globs,
"ignore": &ignore,
"include_hidden": include_hidden,
"case_insensitive": case_insensitive,
"multiline": multiline,
"line_numbers": line_numbers,
"context": context,
"context_before": context_before,
"context_after": context_after,
"include_binary": include_binary,
"head_limit": head_limit,
"offset": offset,
});
let path_str = path.unwrap_or_else(|| ".".into());
let abs_root = match paths::to_abs_string(&path_str) {
Ok(s) => s,
Err(msg) => {
log_ctx.finish(req_json, None, false, Some(msg.clone()), None, None, None);
return Err(ToolError::InvalidInput(msg));
}
};
let mut combined_ignores = ignore.unwrap_or_default();
combined_ignores.extend(self.cli_tools.extra_ignore_patterns.iter().cloned());
let cfg = grep::GrepConfig {
root: abs_root,
pattern,
mode: mode.unwrap_or_default(),
include_globs: globs.unwrap_or_default(),
ignore_globs: combined_ignores,
include_hidden: include_hidden.unwrap_or(false),
case_insensitive: case_insensitive.unwrap_or(false),
multiline: multiline.unwrap_or(false),
line_numbers: line_numbers.unwrap_or(true),
context,
context_before,
context_after,
include_binary: include_binary.unwrap_or(false),
head_limit: head_limit.unwrap_or(self.cli_tools.grep_default_limit as usize),
offset: offset.unwrap_or(0),
};
match grep::run(cfg) {
Ok(output) => {
let summary = serde_json::json!({
"lines": output.lines.len(),
"mode": format!("{:?}", output.mode).to_lowercase(),
"has_more": output.has_more,
});
log_ctx.finish(req_json, None, true, None, Some(summary), None, None);
Ok(output)
}
Err(e) => {
log_ctx.finish(req_json, None, false, Some(e.to_string()), None, None, None);
Err(e)
}
}
}
#[expect(
clippy::unused_async,
reason = "Must remain async for Tool trait BoxFuture dispatch pattern."
)]
pub async fn search_glob(
&self,
pattern: String,
path: Option<String>,
ignore: Option<Vec<String>>,
include_hidden: Option<bool>,
sort: Option<SortOrder>,
head_limit: Option<usize>,
offset: Option<usize>,
) -> Result<GlobOutput, ToolError> {
let log_ctx = logging::ToolLogCtx::start("cli_glob");
let req_json = serde_json::json!({
"pattern": &pattern,
"path": &path,
"ignore": &ignore,
"include_hidden": include_hidden,
"sort": sort.map(|s| format!("{s:?}").to_lowercase()),
"head_limit": head_limit,
"offset": offset,
});
let path_str = path.unwrap_or_else(|| ".".into());
let abs_root = match paths::to_abs_string(&path_str) {
Ok(s) => s,
Err(msg) => {
log_ctx.finish(req_json, None, false, Some(msg.clone()), None, None, None);
return Err(ToolError::InvalidInput(msg));
}
};
let mut combined_ignores = ignore.unwrap_or_default();
combined_ignores.extend(self.cli_tools.extra_ignore_patterns.iter().cloned());
let cfg = glob::GlobConfig {
root: abs_root,
pattern,
ignore_globs: combined_ignores,
include_hidden: include_hidden.unwrap_or(false),
sort: sort.unwrap_or_default(),
head_limit: head_limit.unwrap_or(self.cli_tools.glob_default_limit as usize),
offset: offset.unwrap_or(0),
};
match glob::run(cfg) {
Ok(output) => {
let summary = serde_json::json!({
"entries": output.entries.len(),
"has_more": output.has_more,
});
log_ctx.finish(req_json, None, true, None, Some(summary), None, None);
Ok(output)
}
Err(e) => {
log_ctx.finish(req_json, None, false, Some(e.to_string()), None, None, None);
Err(e)
}
}
}
pub async fn just_search(
&self,
query: Option<String>,
dir: Option<String>,
) -> Result<just::SearchOutput, ToolError> {
let log_ctx = logging::ToolLogCtx::start("cli_just_search");
let req_json = serde_json::json!({
"query": &query,
"dir": &dir,
});
if let Err(e) = just::ensure_just_available().await {
let error_msg = e;
log_ctx.finish(
req_json,
None,
false,
Some(error_msg.clone()),
None,
None,
None,
);
return Err(ToolError::Internal(error_msg));
}
let repo_root = match paths::to_abs_string(".") {
Ok(r) => r,
Err(e) => {
log_ctx.finish(req_json, None, false, Some(e.clone()), None, None, None);
return Err(ToolError::Internal(e));
}
};
let q = query.unwrap_or_default();
let dir_filter = match dir.as_ref().map(|d| paths::to_abs_string(d)).transpose() {
Ok(f) => f,
Err(e) => {
log_ctx.finish(req_json, None, false, Some(e.clone()), None, None, None);
return Err(ToolError::Internal(e));
}
};
self.just_pager.sweep_expired();
let key = just::pager::make_key(dir_filter.as_deref().unwrap_or(&repo_root), &q);
let qlock = self.just_pager.get_or_create(&key);
let needs_refresh = {
let st = qlock.lock_state();
st.is_empty() || st.is_expired()
};
if needs_refresh {
let all = match self.just_registry.get_all_recipes(&repo_root).await {
Ok(r) => r,
Err(e) => {
let error_msg = e;
log_ctx.finish(
req_json,
None,
false,
Some(error_msg.clone()),
None,
None,
None,
);
return Err(ToolError::Internal(error_msg));
}
};
let filtered: Vec<_> = all
.into_iter()
.filter(|(recipe_dir, r)| {
let dir_ok = dir_filter
.as_ref()
.is_none_or(|f| recipe_dir.starts_with(f));
let visible = !r.is_private && !r.is_mcp_hidden;
let q_ok = q.is_empty()
|| r.name
.to_ascii_lowercase()
.contains(&q.to_ascii_lowercase())
|| r.doc.as_ref().is_some_and(|d| {
d.to_ascii_lowercase().contains(&q.to_ascii_lowercase())
});
dir_ok && visible && q_ok
})
.map(|(d, r)| {
let params = r
.params
.iter()
.map(|p| {
if p.kind == just::parser::ParamKind::Star {
format!("{}*", p.name)
} else if p.has_default {
format!("{}?", p.name)
} else {
p.name.clone()
}
})
.collect();
just::SearchItem {
recipe: r.name,
dir: d,
doc: r.doc,
params,
}
})
.collect();
let mut st = qlock.lock_state();
st.reset(filtered);
}
let (items, has_more) = {
let mut st = qlock.lock_state();
let offset = st.next_offset;
let end = (offset + just::pager::PAGE_SIZE).min(st.results.len());
let page = st.results[offset..end].to_vec();
st.next_offset = end;
let has_more = end < st.results.len();
(page, has_more)
};
if !has_more {
self.just_pager.remove_if_same(&key, &qlock);
}
let output = just::SearchOutput { items, has_more };
let summary = serde_json::json!({
"items": output.items.len(),
"has_more": output.has_more,
});
log_ctx.finish(req_json, None, true, None, Some(summary), None, None);
Ok(output)
}
pub async fn just_execute(
&self,
recipe: String,
dir: Option<String>,
args: Option<std::collections::HashMap<String, serde_json::Value>>,
) -> Result<just::ExecuteOutput, ToolError> {
let log_ctx = logging::ToolLogCtx::start("cli_just_execute");
let req_json = serde_json::json!({
"recipe": &recipe,
"dir": &dir,
"args": &args,
});
if let Err(e) = just::ensure_just_available().await {
let error_msg = e;
log_ctx.finish(
req_json,
None,
false,
Some(error_msg.clone()),
None,
None,
None,
);
return Err(ToolError::Internal(error_msg));
}
let repo_root = match paths::to_abs_string(".") {
Ok(r) => r,
Err(e) => {
log_ctx.finish(req_json, None, false, Some(e.clone()), None, None, None);
return Err(ToolError::Internal(e));
}
};
match just::exec::execute_recipe(&self.just_registry, &recipe, dir, args, &repo_root).await
{
Ok(output) => {
let summary = serde_json::json!({
"exit_code": output.exit_code,
"stdout_lines": output.stdout.lines().count(),
"stderr_lines": output.stderr.lines().count(),
});
log_ctx.finish(req_json, None, true, None, Some(summary), None, None);
Ok(output)
}
Err(e) => {
let error_msg = e;
log_ctx.finish(
req_json,
None,
false,
Some(error_msg.clone()),
None,
None,
None,
);
Err(ToolError::Internal(error_msg))
}
}
}
}
#[cfg(test)]
mod ask_agent_filter_tests {
use super::*;
use claudecode::types::Result as ClaudeResult;
#[test]
fn prefers_content_when_result_is_empty_string() {
let r = ClaudeResult {
result: Some(String::new()),
content: Some("ok".into()),
..Default::default()
};
assert_eq!(pick_non_empty_text(&r).as_deref(), Some("ok"));
}
#[test]
fn returns_none_when_both_empty_or_whitespace() {
let r1 = ClaudeResult {
result: None,
content: Some(String::new()),
..Default::default()
};
assert_eq!(pick_non_empty_text(&r1), None);
let r2 = ClaudeResult {
result: None,
content: Some(" ".into()),
..Default::default()
};
assert_eq!(pick_non_empty_text(&r2), None);
let r3 = ClaudeResult {
result: Some(" ".into()),
content: None,
..Default::default()
};
assert_eq!(pick_non_empty_text(&r3), None);
}
#[test]
fn returns_text_when_present_in_content() {
let r = ClaudeResult {
result: None,
content: Some("text".into()),
..Default::default()
};
assert_eq!(pick_non_empty_text(&r).as_deref(), Some("text"));
}
#[test]
fn respects_precedence_of_result_over_content() {
let r = ClaudeResult {
result: Some(" result text ".into()),
content: Some("other".into()),
..Default::default()
};
assert_eq!(pick_non_empty_text(&r).as_deref(), Some(" result text "));
}
}