magi-code 0.96.1

Repository-aware CLI coding agent for terminal work
Documentation
use schemars::JsonSchema;
use serde::{Deserialize, Deserializer, Serialize};

const HOOK_DEFAULT_TIMEOUT_SECONDS: u64 = 5;

const HOOK_MAX_TIMEOUT_SECONDS: u64 = 60;
const HOOK_DEFAULT_OUTPUT_MAX_BYTES: usize = 8192;
const HOOK_MAX_OUTPUT_MAX_BYTES: usize = 65536;
pub(crate) const HOOK_DEFAULT_PROVIDER_CONTEXT_MAX_BYTES: usize = 4096;
pub(crate) const HOOK_MAX_PROVIDER_CONTEXT_MAX_BYTES: usize = 16384;

#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
pub struct HookSettings {
    #[serde(default)]
    pub enabled: bool,
    #[serde(default)]
    pub show_in_tui: bool,
    #[serde(default)]
    pub payload: HookPayloadMode,
    #[serde(default = "default_hook_timeout_seconds")]
    pub timeout_seconds: u64,
    #[serde(default = "default_hook_output_max_bytes")]
    pub stdout_max_bytes: usize,
    #[serde(default = "default_hook_output_max_bytes")]
    pub stderr_max_bytes: usize,
    #[serde(default)]
    pub failure_policy: HookFailurePolicy,
    #[serde(default)]
    pub provider_context_injection: bool,
    #[serde(default = "default_hook_provider_context_max_bytes")]
    pub provider_context_max_bytes: usize,
    #[serde(default)]
    pub injected_content: InjectedContentSettings,
    #[serde(default, deserialize_with = "deserialize_hook_definitions")]
    pub before_tool: Vec<HookDefinition>,
    #[serde(default, deserialize_with = "deserialize_after_hook_definitions")]
    pub after_tool: Vec<HookDefinition>,
    #[serde(default, deserialize_with = "deserialize_after_hook_definitions")]
    pub after_assistant: Vec<HookDefinition>,
    #[serde(default, deserialize_with = "deserialize_after_hook_definitions")]
    pub after_reasoning: Vec<HookDefinition>,
}

impl Default for HookSettings {
    fn default() -> Self {
        Self {
            enabled: false,
            show_in_tui: false,
            payload: HookPayloadMode::Redacted,
            timeout_seconds: HOOK_DEFAULT_TIMEOUT_SECONDS,
            stdout_max_bytes: HOOK_DEFAULT_OUTPUT_MAX_BYTES,
            stderr_max_bytes: HOOK_DEFAULT_OUTPUT_MAX_BYTES,
            failure_policy: HookFailurePolicy::Warn,
            provider_context_injection: false,
            provider_context_max_bytes: HOOK_DEFAULT_PROVIDER_CONTEXT_MAX_BYTES,
            injected_content: InjectedContentSettings::default(),
            before_tool: Vec::new(),
            after_tool: Vec::new(),
            after_assistant: Vec::new(),
            after_reasoning: Vec::new(),
        }
    }
}

impl HookSettings {
    pub(crate) fn is_default(&self) -> bool {
        self == &Self::default()
    }

    fn validate(&self) -> Result<(), String> {
        validate_hook_timeout(self.timeout_seconds)?;
        validate_hook_output_limit(self.stdout_max_bytes, "stdout_max_bytes")?;
        validate_hook_output_limit(self.stderr_max_bytes, "stderr_max_bytes")?;
        validate_hook_provider_context_limit(
            self.provider_context_max_bytes,
            "provider_context_max_bytes",
        )?;
        for hook in &self.before_tool {
            hook.validate(false)?;
        }
        validate_post_phase_hooks(&self.after_tool, self.failure_policy)?;
        validate_post_phase_hooks(&self.after_assistant, self.failure_policy)?;
        validate_post_phase_hooks(&self.after_reasoning, self.failure_policy)?;
        Ok(())
    }
}

#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum HookPayloadMode {
    #[default]
    Redacted,
    Full,
}

