use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use rusqlite::Connection;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::{HarnessHomes, HarnessId};
pub const PROFILES_SCHEMA: &str = "supercode.profiles.v1";
pub const PROFILE_HARNESSES: &[&str] = &[
HarnessId::SUPERCODE,
HarnessId::CODEX,
HarnessId::HERMES,
HarnessId::OPENCLAW,
HarnessId::ORCHESTRATOR,
];
pub const HERMES_DEFAULT_PROFILE: &str = "default";
pub const OPENCLAW_DEFAULT_AGENT: &str = "main";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProfileKind {
Preset,
CodexProfile,
HermesProfile,
OpenclawAgent,
OrchestratorProfile,
}
impl ProfileKind {
pub const fn as_str(self) -> &'static str {
match self {
Self::Preset => "preset",
Self::CodexProfile => "codex_profile",
Self::HermesProfile => "hermes_profile",
Self::OpenclawAgent => "openclaw_agent",
Self::OrchestratorProfile => "orchestrator_profile",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProfileRow {
pub name: String,
pub harness: String,
pub kind: ProfileKind,
pub home: Option<PathBuf>,
pub default: bool,
pub routes: Option<u64>,
pub sessions: Option<u64>,
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worker: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ProfileError {
#[error("harness `{harness}` has no profile concept (profiles exist for: {})", PROFILE_HARNESSES.join(", "))]
UnsupportedHarness {
harness: String,
},
#[error("`{harness}` has no profile `{name}`")]
NotFound {
harness: String,
name: String,
},
}
pub fn list_profiles(
homes: &HarnessHomes,
harness: Option<&str>,
) -> Result<Vec<ProfileRow>, ProfileError> {
if let Some(harness) = harness {
if !PROFILE_HARNESSES.contains(&harness) {
return Err(ProfileError::UnsupportedHarness {
harness: harness.to_string(),
});
}
}
let mut rows = Vec::new();
for id in PROFILE_HARNESSES {
if harness.is_some_and(|requested| requested != *id) {
continue;
}
match *id {
HarnessId::SUPERCODE => rows.extend(preset_rows()),
HarnessId::CODEX => rows.extend(codex_rows(&homes.codex)),
HarnessId::HERMES => rows.extend(hermes_rows(&homes.hermes)),
HarnessId::OPENCLAW => rows.extend(openclaw_rows(&homes.openclaw)),
HarnessId::ORCHESTRATOR => rows.extend(orchestrator_rows(&homes.orchestrator)),
_ => {}
}
}
Ok(rows)
}
pub fn get_profile(
homes: &HarnessHomes,
harness: &str,
name: &str,
) -> Result<ProfileRow, ProfileError> {
list_profiles(homes, Some(harness))?
.into_iter()
.find(|row| row.name == name)
.ok_or_else(|| ProfileError::NotFound {
harness: harness.to_string(),
name: name.to_string(),
})
}
fn preset_rows() -> Vec<ProfileRow> {
let mut rows: Vec<ProfileRow> = crate::presets::RESERVED_PRESET_NAMES
.iter()
.map(|name| ProfileRow {
name: (*name).to_string(),
harness: HarnessId::SUPERCODE.to_string(),
kind: ProfileKind::Preset,
home: None,
default: *name == "supercode-default",
routes: None,
sessions: None,
model: crate::presets::lookup(name)
.and_then(|text| toml::from_str::<toml::Value>(text).ok())
.and_then(|doc| {
doc.get("core")
.and_then(|core| core.get("model"))
.and_then(toml::Value::as_str)
.map(str::to_string)
}),
worker: None,
})
.collect();
rows.sort_by(|left, right| left.name.cmp(&right.name));
rows
}
fn codex_rows(sessions_root: &Path) -> Vec<ProfileRow> {
let Some(codex_home) = sessions_root.parent() else {
return Vec::new();
};
let Ok(text) = std::fs::read_to_string(codex_home.join("config.toml")) else {
return Vec::new();
};
let Ok(doc) = toml::from_str::<toml::Value>(&text) else {
return Vec::new();
};
let selected = doc.get("profile").and_then(toml::Value::as_str);
let Some(profiles) = doc.get("profiles").and_then(toml::Value::as_table) else {
return Vec::new();
};
profiles
.iter()
.map(|(name, table)| ProfileRow {
name: name.clone(),
harness: HarnessId::CODEX.to_string(),
kind: ProfileKind::CodexProfile,
home: None,
default: selected == Some(name.as_str()),
routes: None,
sessions: None,
model: table
.get("model")
.and_then(toml::Value::as_str)
.map(str::to_string),
worker: None,
})
.collect()
}
fn hermes_rows(state_db: &Path) -> Vec<ProfileRow> {
let Some(home) = state_db.parent() else {
return Vec::new();
};
let Ok(loaded) = supercode_interchange::orchestration::codec::from_hermes(home) else {
return Vec::new();
};
let routes = loaded
.io
.get(HERMES_DEFAULT_PROFILE)
.is_some_and(|io| io.raw.contains_key("config.yaml"))
.then(|| route_counts(&loaded.orchestration));
let mut names: Vec<&String> = loaded.orchestration.profiles.keys().collect();
names.sort_by_key(|name| (name.as_str() != HERMES_DEFAULT_PROFILE, name.as_str()));
names
.into_iter()
.map(|name| {
let profile = &loaded.orchestration.profiles[name];
let is_default = name == HERMES_DEFAULT_PROFILE;
ProfileRow {
name: name.clone(),
harness: HarnessId::HERMES.to_string(),
kind: ProfileKind::HermesProfile,
home: Some(profile.dir.clone()),
default: is_default,
routes: routes
.as_ref()
.map(|counts| counts.get(name.as_str()).copied().unwrap_or(0)),
sessions: hermes_session_count(state_db, (!is_default).then_some(name.as_str())),
model: hermes_model(profile),
worker: None,
}
})
.collect()
}
fn route_counts(
orchestration: &supercode_interchange::orchestration::Orchestration,
) -> BTreeMap<String, u64> {
let mut counts: BTreeMap<String, u64> = BTreeMap::new();
for profile in orchestration.profiles.values() {
for route in &profile.routes {
*counts.entry(route.profile.clone()).or_default() += 1;
}
}
counts
}
fn orchestrator_rows(root: &Path) -> Vec<ProfileRow> {
use supercode_interchange::orchestration::codec::{load_home, Flavor};
let Ok(loaded) = load_home(root, Flavor::Orchestrator) else {
return Vec::new();
};
let routes = loaded
.io
.values()
.any(|io| io.raw.contains_key("config.yaml"))
.then(|| route_counts(&loaded.orchestration));
let mut names: Vec<&String> = loaded.orchestration.profiles.keys().collect();
names.sort_by_key(|name| (name.as_str() != HERMES_DEFAULT_PROFILE, name.as_str()));
names
.into_iter()
.map(|name| {
let profile = &loaded.orchestration.profiles[name];
ProfileRow {
name: name.clone(),
harness: HarnessId::ORCHESTRATOR.to_string(),
kind: ProfileKind::OrchestratorProfile,
default: name == HERMES_DEFAULT_PROFILE,
routes: routes
.as_ref()
.map(|counts| counts.get(name.as_str()).copied().unwrap_or(0)),
sessions: profile
.dir
.join("state.db")
.is_file()
.then(|| profile.bindings.len() as u64),
model: profile.worker.as_ref().and_then(|w| w.model.clone()),
worker: profile
.worker
.as_ref()
.map(|w| w.harness.as_str().to_string()),
home: Some(profile.dir.clone()),
}
})
.collect()
}
fn hermes_model(profile: &supercode_interchange::orchestration::Profile) -> Option<String> {
match profile.residue.config.get("model")? {
Value::String(pinned) => Some(pinned.clone()),
Value::Object(block) => block
.get("default")
.or_else(|| block.get("model"))
.and_then(Value::as_str)
.map(str::to_string),
_ => None,
}
}
fn hermes_session_count(state_db: &Path, profile: Option<&str>) -> Option<u64> {
let connection = Connection::open_with_flags(
state_db,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.ok()?;
let count: i64 = match profile {
Some(name) => connection
.query_row(
"SELECT COUNT(*) FROM sessions WHERE profile_name = ?1",
[name],
|row| row.get(0),
)
.ok()?,
None => connection
.query_row(
"SELECT COUNT(*) FROM sessions WHERE profile_name IS NULL",
[],
|row| row.get(0),
)
.ok()?,
};
Some(count.max(0) as u64)
}
fn openclaw_rows(home: &Path) -> Vec<ProfileRow> {
let Ok(loaded) = supercode_interchange::orchestration::codec::from_openclaw(home) else {
return Vec::new();
};
let by_agent: BTreeMap<&str, &str> = loaded
.profiles
.iter()
.map(|(name, io)| (io.agent_id.as_str(), name.as_str()))
.collect();
let mut names: Vec<String> = by_agent.keys().map(|id| (*id).to_string()).collect();
if let Ok(dirs) = std::fs::read_dir(home.join("agents")) {
names.extend(
dirs.flatten()
.filter(|entry| entry.path().is_dir())
.filter(|entry| !directory_is_empty(&entry.path()))
.filter_map(|entry| entry.file_name().into_string().ok()),
);
}
names.sort();
names.dedup();
let route_counts = loaded.root.config_present.then(|| {
let mut counts: BTreeMap<String, u64> = BTreeMap::new();
for route in loaded
.orchestration
.profiles
.values()
.flat_map(|profile| profile.routes.iter())
{
if let Some(agent) = route.residue.0.get("agent_id").and_then(Value::as_str) {
*counts.entry(agent.to_string()).or_default() += 1;
}
}
counts
});
names
.into_iter()
.map(|name| {
let agent_home = home.join("agents").join(&name);
let sessions = std::fs::read_dir(agent_home.join("sessions"))
.ok()
.map(|dir| {
dir.flatten()
.filter(|entry| {
let name = entry.file_name();
let name = name.to_string_lossy();
name.ends_with(".jsonl") && !name.ends_with(".trajectory.jsonl")
})
.count() as u64
});
let entry = by_agent
.get(name.as_str())
.and_then(|profile| loaded.orchestration.profiles.get(*profile))
.and_then(|profile| profile.residue.config.get("openclaw_agent"));
ProfileRow {
name: name.clone(),
harness: HarnessId::OPENCLAW.to_string(),
kind: ProfileKind::OpenclawAgent,
home: Some(agent_home),
default: loaded.root.default_agent == name,
routes: route_counts
.as_ref()
.map(|counts| counts.get(name.as_str()).copied().unwrap_or(0)),
sessions,
model: entry.and_then(|entry| match entry.get("model") {
Some(Value::String(id)) => Some(id.clone()),
Some(object) => object
.get("primary")
.and_then(Value::as_str)
.map(str::to_string),
None => None,
}),
worker: None,
}
})
.collect()
}
pub(crate) fn read_json5(path: &Path) -> Value {
std::fs::read_to_string(path)
.ok()
.and_then(|text| serde_json::from_str::<Value>(&strip_json5(&text)).ok())
.unwrap_or(Value::Null)
}
fn directory_is_empty(path: &Path) -> bool {
std::fs::read_dir(path).is_ok_and(|mut entries| entries.next().is_none())
}
fn strip_json5(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut chars = text.chars().peekable();
let mut in_string = false;
let mut escaped = false;
while let Some(ch) = chars.next() {
if in_string {
out.push(ch);
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == '"' {
in_string = false;
}
continue;
}
match ch {
'"' => {
in_string = true;
out.push(ch);
}
'/' if chars.peek() == Some(&'/') => {
for next in chars.by_ref() {
if next == '\n' {
out.push('\n');
break;
}
}
}
'/' if chars.peek() == Some(&'*') => {
chars.next();
let mut previous = '\0';
for next in chars.by_ref() {
if previous == '*' && next == '/' {
break;
}
previous = next;
}
out.push(' ');
}
_ => out.push(ch),
}
}
let bytes: Vec<char> = out.chars().collect();
let mut cleaned = String::with_capacity(out.len());
let mut index = 0usize;
let mut in_string = false;
let mut escaped = false;
while index < bytes.len() {
let ch = bytes[index];
if in_string {
cleaned.push(ch);
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == '"' {
in_string = false;
}
index += 1;
continue;
}
if ch == '"' {
in_string = true;
cleaned.push(ch);
index += 1;
continue;
}
if ch == ',' {
let mut lookahead = index + 1;
while lookahead < bytes.len() && bytes[lookahead].is_whitespace() {
lookahead += 1;
}
if lookahead < bytes.len() && (bytes[lookahead] == '}' || bytes[lookahead] == ']') {
index += 1;
continue;
}
}
cleaned.push(ch);
index += 1;
}
cleaned
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn presets_are_supercodes_profiles_with_the_default_flagged() {
let rows = preset_rows();
assert_eq!(rows.len(), crate::presets::RESERVED_PRESET_NAMES.len());
let default: Vec<&str> = rows
.iter()
.filter(|row| row.default)
.map(|row| row.name.as_str())
.collect();
assert_eq!(default, ["supercode-default"]);
let cc = rows.iter().find(|row| row.name == "cc-parity").unwrap();
assert_eq!(cc.kind, ProfileKind::Preset);
assert_eq!(cc.model.as_deref(), Some("anthropic/claude-opus-4-8"));
assert!(cc.home.is_none());
}
#[test]
fn an_emptied_agent_directory_is_not_an_agent() {
let root = std::env::temp_dir().join(format!(
"supercode-profiles-shell-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(root.join("agents/deleted")).unwrap();
std::fs::create_dir_all(root.join("agents/undeclared/sessions")).unwrap();
std::fs::create_dir_all(root.join("agents/main")).unwrap();
std::fs::write(
root.join("openclaw.json"),
r#"{"agents": {"list": [{"id": "main"}]}}"#,
)
.unwrap();
let names: Vec<String> = openclaw_rows(&root)
.into_iter()
.map(|row| row.name)
.collect();
assert_eq!(names, ["main", "undeclared"], "{names:?}");
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn orchestrator_profiles_are_folders_carrying_their_own_worker() {
let root = std::env::temp_dir().join(format!(
"supercode-profiles-orchestrator-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(root.join("profiles/ops")).unwrap();
std::fs::write(
root.join("config.yaml"),
"worker:\n harness: claude-code\n model: claude-opus-4-8\ngateway:\n profile_routes:\n - platform: slack\n profile: ops\n",
)
.unwrap();
std::fs::write(
root.join("profiles/ops/config.yaml"),
"worker:\n harness: codex\n",
)
.unwrap();
let rows = orchestrator_rows(&root);
let names: Vec<&str> = rows.iter().map(|row| row.name.as_str()).collect();
assert_eq!(names, ["default", "ops"], "{names:?}");
assert!(rows[0].default && !rows[1].default);
assert_eq!(rows[0].kind, ProfileKind::OrchestratorProfile);
assert_eq!(rows[0].worker.as_deref(), Some("claude-code"));
assert_eq!(rows[0].model.as_deref(), Some("claude-opus-4-8"));
assert_eq!(rows[1].worker.as_deref(), Some("codex"));
assert_eq!(rows[1].model, None);
assert_eq!(rows[1].routes, Some(1));
assert_eq!(rows[0].routes, Some(0));
assert_eq!(rows[0].sessions, None);
assert_eq!(rows[0].home.as_deref(), Some(root.as_path()));
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn unsupported_harness_is_refused_not_silently_empty() {
let error = list_profiles(&HarnessHomes::default(), Some(HarnessId::CLAUDE_CODE))
.expect_err("claude-code has no profile concept");
assert_eq!(
error,
ProfileError::UnsupportedHarness {
harness: HarnessId::CLAUDE_CODE.to_string()
}
);
}
#[test]
fn json5_comments_and_trailing_commas_are_tolerated() {
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";
let value: Value = serde_json::from_str(&strip_json5(text)).unwrap();
assert_eq!(value["note"], "https://example.test/x");
assert_eq!(value["bindings"].as_array().unwrap().len(), 1);
assert_eq!(value["agents"]["entries"]["main"]["default"], true);
}
}