1use std::collections::BTreeMap;
19use std::env;
20
21use axum::extract::{Path, State};
22use axum::response::IntoResponse;
23use axum::Json;
24use embacle::config::CliRunnerType;
25use embacle::discovery::resolve_binary;
26use serde::{Deserialize, Serialize};
27
28use super::error::UhpFailure;
29use super::state::UhpState;
30use crate::runner::ALL_PROVIDERS;
31use crate::state::SharedState;
32
33#[derive(Debug, Clone, Serialize)]
35pub struct Harness {
36 pub id: String,
38 pub object: &'static str,
40 pub name: String,
42 pub base: String,
45 #[serde(rename = "defaultModel", skip_serializing_if = "Option::is_none")]
47 pub default_model: Option<String>,
48 #[serde(default, skip_serializing_if = "Vec::is_empty")]
50 pub skills: Vec<Skill>,
51 #[serde(rename = "mcpServers", default, skip_serializing_if = "Vec::is_empty")]
53 pub mcp_servers: Vec<McpServer>,
54 #[serde(
56 rename = "disabledTools",
57 default,
58 skip_serializing_if = "Vec::is_empty"
59 )]
60 pub disabled_tools: Vec<String>,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct SkillFile {
71 pub path: String,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub content: Option<String>,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub content_b64: Option<String>,
79 #[serde(flatten)]
81 pub rest: serde_json::Map<String, serde_json::Value>,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct Skill {
91 pub name: String,
93 #[serde(default)]
95 pub enabled: bool,
96 #[serde(default)]
98 pub files: Vec<SkillFile>,
99}
100
101impl Skill {
102 pub const MANIFEST: &'static str = "SKILL.md";
104
105 #[must_use]
111 pub fn has_manifest(&self) -> bool {
112 self.files.iter().any(|f| f.path == Self::MANIFEST)
113 }
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct McpServer {
119 pub name: String,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub url: Option<String>,
124 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub transport: Option<String>,
127 #[serde(default)]
133 pub enabled: bool,
134}
135
136#[derive(Debug, Clone, Serialize)]
138pub struct HarnessList {
139 pub harnesses: Vec<Harness>,
141}
142
143#[derive(Debug, Clone, Serialize)]
145pub struct Model {
146 pub id: String,
148 pub backend: String,
150 pub available: bool,
152 pub default: bool,
154}
155
156#[derive(Debug, Clone, Serialize)]
159pub struct BackendModels {
160 pub default: String,
162 pub models: Vec<Model>,
164}
165
166#[derive(Debug, Clone, Serialize)]
173pub struct HarnessModels {
174 pub harness_id: String,
176 pub backend: String,
178 pub default: String,
180 pub models: Vec<Model>,
182}
183
184#[derive(Debug, Clone, Serialize)]
186pub struct ModelCatalog {
187 pub backends: BTreeMap<String, BackendModels>,
189}
190
191fn harness_id(provider: &str) -> String {
193 let slug: String = provider
194 .chars()
195 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
196 .collect();
197 format!("chrn_{slug}")
198}
199
200fn installed(provider: CliRunnerType) -> bool {
202 let env_override = env::var(provider.env_override_key()).ok();
203 resolve_binary(provider.binary_name(), env_override.as_deref()).is_ok()
204}
205
206pub struct Discovered {
208 pub provider: CliRunnerType,
210 pub name: String,
212 pub harness: Harness,
214 pub models: Vec<String>,
216}
217
218pub async fn discover(state: &SharedState) -> Vec<Discovered> {
220 let mut found = Vec::new();
221 for &provider in ALL_PROVIDERS {
222 if !installed(provider) {
223 continue;
224 }
225 let Ok(runner) = state.get_runner(provider).await else {
226 continue;
227 };
228 let name = runner.name().to_owned();
229 let models = runner.available_models().to_vec();
230 let default_model = runner.default_model().to_owned();
231 found.push(Discovered {
232 provider,
233 harness: Harness {
234 id: harness_id(&name),
235 object: "harness",
236 name: name.clone(),
237 base: name.clone(),
238 default_model: Some(default_model),
239 skills: Vec::new(),
240 mcp_servers: Vec::new(),
241 disabled_tools: Vec::new(),
242 },
243 name,
244 models,
245 });
246 }
247 found
248}
249
250pub async fn list(State(state): State<SharedState>) -> impl IntoResponse {
252 let harnesses = discover(&state)
253 .await
254 .into_iter()
255 .map(|d| d.harness)
256 .collect();
257 Json(HarnessList { harnesses })
258}
259
260pub async fn get(
266 State(state): State<UhpState>,
267 Path(id): Path<String>,
268) -> Result<Json<Harness>, UhpFailure> {
269 if let Some(configured) = state.configured.get(&id) {
270 return Ok(Json(configured));
271 }
272 discover(&state.shared)
273 .await
274 .into_iter()
275 .find(|d| d.harness.id == id)
276 .map(|d| Json(d.harness))
277 .ok_or_else(|| {
278 UhpFailure::not_found("harness_not_found", "no harness with that id is configured")
279 })
280}
281
282fn catalogue(found: Vec<Discovered>) -> ModelCatalog {
284 let mut backends = BTreeMap::new();
285 for d in found {
286 let name = d.name;
287 let default = d.harness.default_model.unwrap_or_default();
288 let models = d
289 .models
290 .into_iter()
291 .map(|id| Model {
292 default: id == default,
293 id,
294 backend: name.clone(),
295 available: true,
299 })
300 .collect();
301 backends.insert(name, BackendModels { default, models });
302 }
303 ModelCatalog { backends }
304}
305
306pub async fn models(State(state): State<UhpState>) -> impl IntoResponse {
308 Json(catalogue(discover(&state.shared).await))
309}
310
311pub async fn harness_models(
317 State(state): State<UhpState>,
318 Path(id): Path<String>,
319) -> Result<Json<HarnessModels>, UhpFailure> {
320 let d = discover(&state.shared)
321 .await
322 .into_iter()
323 .find(|d| d.harness.id == id)
324 .ok_or_else(|| {
325 UhpFailure::not_found("harness_not_found", "no harness with that id is configured")
326 })?;
327
328 let name = d.name;
329 let default = d.harness.default_model.unwrap_or_default();
330 Ok(Json(HarnessModels {
331 harness_id: d.harness.id,
332 backend: name.clone(),
333 models: d
334 .models
335 .into_iter()
336 .map(|id| Model {
337 default: id == default,
338 id,
339 backend: name.clone(),
340 available: true,
341 })
342 .collect(),
343 default,
344 }))
345}
346
347pub async fn unknown_route() -> UhpFailure {
349 UhpFailure::not_found("not_found", "no such endpoint on this server")
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355
356 #[test]
357 fn a_harness_id_satisfies_the_schema_pattern() {
358 assert!(harness_id("claude_code").starts_with("chrn_"));
361 assert_eq!(harness_id("claude-code"), "chrn_claude_code");
362 assert_eq!(harness_id("copilot"), "chrn_copilot");
363 }
364
365 #[test]
366 fn the_catalogue_groups_by_backend_and_marks_one_default() {
367 let found = vec![Discovered {
368 provider: CliRunnerType::Copilot,
369 name: "copilot".to_owned(),
370 harness: Harness {
371 id: harness_id("copilot"),
372 object: "harness",
373 name: "copilot".to_owned(),
374 base: "copilot".to_owned(),
375 default_model: Some("gpt-5".to_owned()),
376 skills: Vec::new(),
377 mcp_servers: Vec::new(),
378 disabled_tools: Vec::new(),
379 },
380 models: vec!["gpt-5".to_owned(), "claude-sonnet".to_owned()],
381 }];
382 let cat = catalogue(found);
383
384 let backend = cat.backends.get("copilot").expect("backend present"); assert_eq!(backend.default, "gpt-5");
386 assert_eq!(backend.models.len(), 2);
387 assert_eq!(
388 backend.models.iter().filter(|m| m.default).count(),
389 1,
390 "exactly one model is the default"
391 );
392 assert!(
393 backend.models.iter().all(|m| m.available),
394 "availability is a boolean on every model, never absent"
395 );
396 }
397}