use std::{fmt, path::PathBuf, str::FromStr, time::Duration};
use serde::Serialize;
use crate::{
Error, Result,
harness::{Context, EntryMatch, Part, Scope},
hook::{Event, HooksFile, file::DEFAULT_TIMEOUT},
integration::{Agent, Command, Hook, McpServer, Skill, Source, source},
};
pub const ADAPTER: &str = "agent-harness-adapter";
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum Item {
Instructions,
Skills,
Hooks,
Mcp,
Permissions,
Agents,
Commands,
}
impl Item {
pub const ALL: [Item; 7] = [
Item::Instructions,
Item::Skills,
Item::Hooks,
Item::Mcp,
Item::Permissions,
Item::Agents,
Item::Commands,
];
pub fn as_str(self) -> &'static str {
match self {
Item::Instructions => "instructions",
Item::Skills => "skills",
Item::Hooks => "hooks",
Item::Mcp => "mcp",
Item::Permissions => "permissions",
Item::Agents => "agents",
Item::Commands => "commands",
}
}
}
impl fmt::Display for Item {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for Item {
type Err = ();
fn from_str(s: &str) -> std::result::Result<Self, ()> {
Item::ALL.into_iter().find(|i| i.as_str() == s).ok_or(())
}
}
#[derive(Debug, Clone, Default)]
pub struct Integration {
instructions: Option<String>,
skills: Vec<Skill>,
hooks: Vec<Hook>,
hook_match: Option<EntryMatch>,
mcp_servers: Vec<McpServer>,
allowed: Vec<String>,
mcp_tools: Vec<(String, String)>,
agents: Vec<Agent>,
commands: Vec<Command>,
parts: Vec<(String, Part)>,
sources: Vec<Source>,
missing: Vec<Source>,
warnings: Vec<String>,
adapter: Option<String>,
dispatch: Option<(PathBuf, Vec<(Event, u64)>)>,
}
impl Integration {
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn instructions(mut self, block: impl Into<String>) -> Self {
self.instructions = Some(block.into());
self
}
#[must_use]
pub fn skill(mut self, skill: Skill) -> Self {
self.skills.push(skill);
self
}
#[must_use]
pub fn hook(mut self, hook: Hook) -> Self {
self.hooks.push(hook);
self
}
#[must_use]
pub fn hook_match(mut self, owned: EntryMatch) -> Self {
self.hook_match = Some(owned);
self
}
#[must_use]
pub fn mcp_server(mut self, server: McpServer) -> Self {
self.mcp_servers.push(server);
self
}
#[must_use]
pub fn allow_command(mut self, prefix: impl Into<String>) -> Self {
self.allowed.push(prefix.into());
self
}
#[must_use]
pub fn allow_mcp_tool(mut self, server: impl Into<String>, tool: impl Into<String>) -> Self {
self.mcp_tools.push((server.into(), tool.into()));
self
}
#[must_use]
pub fn agent(mut self, agent: Agent) -> Self {
self.agents.push(agent);
self
}
#[must_use]
pub fn command(mut self, command: Command) -> Self {
self.commands.push(command);
self
}
#[must_use]
pub fn part(mut self, harness: impl Into<String>, part: Part) -> Self {
self.parts.push((harness.into(), part));
self
}
pub fn instructions_from(mut self, file: impl Into<PathBuf>) -> Result<Self> {
let src = self.source_of(Item::Instructions, file.into());
if let Some(src) = src {
self.instructions = Some(super::skill::read(&src)?);
}
Ok(self)
}
pub fn skills_from(mut self, dir: impl Into<PathBuf>) -> Result<Self> {
if let Some(src) = self.source_of(Item::Skills, dir.into()) {
self.skills.extend(source::skills(&src)?);
}
Ok(self)
}
pub fn hooks_from(mut self, file: impl Into<PathBuf>) -> Result<Self> {
let Some(src) = self.source_of(Item::Hooks, file.into()) else {
return Ok(self);
};
let hooks = HooksFile::read(&src)?.unwrap_or_default();
let display = src.display();
for name in hooks.other_events() {
self.warnings.push(format!(
"{display}: `{name}` hooks do not run: agent-harness-adapter runs SessionStart, SessionEnd, UserPromptSubmit, PreToolUse, PostToolUse, Stop and PreCompact"
));
}
if hooks.other_types() > 0 {
self.warnings.push(format!(
"{display}: hooks of another type than `command` do not run"
));
}
let events = hooks
.events()
.into_iter()
.map(|e| {
let total = hooks
.groups(e)
.iter()
.flat_map(|g| &g.hooks)
.filter(|x| x.dispatched())
.map(|x| x.timeout.unwrap_or(DEFAULT_TIMEOUT))
.sum();
(e, total)
})
.collect();
self.dispatch = Some((src, events));
Ok(self)
}
pub fn mcp_servers_from(mut self, file: impl Into<PathBuf>) -> Result<Self> {
if let Some(src) = self.source_of(Item::Mcp, file.into()) {
self.mcp_servers.extend(source::mcp_servers(&src)?);
}
Ok(self)
}
pub fn agents_from(mut self, dir: impl Into<PathBuf>) -> Result<Self> {
if let Some(src) = self.source_of(Item::Agents, dir.into()) {
self.agents.extend(source::agents(&src)?);
}
Ok(self)
}
fn source_of(&mut self, item: Item, path: PathBuf) -> Option<PathBuf> {
let src = Source::new(item, path);
if src.exists() {
let at = src.path.clone();
self.sources.retain(|s| s.item != item);
self.sources.push(src);
Some(at)
} else {
self.missing.push(src);
None
}
}
#[must_use]
pub fn adapter(mut self, command: impl Into<String>) -> Self {
self.adapter = Some(command.into());
self
}
pub fn source(&self, item: Item) -> Option<&Source> {
self.sources.iter().find(|s| s.item == item)
}
pub fn missing(&self) -> &[Source] {
&self.missing
}
pub fn warnings(&self) -> &[String] {
&self.warnings
}
pub(crate) fn resolve(&self, cx: &Context) -> Integration {
let mut out = self.clone();
let Some((file, events)) = &self.dispatch else {
return out;
};
let adapter = self.adapter.as_deref().unwrap_or(ADAPTER);
let path = match (cx.relative(file), cx.scope) {
(Some(rel), Scope::User) => format!("~/{rel}"),
(Some(rel), _) => rel,
(None, _) => file.display().to_string(),
};
let path = if path.contains(|c: char| c.is_whitespace() || "\"'$`\\;&|<>()".contains(c)) {
format!("\"{}\"", path.replace('"', "\\\""))
} else {
path
};
let owner = EntryMatch::Prefix(format!("{adapter} hook --hooks "));
for (event, seconds) in events {
out.hooks.push(
Hook::new(
*event,
format!("{adapter} hook --hooks {path} {{harness}} {{event}}"),
)
.timeout(Duration::from_secs(*seconds))
.owned(owner.clone()),
);
}
out
}
pub fn instructions_block(&self) -> Option<&str> {
self.instructions.as_deref()
}
pub fn skills(&self) -> &[Skill] {
&self.skills
}
pub fn hooks(&self) -> &[Hook] {
&self.hooks
}
pub fn hook_owner(&self, hook: &Hook) -> Result<EntryMatch> {
if let Some(m) = hook.owned_by().or(self.hook_match.as_ref()) {
return Ok(m.clone());
}
let prefixes = hook.prefixes();
if prefixes.iter().any(String::is_empty) {
return Err(Error::Internal(format!(
"the hook command `{}` starts with a placeholder: give it an entry match",
hook.template()
)));
}
Ok(EntryMatch::any_of(
prefixes.into_iter().map(EntryMatch::Prefix),
))
}
pub fn mcp_servers(&self) -> &[McpServer] {
&self.mcp_servers
}
pub fn allowed_commands(&self) -> &[String] {
&self.allowed
}
pub fn allowed_mcp_tools(&self) -> &[(String, String)] {
&self.mcp_tools
}
pub fn agents(&self) -> &[Agent] {
&self.agents
}
pub fn commands(&self) -> &[Command] {
&self.commands
}
pub fn items(&self) -> Vec<Item> {
Item::ALL
.into_iter()
.filter(|i| match i {
Item::Instructions => self.instructions.is_some(),
Item::Skills => !self.skills.is_empty(),
Item::Hooks => !self.hooks.is_empty(),
Item::Mcp => !self.mcp_servers.is_empty(),
Item::Permissions => !self.allowed.is_empty() || !self.mcp_tools.is_empty(),
Item::Agents => !self.agents.is_empty(),
Item::Commands => !self.commands.is_empty(),
})
.collect()
}
pub fn parts_for<'a>(&'a self, harness: &'a str) -> impl Iterator<Item = &'a Part> + 'a {
self.parts
.iter()
.filter(move |(h, _)| h == harness)
.map(|(_, p)| p)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hook::Event;
#[test]
fn items_parse_and_print() {
for i in Item::ALL {
assert_eq!(i.as_str().parse::<Item>(), Ok(i));
assert_eq!(i.to_string(), i.as_str());
}
assert!("nope".parse::<Item>().is_err());
}
#[test]
fn an_integration_holds_its_items_in_profile_order() {
assert!(Integration::new().items().is_empty());
let i = Integration::new()
.command(Command::new("c", "d", "p"))
.instructions("b\n")
.skill(Skill::new("s", "d", "b"))
.hook(Hook::new(Event::Stop, "t hook {harness} {event}"))
.mcp_server(McpServer::stdio("t", "t", ["mcp"]))
.allow_command("t")
.allow_mcp_tool("t", "run")
.agent(Agent::new("a", "d", "p"))
.part("claude", Part::region("notes", "NOTES.md", "n"));
assert_eq!(i.items(), Item::ALL);
assert_eq!(i.instructions_block(), Some("b\n"));
assert_eq!(
(
i.skills().len(),
i.hooks().len(),
i.mcp_servers().len(),
i.allowed_commands(),
i.agents().len(),
i.commands().len()
),
(1, 1, 1, &["t".to_string()][..], 1, 1)
);
assert_eq!(i.parts_for("claude").count(), 1);
assert_eq!(i.parts_for("codex").count(), 0);
}
#[test]
fn a_hook_is_owned_by_its_match_the_integrations_or_its_prefix() {
let plain = Hook::new(Event::Stop, "t hook {harness} {event}");
let first = Hook::new(Event::Stop, "{harness} t");
let own = first.clone().owned(EntryMatch::Contains(" t".into()));
let i = Integration::new();
assert_eq!(
i.hook_owner(&plain).unwrap(),
EntryMatch::Prefix("t hook ".into())
);
let e = i.hook_owner(&first).unwrap_err();
assert!(matches!(e, Error::Internal(_)), "{e}");
assert_eq!(
i.hook_owner(&own).unwrap(),
EntryMatch::Contains(" t".into())
);
let over = plain
.clone()
.command_for("claude", "$CLAUDE_PROJECT_DIR/t hook {harness} {event}");
assert_eq!(
over.command("claude"),
"$CLAUDE_PROJECT_DIR/t hook claude stop"
);
assert_eq!(over.command("codex"), "t hook codex stop");
assert_eq!(
i.hook_owner(&over).unwrap(),
EntryMatch::Any(vec![
EntryMatch::Prefix("t hook ".into()),
EntryMatch::Prefix("$CLAUDE_PROJECT_DIR/t hook ".into())
])
);
let bad = plain.clone().command_for("pi", "{harness} x");
assert!(i.hook_owner(&bad).is_err());
let i = i.hook_match(EntryMatch::Contains("t".into()));
assert_eq!(
i.hook_owner(&first).unwrap(),
EntryMatch::Contains("t".into())
);
assert_eq!(
i.hook_owner(&own).unwrap(),
EntryMatch::Contains(" t".into())
);
}
}