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