feat(v2): complete multi-LLM providers, TUI redesign, and advanced agent features
Multi-LLM Provider Support: - Add llm-core crate with LlmProvider trait abstraction - Implement Anthropic Claude API client with streaming - Implement OpenAI API client with streaming - Add token counting with SimpleTokenCounter and ClaudeTokenCounter - Add retry logic with exponential backoff and jitter Borderless TUI Redesign: - Rewrite theme system with terminal capability detection (Full/Unicode256/Basic) - Add provider tabs component with keybind switching [1]/[2]/[3] - Implement vim-modal input (Normal/Insert/Visual/Command modes) - Redesign chat panel with timestamps and streaming indicators - Add multi-provider status bar with cost tracking - Add Nerd Font icons with graceful ASCII fallbacks - Add syntax highlighting (syntect) and markdown rendering (pulldown-cmark) Advanced Agent Features: - Add system prompt builder with configurable components - Enhance subagent orchestration with parallel execution - Add git integration module for safe command detection - Add streaming tool results via channels - Expand tool set: AskUserQuestion, TodoWrite, LS, MultiEdit, BashOutput, KillShell - Add WebSearch with provider abstraction Plugin System Enhancement: - Add full agent definition parsing from YAML frontmatter - Add skill system with progressive disclosure - Wire plugin hooks into HookManager 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1.39", features = ["process", "time", "io-util"] }
|
||||
color-eyre = "0.6"
|
||||
regex = "1.10"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.23.0"
|
||||
|
||||
@@ -56,17 +56,38 @@ pub enum HookResult {
|
||||
Deny,
|
||||
}
|
||||
|
||||
/// A registered hook that can be executed
|
||||
#[derive(Debug, Clone)]
|
||||
struct Hook {
|
||||
event: String, // Event name like "PreToolUse", "PostToolUse", etc.
|
||||
command: String,
|
||||
pattern: Option<String>, // Optional regex pattern for matching tool names
|
||||
timeout: Option<u64>,
|
||||
}
|
||||
|
||||
pub struct HookManager {
|
||||
project_root: PathBuf,
|
||||
hooks: Vec<Hook>,
|
||||
}
|
||||
|
||||
impl HookManager {
|
||||
pub fn new(project_root: &str) -> Self {
|
||||
Self {
|
||||
project_root: PathBuf::from(project_root),
|
||||
hooks: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a single hook
|
||||
pub fn register_hook(&mut self, event: String, command: String, pattern: Option<String>, timeout: Option<u64>) {
|
||||
self.hooks.push(Hook {
|
||||
event,
|
||||
command,
|
||||
pattern,
|
||||
timeout,
|
||||
});
|
||||
}
|
||||
|
||||
/// Execute a hook for the given event
|
||||
///
|
||||
/// Returns:
|
||||
@@ -74,18 +95,66 @@ impl HookManager {
|
||||
/// - Ok(HookResult::Deny) if hook denies (exit code 2)
|
||||
/// - Err if hook fails (other exit codes) or times out
|
||||
pub async fn execute(&self, event: &HookEvent, timeout_ms: Option<u64>) -> Result<HookResult> {
|
||||
// First check for legacy file-based hooks
|
||||
let hook_path = self.get_hook_path(event);
|
||||
let has_file_hook = hook_path.exists();
|
||||
|
||||
// If hook doesn't exist, allow by default
|
||||
if !hook_path.exists() {
|
||||
// Get registered hooks for this event
|
||||
let event_name = event.hook_name();
|
||||
let mut matching_hooks: Vec<&Hook> = self.hooks.iter()
|
||||
.filter(|h| h.event == event_name)
|
||||
.collect();
|
||||
|
||||
// If we need to filter by pattern (for PreToolUse events)
|
||||
if let HookEvent::PreToolUse { tool, .. } = event {
|
||||
matching_hooks.retain(|h| {
|
||||
if let Some(pattern) = &h.pattern {
|
||||
// Use regex to match tool name
|
||||
if let Ok(re) = regex::Regex::new(pattern) {
|
||||
re.is_match(tool)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
true // No pattern means match all
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// If no hooks at all, allow by default
|
||||
if !has_file_hook && matching_hooks.is_empty() {
|
||||
return Ok(HookResult::Allow);
|
||||
}
|
||||
|
||||
// Execute file-based hook first (if exists)
|
||||
if has_file_hook {
|
||||
let result = self.execute_hook_command(&hook_path.to_string_lossy(), event, timeout_ms).await?;
|
||||
if result == HookResult::Deny {
|
||||
return Ok(HookResult::Deny);
|
||||
}
|
||||
}
|
||||
|
||||
// Execute registered hooks
|
||||
for hook in matching_hooks {
|
||||
let hook_timeout = hook.timeout.or(timeout_ms);
|
||||
let result = self.execute_hook_command(&hook.command, event, hook_timeout).await?;
|
||||
if result == HookResult::Deny {
|
||||
return Ok(HookResult::Deny);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(HookResult::Allow)
|
||||
}
|
||||
|
||||
/// Execute a single hook command
|
||||
async fn execute_hook_command(&self, command: &str, event: &HookEvent, timeout_ms: Option<u64>) -> Result<HookResult> {
|
||||
// Serialize event to JSON
|
||||
let input_json = serde_json::to_string(event)?;
|
||||
|
||||
// Spawn the hook process
|
||||
let mut child = Command::new(&hook_path)
|
||||
let mut child = Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(command)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
|
||||
154
crates/platform/hooks/tests/plugin_hooks.rs
Normal file
154
crates/platform/hooks/tests/plugin_hooks.rs
Normal file
@@ -0,0 +1,154 @@
|
||||
// Integration test for plugin hooks with HookManager
|
||||
use color_eyre::eyre::Result;
|
||||
use hooks::{HookEvent, HookManager, HookResult};
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_register_and_execute_plugin_hooks() -> Result<()> {
|
||||
// Create temporary directory to act as project root
|
||||
let temp_dir = TempDir::new()?;
|
||||
|
||||
// Create hook manager
|
||||
let mut hook_mgr = HookManager::new(temp_dir.path().to_str().unwrap());
|
||||
|
||||
// Register a hook that matches Edit|Write tools
|
||||
hook_mgr.register_hook(
|
||||
"PreToolUse".to_string(),
|
||||
"echo 'Hook executed' && exit 0".to_string(),
|
||||
Some("Edit|Write".to_string()),
|
||||
Some(5000),
|
||||
);
|
||||
|
||||
// Test that the hook executes for Edit tool
|
||||
let event = HookEvent::PreToolUse {
|
||||
tool: "Edit".to_string(),
|
||||
args: serde_json::json!({"path": "/tmp/test.txt"}),
|
||||
};
|
||||
|
||||
let result = hook_mgr.execute(&event, Some(5000)).await?;
|
||||
assert_eq!(result, HookResult::Allow);
|
||||
|
||||
// Test that the hook executes for Write tool
|
||||
let event = HookEvent::PreToolUse {
|
||||
tool: "Write".to_string(),
|
||||
args: serde_json::json!({"path": "/tmp/test.txt"}),
|
||||
};
|
||||
|
||||
let result = hook_mgr.execute(&event, Some(5000)).await?;
|
||||
assert_eq!(result, HookResult::Allow);
|
||||
|
||||
// Test that the hook does NOT execute for Read tool (doesn't match pattern)
|
||||
let event = HookEvent::PreToolUse {
|
||||
tool: "Read".to_string(),
|
||||
args: serde_json::json!({"path": "/tmp/test.txt"}),
|
||||
};
|
||||
|
||||
let result = hook_mgr.execute(&event, Some(5000)).await?;
|
||||
assert_eq!(result, HookResult::Allow);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_deny_hook() -> Result<()> {
|
||||
// Create temporary directory to act as project root
|
||||
let temp_dir = TempDir::new()?;
|
||||
|
||||
// Create hook manager
|
||||
let mut hook_mgr = HookManager::new(temp_dir.path().to_str().unwrap());
|
||||
|
||||
// Register a hook that denies Write operations
|
||||
hook_mgr.register_hook(
|
||||
"PreToolUse".to_string(),
|
||||
"exit 2".to_string(), // Exit code 2 means deny
|
||||
Some("Write".to_string()),
|
||||
Some(5000),
|
||||
);
|
||||
|
||||
// Test that the hook denies Write tool
|
||||
let event = HookEvent::PreToolUse {
|
||||
tool: "Write".to_string(),
|
||||
args: serde_json::json!({"path": "/tmp/test.txt"}),
|
||||
};
|
||||
|
||||
let result = hook_mgr.execute(&event, Some(5000)).await?;
|
||||
assert_eq!(result, HookResult::Deny);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_multiple_hooks_same_event() -> Result<()> {
|
||||
// Create temporary directory to act as project root
|
||||
let temp_dir = TempDir::new()?;
|
||||
|
||||
// Create hook manager
|
||||
let mut hook_mgr = HookManager::new(temp_dir.path().to_str().unwrap());
|
||||
|
||||
// Register multiple hooks for the same event
|
||||
hook_mgr.register_hook(
|
||||
"PreToolUse".to_string(),
|
||||
"echo 'Hook 1' && exit 0".to_string(),
|
||||
Some("Edit".to_string()),
|
||||
Some(5000),
|
||||
);
|
||||
|
||||
hook_mgr.register_hook(
|
||||
"PreToolUse".to_string(),
|
||||
"echo 'Hook 2' && exit 0".to_string(),
|
||||
Some("Edit".to_string()),
|
||||
Some(5000),
|
||||
);
|
||||
|
||||
// Test that both hooks execute
|
||||
let event = HookEvent::PreToolUse {
|
||||
tool: "Edit".to_string(),
|
||||
args: serde_json::json!({"path": "/tmp/test.txt"}),
|
||||
};
|
||||
|
||||
let result = hook_mgr.execute(&event, Some(5000)).await?;
|
||||
assert_eq!(result, HookResult::Allow);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_hook_with_no_pattern_matches_all() -> Result<()> {
|
||||
// Create temporary directory to act as project root
|
||||
let temp_dir = TempDir::new()?;
|
||||
|
||||
// Create hook manager
|
||||
let mut hook_mgr = HookManager::new(temp_dir.path().to_str().unwrap());
|
||||
|
||||
// Register a hook with no pattern (matches all tools)
|
||||
hook_mgr.register_hook(
|
||||
"PreToolUse".to_string(),
|
||||
"echo 'Hook for all tools' && exit 0".to_string(),
|
||||
None, // No pattern = match all
|
||||
Some(5000),
|
||||
);
|
||||
|
||||
// Test that the hook executes for any tool
|
||||
let event = HookEvent::PreToolUse {
|
||||
tool: "Read".to_string(),
|
||||
args: serde_json::json!({"path": "/tmp/test.txt"}),
|
||||
};
|
||||
let result = hook_mgr.execute(&event, Some(5000)).await?;
|
||||
assert_eq!(result, HookResult::Allow);
|
||||
|
||||
let event = HookEvent::PreToolUse {
|
||||
tool: "Write".to_string(),
|
||||
args: serde_json::json!({"path": "/tmp/test.txt"}),
|
||||
};
|
||||
let result = hook_mgr.execute(&event, Some(5000)).await?;
|
||||
assert_eq!(result, HookResult::Allow);
|
||||
|
||||
let event = HookEvent::PreToolUse {
|
||||
tool: "Bash".to_string(),
|
||||
args: serde_json::json!({"command": "ls"}),
|
||||
};
|
||||
let result = hook_mgr.execute(&event, Some(5000)).await?;
|
||||
assert_eq!(result, HookResult::Allow);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user