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:
@@ -8,6 +8,7 @@ color-eyre = "0.6"
|
||||
dirs = "5.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
serde_yaml = "0.9"
|
||||
walkdir = "2.5"
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -44,6 +44,109 @@ pub struct McpServerConfig {
|
||||
pub env: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// Plugin hook configuration from hooks/hooks.json
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct PluginHooksConfig {
|
||||
pub description: Option<String>,
|
||||
pub hooks: HashMap<String, Vec<HookMatcher>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct HookMatcher {
|
||||
pub matcher: Option<String>, // Regex pattern for tool names
|
||||
pub hooks: Vec<HookDefinition>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct HookDefinition {
|
||||
#[serde(rename = "type")]
|
||||
pub hook_type: String, // "command" or "prompt"
|
||||
pub command: Option<String>,
|
||||
pub prompt: Option<String>,
|
||||
pub timeout: Option<u64>,
|
||||
}
|
||||
|
||||
/// Parsed slash command from markdown file
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SlashCommand {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub argument_hint: Option<String>,
|
||||
pub allowed_tools: Option<Vec<String>>,
|
||||
pub body: String, // Markdown content after frontmatter
|
||||
pub source_path: PathBuf,
|
||||
}
|
||||
|
||||
/// Parsed agent definition from markdown file
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AgentDefinition {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub tools: Vec<String>, // Tool whitelist
|
||||
pub model: Option<String>, // haiku, sonnet, opus
|
||||
pub color: Option<String>,
|
||||
pub system_prompt: String, // Markdown body
|
||||
pub source_path: PathBuf,
|
||||
}
|
||||
|
||||
/// Parsed skill definition
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Skill {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub version: Option<String>,
|
||||
pub content: String, // Core SKILL.md content
|
||||
pub references: Vec<PathBuf>, // Reference files
|
||||
pub examples: Vec<PathBuf>, // Example files
|
||||
pub source_path: PathBuf,
|
||||
}
|
||||
|
||||
/// YAML frontmatter for command files
|
||||
#[derive(Deserialize)]
|
||||
struct CommandFrontmatter {
|
||||
description: Option<String>,
|
||||
#[serde(rename = "argument-hint")]
|
||||
argument_hint: Option<String>,
|
||||
#[serde(rename = "allowed-tools")]
|
||||
allowed_tools: Option<String>,
|
||||
}
|
||||
|
||||
/// YAML frontmatter for agent files
|
||||
#[derive(Deserialize)]
|
||||
struct AgentFrontmatter {
|
||||
name: String,
|
||||
description: String,
|
||||
#[serde(default)]
|
||||
tools: Vec<String>,
|
||||
model: Option<String>,
|
||||
color: Option<String>,
|
||||
}
|
||||
|
||||
/// YAML frontmatter for skill files
|
||||
#[derive(Deserialize)]
|
||||
struct SkillFrontmatter {
|
||||
name: String,
|
||||
description: String,
|
||||
version: Option<String>,
|
||||
}
|
||||
|
||||
/// Parse YAML frontmatter from markdown content
|
||||
fn parse_frontmatter<T: serde::de::DeserializeOwned>(content: &str) -> Result<(T, String)> {
|
||||
if !content.starts_with("---") {
|
||||
return Err(eyre!("No frontmatter found"));
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = content.splitn(3, "---").collect();
|
||||
if parts.len() < 3 {
|
||||
return Err(eyre!("Invalid frontmatter format"));
|
||||
}
|
||||
|
||||
let frontmatter: T = serde_yaml::from_str(parts[1].trim())?;
|
||||
let body = parts[2].trim().to_string();
|
||||
|
||||
Ok((frontmatter, body))
|
||||
}
|
||||
|
||||
/// A loaded plugin with its manifest and base path
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Plugin {
|
||||
@@ -64,7 +167,7 @@ impl Plugin {
|
||||
|
||||
/// Get the path to a skill file
|
||||
pub fn skill_path(&self, skill_name: &str) -> PathBuf {
|
||||
self.base_path.join("skills").join(format!("{}.md", skill_name))
|
||||
self.base_path.join("skills").join(skill_name).join("SKILL.md")
|
||||
}
|
||||
|
||||
/// Get the path to a hook script
|
||||
@@ -73,6 +176,210 @@ impl Plugin {
|
||||
self.base_path.join("hooks").join(path)
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a command file
|
||||
pub fn parse_command(&self, name: &str) -> Result<SlashCommand> {
|
||||
let path = self.command_path(name);
|
||||
let content = fs::read_to_string(&path)?;
|
||||
let (fm, body): (CommandFrontmatter, String) = parse_frontmatter(&content)?;
|
||||
|
||||
let allowed_tools = fm.allowed_tools.map(|s| {
|
||||
s.split(',').map(|t| t.trim().to_string()).collect()
|
||||
});
|
||||
|
||||
Ok(SlashCommand {
|
||||
name: name.to_string(),
|
||||
description: fm.description,
|
||||
argument_hint: fm.argument_hint,
|
||||
allowed_tools,
|
||||
body,
|
||||
source_path: path,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse an agent file
|
||||
pub fn parse_agent(&self, name: &str) -> Result<AgentDefinition> {
|
||||
let path = self.agent_path(name);
|
||||
let content = fs::read_to_string(&path)?;
|
||||
let (fm, body): (AgentFrontmatter, String) = parse_frontmatter(&content)?;
|
||||
|
||||
Ok(AgentDefinition {
|
||||
name: fm.name,
|
||||
description: fm.description,
|
||||
tools: fm.tools,
|
||||
model: fm.model,
|
||||
color: fm.color,
|
||||
system_prompt: body,
|
||||
source_path: path,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a skill file
|
||||
pub fn parse_skill(&self, name: &str) -> Result<Skill> {
|
||||
let path = self.skill_path(name);
|
||||
let content = fs::read_to_string(&path)?;
|
||||
let (fm, body): (SkillFrontmatter, String) = parse_frontmatter(&content)?;
|
||||
|
||||
// Discover reference and example files in the skill directory
|
||||
let skill_dir = self.base_path.join("skills").join(name);
|
||||
let references_dir = skill_dir.join("references");
|
||||
let examples_dir = skill_dir.join("examples");
|
||||
|
||||
let references = if references_dir.exists() {
|
||||
fs::read_dir(&references_dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let examples = if examples_dir.exists() {
|
||||
fs::read_dir(&examples_dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
Ok(Skill {
|
||||
name: fm.name,
|
||||
description: fm.description,
|
||||
version: fm.version,
|
||||
content: body,
|
||||
references,
|
||||
examples,
|
||||
source_path: path,
|
||||
})
|
||||
}
|
||||
|
||||
/// Auto-discover commands in the commands/ directory
|
||||
pub fn discover_commands(&self) -> Vec<String> {
|
||||
let commands_dir = self.base_path.join("commands");
|
||||
if !commands_dir.exists() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
std::fs::read_dir(&commands_dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.path().extension().map(|ext| ext == "md").unwrap_or(false))
|
||||
.filter_map(|e| {
|
||||
e.path().file_stem()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Auto-discover agents in the agents/ directory
|
||||
pub fn discover_agents(&self) -> Vec<String> {
|
||||
let agents_dir = self.base_path.join("agents");
|
||||
if !agents_dir.exists() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
std::fs::read_dir(&agents_dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.path().extension().map(|ext| ext == "md").unwrap_or(false))
|
||||
.filter_map(|e| {
|
||||
e.path().file_stem()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Auto-discover skills in skills/*/SKILL.md
|
||||
pub fn discover_skills(&self) -> Vec<String> {
|
||||
let skills_dir = self.base_path.join("skills");
|
||||
if !skills_dir.exists() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
std::fs::read_dir(&skills_dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.path().is_dir())
|
||||
.filter(|e| e.path().join("SKILL.md").exists())
|
||||
.filter_map(|e| {
|
||||
e.path().file_name()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get all commands (manifest + discovered)
|
||||
pub fn all_command_names(&self) -> Vec<String> {
|
||||
let mut names: std::collections::HashSet<String> =
|
||||
self.manifest.commands.iter().cloned().collect();
|
||||
names.extend(self.discover_commands());
|
||||
names.into_iter().collect()
|
||||
}
|
||||
|
||||
/// Get all agent names (manifest + discovered)
|
||||
pub fn all_agent_names(&self) -> Vec<String> {
|
||||
let mut names: std::collections::HashSet<String> =
|
||||
self.manifest.agents.iter().cloned().collect();
|
||||
names.extend(self.discover_agents());
|
||||
names.into_iter().collect()
|
||||
}
|
||||
|
||||
/// Get all skill names (manifest + discovered)
|
||||
pub fn all_skill_names(&self) -> Vec<String> {
|
||||
let mut names: std::collections::HashSet<String> =
|
||||
self.manifest.skills.iter().cloned().collect();
|
||||
names.extend(self.discover_skills());
|
||||
names.into_iter().collect()
|
||||
}
|
||||
|
||||
/// Load hooks configuration from hooks/hooks.json
|
||||
pub fn load_hooks_config(&self) -> Result<Option<PluginHooksConfig>> {
|
||||
let hooks_path = self.base_path.join("hooks").join("hooks.json");
|
||||
if !hooks_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&hooks_path)?;
|
||||
let config: PluginHooksConfig = serde_json::from_str(&content)?;
|
||||
Ok(Some(config))
|
||||
}
|
||||
|
||||
/// Register hooks from this plugin's config into a hook manager
|
||||
/// This requires the hooks crate to be available where this is called
|
||||
pub fn register_hooks_with_manager(&self, config: &PluginHooksConfig) -> Vec<(String, String, Option<String>, Option<u64>)> {
|
||||
let mut hooks_to_register = Vec::new();
|
||||
|
||||
for (event_name, matchers) in &config.hooks {
|
||||
for matcher in matchers {
|
||||
for hook_def in &matcher.hooks {
|
||||
if let Some(command) = &hook_def.command {
|
||||
// Substitute ${CLAUDE_PLUGIN_ROOT}
|
||||
let resolved = command.replace(
|
||||
"${CLAUDE_PLUGIN_ROOT}",
|
||||
&self.base_path.to_string_lossy()
|
||||
);
|
||||
|
||||
hooks_to_register.push((
|
||||
event_name.clone(),
|
||||
resolved,
|
||||
matcher.matcher.clone(),
|
||||
hook_def.timeout,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hooks_to_register
|
||||
}
|
||||
}
|
||||
|
||||
/// Plugin loader and registry
|
||||
@@ -232,6 +539,45 @@ impl PluginManager {
|
||||
|
||||
servers
|
||||
}
|
||||
|
||||
/// Get all parsed commands
|
||||
pub fn load_all_commands(&self) -> Vec<SlashCommand> {
|
||||
let mut commands = Vec::new();
|
||||
for plugin in &self.plugins {
|
||||
for cmd_name in &plugin.manifest.commands {
|
||||
if let Ok(cmd) = plugin.parse_command(cmd_name) {
|
||||
commands.push(cmd);
|
||||
}
|
||||
}
|
||||
}
|
||||
commands
|
||||
}
|
||||
|
||||
/// Get all parsed agents
|
||||
pub fn load_all_agents(&self) -> Vec<AgentDefinition> {
|
||||
let mut agents = Vec::new();
|
||||
for plugin in &self.plugins {
|
||||
for agent_name in &plugin.manifest.agents {
|
||||
if let Ok(agent) = plugin.parse_agent(agent_name) {
|
||||
agents.push(agent);
|
||||
}
|
||||
}
|
||||
}
|
||||
agents
|
||||
}
|
||||
|
||||
/// Get all parsed skills
|
||||
pub fn load_all_skills(&self) -> Vec<Skill> {
|
||||
let mut skills = Vec::new();
|
||||
for plugin in &self.plugins {
|
||||
for skill_name in &plugin.manifest.skills {
|
||||
if let Ok(skill) = plugin.parse_skill(skill_name) {
|
||||
skills.push(skill);
|
||||
}
|
||||
}
|
||||
}
|
||||
skills
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PluginManager {
|
||||
@@ -274,12 +620,12 @@ mod tests {
|
||||
|
||||
fs::write(
|
||||
dir.join("commands/test-cmd.md"),
|
||||
"# Test Command\nThis is a test command.",
|
||||
"---\ndescription: A test command\nargument-hint: <file>\nallowed-tools: read,write\n---\n\nThis is a test command body.",
|
||||
)?;
|
||||
|
||||
fs::write(
|
||||
dir.join("agents/test-agent.md"),
|
||||
"# Test Agent\nThis is a test agent.",
|
||||
"---\nname: test-agent\ndescription: A test agent\ntools:\n - read\n - write\nmodel: sonnet\ncolor: blue\n---\n\nYou are a helpful test agent.",
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
@@ -351,4 +697,77 @@ mod tests {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_command() -> Result<()> {
|
||||
let temp_dir = tempfile::tempdir()?;
|
||||
let plugin_dir = temp_dir.path().join("test-plugin");
|
||||
create_test_plugin(&plugin_dir)?;
|
||||
|
||||
let manager = PluginManager::with_dirs(vec![temp_dir.path().to_path_buf()]);
|
||||
let plugin = manager.load_plugin(&plugin_dir)?;
|
||||
|
||||
let cmd = plugin.parse_command("test-cmd")?;
|
||||
assert_eq!(cmd.name, "test-cmd");
|
||||
assert_eq!(cmd.description, Some("A test command".to_string()));
|
||||
assert_eq!(cmd.argument_hint, Some("<file>".to_string()));
|
||||
assert_eq!(cmd.allowed_tools, Some(vec!["read".to_string(), "write".to_string()]));
|
||||
assert_eq!(cmd.body, "This is a test command body.");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_agent() -> Result<()> {
|
||||
let temp_dir = tempfile::tempdir()?;
|
||||
let plugin_dir = temp_dir.path().join("test-plugin");
|
||||
create_test_plugin(&plugin_dir)?;
|
||||
|
||||
let manager = PluginManager::with_dirs(vec![temp_dir.path().to_path_buf()]);
|
||||
let plugin = manager.load_plugin(&plugin_dir)?;
|
||||
|
||||
let agent = plugin.parse_agent("test-agent")?;
|
||||
assert_eq!(agent.name, "test-agent");
|
||||
assert_eq!(agent.description, "A test agent");
|
||||
assert_eq!(agent.tools, vec!["read", "write"]);
|
||||
assert_eq!(agent.model, Some("sonnet".to_string()));
|
||||
assert_eq!(agent.color, Some("blue".to_string()));
|
||||
assert_eq!(agent.system_prompt, "You are a helpful test agent.");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_all_commands() -> Result<()> {
|
||||
let temp_dir = tempfile::tempdir()?;
|
||||
let plugin_dir = temp_dir.path().join("test-plugin");
|
||||
create_test_plugin(&plugin_dir)?;
|
||||
|
||||
let mut manager = PluginManager::with_dirs(vec![temp_dir.path().to_path_buf()]);
|
||||
manager.load_all()?;
|
||||
|
||||
let commands = manager.load_all_commands();
|
||||
assert_eq!(commands.len(), 1);
|
||||
assert_eq!(commands[0].name, "test-cmd");
|
||||
assert_eq!(commands[0].description, Some("A test command".to_string()));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_all_agents() -> Result<()> {
|
||||
let temp_dir = tempfile::tempdir()?;
|
||||
let plugin_dir = temp_dir.path().join("test-plugin");
|
||||
create_test_plugin(&plugin_dir)?;
|
||||
|
||||
let mut manager = PluginManager::with_dirs(vec![temp_dir.path().to_path_buf()]);
|
||||
manager.load_all()?;
|
||||
|
||||
let agents = manager.load_all_agents();
|
||||
assert_eq!(agents.len(), 1);
|
||||
assert_eq!(agents[0].name, "test-agent");
|
||||
assert_eq!(agents[0].description, "A test agent");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
175
crates/platform/plugins/tests/plugin_hooks_integration.rs
Normal file
175
crates/platform/plugins/tests/plugin_hooks_integration.rs
Normal file
@@ -0,0 +1,175 @@
|
||||
// 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(())
|
||||
}
|
||||
Reference in New Issue
Block a user