1use std::path::Path;
25
26use serde::{Deserialize, Serialize};
27use serde_json::Value;
28
29use crate::catalog::HarnessHomes;
30use crate::profiles::{read_json5, yaml_child, yaml_key};
31use crate::HarnessId;
32
33pub const TRIGGERS_SCHEMA: &str = "supercode.triggers.v1";
35
36pub const TRIGGER_HARNESSES: &[&str] = &[
38 HarnessId::HERMES,
39 HarnessId::OPENCLAW,
40 HarnessId::ORCHESTRATOR,
41];
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "snake_case")]
46pub enum TriggerKind {
47 Webhook,
49 HookMapping,
51 BuiltinWake,
53 BuiltinAgent,
55}
56
57#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
59pub struct TriggerTarget {
60 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub action: Option<String>,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub profile: Option<String>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub session_key: Option<String>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub wake_mode: Option<String>,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub model: Option<String>,
71}
72
73#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
75pub struct TriggerDeliver {
76 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub target: Option<String>,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub chat_id: Option<String>,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84pub struct TriggerRow {
85 pub name: String,
86 pub harness: String,
87 pub kind: TriggerKind,
88 pub route: String,
90 #[serde(default, skip_serializing_if = "Vec::is_empty")]
93 pub events: Vec<String>,
94 pub target: TriggerTarget,
95 pub deliver: TriggerDeliver,
96 pub enabled: bool,
97 pub authenticated: bool,
99 pub source: String,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub description: Option<String>,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq)]
107pub enum TriggerError {
108 UnsupportedHarness { harness: String },
110}
111
112impl std::fmt::Display for TriggerError {
113 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114 match self {
115 TriggerError::UnsupportedHarness { harness } => write!(
116 f,
117 "`{harness}` has no inbound-trigger store supercode reads; `triggers.list` is supported for: {}",
118 TRIGGER_HARNESSES.join(", ")
119 ),
120 }
121 }
122}
123
124impl std::error::Error for TriggerError {}
125
126pub fn list_triggers(
128 homes: &HarnessHomes,
129 harness: Option<&str>,
130) -> Result<Vec<TriggerRow>, TriggerError> {
131 let harnesses: Vec<&str> = match harness {
132 Some(id) if TRIGGER_HARNESSES.contains(&id) => vec![id],
133 Some(id) => {
134 return Err(TriggerError::UnsupportedHarness {
135 harness: id.to_string(),
136 })
137 }
138 None => TRIGGER_HARNESSES.to_vec(),
139 };
140 let mut rows = Vec::new();
141 for id in harnesses {
142 match id {
143 HarnessId::HERMES => rows.extend(hermes_rows(
144 HarnessId::HERMES,
145 homes.hermes.parent().unwrap_or(Path::new(".")),
146 None,
147 )),
148 HarnessId::OPENCLAW => rows.extend(openclaw_rows(&homes.openclaw)),
149 HarnessId::ORCHESTRATOR => {
154 for (name, dir) in crate::orchestrator_profile_dirs(&homes.orchestrator) {
155 let profile = (name != "default").then_some(name);
156 rows.extend(hermes_rows(
157 HarnessId::ORCHESTRATOR,
158 &dir,
159 profile.as_deref(),
160 ));
161 }
162 }
163 _ => {}
164 }
165 }
166 Ok(rows)
167}
168
169fn text(value: &Value, key: &str) -> Option<String> {
170 value
171 .get(key)
172 .and_then(Value::as_str)
173 .filter(|s| !s.is_empty())
174 .map(str::to_string)
175}
176
177fn string_list(value: &Value, key: &str) -> Vec<String> {
178 value
179 .get(key)
180 .and_then(Value::as_array)
181 .map(|list| {
182 list.iter()
183 .filter_map(Value::as_str)
184 .map(str::to_string)
185 .collect()
186 })
187 .unwrap_or_default()
188}
189
190fn hermes_route_row(
191 harness: &str,
192 name: &str,
193 route: &Value,
194 source: &str,
195 profile: Option<&str>,
196) -> TriggerRow {
197 let deliver_extra = route.get("deliver_extra").cloned().unwrap_or(Value::Null);
198 TriggerRow {
199 name: name.to_string(),
200 harness: harness.into(),
201 kind: TriggerKind::Webhook,
202 route: match profile {
203 Some(p) => format!("/p/{p}/webhooks/{name}"),
204 None => format!("/webhooks/{name}"),
205 },
206 events: string_list(route, "events"),
207 target: TriggerTarget {
208 action: Some("background".into()),
209 profile: profile.map(str::to_string),
210 ..TriggerTarget::default()
211 },
212 deliver: TriggerDeliver {
213 target: text(route, "deliver").or_else(|| Some("log".into())),
214 chat_id: text(&deliver_extra, "chat_id").or_else(|| text(route, "deliver_chat_id")),
215 },
216 enabled: route
217 .get("enabled")
218 .and_then(Value::as_bool)
219 .unwrap_or(true),
220 authenticated: route.get("secret").is_some(),
222 source: source.to_string(),
223 description: text(route, "description"),
224 }
225}
226
227fn hermes_rows(harness: &str, home: &Path, profile: Option<&str>) -> Vec<TriggerRow> {
234 let mut rows = Vec::new();
235 let subs_path = home.join("webhook_subscriptions.json");
236 if let Ok(text) = std::fs::read_to_string(&subs_path) {
237 if let Ok(Value::Object(map)) = serde_json::from_str::<Value>(&text) {
238 let source = subs_path.display().to_string();
239 for (name, route) in map {
240 rows.push(hermes_route_row(harness, &name, &route, &source, profile));
241 }
242 }
243 }
244 let config_path = home.join("config.yaml");
245 if let Ok(config) = std::fs::read_to_string(&config_path) {
246 let webhook = yaml_child(&yaml_child(&config, "platforms"), "webhook");
247 let routes = yaml_child(&yaml_child(&webhook, "extra"), "routes");
248 let source = config_path.display().to_string();
249 for (name, block) in yaml_route_blocks(&routes) {
250 let mut route = serde_json::Map::new();
251 for line in block.lines() {
252 let trimmed = line.trim();
253 let (Some(key), Some(value)) = (yaml_key(trimmed), scalar(trimmed)) else {
254 continue;
255 };
256 route.insert(key.to_string(), Value::String(value));
257 }
258 if let Some(events) = route.get("events").and_then(Value::as_str) {
259 let list: Vec<Value> = events
260 .trim_matches(|c| c == '[' || c == ']')
261 .split(',')
262 .map(|e| e.trim().trim_matches(|c| c == '"' || c == '\''))
263 .filter(|e| !e.is_empty())
264 .map(|e| Value::String(e.to_string()))
265 .collect();
266 route.insert("events".into(), Value::Array(list));
267 }
268 if let Some(enabled) = route.get("enabled").and_then(Value::as_str) {
269 let flag = enabled != "false";
270 route.insert("enabled".into(), Value::Bool(flag));
271 }
272 rows.push(hermes_route_row(
273 harness,
274 &name,
275 &Value::Object(route),
276 &source,
277 profile,
278 ));
279 }
280 }
281 rows
282}
283
284fn yaml_route_blocks(block: &str) -> Vec<(String, String)> {
286 let mut out: Vec<(String, String)> = Vec::new();
287 let mut base: Option<usize> = None;
288 for line in block.lines() {
289 let trimmed = line.trim_start();
290 if trimmed.is_empty() || trimmed.starts_with('#') {
291 continue;
292 }
293 let indent = line.len() - trimmed.len();
294 let base_indent = *base.get_or_insert(indent);
295 if indent == base_indent {
296 if let Some(name) = yaml_key(trimmed) {
297 out.push((name.to_string(), String::new()));
298 }
299 } else if let Some((_, body)) = out.last_mut() {
300 body.push_str(line);
301 body.push('\n');
302 }
303 }
304 out
305}
306
307fn scalar(line: &str) -> Option<String> {
308 let (_, tail) = line.split_once(':')?;
309 let tail = tail.trim();
310 let tail = tail.split_once(" #").map(|(head, _)| head).unwrap_or(tail);
311 Some(
312 tail.trim()
313 .trim_matches(|ch| ch == '"' || ch == '\'')
314 .to_string(),
315 )
316}
317
318fn openclaw_rows(home: &Path) -> Vec<TriggerRow> {
320 let config_path = home.join("openclaw.json");
321 let config = read_json5(&config_path);
322 let hooks = config.get("hooks").cloned().unwrap_or(Value::Null);
323 if hooks.is_null() {
324 return Vec::new();
325 }
326 let source = config_path.display().to_string();
327 let enabled = hooks
328 .get("enabled")
329 .and_then(Value::as_bool)
330 .unwrap_or(false);
331 let authenticated = hooks.get("token").is_some() || hooks.get("tokenFile").is_some();
333 let base = text(&hooks, "path").unwrap_or_else(|| "/hooks".into());
334 let base = base.trim_end_matches('/').to_string();
335 let mut rows = vec![
336 TriggerRow {
337 name: "wake".into(),
338 harness: HarnessId::OPENCLAW.into(),
339 kind: TriggerKind::BuiltinWake,
340 route: format!("{base}/wake"),
341 events: Vec::new(),
342 target: TriggerTarget {
343 action: Some("wake".into()),
344 session_key: Some("main".into()),
345 ..TriggerTarget::default()
346 },
347 deliver: TriggerDeliver::default(),
348 enabled,
349 authenticated,
350 source: source.clone(),
351 description: Some("built-in: enqueue a system event into the main session".into()),
352 },
353 TriggerRow {
354 name: "agent".into(),
355 harness: HarnessId::OPENCLAW.into(),
356 kind: TriggerKind::BuiltinAgent,
357 route: format!("{base}/agent"),
358 events: Vec::new(),
359 target: TriggerTarget {
360 action: Some("agent".into()),
361 session_key: Some("isolated".into()),
362 ..TriggerTarget::default()
363 },
364 deliver: TriggerDeliver::default(),
365 enabled,
366 authenticated,
367 source: source.clone(),
368 description: Some("built-in: run an isolated agent turn".into()),
369 },
370 ];
371 if let Some(mappings) = hooks.get("mappings").and_then(Value::as_array) {
372 for (index, mapping) in mappings.iter().enumerate() {
373 let matcher = mapping.get("match").cloned().unwrap_or(Value::Null);
374 let name = text(mapping, "id")
375 .or_else(|| text(&matcher, "path").map(|p| p.trim_start_matches('/').to_string()))
376 .unwrap_or_else(|| format!("mapping-{index}"));
377 let path = text(&matcher, "path")
378 .map(|p| format!("{base}/{}", p.trim_start_matches('/')))
379 .unwrap_or_else(|| format!("{base}/{name}"));
380 let mut events = Vec::new();
381 for key in ["source", "event"] {
382 if let Some(v) = text(&matcher, key) {
383 events.push(format!("{key}={v}"));
384 }
385 }
386 rows.push(TriggerRow {
387 name,
388 harness: HarnessId::OPENCLAW.into(),
389 kind: TriggerKind::HookMapping,
390 route: path,
391 events,
392 target: TriggerTarget {
393 action: text(mapping, "action"),
394 profile: text(mapping, "agentId"),
395 session_key: text(mapping, "sessionKey"),
396 wake_mode: text(mapping, "wakeMode"),
397 model: text(mapping, "model"),
398 },
399 deliver: TriggerDeliver {
400 target: text(mapping, "deliver").or_else(|| text(mapping, "channel")),
401 chat_id: text(mapping, "to"),
402 },
403 enabled: enabled
404 && mapping
405 .get("enabled")
406 .and_then(Value::as_bool)
407 .unwrap_or(true),
408 authenticated,
409 source: source.clone(),
410 description: text(mapping, "description"),
411 });
412 }
413 }
414 rows
415}
416
417#[cfg(test)]
418mod tests {
419 use super::*;
420
421 fn scratch(tag: &str) -> std::path::PathBuf {
422 let dir = std::env::temp_dir().join(format!(
423 "supercode-triggers-{tag}-{}-{}",
424 std::process::id(),
425 std::time::SystemTime::now()
426 .duration_since(std::time::UNIX_EPOCH)
427 .unwrap()
428 .as_nanos()
429 ));
430 std::fs::create_dir_all(&dir).unwrap();
431 dir
432 }
433
434 #[test]
435 fn hermes_dynamic_and_static_routes_both_list_without_their_secrets() {
436 let dir = scratch("hermes");
437 std::fs::write(
438 dir.join("webhook_subscriptions.json"),
439 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"}}"#,
440 )
441 .unwrap();
442 std::fs::write(
443 dir.join("config.yaml"),
444 "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",
445 )
446 .unwrap();
447 let rows = hermes_rows(HarnessId::HERMES, &dir, None);
448 assert_eq!(rows.len(), 2, "{rows:#?}");
449 let deploys = &rows[0];
450 assert_eq!(deploys.route, "/webhooks/deploys");
451 assert_eq!(deploys.events, vec!["push", "release"]);
452 assert_eq!(deploys.deliver.target.as_deref(), Some("telegram"));
453 assert_eq!(deploys.deliver.chat_id.as_deref(), Some("123"));
454 assert!(deploys.authenticated && deploys.enabled);
455 let alerts = &rows[1];
456 assert_eq!(alerts.kind, TriggerKind::Webhook);
457 assert!(!alerts.enabled && alerts.authenticated);
458 let rendered = serde_json::to_string(&rows).unwrap();
459 assert!(!rendered.contains("FAKE-"), "{rendered}");
460 }
461
462 #[test]
463 fn openclaw_hooks_block_yields_builtins_and_mappings() {
464 let dir = scratch("openclaw");
465 std::fs::write(
466 dir.join("openclaw.json"),
467 r#"{ "hooks": { "enabled": true, "token": "FAKE-HOOK-TOKEN", "path": "/hooks",
468 "mappings": [ { "id": "gmail", "match": { "path": "gmail", "source": "gmail" }, "action": "agent", "agentId": "main", "sessionKey": "hook:gmail:{{id}}", "deliver": "slack", "to": "C1" } ] } }"#,
469 )
470 .unwrap();
471 let rows = openclaw_rows(dir.path_buf_hack());
472 let names: Vec<&str> = rows.iter().map(|r| r.name.as_str()).collect();
473 assert_eq!(names, vec!["wake", "agent", "gmail"]);
474 assert_eq!(rows[2].route, "/hooks/gmail");
475 assert_eq!(rows[2].events, vec!["source=gmail"]);
476 assert_eq!(rows[2].target.action.as_deref(), Some("agent"));
477 assert_eq!(
478 rows[2].target.session_key.as_deref(),
479 Some("hook:gmail:{{id}}")
480 );
481 assert!(rows.iter().all(|r| r.authenticated && r.enabled));
482 let rendered = serde_json::to_string(&rows).unwrap();
483 assert!(!rendered.contains("FAKE-"), "{rendered}");
484 }
485
486 trait PathBufHack {
487 fn path_buf_hack(&self) -> &Path;
488 }
489 impl PathBufHack for std::path::PathBuf {
490 fn path_buf_hack(&self) -> &Path {
491 self.as_path()
492 }
493 }
494
495 #[test]
496 fn a_core_harness_is_refused() {
497 let err = list_triggers(&HarnessHomes::default(), Some("claude-code")).unwrap_err();
498 assert!(err.to_string().contains("triggers.list"));
499 }
500}