use std::path::Path;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::catalog::HarnessHomes;
use crate::profiles::{read_json5, yaml_child, yaml_key};
use crate::HarnessId;
pub const TRIGGERS_SCHEMA: &str = "supercode.triggers.v1";
pub const TRIGGER_HARNESSES: &[&str] = &[
HarnessId::HERMES,
HarnessId::OPENCLAW,
HarnessId::ORCHESTRATOR,
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TriggerKind {
Webhook,
HookMapping,
BuiltinWake,
BuiltinAgent,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TriggerTarget {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub action: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wake_mode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TriggerDeliver {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub chat_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TriggerRow {
pub name: String,
pub harness: String,
pub kind: TriggerKind,
pub route: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub events: Vec<String>,
pub target: TriggerTarget,
pub deliver: TriggerDeliver,
pub enabled: bool,
pub authenticated: bool,
pub source: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TriggerError {
UnsupportedHarness { harness: String },
}
impl std::fmt::Display for TriggerError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TriggerError::UnsupportedHarness { harness } => write!(
f,
"`{harness}` has no inbound-trigger store supercode reads; `triggers.list` is supported for: {}",
TRIGGER_HARNESSES.join(", ")
),
}
}
}
impl std::error::Error for TriggerError {}
pub fn list_triggers(
homes: &HarnessHomes,
harness: Option<&str>,
) -> Result<Vec<TriggerRow>, TriggerError> {
let harnesses: Vec<&str> = match harness {
Some(id) if TRIGGER_HARNESSES.contains(&id) => vec![id],
Some(id) => {
return Err(TriggerError::UnsupportedHarness {
harness: id.to_string(),
})
}
None => TRIGGER_HARNESSES.to_vec(),
};
let mut rows = Vec::new();
for id in harnesses {
match id {
HarnessId::HERMES => rows.extend(hermes_rows(
HarnessId::HERMES,
homes.hermes.parent().unwrap_or(Path::new(".")),
None,
)),
HarnessId::OPENCLAW => rows.extend(openclaw_rows(&homes.openclaw)),
HarnessId::ORCHESTRATOR => {
for (name, dir) in crate::orchestrator_profile_dirs(&homes.orchestrator) {
let profile = (name != "default").then_some(name);
rows.extend(hermes_rows(
HarnessId::ORCHESTRATOR,
&dir,
profile.as_deref(),
));
}
}
_ => {}
}
}
Ok(rows)
}
fn text(value: &Value, key: &str) -> Option<String> {
value
.get(key)
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(str::to_string)
}
fn string_list(value: &Value, key: &str) -> Vec<String> {
value
.get(key)
.and_then(Value::as_array)
.map(|list| {
list.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect()
})
.unwrap_or_default()
}
fn hermes_route_row(
harness: &str,
name: &str,
route: &Value,
source: &str,
profile: Option<&str>,
) -> TriggerRow {
let deliver_extra = route.get("deliver_extra").cloned().unwrap_or(Value::Null);
TriggerRow {
name: name.to_string(),
harness: harness.into(),
kind: TriggerKind::Webhook,
route: match profile {
Some(p) => format!("/p/{p}/webhooks/{name}"),
None => format!("/webhooks/{name}"),
},
events: string_list(route, "events"),
target: TriggerTarget {
action: Some("background".into()),
profile: profile.map(str::to_string),
..TriggerTarget::default()
},
deliver: TriggerDeliver {
target: text(route, "deliver").or_else(|| Some("log".into())),
chat_id: text(&deliver_extra, "chat_id").or_else(|| text(route, "deliver_chat_id")),
},
enabled: route
.get("enabled")
.and_then(Value::as_bool)
.unwrap_or(true),
authenticated: route.get("secret").is_some(),
source: source.to_string(),
description: text(route, "description"),
}
}
fn hermes_rows(harness: &str, home: &Path, profile: Option<&str>) -> Vec<TriggerRow> {
let mut rows = Vec::new();
let subs_path = home.join("webhook_subscriptions.json");
if let Ok(text) = std::fs::read_to_string(&subs_path) {
if let Ok(Value::Object(map)) = serde_json::from_str::<Value>(&text) {
let source = subs_path.display().to_string();
for (name, route) in map {
rows.push(hermes_route_row(harness, &name, &route, &source, profile));
}
}
}
let config_path = home.join("config.yaml");
if let Ok(config) = std::fs::read_to_string(&config_path) {
let webhook = yaml_child(&yaml_child(&config, "platforms"), "webhook");
let routes = yaml_child(&yaml_child(&webhook, "extra"), "routes");
let source = config_path.display().to_string();
for (name, block) in yaml_route_blocks(&routes) {
let mut route = serde_json::Map::new();
for line in block.lines() {
let trimmed = line.trim();
let (Some(key), Some(value)) = (yaml_key(trimmed), scalar(trimmed)) else {
continue;
};
route.insert(key.to_string(), Value::String(value));
}
if let Some(events) = route.get("events").and_then(Value::as_str) {
let list: Vec<Value> = events
.trim_matches(|c| c == '[' || c == ']')
.split(',')
.map(|e| e.trim().trim_matches(|c| c == '"' || c == '\''))
.filter(|e| !e.is_empty())
.map(|e| Value::String(e.to_string()))
.collect();
route.insert("events".into(), Value::Array(list));
}
if let Some(enabled) = route.get("enabled").and_then(Value::as_str) {
let flag = enabled != "false";
route.insert("enabled".into(), Value::Bool(flag));
}
rows.push(hermes_route_row(
harness,
&name,
&Value::Object(route),
&source,
profile,
));
}
}
rows
}
fn yaml_route_blocks(block: &str) -> Vec<(String, String)> {
let mut out: Vec<(String, String)> = Vec::new();
let mut base: Option<usize> = None;
for line in block.lines() {
let trimmed = line.trim_start();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let indent = line.len() - trimmed.len();
let base_indent = *base.get_or_insert(indent);
if indent == base_indent {
if let Some(name) = yaml_key(trimmed) {
out.push((name.to_string(), String::new()));
}
} else if let Some((_, body)) = out.last_mut() {
body.push_str(line);
body.push('\n');
}
}
out
}
fn scalar(line: &str) -> Option<String> {
let (_, tail) = line.split_once(':')?;
let tail = tail.trim();
let tail = tail.split_once(" #").map(|(head, _)| head).unwrap_or(tail);
Some(
tail.trim()
.trim_matches(|ch| ch == '"' || ch == '\'')
.to_string(),
)
}
fn openclaw_rows(home: &Path) -> Vec<TriggerRow> {
let config_path = home.join("openclaw.json");
let config = read_json5(&config_path);
let hooks = config.get("hooks").cloned().unwrap_or(Value::Null);
if hooks.is_null() {
return Vec::new();
}
let source = config_path.display().to_string();
let enabled = hooks
.get("enabled")
.and_then(Value::as_bool)
.unwrap_or(false);
let authenticated = hooks.get("token").is_some() || hooks.get("tokenFile").is_some();
let base = text(&hooks, "path").unwrap_or_else(|| "/hooks".into());
let base = base.trim_end_matches('/').to_string();
let mut rows = vec![
TriggerRow {
name: "wake".into(),
harness: HarnessId::OPENCLAW.into(),
kind: TriggerKind::BuiltinWake,
route: format!("{base}/wake"),
events: Vec::new(),
target: TriggerTarget {
action: Some("wake".into()),
session_key: Some("main".into()),
..TriggerTarget::default()
},
deliver: TriggerDeliver::default(),
enabled,
authenticated,
source: source.clone(),
description: Some("built-in: enqueue a system event into the main session".into()),
},
TriggerRow {
name: "agent".into(),
harness: HarnessId::OPENCLAW.into(),
kind: TriggerKind::BuiltinAgent,
route: format!("{base}/agent"),
events: Vec::new(),
target: TriggerTarget {
action: Some("agent".into()),
session_key: Some("isolated".into()),
..TriggerTarget::default()
},
deliver: TriggerDeliver::default(),
enabled,
authenticated,
source: source.clone(),
description: Some("built-in: run an isolated agent turn".into()),
},
];
if let Some(mappings) = hooks.get("mappings").and_then(Value::as_array) {
for (index, mapping) in mappings.iter().enumerate() {
let matcher = mapping.get("match").cloned().unwrap_or(Value::Null);
let name = text(mapping, "id")
.or_else(|| text(&matcher, "path").map(|p| p.trim_start_matches('/').to_string()))
.unwrap_or_else(|| format!("mapping-{index}"));
let path = text(&matcher, "path")
.map(|p| format!("{base}/{}", p.trim_start_matches('/')))
.unwrap_or_else(|| format!("{base}/{name}"));
let mut events = Vec::new();
for key in ["source", "event"] {
if let Some(v) = text(&matcher, key) {
events.push(format!("{key}={v}"));
}
}
rows.push(TriggerRow {
name,
harness: HarnessId::OPENCLAW.into(),
kind: TriggerKind::HookMapping,
route: path,
events,
target: TriggerTarget {
action: text(mapping, "action"),
profile: text(mapping, "agentId"),
session_key: text(mapping, "sessionKey"),
wake_mode: text(mapping, "wakeMode"),
model: text(mapping, "model"),
},
deliver: TriggerDeliver {
target: text(mapping, "deliver").or_else(|| text(mapping, "channel")),
chat_id: text(mapping, "to"),
},
enabled: enabled
&& mapping
.get("enabled")
.and_then(Value::as_bool)
.unwrap_or(true),
authenticated,
source: source.clone(),
description: text(mapping, "description"),
});
}
}
rows
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"supercode-triggers-{tag}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn hermes_dynamic_and_static_routes_both_list_without_their_secrets() {
let dir = scratch("hermes");
std::fs::write(
dir.join("webhook_subscriptions.json"),
r#"{"deploys": {"description": "CI deploys", "events": ["push", "release"], "prompt": "Summarize {repo}", "skills": ["git"], "deliver": "telegram", "deliver_extra": {"chat_id": "123"}, "secret": "FAKE-HMAC-DO-NOT-EMIT", "created_at": "2026-09-03T00:00:00Z"}}"#,
)
.unwrap();
std::fs::write(
dir.join("config.yaml"),
"platforms:\n webhook:\n enabled: true\n extra:\n routes:\n alerts:\n prompt: \"Triage\"\n deliver: log\n enabled: false\n secret: \"FAKE-STATIC-SECRET\"\n",
)
.unwrap();
let rows = hermes_rows(HarnessId::HERMES, &dir, None);
assert_eq!(rows.len(), 2, "{rows:#?}");
let deploys = &rows[0];
assert_eq!(deploys.route, "/webhooks/deploys");
assert_eq!(deploys.events, vec!["push", "release"]);
assert_eq!(deploys.deliver.target.as_deref(), Some("telegram"));
assert_eq!(deploys.deliver.chat_id.as_deref(), Some("123"));
assert!(deploys.authenticated && deploys.enabled);
let alerts = &rows[1];
assert_eq!(alerts.kind, TriggerKind::Webhook);
assert!(!alerts.enabled && alerts.authenticated);
let rendered = serde_json::to_string(&rows).unwrap();
assert!(!rendered.contains("FAKE-"), "{rendered}");
}
#[test]
fn openclaw_hooks_block_yields_builtins_and_mappings() {
let dir = scratch("openclaw");
std::fs::write(
dir.join("openclaw.json"),
r#"{ "hooks": { "enabled": true, "token": "FAKE-HOOK-TOKEN", "path": "/hooks",
"mappings": [ { "id": "gmail", "match": { "path": "gmail", "source": "gmail" }, "action": "agent", "agentId": "main", "sessionKey": "hook:gmail:{{id}}", "deliver": "slack", "to": "C1" } ] } }"#,
)
.unwrap();
let rows = openclaw_rows(dir.path_buf_hack());
let names: Vec<&str> = rows.iter().map(|r| r.name.as_str()).collect();
assert_eq!(names, vec!["wake", "agent", "gmail"]);
assert_eq!(rows[2].route, "/hooks/gmail");
assert_eq!(rows[2].events, vec!["source=gmail"]);
assert_eq!(rows[2].target.action.as_deref(), Some("agent"));
assert_eq!(
rows[2].target.session_key.as_deref(),
Some("hook:gmail:{{id}}")
);
assert!(rows.iter().all(|r| r.authenticated && r.enabled));
let rendered = serde_json::to_string(&rows).unwrap();
assert!(!rendered.contains("FAKE-"), "{rendered}");
}
trait PathBufHack {
fn path_buf_hack(&self) -> &Path;
}
impl PathBufHack for std::path::PathBuf {
fn path_buf_hack(&self) -> &Path {
self.as_path()
}
}
#[test]
fn a_core_harness_is_refused() {
let err = list_triggers(&HarnessHomes::default(), Some("claude-code")).unwrap_err();
assert!(err.to_string().contains("triggers.list"));
}
}