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>
176 lines
5.5 KiB
Rust
176 lines
5.5 KiB
Rust
// End-to-end integration test for plugin hooks
|
|
use color_eyre::eyre::Result;
|
|
use plugins::PluginManager;
|
|
use std::fs;
|
|
use tempfile::TempDir;
|
|
|
|
fn create_test_plugin_with_hooks(plugin_dir: &std::path::Path) -> Result<()> {
|
|
fs::create_dir_all(plugin_dir)?;
|
|
|
|
// Create plugin manifest
|
|
let manifest = serde_json::json!({
|
|
"name": "test-hook-plugin",
|
|
"version": "1.0.0",
|
|
"description": "Test plugin with hooks",
|
|
"commands": [],
|
|
"agents": [],
|
|
"skills": [],
|
|
"hooks": {},
|
|
"mcp_servers": []
|
|
});
|
|
fs::write(
|
|
plugin_dir.join("plugin.json"),
|
|
serde_json::to_string_pretty(&manifest)?,
|
|
)?;
|
|
|
|
// Create hooks directory and hooks.json
|
|
let hooks_dir = plugin_dir.join("hooks");
|
|
fs::create_dir_all(&hooks_dir)?;
|
|
|
|
let hooks_config = serde_json::json!({
|
|
"description": "Validate edit and write operations",
|
|
"hooks": {
|
|
"PreToolUse": [
|
|
{
|
|
"matcher": "Edit|Write",
|
|
"hooks": [
|
|
{
|
|
"type": "command",
|
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/validate.py",
|
|
"timeout": 5000
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"matcher": "Bash",
|
|
"hooks": [
|
|
{
|
|
"type": "command",
|
|
"command": "echo 'Bash hook' && exit 0"
|
|
}
|
|
]
|
|
}
|
|
],
|
|
"PostToolUse": [
|
|
{
|
|
"hooks": [
|
|
{
|
|
"type": "command",
|
|
"command": "echo 'Post-tool hook' && exit 0"
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
});
|
|
fs::write(
|
|
hooks_dir.join("hooks.json"),
|
|
serde_json::to_string_pretty(&hooks_config)?,
|
|
)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_load_plugin_hooks_config() -> Result<()> {
|
|
let temp_dir = TempDir::new()?;
|
|
let plugin_dir = temp_dir.path().join("test-plugin");
|
|
create_test_plugin_with_hooks(&plugin_dir)?;
|
|
|
|
// Load all plugins
|
|
let mut plugin_manager = PluginManager::with_dirs(vec![temp_dir.path().to_path_buf()]);
|
|
plugin_manager.load_all()?;
|
|
|
|
assert_eq!(plugin_manager.plugins().len(), 1);
|
|
let plugin = &plugin_manager.plugins()[0];
|
|
|
|
// Load hooks config
|
|
let hooks_config = plugin.load_hooks_config()?;
|
|
assert!(hooks_config.is_some());
|
|
|
|
let config = hooks_config.unwrap();
|
|
assert_eq!(config.description, Some("Validate edit and write operations".to_string()));
|
|
assert!(config.hooks.contains_key("PreToolUse"));
|
|
assert!(config.hooks.contains_key("PostToolUse"));
|
|
|
|
// Check PreToolUse hooks
|
|
let pre_tool_hooks = &config.hooks["PreToolUse"];
|
|
assert_eq!(pre_tool_hooks.len(), 2);
|
|
|
|
// First matcher: Edit|Write
|
|
assert_eq!(pre_tool_hooks[0].matcher, Some("Edit|Write".to_string()));
|
|
assert_eq!(pre_tool_hooks[0].hooks.len(), 1);
|
|
assert_eq!(pre_tool_hooks[0].hooks[0].hook_type, "command");
|
|
assert!(pre_tool_hooks[0].hooks[0].command.as_ref().unwrap().contains("validate.py"));
|
|
|
|
// Second matcher: Bash
|
|
assert_eq!(pre_tool_hooks[1].matcher, Some("Bash".to_string()));
|
|
assert_eq!(pre_tool_hooks[1].hooks.len(), 1);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_plugin_hooks_substitution() -> Result<()> {
|
|
let temp_dir = TempDir::new()?;
|
|
let plugin_dir = temp_dir.path().join("test-plugin");
|
|
create_test_plugin_with_hooks(&plugin_dir)?;
|
|
|
|
// Load all plugins
|
|
let mut plugin_manager = PluginManager::with_dirs(vec![temp_dir.path().to_path_buf()]);
|
|
plugin_manager.load_all()?;
|
|
|
|
assert_eq!(plugin_manager.plugins().len(), 1);
|
|
let plugin = &plugin_manager.plugins()[0];
|
|
|
|
// Load hooks config and register
|
|
let hooks_config = plugin.load_hooks_config()?.unwrap();
|
|
let hooks_to_register = plugin.register_hooks_with_manager(&hooks_config);
|
|
|
|
// Check that ${CLAUDE_PLUGIN_ROOT} was substituted
|
|
let edit_write_hook = hooks_to_register.iter()
|
|
.find(|(event, _, pattern, _)| {
|
|
event == "PreToolUse" && pattern.as_ref().map(|p| p.contains("Edit")).unwrap_or(false)
|
|
})
|
|
.unwrap();
|
|
|
|
// The command should have the plugin path substituted
|
|
assert!(edit_write_hook.1.contains(&plugin_dir.to_string_lossy().to_string()));
|
|
assert!(edit_write_hook.1.contains("validate.py"));
|
|
assert!(!edit_write_hook.1.contains("${CLAUDE_PLUGIN_ROOT}"));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_multiple_plugins_with_hooks() -> Result<()> {
|
|
let temp_dir = TempDir::new()?;
|
|
|
|
// Create two plugins with hooks
|
|
let plugin1_dir = temp_dir.path().join("plugin1");
|
|
create_test_plugin_with_hooks(&plugin1_dir)?;
|
|
|
|
let plugin2_dir = temp_dir.path().join("plugin2");
|
|
create_test_plugin_with_hooks(&plugin2_dir)?;
|
|
|
|
// Load all plugins
|
|
let mut plugin_manager = PluginManager::with_dirs(vec![temp_dir.path().to_path_buf()]);
|
|
plugin_manager.load_all()?;
|
|
|
|
assert_eq!(plugin_manager.plugins().len(), 2);
|
|
|
|
// Collect all hooks from all plugins
|
|
let mut total_hooks = 0;
|
|
for plugin in plugin_manager.plugins() {
|
|
if let Ok(Some(hooks_config)) = plugin.load_hooks_config() {
|
|
let hooks = plugin.register_hooks_with_manager(&hooks_config);
|
|
total_hooks += hooks.len();
|
|
}
|
|
}
|
|
|
|
// Each plugin has 3 hooks (2 PreToolUse + 1 PostToolUse)
|
|
assert_eq!(total_hooks, 6);
|
|
|
|
Ok(())
|
|
}
|