1use std::collections::BTreeMap;
23use std::path::{Path, PathBuf};
24
25use rusqlite::Connection;
26use serde::{Deserialize, Serialize};
27use serde_json::Value;
28
29use crate::{HarnessHomes, HarnessId};
30
31pub const PROFILES_SCHEMA: &str = "supercode.profiles.v1";
33
34pub const PROFILE_HARNESSES: &[&str] = &[
37 HarnessId::SUPERCODE,
38 HarnessId::CODEX,
39 HarnessId::HERMES,
40 HarnessId::OPENCLAW,
41 HarnessId::ORCHESTRATOR,
42];
43
44pub const HERMES_DEFAULT_PROFILE: &str = "default";
47
48pub const OPENCLAW_DEFAULT_AGENT: &str = "main";
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case")]
56pub enum ProfileKind {
57 Preset,
59 CodexProfile,
61 HermesProfile,
63 OpenclawAgent,
65 OrchestratorProfile,
69}
70
71impl ProfileKind {
72 pub const fn as_str(self) -> &'static str {
74 match self {
75 Self::Preset => "preset",
76 Self::CodexProfile => "codex_profile",
77 Self::HermesProfile => "hermes_profile",
78 Self::OpenclawAgent => "openclaw_agent",
79 Self::OrchestratorProfile => "orchestrator_profile",
80 }
81 }
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86pub struct ProfileRow {
87 pub name: String,
89 pub harness: String,
91 pub kind: ProfileKind,
93 pub home: Option<PathBuf>,
95 pub default: bool,
97 pub routes: Option<u64>,
100 pub sessions: Option<u64>,
102 pub model: Option<String>,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub worker: Option<String>,
110}
111
112#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
114pub enum ProfileError {
115 #[error("harness `{harness}` has no profile concept (profiles exist for: {})", PROFILE_HARNESSES.join(", "))]
117 UnsupportedHarness {
118 harness: String,
120 },
121 #[error("`{harness}` has no profile `{name}`")]
123 NotFound {
124 harness: String,
126 name: String,
128 },
129}
130
131pub fn list_profiles(
135 homes: &HarnessHomes,
136 harness: Option<&str>,
137) -> Result<Vec<ProfileRow>, ProfileError> {
138 if let Some(harness) = harness {
139 if !PROFILE_HARNESSES.contains(&harness) {
140 return Err(ProfileError::UnsupportedHarness {
141 harness: harness.to_string(),
142 });
143 }
144 }
145 let mut rows = Vec::new();
146 for id in PROFILE_HARNESSES {
147 if harness.is_some_and(|requested| requested != *id) {
148 continue;
149 }
150 match *id {
151 HarnessId::SUPERCODE => rows.extend(preset_rows()),
152 HarnessId::CODEX => rows.extend(codex_rows(&homes.codex)),
153 HarnessId::HERMES => rows.extend(hermes_rows(&homes.hermes)),
154 HarnessId::OPENCLAW => rows.extend(openclaw_rows(&homes.openclaw)),
155 HarnessId::ORCHESTRATOR => rows.extend(orchestrator_rows(&homes.orchestrator)),
156 _ => {}
157 }
158 }
159 Ok(rows)
160}
161
162pub fn get_profile(
164 homes: &HarnessHomes,
165 harness: &str,
166 name: &str,
167) -> Result<ProfileRow, ProfileError> {
168 list_profiles(homes, Some(harness))?
169 .into_iter()
170 .find(|row| row.name == name)
171 .ok_or_else(|| ProfileError::NotFound {
172 harness: harness.to_string(),
173 name: name.to_string(),
174 })
175}
176
177fn preset_rows() -> Vec<ProfileRow> {
185 let mut rows: Vec<ProfileRow> = crate::presets::RESERVED_PRESET_NAMES
186 .iter()
187 .map(|name| ProfileRow {
188 name: (*name).to_string(),
189 harness: HarnessId::SUPERCODE.to_string(),
190 kind: ProfileKind::Preset,
191 home: None,
192 default: *name == "supercode-default",
193 routes: None,
194 sessions: None,
195 model: crate::presets::lookup(name)
196 .and_then(|text| toml::from_str::<toml::Value>(text).ok())
197 .and_then(|doc| {
198 doc.get("core")
199 .and_then(|core| core.get("model"))
200 .and_then(toml::Value::as_str)
201 .map(str::to_string)
202 }),
203 worker: None,
204 })
205 .collect();
206 rows.sort_by(|left, right| left.name.cmp(&right.name));
207 rows
208}
209
210fn codex_rows(sessions_root: &Path) -> Vec<ProfileRow> {
221 let Some(codex_home) = sessions_root.parent() else {
222 return Vec::new();
223 };
224 let Ok(text) = std::fs::read_to_string(codex_home.join("config.toml")) else {
225 return Vec::new();
226 };
227 let Ok(doc) = toml::from_str::<toml::Value>(&text) else {
228 return Vec::new();
229 };
230 let selected = doc.get("profile").and_then(toml::Value::as_str);
231 let Some(profiles) = doc.get("profiles").and_then(toml::Value::as_table) else {
232 return Vec::new();
233 };
234 profiles
235 .iter()
236 .map(|(name, table)| ProfileRow {
237 name: name.clone(),
238 harness: HarnessId::CODEX.to_string(),
239 kind: ProfileKind::CodexProfile,
240 home: None,
241 default: selected == Some(name.as_str()),
242 routes: None,
243 sessions: None,
244 model: table
245 .get("model")
246 .and_then(toml::Value::as_str)
247 .map(str::to_string),
248 worker: None,
249 })
250 .collect()
251}
252
253fn hermes_rows(state_db: &Path) -> Vec<ProfileRow> {
264 let Some(home) = state_db.parent() else {
265 return Vec::new();
266 };
267 if !home.is_dir() {
268 return Vec::new();
269 }
270 let config = std::fs::read_to_string(home.join("config.yaml")).ok();
271 let gateway = yaml_child(config.as_deref().unwrap_or_default(), "gateway");
272 let profile_routes = yaml_child(&gateway, "profile_routes");
273 let routes = config
274 .as_ref()
275 .map(|_| count_yaml_route_targets(&profile_routes));
276
277 let mut names = vec![HERMES_DEFAULT_PROFILE.to_string()];
278 if let Ok(entries) = std::fs::read_dir(home.join("profiles")) {
279 let mut found: Vec<String> = entries
280 .flatten()
281 .filter(|entry| entry.path().is_dir())
282 .filter_map(|entry| entry.file_name().into_string().ok())
283 .collect();
284 found.sort();
285 names.extend(found);
286 }
287 names
288 .into_iter()
289 .map(|name| {
290 let is_default = name == HERMES_DEFAULT_PROFILE;
291 let profile_home = if is_default {
292 home.to_path_buf()
293 } else {
294 home.join("profiles").join(&name)
295 };
296 let model = if is_default {
297 config.as_deref().and_then(hermes_model)
298 } else {
299 std::fs::read_to_string(profile_home.join("config.yaml"))
300 .ok()
301 .as_deref()
302 .and_then(hermes_model)
303 };
304 ProfileRow {
305 name: name.clone(),
306 harness: HarnessId::HERMES.to_string(),
307 kind: ProfileKind::HermesProfile,
308 home: Some(profile_home),
309 default: is_default,
310 routes: routes
311 .as_ref()
312 .map(|counts| counts.get(name.as_str()).copied().unwrap_or(0)),
313 sessions: hermes_session_count(state_db, (!is_default).then_some(name.as_str())),
314 model,
315 worker: None,
316 }
317 })
318 .collect()
319}
320
321fn orchestrator_rows(root: &Path) -> Vec<ProfileRow> {
335 let dirs = crate::orchestrator_profile_dirs(root);
336 let mut routes: Option<BTreeMap<String, u64>> = None;
339 for (_, dir) in &dirs {
340 let Ok(config) = std::fs::read_to_string(dir.join("config.yaml")) else {
341 continue;
342 };
343 let block = yaml_child(&yaml_child(&config, "gateway"), "profile_routes");
344 let counts = routes.get_or_insert_with(BTreeMap::new);
345 for (target, found) in count_yaml_route_targets(&block) {
346 *counts.entry(target).or_default() += found;
347 }
348 }
349 dirs.into_iter()
350 .map(|(name, dir)| {
351 let config = std::fs::read_to_string(dir.join("config.yaml")).ok();
352 let worker = config.as_deref().map(|text| yaml_child(text, "worker"));
353 ProfileRow {
354 name: name.clone(),
355 harness: HarnessId::ORCHESTRATOR.to_string(),
356 kind: ProfileKind::OrchestratorProfile,
357 default: name == HERMES_DEFAULT_PROFILE,
358 routes: routes
359 .as_ref()
360 .map(|counts| counts.get(name.as_str()).copied().unwrap_or(0)),
361 sessions: orchestrator_binding_count(&dir.join("state.db")),
362 model: worker
363 .as_deref()
364 .and_then(|block| yaml_scalar(block, "model")),
365 worker: worker
366 .as_deref()
367 .and_then(|block| yaml_scalar(block, "harness")),
368 home: Some(dir),
369 }
370 })
371 .collect()
372}
373
374fn orchestrator_binding_count(state_db: &Path) -> Option<u64> {
377 let connection = Connection::open_with_flags(
378 state_db,
379 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
380 )
381 .ok()?;
382 let count: i64 = connection
383 .query_row("SELECT COUNT(*) FROM bindings", [], |row| row.get(0))
384 .ok()?;
385 Some(count.max(0) as u64)
386}
387
388fn hermes_model(config: &str) -> Option<String> {
395 if let Some(pinned) = yaml_scalar(config, "model") {
396 return Some(pinned);
397 }
398 let block = yaml_child(config, "model");
399 yaml_scalar(&block, "default").or_else(|| yaml_scalar(&block, "model"))
400}
401
402fn hermes_session_count(state_db: &Path, profile: Option<&str>) -> Option<u64> {
406 let connection = Connection::open_with_flags(
407 state_db,
408 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
409 )
410 .ok()?;
411 let count: i64 = match profile {
412 Some(name) => connection
413 .query_row(
414 "SELECT COUNT(*) FROM sessions WHERE profile_name = ?1",
415 [name],
416 |row| row.get(0),
417 )
418 .ok()?,
419 None => connection
420 .query_row(
421 "SELECT COUNT(*) FROM sessions WHERE profile_name IS NULL",
422 [],
423 |row| row.get(0),
424 )
425 .ok()?,
426 };
427 Some(count.max(0) as u64)
428}
429
430fn openclaw_rows(home: &Path) -> Vec<ProfileRow> {
454 let config_path = home.join("openclaw.json");
455 let config = read_json5(&config_path);
456 let entries = config.pointer("/agents/list").map(entry_map);
457 let bindings = config.pointer("/bindings").and_then(Value::as_array);
458 let entries = entries.or_else(|| config.pointer("/agents/entries").map(entry_map));
459 let route_counts = (!config.is_null()).then(|| {
462 bindings
463 .map(|list| count_binding_targets(list))
464 .unwrap_or_default()
465 });
466
467 let declared_ids: Vec<&str> = entries
468 .iter()
469 .flatten()
470 .map(|(id, _)| id.as_str())
471 .collect();
472 let mut names: Vec<String> = Vec::new();
473 if let Ok(dirs) = std::fs::read_dir(home.join("agents")) {
474 names.extend(
475 dirs.flatten()
476 .filter(|entry| entry.path().is_dir())
477 .filter(|entry| {
478 declared_ids.contains(&entry.file_name().to_string_lossy().as_ref())
481 || !directory_is_empty(&entry.path())
482 })
483 .filter_map(|entry| entry.file_name().into_string().ok()),
484 );
485 }
486 if let Some(declared) = &entries {
487 names.extend(declared.iter().map(|(id, _)| id.clone()));
488 }
489 names.sort();
490 names.dedup();
491
492 let declared_default = entries.as_ref().and_then(|entries| {
497 entries
498 .iter()
499 .find(|(_, entry)| entry.get("default").and_then(Value::as_bool) == Some(true))
500 .map(|(id, _)| id.clone())
501 .or_else(|| {
502 entries
503 .iter()
504 .find(|(id, _)| id == OPENCLAW_DEFAULT_AGENT)
505 .map(|(id, _)| id.clone())
506 })
507 .or_else(|| entries.first().map(|(id, _)| id.clone()))
508 });
509
510 names
511 .into_iter()
512 .map(|name| {
513 let agent_home = home.join("agents").join(&name);
514 let sessions = std::fs::read_dir(agent_home.join("sessions"))
517 .ok()
518 .map(|dir| {
519 dir.flatten()
520 .filter(|entry| {
521 let name = entry.file_name();
522 let name = name.to_string_lossy();
523 name.ends_with(".jsonl") && !name.ends_with(".trajectory.jsonl")
524 })
525 .count() as u64
526 });
527 let entry = entries.as_ref().and_then(|entries| {
528 entries
529 .iter()
530 .find(|(id, _)| *id == name)
531 .map(|(_, entry)| entry)
532 });
533 ProfileRow {
534 name: name.clone(),
535 harness: HarnessId::OPENCLAW.to_string(),
536 kind: ProfileKind::OpenclawAgent,
537 home: Some(agent_home),
538 default: declared_default.as_deref() == Some(name.as_str()),
539 routes: route_counts
540 .as_ref()
541 .map(|counts| counts.get(name.as_str()).copied().unwrap_or(0)),
542 sessions,
543 model: entry.and_then(|entry| match entry.get("model") {
548 Some(Value::String(id)) => Some(id.clone()),
549 Some(object) => object
550 .get("primary")
551 .and_then(Value::as_str)
552 .map(str::to_string),
553 None => None,
554 }),
555 worker: None,
556 }
557 })
558 .collect()
559}
560
561pub(crate) fn read_json5(path: &Path) -> Value {
565 std::fs::read_to_string(path)
566 .ok()
567 .and_then(|text| serde_json::from_str::<Value>(&strip_json5(&text)).ok())
568 .unwrap_or(Value::Null)
569}
570
571fn directory_is_empty(path: &Path) -> bool {
574 std::fs::read_dir(path).is_ok_and(|mut entries| entries.next().is_none())
575}
576
577fn count_binding_targets(list: &[Value]) -> BTreeMap<String, u64> {
580 let mut counts: BTreeMap<String, u64> = BTreeMap::new();
581 for binding in list {
582 if let Some(agent) = binding.get("agentId").and_then(Value::as_str) {
583 *counts.entry(agent.to_string()).or_default() += 1;
584 }
585 }
586 counts
587}
588
589fn entry_map(value: &Value) -> Vec<(String, Value)> {
597 match value {
598 Value::Array(list) => list
599 .iter()
600 .filter_map(|entry| {
601 entry
602 .get("id")
603 .or_else(|| entry.get("agentId"))
604 .and_then(Value::as_str)
605 .map(|id| (id.to_string(), entry.clone()))
606 })
607 .collect(),
608 Value::Object(map) => map
609 .iter()
610 .map(|(name, entry)| (name.clone(), entry.clone()))
611 .collect(),
612 _ => Vec::new(),
613 }
614}
615
616fn strip_json5(text: &str) -> String {
620 let mut out = String::with_capacity(text.len());
621 let mut chars = text.chars().peekable();
622 let mut in_string = false;
623 let mut escaped = false;
624 while let Some(ch) = chars.next() {
625 if in_string {
626 out.push(ch);
627 if escaped {
628 escaped = false;
629 } else if ch == '\\' {
630 escaped = true;
631 } else if ch == '"' {
632 in_string = false;
633 }
634 continue;
635 }
636 match ch {
637 '"' => {
638 in_string = true;
639 out.push(ch);
640 }
641 '/' if chars.peek() == Some(&'/') => {
642 for next in chars.by_ref() {
643 if next == '\n' {
644 out.push('\n');
645 break;
646 }
647 }
648 }
649 '/' if chars.peek() == Some(&'*') => {
650 chars.next();
651 let mut previous = '\0';
652 for next in chars.by_ref() {
653 if previous == '*' && next == '/' {
654 break;
655 }
656 previous = next;
657 }
658 out.push(' ');
659 }
660 _ => out.push(ch),
661 }
662 }
663 let bytes: Vec<char> = out.chars().collect();
665 let mut cleaned = String::with_capacity(out.len());
666 let mut index = 0usize;
667 let mut in_string = false;
668 let mut escaped = false;
669 while index < bytes.len() {
670 let ch = bytes[index];
671 if in_string {
672 cleaned.push(ch);
673 if escaped {
674 escaped = false;
675 } else if ch == '\\' {
676 escaped = true;
677 } else if ch == '"' {
678 in_string = false;
679 }
680 index += 1;
681 continue;
682 }
683 if ch == '"' {
684 in_string = true;
685 cleaned.push(ch);
686 index += 1;
687 continue;
688 }
689 if ch == ',' {
690 let mut lookahead = index + 1;
691 while lookahead < bytes.len() && bytes[lookahead].is_whitespace() {
692 lookahead += 1;
693 }
694 if lookahead < bytes.len() && (bytes[lookahead] == '}' || bytes[lookahead] == ']') {
695 index += 1;
696 continue;
697 }
698 }
699 cleaned.push(ch);
700 index += 1;
701 }
702 cleaned
703}
704
705pub(crate) fn yaml_child(text: &str, key: &str) -> String {
718 let mut out = String::new();
719 let mut parent_indent: Option<usize> = None;
720 for line in text.lines() {
721 let trimmed = line.trim_start();
722 if trimmed.is_empty() || trimmed.starts_with('#') {
723 continue;
724 }
725 let indent = line.len() - trimmed.len();
726 match parent_indent {
727 None => {
728 if yaml_key(trimmed).is_some_and(|found| found == key) {
729 parent_indent = Some(indent);
730 }
731 }
732 Some(parent) => {
733 if indent <= parent {
734 break;
735 }
736 out.push_str(line);
737 out.push('\n');
738 }
739 }
740 }
741 out
742}
743
744pub(crate) fn yaml_scalar(text: &str, key: &str) -> Option<String> {
746 let root = text
747 .lines()
748 .filter(|line| !line.trim_start().is_empty() && !line.trim_start().starts_with('#'))
749 .map(|line| line.len() - line.trim_start().len())
750 .min()?;
751 for line in text.lines() {
752 let trimmed = line.trim_start();
753 if trimmed.is_empty() || trimmed.starts_with('#') {
754 continue;
755 }
756 if line.len() - trimmed.len() != root {
757 continue;
758 }
759 if yaml_key(trimmed) != Some(key) {
760 continue;
761 }
762 let value = yaml_value(trimmed)?;
763 if !value.is_empty() {
764 return Some(value);
765 }
766 }
767 None
768}
769
770fn count_yaml_route_targets(block: &str) -> BTreeMap<String, u64> {
778 let mut counts: BTreeMap<String, u64> = BTreeMap::new();
779 let entry_indent = block
780 .lines()
781 .filter(|line| !line.trim_start().is_empty() && !line.trim_start().starts_with('#'))
782 .map(|line| line.len() - line.trim_start().len())
783 .min();
784 for line in block.lines() {
785 let trimmed = line.trim_start();
786 if trimmed.is_empty() || trimmed.starts_with('#') {
787 continue;
788 }
789 let indent = line.len() - trimmed.len();
790 let body = trimmed.strip_prefix("- ").unwrap_or(trimmed);
791 let (Some(key), Some(value)) = (yaml_key(body), yaml_value(body)) else {
792 continue;
793 };
794 if key == "profile" && !value.is_empty() {
795 *counts.entry(value).or_default() += 1;
796 } else if Some(indent) == entry_indent && !trimmed.starts_with("- ") && !value.is_empty() {
797 *counts.entry(value).or_default() += 1;
798 }
799 }
800 counts
801}
802
803pub(crate) fn yaml_key(line: &str) -> Option<&str> {
804 let (head, _) = line.split_once(':')?;
805 let head = head.trim();
806 (!head.is_empty() && !head.contains(char::is_whitespace)).then_some(head)
807}
808
809fn yaml_value(line: &str) -> Option<String> {
810 let (_, tail) = line.split_once(':')?;
811 let tail = tail.trim();
812 let tail = tail.split_once(" #").map(|(head, _)| head).unwrap_or(tail);
813 Some(
814 tail.trim()
815 .trim_matches(|ch| ch == '"' || ch == '\'')
816 .to_string(),
817 )
818}
819
820#[cfg(test)]
821mod tests {
822 use super::*;
823
824 #[test]
825 fn presets_are_supercodes_profiles_with_the_default_flagged() {
826 let rows = preset_rows();
827 assert_eq!(rows.len(), crate::presets::RESERVED_PRESET_NAMES.len());
828 let default: Vec<&str> = rows
829 .iter()
830 .filter(|row| row.default)
831 .map(|row| row.name.as_str())
832 .collect();
833 assert_eq!(default, ["supercode-default"]);
834 let cc = rows.iter().find(|row| row.name == "cc-parity").unwrap();
835 assert_eq!(cc.kind, ProfileKind::Preset);
836 assert_eq!(cc.model.as_deref(), Some("anthropic/claude-opus-4-8"));
837 assert!(cc.home.is_none());
838 }
839
840 #[test]
844 fn an_emptied_agent_directory_is_not_an_agent() {
845 let root = std::env::temp_dir().join(format!(
846 "supercode-profiles-shell-{}-{}",
847 std::process::id(),
848 std::time::SystemTime::now()
849 .duration_since(std::time::UNIX_EPOCH)
850 .unwrap()
851 .as_nanos()
852 ));
853 std::fs::create_dir_all(root.join("agents/deleted")).unwrap();
854 std::fs::create_dir_all(root.join("agents/undeclared/sessions")).unwrap();
855 std::fs::create_dir_all(root.join("agents/main")).unwrap();
856 std::fs::write(
857 root.join("openclaw.json"),
858 r#"{"agents": {"list": [{"id": "main"}]}}"#,
859 )
860 .unwrap();
861 let names: Vec<String> = openclaw_rows(&root)
862 .into_iter()
863 .map(|row| row.name)
864 .collect();
865 assert_eq!(names, ["main", "undeclared"], "{names:?}");
866 std::fs::remove_dir_all(&root).ok();
867 }
868
869 #[test]
873 fn orchestrator_profiles_are_folders_carrying_their_own_worker() {
874 let root = std::env::temp_dir().join(format!(
875 "supercode-profiles-orchestrator-{}-{}",
876 std::process::id(),
877 std::time::SystemTime::now()
878 .duration_since(std::time::UNIX_EPOCH)
879 .unwrap()
880 .as_nanos()
881 ));
882 std::fs::create_dir_all(root.join("profiles/ops")).unwrap();
883 std::fs::write(
884 root.join("config.yaml"),
885 "worker:\n harness: claude-code\n model: claude-opus-4-8\ngateway:\n profile_routes:\n - platform: slack\n profile: ops\n",
886 )
887 .unwrap();
888 std::fs::write(
889 root.join("profiles/ops/config.yaml"),
890 "worker:\n harness: codex\n",
891 )
892 .unwrap();
893 let rows = orchestrator_rows(&root);
894 let names: Vec<&str> = rows.iter().map(|row| row.name.as_str()).collect();
895 assert_eq!(names, ["default", "ops"], "{names:?}");
896 assert!(rows[0].default && !rows[1].default);
897 assert_eq!(rows[0].kind, ProfileKind::OrchestratorProfile);
898 assert_eq!(rows[0].worker.as_deref(), Some("claude-code"));
899 assert_eq!(rows[0].model.as_deref(), Some("claude-opus-4-8"));
900 assert_eq!(rows[1].worker.as_deref(), Some("codex"));
901 assert_eq!(rows[1].model, None);
902 assert_eq!(rows[1].routes, Some(1));
904 assert_eq!(rows[0].routes, Some(0));
905 assert_eq!(rows[0].sessions, None);
907 assert_eq!(rows[0].home.as_deref(), Some(root.as_path()));
908 std::fs::remove_dir_all(&root).ok();
909 }
910
911 #[test]
912 fn unsupported_harness_is_refused_not_silently_empty() {
913 let error = list_profiles(&HarnessHomes::default(), Some(HarnessId::CLAUDE_CODE))
914 .expect_err("claude-code has no profile concept");
915 assert_eq!(
916 error,
917 ProfileError::UnsupportedHarness {
918 harness: HarnessId::CLAUDE_CODE.to_string()
919 }
920 );
921 }
922
923 #[test]
924 fn yaml_reader_counts_both_documented_route_shapes() {
925 let listed = "gateway:\n profile_routes:\n - platform: slack\n chat_id: C1\n profile: coder\n - platform: discord\n profile: coder\n";
926 let block = yaml_child(&yaml_child(listed, "gateway"), "profile_routes");
927 assert_eq!(count_yaml_route_targets(&block).get("coder"), Some(&2));
928
929 let flat = "gateway:\n profile_routes:\n slack: coder\n discord: main\n";
930 let block = yaml_child(&yaml_child(flat, "gateway"), "profile_routes");
931 let counts = count_yaml_route_targets(&block);
932 assert_eq!(counts.get("coder"), Some(&1));
933 assert_eq!(counts.get("main"), Some(&1));
934
935 let nested = "gateway:\n profile_routes:\n slack:\n profile: coder\n";
936 let block = yaml_child(&yaml_child(nested, "gateway"), "profile_routes");
937 assert_eq!(count_yaml_route_targets(&block).get("coder"), Some(&1));
938 }
939
940 #[test]
945 fn hermes_model_reads_the_block_form_the_real_config_writes() {
946 let real = "model:\n # Default model to use\n default: \"anthropic/claude-opus-4.6\"\n\n # provider: auto\ntools:\n enabled: true\n";
947 assert_eq!(
948 hermes_model(real).as_deref(),
949 Some("anthropic/claude-opus-4.6")
950 );
951 let alias = "model:\n model: anthropic/claude-opus-4.6\n";
952 assert_eq!(
953 hermes_model(alias).as_deref(),
954 Some("anthropic/claude-opus-4.6")
955 );
956 let flat = "model: anthropic/claude-opus-4.6\n";
957 assert_eq!(
958 hermes_model(flat).as_deref(),
959 Some("anthropic/claude-opus-4.6")
960 );
961 assert_eq!(hermes_model("gateway:\n port: 1\n"), None);
962 }
963
964 #[test]
965 fn yaml_child_stops_at_the_next_sibling_key() {
966 let text = "gateway:\n profile_routes:\n - profile: coder\nmodel: sonnet\n";
967 assert_eq!(yaml_scalar(text, "model").as_deref(), Some("sonnet"));
968 let block = yaml_child(&yaml_child(text, "gateway"), "profile_routes");
969 assert!(!block.contains("model"), "{block}");
970 }
971
972 #[test]
973 fn json5_comments_and_trailing_commas_are_tolerated() {
974 let text = "{\n // the default agent\n \"agents\": { \"entries\": { \"main\": { \"default\": true, } } },\n /* routes */\n \"bindings\": [ { \"agentId\": \"main\" }, ],\n \"note\": \"https://example.test/x\",\n}\n";
975 let value: Value = serde_json::from_str(&strip_json5(text)).unwrap();
976 assert_eq!(value["note"], "https://example.test/x");
977 assert_eq!(value["bindings"].as_array().unwrap().len(), 1);
978 assert_eq!(value["agents"]["entries"]["main"]["default"], true);
979 }
980}