Milestone M6 implementation adds a comprehensive hook system that allows
users to run custom scripts at various lifecycle events.
New crate: crates/platform/hooks
- HookEvent enum with multiple event types:
* PreToolUse: fires before tool execution, can deny operations (exit code 2)
* PostToolUse: fires after tool execution
* SessionStart: fires at session start, can persist env vars
* SessionEnd, UserPromptSubmit, PreCompact (defined for future use)
- HookManager for executing hooks with timeout support
- JSON I/O: hooks receive event data via stdin, can output to stdout
- Hooks located in .owlen/hooks/{EventName}
CLI integration:
- All tool commands (Read, Write, Edit, Glob, Grep, Bash, SlashCommand)
now fire PreToolUse hooks before execution
- Hooks can deny operations by exiting with code 2
- Hooks timeout after 5 seconds by default
Tests added:
- pretooluse_can_deny_call: verifies hooks can block tool execution
- posttooluse_runs_parallel: verifies PostToolUse hooks execute
- sessionstart_persists_env: verifies SessionStart can create env files
- hook_timeout_works: verifies timeout mechanism
- hook_not_found_is_ok: verifies missing hooks don't cause errors
All 63 tests passing.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
362 lines
14 KiB
Rust
362 lines
14 KiB
Rust
use clap::Parser;
|
|
use color_eyre::eyre::{Result, eyre};
|
|
use config_agent::load_settings;
|
|
use futures_util::TryStreamExt;
|
|
use hooks::{HookEvent, HookManager, HookResult};
|
|
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 },
|
|
Bash { command: String, #[arg(long)] timeout: Option<u64> },
|
|
Slash { command_name: String, args: Vec<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();
|
|
|
|
// Create hook manager
|
|
let hook_mgr = HookManager::new(".");
|
|
|
|
if let Some(cmd) = args.cmd {
|
|
match cmd {
|
|
Cmd::Read { path } => {
|
|
// Check permission
|
|
match perms.check(Tool::Read, None) {
|
|
PermissionDecision::Allow => {
|
|
// Check PreToolUse hook
|
|
let event = HookEvent::PreToolUse {
|
|
tool: "Read".to_string(),
|
|
args: serde_json::json!({"path": &path}),
|
|
};
|
|
match hook_mgr.execute(&event, Some(5000)).await? {
|
|
HookResult::Deny => {
|
|
return Err(eyre!("Hook denied Read operation"));
|
|
}
|
|
HookResult::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 => {
|
|
// Check PreToolUse hook
|
|
let event = HookEvent::PreToolUse {
|
|
tool: "Glob".to_string(),
|
|
args: serde_json::json!({"pattern": &pattern}),
|
|
};
|
|
match hook_mgr.execute(&event, Some(5000)).await? {
|
|
HookResult::Deny => {
|
|
return Err(eyre!("Hook denied Glob operation"));
|
|
}
|
|
HookResult::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 => {
|
|
// Check PreToolUse hook
|
|
let event = HookEvent::PreToolUse {
|
|
tool: "Grep".to_string(),
|
|
args: serde_json::json!({"root": &root, "pattern": &pattern}),
|
|
};
|
|
match hook_mgr.execute(&event, Some(5000)).await? {
|
|
HookResult::Deny => {
|
|
return Err(eyre!("Hook denied Grep operation"));
|
|
}
|
|
HookResult::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 => {
|
|
// Check PreToolUse hook
|
|
let event = HookEvent::PreToolUse {
|
|
tool: "Write".to_string(),
|
|
args: serde_json::json!({"path": &path, "content": &content}),
|
|
};
|
|
match hook_mgr.execute(&event, Some(5000)).await? {
|
|
HookResult::Deny => {
|
|
return Err(eyre!("Hook denied Write operation"));
|
|
}
|
|
HookResult::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 => {
|
|
// Check PreToolUse hook
|
|
let event = HookEvent::PreToolUse {
|
|
tool: "Edit".to_string(),
|
|
args: serde_json::json!({"path": &path, "old_string": &old_string, "new_string": &new_string}),
|
|
};
|
|
match hook_mgr.execute(&event, Some(5000)).await? {
|
|
HookResult::Deny => {
|
|
return Err(eyre!("Hook denied Edit operation"));
|
|
}
|
|
HookResult::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."));
|
|
}
|
|
}
|
|
}
|
|
Cmd::Bash { command, timeout } => {
|
|
// Check permission with command context for pattern matching
|
|
match perms.check(Tool::Bash, Some(&command)) {
|
|
PermissionDecision::Allow => {
|
|
// Check PreToolUse hook
|
|
let event = HookEvent::PreToolUse {
|
|
tool: "Bash".to_string(),
|
|
args: serde_json::json!({"command": &command, "timeout": timeout}),
|
|
};
|
|
match hook_mgr.execute(&event, Some(5000)).await? {
|
|
HookResult::Deny => {
|
|
return Err(eyre!("Hook denied Bash operation"));
|
|
}
|
|
HookResult::Allow => {}
|
|
}
|
|
|
|
let mut session = tools_bash::BashSession::new().await?;
|
|
let output = session.execute(&command, timeout).await?;
|
|
|
|
// Print stdout
|
|
if !output.stdout.is_empty() {
|
|
print!("{}", output.stdout);
|
|
}
|
|
|
|
// Print stderr to stderr
|
|
if !output.stderr.is_empty() {
|
|
eprint!("{}", output.stderr);
|
|
}
|
|
|
|
session.close().await?;
|
|
|
|
// Exit with same code as command
|
|
if !output.success {
|
|
std::process::exit(output.exit_code);
|
|
}
|
|
|
|
return Ok(());
|
|
}
|
|
PermissionDecision::Ask => {
|
|
return Err(eyre!(
|
|
"Permission denied: Bash operation requires approval. Use --mode code to allow."
|
|
));
|
|
}
|
|
PermissionDecision::Deny => {
|
|
return Err(eyre!("Permission denied: Bash operation is blocked."));
|
|
}
|
|
}
|
|
}
|
|
Cmd::Slash { command_name, args } => {
|
|
// Check permission
|
|
match perms.check(Tool::SlashCommand, None) {
|
|
PermissionDecision::Allow => {
|
|
// Check PreToolUse hook
|
|
let event = HookEvent::PreToolUse {
|
|
tool: "SlashCommand".to_string(),
|
|
args: serde_json::json!({"command_name": &command_name, "args": &args}),
|
|
};
|
|
match hook_mgr.execute(&event, Some(5000)).await? {
|
|
HookResult::Deny => {
|
|
return Err(eyre!("Hook denied SlashCommand operation"));
|
|
}
|
|
HookResult::Allow => {}
|
|
}
|
|
|
|
// Look for command file in .owlen/commands/
|
|
let command_path = format!(".owlen/commands/{}.md", command_name);
|
|
|
|
// Read the command file
|
|
let content = match tools_fs::read_file(&command_path) {
|
|
Ok(c) => c,
|
|
Err(_) => {
|
|
return Err(eyre!(
|
|
"Slash command '{}' not found at {}",
|
|
command_name,
|
|
command_path
|
|
));
|
|
}
|
|
};
|
|
|
|
// Parse with arguments
|
|
let args_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
|
|
let slash_cmd = tools_slash::parse_slash_command(&content, &args_refs)?;
|
|
|
|
// Resolve file references
|
|
let resolved_body = slash_cmd.resolve_file_refs()?;
|
|
|
|
// Print the resolved command body
|
|
println!("{}", resolved_body);
|
|
|
|
return Ok(());
|
|
}
|
|
PermissionDecision::Ask => {
|
|
return Err(eyre!(
|
|
"Permission denied: Slash command requires approval. Use --mode code to allow."
|
|
));
|
|
}
|
|
PermissionDecision::Deny => {
|
|
return Err(eyre!("Permission denied: Slash command 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(())
|
|
}
|