This commit implements the complete M3 milestone (Edit & Write tools) including: Write tool: - Creates new files with parent directory creation - Overwrites existing files safely - Simple and straightforward implementation Edit tool: - Exact string replacement with uniqueness enforcement - Detects ambiguous matches (multiple occurrences) and fails safely - Detects no-match scenarios and fails with clear error - Automatic backup before modification - Rollback on write failure (restores from backup) - Supports multiline string replacements CLI integration: - Added `write` subcommand: `owlen write <path> <content>` - Added `edit` subcommand: `owlen edit <path> <old_string> <new_string>` - Permission checks for both Write and Edit tools - Clear error messages for permission denials Permission enforcement: - Plan mode (default): blocks Write and Edit (asks for approval) - AcceptEdits mode: allows Write and Edit - Code mode: allows all operations Testing: - 6 new tests in tools-fs for Write/Edit functionality - 5 new tests in CLI for permission enforcement with Edit/Write - Tests verify plan mode blocks, acceptEdits allows, code mode allows all - All 32 workspace tests passing Dependencies: - Added `similar` crate for future diff/patch enhancements M3 milestone complete! ✅ 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
195 lines
6.8 KiB
Rust
195 lines
6.8 KiB
Rust
use clap::Parser;
|
|
use color_eyre::eyre::{Result, eyre};
|
|
use config_agent::load_settings;
|
|
use futures_util::TryStreamExt;
|
|
use llm_ollama::{OllamaClient, OllamaOptions, types::ChatMessage};
|
|
use permissions::{PermissionDecision, Tool};
|
|
use std::io::{self, Write};
|
|
|
|
#[derive(clap::Subcommand, Debug)]
|
|
enum Cmd {
|
|
Read { path: String },
|
|
Glob { pattern: String },
|
|
Grep { root: String, pattern: String },
|
|
Write { path: String, content: String },
|
|
Edit { path: String, old_string: String, new_string: String },
|
|
}
|
|
|
|
#[derive(Parser, Debug)]
|
|
#[command(name = "code", version)]
|
|
struct Args {
|
|
#[arg(long)]
|
|
ollama_url: Option<String>,
|
|
#[arg(long)]
|
|
model: Option<String>,
|
|
#[arg(long)]
|
|
api_key: Option<String>,
|
|
#[arg(long)]
|
|
print: bool,
|
|
/// Override the permission mode (plan, acceptEdits, code)
|
|
#[arg(long)]
|
|
mode: Option<String>,
|
|
#[arg()]
|
|
prompt: Vec<String>,
|
|
#[command(subcommand)]
|
|
cmd: Option<Cmd>,
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() -> Result<()> {
|
|
color_eyre::install()?;
|
|
let args = Args::parse();
|
|
let mut settings = load_settings(None).unwrap_or_default();
|
|
|
|
// Override mode if specified via CLI
|
|
if let Some(mode) = args.mode {
|
|
settings.mode = mode;
|
|
}
|
|
|
|
// Create permission manager from settings
|
|
let perms = settings.create_permission_manager();
|
|
|
|
if let Some(cmd) = args.cmd {
|
|
match cmd {
|
|
Cmd::Read { path } => {
|
|
// Check permission
|
|
match perms.check(Tool::Read, None) {
|
|
PermissionDecision::Allow => {
|
|
let s = tools_fs::read_file(&path)?;
|
|
println!("{}", s);
|
|
return Ok(());
|
|
}
|
|
PermissionDecision::Ask => {
|
|
return Err(eyre!(
|
|
"Permission denied: Read operation requires approval. Use --mode code to allow."
|
|
));
|
|
}
|
|
PermissionDecision::Deny => {
|
|
return Err(eyre!("Permission denied: Read operation is blocked."));
|
|
}
|
|
}
|
|
}
|
|
Cmd::Glob { pattern } => {
|
|
// Check permission
|
|
match perms.check(Tool::Glob, None) {
|
|
PermissionDecision::Allow => {
|
|
for p in tools_fs::glob_list(&pattern)? {
|
|
println!("{}", p);
|
|
}
|
|
return Ok(());
|
|
}
|
|
PermissionDecision::Ask => {
|
|
return Err(eyre!(
|
|
"Permission denied: Glob operation requires approval. Use --mode code to allow."
|
|
));
|
|
}
|
|
PermissionDecision::Deny => {
|
|
return Err(eyre!("Permission denied: Glob operation is blocked."));
|
|
}
|
|
}
|
|
}
|
|
Cmd::Grep { root, pattern } => {
|
|
// Check permission
|
|
match perms.check(Tool::Grep, None) {
|
|
PermissionDecision::Allow => {
|
|
for (path, line_number, text) in tools_fs::grep(&root, &pattern)? {
|
|
println!("{path}:{line_number}:{text}")
|
|
}
|
|
return Ok(());
|
|
}
|
|
PermissionDecision::Ask => {
|
|
return Err(eyre!(
|
|
"Permission denied: Grep operation requires approval. Use --mode code to allow."
|
|
));
|
|
}
|
|
PermissionDecision::Deny => {
|
|
return Err(eyre!("Permission denied: Grep operation is blocked."));
|
|
}
|
|
}
|
|
}
|
|
Cmd::Write { path, content } => {
|
|
// Check permission
|
|
match perms.check(Tool::Write, None) {
|
|
PermissionDecision::Allow => {
|
|
tools_fs::write_file(&path, &content)?;
|
|
println!("File written: {}", path);
|
|
return Ok(());
|
|
}
|
|
PermissionDecision::Ask => {
|
|
return Err(eyre!(
|
|
"Permission denied: Write operation requires approval. Use --mode acceptEdits or --mode code to allow."
|
|
));
|
|
}
|
|
PermissionDecision::Deny => {
|
|
return Err(eyre!("Permission denied: Write operation is blocked."));
|
|
}
|
|
}
|
|
}
|
|
Cmd::Edit { path, old_string, new_string } => {
|
|
// Check permission
|
|
match perms.check(Tool::Edit, None) {
|
|
PermissionDecision::Allow => {
|
|
tools_fs::edit_file(&path, &old_string, &new_string)?;
|
|
println!("File edited: {}", path);
|
|
return Ok(());
|
|
}
|
|
PermissionDecision::Ask => {
|
|
return Err(eyre!(
|
|
"Permission denied: Edit operation requires approval. Use --mode acceptEdits or --mode code to allow."
|
|
));
|
|
}
|
|
PermissionDecision::Deny => {
|
|
return Err(eyre!("Permission denied: Edit operation is blocked."));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let prompt = if args.prompt.is_empty() {
|
|
"Say hello".to_string()
|
|
} else {
|
|
args.prompt.join(" ")
|
|
};
|
|
|
|
let model = args.model.unwrap_or(settings.model);
|
|
let api_key = args.api_key.or(settings.api_key);
|
|
|
|
// Use Ollama Cloud when model has "-cloud" suffix AND API key is set
|
|
let use_cloud = model.ends_with("-cloud") && api_key.is_some();
|
|
let client = if use_cloud {
|
|
OllamaClient::with_cloud().with_api_key(api_key.unwrap())
|
|
} else {
|
|
let base_url = args.ollama_url.unwrap_or(settings.ollama_url);
|
|
let mut client = OllamaClient::new(base_url);
|
|
if let Some(key) = api_key {
|
|
client = client.with_api_key(key);
|
|
}
|
|
client
|
|
};
|
|
let opts = OllamaOptions {
|
|
model,
|
|
stream: true,
|
|
};
|
|
|
|
let msgs = vec![ChatMessage {
|
|
role: "user".into(),
|
|
content: prompt,
|
|
}];
|
|
|
|
let mut stream = client.chat_stream(&msgs, &opts).await?;
|
|
while let Some(chunk) = stream.try_next().await? {
|
|
if let Some(m) = chunk.message {
|
|
if let Some(c) = m.content {
|
|
print!("{c}");
|
|
io::stdout().flush()?;
|
|
}
|
|
}
|
|
if matches!(chunk.done, Some(true)) {
|
|
break;
|
|
}
|
|
}
|
|
println!(); // Newline after response
|
|
Ok(())
|
|
}
|