impl HookPayloadMode {
    pub(crate) fn as_str(self) -> &'static str {
        match self {
            Self::Redacted => "redacted",
            Self::Full => "full",
        }
    }
}

#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum HookFailurePolicy {
    Ignore,
    #[default]
    Warn,
    Block,
    Fail,
}

impl HookFailurePolicy {
    pub(crate) fn as_str(self) -> &'static str {
        match self {
            Self::Ignore => "ignore",
            Self::Warn => "warn",
            Self::Block => "block",
            Self::Fail => "fail",
        }
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct InjectedContentSettings {
    #[serde(default)]
    pub show_in_transcript: bool,
    #[serde(default)]
    pub show_in_activity_tree: bool,
    #[serde(default)]
    pub style: InjectedContentStyle,
}

#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum InjectedContentStyle {
    #[default]
    Content,
    Metadata,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct HookDefinition {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub label: Option<String>,
    pub command: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub payload: Option<HookPayloadMode>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub failure_policy: Option<HookFailurePolicy>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub timeout_seconds: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub stdout_max_bytes: Option<usize>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub stderr_max_bytes: Option<usize>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub provider_context_injection: Option<bool>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub provider_context_max_bytes: Option<usize>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub include_tools: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub exclude_tools: Vec<String>,
}

impl HookDefinition {
    pub(crate) fn effective_label(&self) -> String {
        self.label
            .as_deref()
            .map(str::trim)
            .filter(|label| !label.is_empty())
            .unwrap_or("hook")
            .to_string()
    }

    pub(crate) fn matches_tool(&self, tool_name: &str) -> bool {
        (self.include_tools.is_empty() || self.include_tools.iter().any(|tool| tool == tool_name))
            && !self.exclude_tools.iter().any(|tool| tool == tool_name)
    }

    pub(crate) fn effective_provider_context_injection(&self, settings: &HookSettings) -> bool {
        self.provider_context_injection
            .unwrap_or(settings.provider_context_injection)
    }

    pub(crate) fn effective_provider_context_max_bytes(&self, settings: &HookSettings) -> usize {
        self.provider_context_max_bytes
            .unwrap_or(settings.provider_context_max_bytes)
    }

    fn validate(&self, after: bool) -> Result<(), String> {
        if self.command.trim().is_empty() {
            return Err("hook command must be non-empty".to_string());
        }
        if after && self.failure_policy == Some(HookFailurePolicy::Block) {
            return Err(
                "hook failure_policy 'block' is valid only for before_tool hooks".to_string(),
            );
        }
        if let Some(timeout) = self.timeout_seconds {
            validate_hook_timeout(timeout)?;
        }
        if let Some(limit) = self.stdout_max_bytes {
            validate_hook_output_limit(limit, "stdout_max_bytes")?;
        }
        if let Some(limit) = self.stderr_max_bytes {
            validate_hook_output_limit(limit, "stderr_max_bytes")?;
        }
        if let Some(limit) = self.provider_context_max_bytes {
            validate_hook_provider_context_limit(limit, "provider_context_max_bytes")?;
        }
        Ok(())
    }
}

fn default_hook_timeout_seconds() -> u64 {
    HOOK_DEFAULT_TIMEOUT_SECONDS
}

fn default_hook_output_max_bytes() -> usize {
    HOOK_DEFAULT_OUTPUT_MAX_BYTES
}

fn default_hook_provider_context_max_bytes() -> usize {
    HOOK_DEFAULT_PROVIDER_CONTEXT_MAX_BYTES
}

fn validate_hook_timeout(value: u64) -> Result<(), String> {
    if (1..=HOOK_MAX_TIMEOUT_SECONDS).contains(&value) {
        Ok(())
    } else {
        Err(format!(
            "hooks timeout_seconds must be between 1 and {HOOK_MAX_TIMEOUT_SECONDS}"
        ))
    }
}

fn validate_hook_output_limit(value: usize, field: &str) -> Result<(), String> {
    if (1..=HOOK_MAX_OUTPUT_MAX_BYTES).contains(&value) {
        Ok(())
    } else {
        Err(format!(
            "hooks {field} must be between 1 and {HOOK_MAX_OUTPUT_MAX_BYTES}"
        ))
    }
}

fn validate_hook_provider_context_limit(value: usize, field: &str) -> Result<(), String> {
    if (1..=HOOK_MAX_PROVIDER_CONTEXT_MAX_BYTES).contains(&value) {
        Ok(())
    } else {
        Err(format!(
            "hooks {field} must be between 1 and {HOOK_MAX_PROVIDER_CONTEXT_MAX_BYTES}"
        ))
    }
}

fn validate_post_phase_hooks(
    hooks: &[HookDefinition],
    default_policy: HookFailurePolicy,
) -> Result<(), String> {
    for hook in hooks {
        hook.validate(true)?;
        if hook.failure_policy.unwrap_or(default_policy) == HookFailurePolicy::Block {
            return Err(
                "hook failure_policy 'block' is valid only for before_tool hooks".to_string(),
            );
        }
    }
    Ok(())
}

fn deserialize_hook_definitions<'de, D>(deserializer: D) -> Result<Vec<HookDefinition>, D::Error>
where
    D: Deserializer<'de>,
{
    let hooks = Vec::<HookDefinition>::deserialize(deserializer)?;
    for hook in &hooks {
        hook.validate(false).map_err(serde::de::Error::custom)?;
    }
    Ok(hooks)
}

fn deserialize_after_hook_definitions<'de, D>(
    deserializer: D,
) -> Result<Vec<HookDefinition>, D::Error>
where
    D: Deserializer<'de>,
{
    let hooks = Vec::<HookDefinition>::deserialize(deserializer)?;
    for hook in &hooks {
        hook.validate(true).map_err(serde::de::Error::custom)?;
    }
    Ok(hooks)
}

