feat(tools): add filesystem tools crate with glob pattern support
- Add new tools-fs crate with read, glob, and grep utilities - Fix glob command to support actual glob patterns (**, *) instead of just directory walking - Rename binary from "code" to "owlen" to match package name - Fix test to reference correct binary name "owlen" - Add API key support to OllamaClient for authentication 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@ members = [
|
|||||||
"crates/cli",
|
"crates/cli",
|
||||||
"crates/llm/ollama",
|
"crates/llm/ollama",
|
||||||
"crates/config"
|
"crates/config"
|
||||||
]
|
, "crates/tools/fs"]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "code"
|
name = "owlen"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
@@ -12,6 +12,7 @@ serde = { version = "1", features = ["derive"] }
|
|||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
color-eyre = "0.6"
|
color-eyre = "0.6"
|
||||||
llm-ollama = { path = "../llm/ollama" }
|
llm-ollama = { path = "../llm/ollama" }
|
||||||
|
tools-fs = {path = "../tools/fs"}
|
||||||
config-agent = { package = "config-agent", path = "../config" }
|
config-agent = { package = "config-agent", path = "../config" }
|
||||||
futures-util = "0.3.31"
|
futures-util = "0.3.31"
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use std::io::{self, Write};
|
|||||||
#[derive(clap::Subcommand, Debug)]
|
#[derive(clap::Subcommand, Debug)]
|
||||||
enum Cmd {
|
enum Cmd {
|
||||||
Read {path: String},
|
Read {path: String},
|
||||||
Glob {root: String},
|
Glob {pattern: String},
|
||||||
Grep {root: String, pattern: String},
|
Grep {root: String, pattern: String},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,8 +42,8 @@ async fn main() -> Result<()> {
|
|||||||
println!("{}", s);
|
println!("{}", s);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
Cmd::Glob { root } => {
|
Cmd::Glob { pattern } => {
|
||||||
for p in tools_fs::glob_list(&root)? {
|
for p in tools_fs::glob_list(&pattern)? {
|
||||||
println!("{}", p);
|
println!("{}", p);
|
||||||
}
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ async fn headless_streams_ndjson() {
|
|||||||
.body(response);
|
.body(response);
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("code"));
|
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen"));
|
||||||
cmd.arg("--ollama-url").arg(server.base_url())
|
cmd.arg("--ollama-url").arg(server.base_url())
|
||||||
.arg("--model").arg("qwen2.5")
|
.arg("--model").arg("qwen2.5")
|
||||||
.arg("--print")
|
.arg("--print")
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use thiserror::Error;
|
|||||||
pub struct OllamaClient {
|
pub struct OllamaClient {
|
||||||
http: Client,
|
http: Client,
|
||||||
base_url: String, // e.g. "http://localhost:11434"
|
base_url: String, // e.g. "http://localhost:11434"
|
||||||
|
api_key: Option<String>, // For Ollama Cloud authentication
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
@@ -31,9 +32,15 @@ impl OllamaClient {
|
|||||||
Self {
|
Self {
|
||||||
http: Client::new(),
|
http: Client::new(),
|
||||||
base_url: base_url.into().trim_end_matches('/').to_string(),
|
base_url: base_url.into().trim_end_matches('/').to_string(),
|
||||||
|
api_key: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
|
||||||
|
self.api_key = Some(api_key.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn with_cloud() -> Self {
|
pub fn with_cloud() -> Self {
|
||||||
// Same API, different base
|
// Same API, different base
|
||||||
Self::new("https://ollama.com")
|
Self::new("https://ollama.com")
|
||||||
@@ -52,7 +59,14 @@ impl OllamaClient {
|
|||||||
}
|
}
|
||||||
let url = format!("{}/api/chat", self.base_url);
|
let url = format!("{}/api/chat", self.base_url);
|
||||||
let body = Body {model: &opts.model, messages, stream: true};
|
let body = Body {model: &opts.model, messages, stream: true};
|
||||||
let resp = self.http.post(url).json(&body).send().await?;
|
let mut req = self.http.post(url).json(&body);
|
||||||
|
|
||||||
|
// Add Authorization header if API key is present
|
||||||
|
if let Some(ref key) = self.api_key {
|
||||||
|
req = req.header("Authorization", format!("Bearer {}", key));
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp = req.send().await?;
|
||||||
let bytes_stream = resp.bytes_stream();
|
let bytes_stream = resp.bytes_stream();
|
||||||
|
|
||||||
// NDJSON parser: split by '\n', parse each as JSON and stream the results
|
// NDJSON parser: split by '\n', parse each as JSON and stream the results
|
||||||
|
|||||||
15
crates/tools/fs/Cargo.toml
Normal file
15
crates/tools/fs/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[package]
|
||||||
|
name = "tools-fs"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
ignore = "0.4"
|
||||||
|
walkdir = "2.5"
|
||||||
|
globset = "0.4"
|
||||||
|
grep-regex = "0.1"
|
||||||
|
grep-searcher = "0.1"
|
||||||
|
color-eyre = "0.6"
|
||||||
|
tempfile = "3.23.0"
|
||||||
77
crates/tools/fs/src/lib.rs
Normal file
77
crates/tools/fs/src/lib.rs
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
use color_eyre::eyre::Result;
|
||||||
|
use ignore::WalkBuilder;
|
||||||
|
use grep_regex::RegexMatcher;
|
||||||
|
use grep_searcher::{sinks::UTF8, SearcherBuilder};
|
||||||
|
use globset::Glob;
|
||||||
|
|
||||||
|
pub fn read_file(path: &str) -> Result<String> {
|
||||||
|
Ok(std::fs::read_to_string(path)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn glob_list(pattern: &str) -> Result<Vec<String>> {
|
||||||
|
let glob = Glob::new(pattern)?.compile_matcher();
|
||||||
|
|
||||||
|
// Extract the literal prefix to determine the root directory
|
||||||
|
let root = pattern
|
||||||
|
.split("**")
|
||||||
|
.next()
|
||||||
|
.and_then(|s| {
|
||||||
|
let trimmed = s.trim_end_matches('/');
|
||||||
|
if trimmed.is_empty() { None } else { Some(trimmed) }
|
||||||
|
})
|
||||||
|
.unwrap_or(".");
|
||||||
|
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for result in WalkBuilder::new(root)
|
||||||
|
.standard_filters(true)
|
||||||
|
.git_ignore(true)
|
||||||
|
.git_global(false)
|
||||||
|
.git_exclude(false)
|
||||||
|
.require_git(false)
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
let entity = result?;
|
||||||
|
if entity.file_type().map(|filetype| filetype.is_file()).unwrap_or(false) {
|
||||||
|
if let Some(path) = entity.path().to_str() {
|
||||||
|
// Match against the glob pattern
|
||||||
|
if glob.is_match(path) {
|
||||||
|
out.push(path.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn grep(root: &str, pattern: &str) -> Result<Vec<(String, usize, String)>> {
|
||||||
|
let matcher = RegexMatcher::new_line_matcher(pattern)?;
|
||||||
|
let mut searcher = SearcherBuilder::new().line_number(true).build();
|
||||||
|
|
||||||
|
let mut results = Vec::new();
|
||||||
|
for result in WalkBuilder::new(root)
|
||||||
|
.standard_filters(true)
|
||||||
|
.git_ignore(true)
|
||||||
|
.git_global(false)
|
||||||
|
.git_exclude(false)
|
||||||
|
.require_git(false)
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
let entity = result?;
|
||||||
|
if !entity.file_type().map(|filetype| filetype.is_file()).unwrap_or(false) { continue; }
|
||||||
|
let path = entity.path().to_path_buf();
|
||||||
|
let mut line_hits: Vec<(usize, String)> = Vec::new();
|
||||||
|
let sink = UTF8(|line_number, line| {
|
||||||
|
line_hits.push((line_number as usize, line.to_string()));
|
||||||
|
Ok(true)
|
||||||
|
});
|
||||||
|
let _ = searcher.search_path(&matcher, &path, sink);
|
||||||
|
if !line_hits.is_empty() {
|
||||||
|
let p = path.to_string_lossy().to_string();
|
||||||
|
for (line_number, text) in line_hits {
|
||||||
|
results.push((p.clone(), line_number, text));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
28
crates/tools/fs/tests/fs_tools.rs
Normal file
28
crates/tools/fs/tests/fs_tools.rs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
use tools_fs::{read_file, glob_list, grep};
|
||||||
|
use std::fs;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_and_glob_respect_gitignore() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let root = dir.path();
|
||||||
|
fs::write(root.join("a.txt"), "hello").unwrap();
|
||||||
|
fs::create_dir(root.join("secret")).unwrap();
|
||||||
|
fs::write(root.join("secret/secret.txt"), "token=123").unwrap();
|
||||||
|
fs::write(root.join(".gitignore"), "secret/\n").unwrap();
|
||||||
|
|
||||||
|
let files = glob_list(root.to_str().unwrap()).unwrap();
|
||||||
|
assert!(files.iter().any(|p| p.ends_with("a.txt")));
|
||||||
|
assert!(!files.iter().any(|p| p.contains("secret.txt")));
|
||||||
|
assert_eq!(read_file(root.join("a.txt").to_str().unwrap()).unwrap(), "hello");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grep_finds_lines() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let root = dir.path();
|
||||||
|
fs::write(root.join("a.rs"), "fn main() { println!(\"hello\"); }").unwrap();
|
||||||
|
|
||||||
|
let hits = grep(root.to_str().unwrap(), "hello").unwrap();
|
||||||
|
assert!(hits.iter().any(|(_p, _ln, text)| text.contains("hello")));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user