Skip to main content

team_core/
runtimes.rs

1//! Runtime adapter descriptors.
2//!
3//! Canonical descriptors for the runtimes teamctl ships with (Claude Code,
4//! Codex, Gemini) are baked into the binary via [`embedded_defaults`]. Users
5//! can override or extend them by dropping their own `<root>/runtimes/<id>.yaml`
6//! into the compose tree -- file-based descriptors win on key collision.
7
8use std::collections::BTreeMap;
9use std::path::{Path, PathBuf};
10
11use anyhow::{Context, Result};
12use serde::{Deserialize, Serialize};
13
14/// Canonical descriptors that ship with teamctl. Keep this list in sync
15/// with the YAML files under `crates/team-core/runtimes/`.
16const EMBEDDED: &[(&str, &str)] = &[
17    ("claude-code", include_str!("../runtimes/claude-code.yaml")),
18    ("codex", include_str!("../runtimes/codex.yaml")),
19    ("gemini", include_str!("../runtimes/gemini.yaml")),
20];
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct Runtime {
24    /// Path / name of the CLI binary (resolved on $PATH by the wrapper).
25    pub binary: String,
26    #[serde(default)]
27    pub supports_mcp: bool,
28    #[serde(default)]
29    pub session_resume: Option<String>,
30    #[serde(default)]
31    pub default_model: Option<String>,
32    #[serde(default)]
33    pub env: BTreeMap<String, String>,
34
35    /// Patterns that, if matched in the runtime's stdout/stderr, indicate a
36    /// rate-limit hit. `teamctl rl-watch` consumes these.
37    #[serde(default)]
38    pub rate_limit_patterns: Vec<RateLimitPattern>,
39}
40
41/// One rate-limit detector. `match` is a regex tested against each line
42/// of runtime output. If matched, the wrapper records a hit. The optional
43/// captures attempt to extract when the limit lifts.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct RateLimitPattern {
46    /// Regex tested against each output line.
47    pub r#match: String,
48    /// Optional regex with one capture group of an absolute reset clock,
49    /// e.g. "resets at (4pm)" or "resets at (16:00)" or an RFC3339 timestamp.
50    #[serde(default)]
51    pub resets_at_capture: Option<String>,
52    /// Optional regex with one capture group of a relative duration,
53    /// e.g. "in (5h 15m)" or "in (1h)" or "(\\d+) seconds".
54    #[serde(default)]
55    pub resets_in_capture: Option<String>,
56}
57
58/// Embedded canonical runtime descriptors -- the ones teamctl ships with.
59/// Always available; do not require any files on disk.
60pub fn embedded_defaults() -> Result<BTreeMap<String, Runtime>> {
61    EMBEDDED
62        .iter()
63        .map(|(stem, src)| {
64            let r: Runtime = serde_yaml::from_str(src)
65                .with_context(|| format!("parse embedded runtime `{stem}`"))?;
66            Ok(((*stem).to_string(), r))
67        })
68        .collect()
69}
70
71/// Resolve the runtime adapter map for a compose tree.
72///
73/// Starts from the [`embedded_defaults`] (Claude Code / Codex / Gemini) and
74/// overlays any `<root>/runtimes/<name>.yaml` files. File-based descriptors
75/// override the embedded ones when keys collide and can introduce new
76/// runtimes the binary has never heard of.
77pub fn load_all(root: &Path) -> Result<BTreeMap<String, Runtime>> {
78    let mut map = embedded_defaults()?;
79    let dir = root.join("runtimes");
80    if !dir.exists() {
81        return Ok(map);
82    }
83    for entry in std::fs::read_dir(&dir).with_context(|| format!("read {}", dir.display()))? {
84        let entry = entry?;
85        let path: PathBuf = entry.path();
86        if path.extension().and_then(|s| s.to_str()) != Some("yaml") {
87            continue;
88        }
89        let stem = path
90            .file_stem()
91            .and_then(|s| s.to_str())
92            .unwrap_or_default()
93            .to_string();
94        let content =
95            std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
96        let r: Runtime =
97            serde_yaml::from_str(&content).with_context(|| format!("parse {}", path.display()))?;
98        map.insert(stem, r);
99    }
100    Ok(map)
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[test]
108    fn embedded_defaults_parse() {
109        let m = embedded_defaults().unwrap();
110        assert!(m.contains_key("claude-code"));
111        assert!(m.contains_key("codex"));
112        assert!(m.contains_key("gemini"));
113        assert_eq!(m["claude-code"].binary, "claude");
114        assert!(m["claude-code"].supports_mcp);
115    }
116
117    #[test]
118    fn load_nonexistent_returns_embedded_defaults() {
119        let tmp = tempfile::tempdir().unwrap();
120        let m = load_all(tmp.path()).unwrap();
121        // No files on disk, but the embedded defaults must still be there.
122        assert!(m.contains_key("claude-code"));
123        assert!(m.contains_key("codex"));
124        assert!(m.contains_key("gemini"));
125    }
126
127    #[test]
128    fn user_file_overrides_embedded_default() {
129        let tmp = tempfile::tempdir().unwrap();
130        let dir = tmp.path().join("runtimes");
131        std::fs::create_dir_all(&dir).unwrap();
132        std::fs::write(
133            dir.join("claude-code.yaml"),
134            "binary: my-claude-fork\nsupports_mcp: false\n",
135        )
136        .unwrap();
137        let m = load_all(tmp.path()).unwrap();
138        assert_eq!(m["claude-code"].binary, "my-claude-fork");
139        assert!(!m["claude-code"].supports_mcp);
140        // Other embedded defaults are untouched.
141        assert_eq!(m["codex"].binary, "codex");
142    }
143
144    #[test]
145    fn user_file_can_add_new_runtime() {
146        let tmp = tempfile::tempdir().unwrap();
147        let dir = tmp.path().join("runtimes");
148        std::fs::create_dir_all(&dir).unwrap();
149        std::fs::write(
150            dir.join("aider.yaml"),
151            "binary: aider\nsupports_mcp: false\n",
152        )
153        .unwrap();
154        let m = load_all(tmp.path()).unwrap();
155        assert_eq!(m["aider"].binary, "aider");
156        // Embedded defaults coexist.
157        assert!(m.contains_key("claude-code"));
158    }
159}