impl<'de> Deserialize<'de> for HookSettings {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        #[derive(Deserialize)]
        struct RawHookSettings {
            #[serde(default)]
            enabled: bool,
            #[serde(default)]
            show_in_tui: bool,
            #[serde(default)]
            payload: HookPayloadMode,
            #[serde(default = "default_hook_timeout_seconds")]
            timeout_seconds: u64,
            #[serde(default = "default_hook_output_max_bytes")]
            stdout_max_bytes: usize,
            #[serde(default = "default_hook_output_max_bytes")]
            stderr_max_bytes: usize,
            #[serde(default)]
            failure_policy: HookFailurePolicy,
            #[serde(default)]
            provider_context_injection: bool,
            #[serde(default = "default_hook_provider_context_max_bytes")]
            provider_context_max_bytes: usize,
            #[serde(default)]
            injected_content: InjectedContentSettings,
            #[serde(default, deserialize_with = "deserialize_hook_definitions")]
            before_tool: Vec<HookDefinition>,
            #[serde(default, deserialize_with = "deserialize_after_hook_definitions")]
            after_tool: Vec<HookDefinition>,
            #[serde(default, deserialize_with = "deserialize_after_hook_definitions")]
            after_assistant: Vec<HookDefinition>,
            #[serde(default, deserialize_with = "deserialize_after_hook_definitions")]
            after_reasoning: Vec<HookDefinition>,
        }
        let raw = RawHookSettings::deserialize(deserializer)?;
        let settings = HookSettings {
            enabled: raw.enabled,
            show_in_tui: raw.show_in_tui,
            payload: raw.payload,
            timeout_seconds: raw.timeout_seconds,
            stdout_max_bytes: raw.stdout_max_bytes,
            stderr_max_bytes: raw.stderr_max_bytes,
            failure_policy: raw.failure_policy,
            provider_context_injection: raw.provider_context_injection,
            provider_context_max_bytes: raw.provider_context_max_bytes,
            injected_content: raw.injected_content,
            before_tool: raw.before_tool,
            after_tool: raw.after_tool,
            after_assistant: raw.after_assistant,
            after_reasoning: raw.after_reasoning,
        };
        settings.validate().map_err(serde::de::Error::custom)?;
        Ok(settings)
    }
}