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> {
267 let Some(home) = state_db.parent() else {
268 return Vec::new();
269 };
270 let Ok(loaded) = supercode_interchange::orchestration::codec::from_hermes(home) else {
271 return Vec::new();
272 };
273 let routes = loaded
274 .io
275 .get(HERMES_DEFAULT_PROFILE)
276 .is_some_and(|io| io.raw.contains_key("config.yaml"))
277 .then(|| route_counts(&loaded.orchestration));
278 let mut names: Vec<&String> = loaded.orchestration.profiles.keys().collect();
279 names.sort_by_key(|name| (name.as_str() != HERMES_DEFAULT_PROFILE, name.as_str()));
280 names
281 .into_iter()
282 .map(|name| {
283 let profile = &loaded.orchestration.profiles[name];
284 let is_default = name == HERMES_DEFAULT_PROFILE;
285 ProfileRow {
286 name: name.clone(),
287 harness: HarnessId::HERMES.to_string(),
288 kind: ProfileKind::HermesProfile,
289 home: Some(profile.dir.clone()),
290 default: is_default,
291 routes: routes
292 .as_ref()
293 .map(|counts| counts.get(name.as_str()).copied().unwrap_or(0)),
294 sessions: hermes_session_count(state_db, (!is_default).then_some(name.as_str())),
295 model: hermes_model(profile),
296 worker: None,
297 }
298 })
299 .collect()
300}
301
302fn route_counts(
304 orchestration: &supercode_interchange::orchestration::Orchestration,
305) -> BTreeMap<String, u64> {
306 let mut counts: BTreeMap<String, u64> = BTreeMap::new();
307 for profile in orchestration.profiles.values() {
308 for route in &profile.routes {
309 *counts.entry(route.profile.clone()).or_default() += 1;
310 }
311 }
312 counts
313}
314
315fn orchestrator_rows(root: &Path) -> Vec<ProfileRow> {
318 use supercode_interchange::orchestration::codec::{load_home, Flavor};
319 let Ok(loaded) = load_home(root, Flavor::Orchestrator) else {
320 return Vec::new();
321 };
322 let routes = loaded
323 .io
324 .values()
325 .any(|io| io.raw.contains_key("config.yaml"))
326 .then(|| route_counts(&loaded.orchestration));
327 let mut names: Vec<&String> = loaded.orchestration.profiles.keys().collect();
328 names.sort_by_key(|name| (name.as_str() != HERMES_DEFAULT_PROFILE, name.as_str()));
329 names
330 .into_iter()
331 .map(|name| {
332 let profile = &loaded.orchestration.profiles[name];
333 ProfileRow {
334 name: name.clone(),
335 harness: HarnessId::ORCHESTRATOR.to_string(),
336 kind: ProfileKind::OrchestratorProfile,
337 default: name == HERMES_DEFAULT_PROFILE,
338 routes: routes
339 .as_ref()
340 .map(|counts| counts.get(name.as_str()).copied().unwrap_or(0)),
341 sessions: profile
342 .dir
343 .join("state.db")
344 .is_file()
345 .then(|| profile.bindings.len() as u64),
346 model: profile.worker.as_ref().and_then(|w| w.model.clone()),
347 worker: profile
348 .worker
349 .as_ref()
350 .map(|w| w.harness.as_str().to_string()),
351 home: Some(profile.dir.clone()),
352 }
353 })
354 .collect()
355}
356
357fn hermes_model(profile: &supercode_interchange::orchestration::Profile) -> Option<String> {
360 match profile.residue.config.get("model")? {
361 Value::String(pinned) => Some(pinned.clone()),
362 Value::Object(block) => block
363 .get("default")
364 .or_else(|| block.get("model"))
365 .and_then(Value::as_str)
366 .map(str::to_string),
367 _ => None,
368 }
369}
370
371fn hermes_session_count(state_db: &Path, profile: Option<&str>) -> Option<u64> {
375 let connection = Connection::open_with_flags(
376 state_db,
377 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
378 )
379 .ok()?;
380 let count: i64 = match profile {
381 Some(name) => connection
382 .query_row(
383 "SELECT COUNT(*) FROM sessions WHERE profile_name = ?1",
384 [name],
385 |row| row.get(0),
386 )
387 .ok()?,
388 None => connection
389 .query_row(
390 "SELECT COUNT(*) FROM sessions WHERE profile_name IS NULL",
391 [],
392 |row| row.get(0),
393 )
394 .ok()?,
395 };
396 Some(count.max(0) as u64)
397}
398
399fn openclaw_rows(home: &Path) -> Vec<ProfileRow> {
427 let Ok(loaded) = supercode_interchange::orchestration::codec::from_openclaw(home) else {
428 return Vec::new();
429 };
430 let by_agent: BTreeMap<&str, &str> = loaded
431 .profiles
432 .iter()
433 .map(|(name, io)| (io.agent_id.as_str(), name.as_str()))
434 .collect();
435 let mut names: Vec<String> = by_agent.keys().map(|id| (*id).to_string()).collect();
436 if let Ok(dirs) = std::fs::read_dir(home.join("agents")) {
437 names.extend(
438 dirs.flatten()
439 .filter(|entry| entry.path().is_dir())
440 .filter(|entry| !directory_is_empty(&entry.path()))
441 .filter_map(|entry| entry.file_name().into_string().ok()),
442 );
443 }
444 names.sort();
445 names.dedup();
446 let route_counts = loaded.root.config_present.then(|| {
447 let mut counts: BTreeMap<String, u64> = BTreeMap::new();
448 for route in loaded
449 .orchestration
450 .profiles
451 .values()
452 .flat_map(|profile| profile.routes.iter())
453 {
454 if let Some(agent) = route.residue.0.get("agent_id").and_then(Value::as_str) {
455 *counts.entry(agent.to_string()).or_default() += 1;
456 }
457 }
458 counts
459 });
460 names
461 .into_iter()
462 .map(|name| {
463 let agent_home = home.join("agents").join(&name);
464 let sessions = std::fs::read_dir(agent_home.join("sessions"))
465 .ok()
466 .map(|dir| {
467 dir.flatten()
468 .filter(|entry| {
469 let name = entry.file_name();
470 let name = name.to_string_lossy();
471 name.ends_with(".jsonl") && !name.ends_with(".trajectory.jsonl")
472 })
473 .count() as u64
474 });
475 let entry = by_agent
476 .get(name.as_str())
477 .and_then(|profile| loaded.orchestration.profiles.get(*profile))
478 .and_then(|profile| profile.residue.config.get("openclaw_agent"));
479 ProfileRow {
480 name: name.clone(),
481 harness: HarnessId::OPENCLAW.to_string(),
482 kind: ProfileKind::OpenclawAgent,
483 home: Some(agent_home),
484 default: loaded.root.default_agent == name,
485 routes: route_counts
486 .as_ref()
487 .map(|counts| counts.get(name.as_str()).copied().unwrap_or(0)),
488 sessions,
489 model: entry.and_then(|entry| match entry.get("model") {
490 Some(Value::String(id)) => Some(id.clone()),
491 Some(object) => object
492 .get("primary")
493 .and_then(Value::as_str)
494 .map(str::to_string),
495 None => None,
496 }),
497 worker: None,
498 }
499 })
500 .collect()
501}
502
503pub(crate) fn read_json5(path: &Path) -> Value {
504 std::fs::read_to_string(path)
505 .ok()
506 .and_then(|text| serde_json::from_str::<Value>(&strip_json5(&text)).ok())
507 .unwrap_or(Value::Null)
508}
509
510fn directory_is_empty(path: &Path) -> bool {
513 std::fs::read_dir(path).is_ok_and(|mut entries| entries.next().is_none())
514}
515
516fn strip_json5(text: &str) -> String {
520 let mut out = String::with_capacity(text.len());
521 let mut chars = text.chars().peekable();
522 let mut in_string = false;
523 let mut escaped = false;
524 while let Some(ch) = chars.next() {
525 if in_string {
526 out.push(ch);
527 if escaped {
528 escaped = false;
529 } else if ch == '\\' {
530 escaped = true;
531 } else if ch == '"' {
532 in_string = false;
533 }
534 continue;
535 }
536 match ch {
537 '"' => {
538 in_string = true;
539 out.push(ch);
540 }
541 '/' if chars.peek() == Some(&'/') => {
542 for next in chars.by_ref() {
543 if next == '\n' {
544 out.push('\n');
545 break;
546 }
547 }
548 }
549 '/' if chars.peek() == Some(&'*') => {
550 chars.next();
551 let mut previous = '\0';
552 for next in chars.by_ref() {
553 if previous == '*' && next == '/' {
554 break;
555 }
556 previous = next;
557 }
558 out.push(' ');
559 }
560 _ => out.push(ch),
561 }
562 }
563 let bytes: Vec<char> = out.chars().collect();
565 let mut cleaned = String::with_capacity(out.len());
566 let mut index = 0usize;
567 let mut in_string = false;
568 let mut escaped = false;
569 while index < bytes.len() {
570 let ch = bytes[index];
571 if in_string {
572 cleaned.push(ch);
573 if escaped {
574 escaped = false;
575 } else if ch == '\\' {
576 escaped = true;
577 } else if ch == '"' {
578 in_string = false;
579 }
580 index += 1;
581 continue;
582 }
583 if ch == '"' {
584 in_string = true;
585 cleaned.push(ch);
586 index += 1;
587 continue;
588 }
589 if ch == ',' {
590 let mut lookahead = index + 1;
591 while lookahead < bytes.len() && bytes[lookahead].is_whitespace() {
592 lookahead += 1;
593 }
594 if lookahead < bytes.len() && (bytes[lookahead] == '}' || bytes[lookahead] == ']') {
595 index += 1;
596 continue;
597 }
598 }
599 cleaned.push(ch);
600 index += 1;
601 }
602 cleaned
603}
604
605#[cfg(test)]
617mod tests {
618 use super::*;
619
620 #[test]
621 fn presets_are_supercodes_profiles_with_the_default_flagged() {
622 let rows = preset_rows();
623 assert_eq!(rows.len(), crate::presets::RESERVED_PRESET_NAMES.len());
624 let default: Vec<&str> = rows
625 .iter()
626 .filter(|row| row.default)
627 .map(|row| row.name.as_str())
628 .collect();
629 assert_eq!(default, ["supercode-default"]);
630 let cc = rows.iter().find(|row| row.name == "cc-parity").unwrap();
631 assert_eq!(cc.kind, ProfileKind::Preset);
632 assert_eq!(cc.model.as_deref(), Some("anthropic/claude-opus-4-8"));
633 assert!(cc.home.is_none());
634 }
635
636 #[test]
640 fn an_emptied_agent_directory_is_not_an_agent() {
641 let root = std::env::temp_dir().join(format!(
642 "supercode-profiles-shell-{}-{}",
643 std::process::id(),
644 std::time::SystemTime::now()
645 .duration_since(std::time::UNIX_EPOCH)
646 .unwrap()
647 .as_nanos()
648 ));
649 std::fs::create_dir_all(root.join("agents/deleted")).unwrap();
650 std::fs::create_dir_all(root.join("agents/undeclared/sessions")).unwrap();
651 std::fs::create_dir_all(root.join("agents/main")).unwrap();
652 std::fs::write(
653 root.join("openclaw.json"),
654 r#"{"agents": {"list": [{"id": "main"}]}}"#,
655 )
656 .unwrap();
657 let names: Vec<String> = openclaw_rows(&root)
658 .into_iter()
659 .map(|row| row.name)
660 .collect();
661 assert_eq!(names, ["main", "undeclared"], "{names:?}");
662 std::fs::remove_dir_all(&root).ok();
663 }
664
665 #[test]
669 fn orchestrator_profiles_are_folders_carrying_their_own_worker() {
670 let root = std::env::temp_dir().join(format!(
671 "supercode-profiles-orchestrator-{}-{}",
672 std::process::id(),
673 std::time::SystemTime::now()
674 .duration_since(std::time::UNIX_EPOCH)
675 .unwrap()
676 .as_nanos()
677 ));
678 std::fs::create_dir_all(root.join("profiles/ops")).unwrap();
679 std::fs::write(
680 root.join("config.yaml"),
681 "worker:\n harness: claude-code\n model: claude-opus-4-8\ngateway:\n profile_routes:\n - platform: slack\n profile: ops\n",
682 )
683 .unwrap();
684 std::fs::write(
685 root.join("profiles/ops/config.yaml"),
686 "worker:\n harness: codex\n",
687 )
688 .unwrap();
689 let rows = orchestrator_rows(&root);
690 let names: Vec<&str> = rows.iter().map(|row| row.name.as_str()).collect();
691 assert_eq!(names, ["default", "ops"], "{names:?}");
692 assert!(rows[0].default && !rows[1].default);
693 assert_eq!(rows[0].kind, ProfileKind::OrchestratorProfile);
694 assert_eq!(rows[0].worker.as_deref(), Some("claude-code"));
695 assert_eq!(rows[0].model.as_deref(), Some("claude-opus-4-8"));
696 assert_eq!(rows[1].worker.as_deref(), Some("codex"));
697 assert_eq!(rows[1].model, None);
698 assert_eq!(rows[1].routes, Some(1));
700 assert_eq!(rows[0].routes, Some(0));
701 assert_eq!(rows[0].sessions, None);
703 assert_eq!(rows[0].home.as_deref(), Some(root.as_path()));
704 std::fs::remove_dir_all(&root).ok();
705 }
706
707 #[test]
708 fn unsupported_harness_is_refused_not_silently_empty() {
709 let error = list_profiles(&HarnessHomes::default(), Some(HarnessId::CLAUDE_CODE))
710 .expect_err("claude-code has no profile concept");
711 assert_eq!(
712 error,
713 ProfileError::UnsupportedHarness {
714 harness: HarnessId::CLAUDE_CODE.to_string()
715 }
716 );
717 }
718
719 #[test]
724 fn json5_comments_and_trailing_commas_are_tolerated() {
725 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";
726 let value: Value = serde_json::from_str(&strip_json5(text)).unwrap();
727 assert_eq!(value["note"], "https://example.test/x");
728 assert_eq!(value["bindings"].as_array().unwrap().len(), 1);
729 assert_eq!(value["agents"]["entries"]["main"]["default"], true);
730 }
731}