1use std::collections::BTreeMap;
19use std::env;
20use std::path::{Component, Path as FsPath};
21
22use axum::extract::{Path, State};
23use axum::response::IntoResponse;
24use axum::Json;
25use base64::engine::general_purpose::STANDARD;
26use base64::{DecodeError, Engine};
27use embacle::config::CliRunnerType;
28use embacle::discovery::resolve_binary;
29use serde::{Deserialize, Serialize};
30
31use super::error::UhpFailure;
32use super::state::UhpState;
33use crate::runner::ALL_PROVIDERS;
34use crate::state::SharedState;
35
36#[derive(Debug, Clone, Serialize)]
38pub struct Harness {
39 pub id: String,
41 pub object: &'static str,
43 pub name: String,
45 pub base: String,
48 #[serde(rename = "defaultModel", skip_serializing_if = "Option::is_none")]
50 pub default_model: Option<String>,
51 #[serde(default, skip_serializing_if = "Vec::is_empty")]
53 pub skills: Vec<Skill>,
54 #[serde(rename = "mcpServers", default, skip_serializing_if = "Vec::is_empty")]
56 pub mcp_servers: Vec<McpServer>,
57 #[serde(
59 rename = "disabledTools",
60 default,
61 skip_serializing_if = "Vec::is_empty"
62 )]
63 pub disabled_tools: Vec<String>,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct SkillFile {
74 pub path: String,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub content: Option<String>,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub content_b64: Option<String>,
82 #[serde(flatten)]
84 pub rest: serde_json::Map<String, serde_json::Value>,
85}
86
87impl SkillFile {
88 #[must_use]
95 pub fn relative_path(&self) -> Option<&FsPath> {
96 let path = FsPath::new(&self.path);
97 let mut components = path.components().peekable();
98 components.peek()?;
99 components
100 .all(|c| matches!(c, Component::Normal(_)))
101 .then_some(path)
102 }
103
104 pub fn bytes(&self) -> Result<Vec<u8>, DecodeError> {
110 match (&self.content, &self.content_b64) {
111 (Some(text), _) => Ok(text.as_bytes().to_vec()),
112 (None, Some(encoded)) => STANDARD.decode(encoded),
113 (None, None) => Ok(Vec::new()),
114 }
115 }
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct Skill {
125 pub name: String,
127 #[serde(default)]
129 pub enabled: bool,
130 #[serde(default)]
132 pub files: Vec<SkillFile>,
133}
134
135impl Skill {
136 pub const MANIFEST: &'static str = "SKILL.md";
138
139 #[must_use]
145 pub fn has_manifest(&self) -> bool {
146 self.files.iter().any(|f| f.path == Self::MANIFEST)
147 }
148
149 #[must_use]
154 pub fn has_plain_name(&self) -> bool {
155 let mut components = FsPath::new(&self.name).components();
156 matches!(
157 (components.next(), components.next()),
158 (Some(Component::Normal(_)), None)
159 )
160 }
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct McpServer {
166 pub name: String,
168 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub url: Option<String>,
171 #[serde(default, skip_serializing_if = "Option::is_none")]
173 pub transport: Option<String>,
174 #[serde(default)]
180 pub enabled: bool,
181}
182
183#[derive(Debug, Clone, Serialize)]
185pub struct HarnessList {
186 pub harnesses: Vec<Harness>,
188}
189
190#[derive(Debug, Clone, Serialize)]
192pub struct Model {
193 pub id: String,
195 pub backend: String,
197 pub available: bool,
199 pub default: bool,
201}
202
203#[derive(Debug, Clone, Serialize)]
206pub struct BackendModels {
207 pub default: String,
209 pub models: Vec<Model>,
211}
212
213#[derive(Debug, Clone, Serialize)]
220pub struct HarnessModels {
221 pub harness_id: String,
223 pub backend: String,
225 pub default: String,
227 pub models: Vec<Model>,
229}
230
231#[derive(Debug, Clone, Serialize)]
233pub struct ModelCatalog {
234 pub backends: BTreeMap<String, BackendModels>,
236}
237
238fn harness_id(provider: &str) -> String {
240 let slug: String = provider
241 .chars()
242 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
243 .collect();
244 format!("chrn_{slug}")
245}
246
247fn installed(provider: CliRunnerType) -> bool {
249 let env_override = env::var(provider.env_override_key()).ok();
250 resolve_binary(provider.binary_name(), env_override.as_deref()).is_ok()
251}
252
253pub struct Discovered {
255 pub provider: CliRunnerType,
257 pub name: String,
259 pub harness: Harness,
261 pub models: Vec<String>,
263}
264
265pub async fn discover(state: &SharedState) -> Vec<Discovered> {
267 let mut found = Vec::new();
268 for &provider in ALL_PROVIDERS.iter() {
269 if !installed(provider) {
270 continue;
271 }
272 let Ok(runner) = state.get_runner(provider).await else {
273 continue;
274 };
275 let name = runner.name().to_owned();
276 let models = runner.available_models().to_vec();
277 let default_model = runner.default_model().to_owned();
278 found.push(Discovered {
279 provider,
280 harness: Harness {
281 id: harness_id(&name),
282 object: "harness",
283 name: name.clone(),
284 base: name.clone(),
285 default_model: Some(default_model),
286 skills: Vec::new(),
287 mcp_servers: Vec::new(),
288 disabled_tools: Vec::new(),
289 },
290 name,
291 models,
292 });
293 }
294 found
295}
296
297pub async fn resolve(state: &UhpState, id: &str) -> Option<Discovered> {
306 let found = discover(&state.shared).await;
307 let Some(configured) = state.configured.get(id) else {
308 return found.into_iter().find(|d| d.harness.id == id);
309 };
310 let base = found
311 .into_iter()
312 .find(|d| d.harness.base == configured.base)?;
313 Some(Discovered {
314 provider: base.provider,
315 name: base.name,
316 harness: Harness {
317 default_model: configured
318 .default_model
319 .clone()
320 .or(base.harness.default_model),
321 ..configured
322 },
323 models: base.models,
324 })
325}
326
327pub async fn list(State(state): State<UhpState>) -> impl IntoResponse {
332 let mut harnesses: Vec<Harness> = discover(&state.shared)
333 .await
334 .into_iter()
335 .map(|d| d.harness)
336 .collect();
337 harnesses.extend(state.configured.all());
338 Json(HarnessList { harnesses })
339}
340
341pub async fn get(
347 State(state): State<UhpState>,
348 Path(id): Path<String>,
349) -> Result<Json<Harness>, UhpFailure> {
350 if let Some(configured) = state.configured.get(&id) {
351 return Ok(Json(configured));
352 }
353 discover(&state.shared)
354 .await
355 .into_iter()
356 .find(|d| d.harness.id == id)
357 .map(|d| Json(d.harness))
358 .ok_or_else(|| {
359 UhpFailure::not_found("harness_not_found", "no harness with that id is configured")
360 })
361}
362
363fn catalogue(found: Vec<Discovered>) -> ModelCatalog {
365 let mut backends = BTreeMap::new();
366 for d in found {
367 let name = d.name;
368 let default = d.harness.default_model.unwrap_or_default();
369 let models = d
370 .models
371 .into_iter()
372 .map(|id| Model {
373 default: id == default,
374 id,
375 backend: name.clone(),
376 available: true,
380 })
381 .collect();
382 backends.insert(name, BackendModels { default, models });
383 }
384 ModelCatalog { backends }
385}
386
387pub async fn models(State(state): State<UhpState>) -> impl IntoResponse {
389 Json(catalogue(discover(&state.shared).await))
390}
391
392pub async fn harness_models(
400 State(state): State<UhpState>,
401 Path(id): Path<String>,
402) -> Result<Json<HarnessModels>, UhpFailure> {
403 let d = resolve(&state, &id).await.ok_or_else(|| {
404 UhpFailure::not_found("harness_not_found", "no harness with that id is configured")
405 })?;
406
407 let name = d.name;
408 let default = d.harness.default_model.unwrap_or_default();
409 Ok(Json(HarnessModels {
410 harness_id: d.harness.id,
411 backend: name.clone(),
412 models: d
413 .models
414 .into_iter()
415 .map(|id| Model {
416 default: id == default,
417 id,
418 backend: name.clone(),
419 available: true,
420 })
421 .collect(),
422 default,
423 }))
424}
425
426pub async fn unknown_route() -> UhpFailure {
428 UhpFailure::not_found("not_found", "no such endpoint on this server")
429}
430
431#[cfg(test)]
432mod tests {
433 use super::*;
434
435 #[test]
436 fn a_harness_id_satisfies_the_schema_pattern() {
437 assert!(harness_id("claude_code").starts_with("chrn_"));
440 assert_eq!(harness_id("claude-code"), "chrn_claude_code");
441 assert_eq!(harness_id("copilot"), "chrn_copilot");
442 }
443
444 #[test]
445 fn the_catalogue_groups_by_backend_and_marks_one_default() {
446 let found = vec![Discovered {
447 provider: CliRunnerType::Copilot,
448 name: "copilot".to_owned(),
449 harness: Harness {
450 id: harness_id("copilot"),
451 object: "harness",
452 name: "copilot".to_owned(),
453 base: "copilot".to_owned(),
454 default_model: Some("gpt-5".to_owned()),
455 skills: Vec::new(),
456 mcp_servers: Vec::new(),
457 disabled_tools: Vec::new(),
458 },
459 models: vec!["gpt-5".to_owned(), "claude-sonnet".to_owned()],
460 }];
461 let cat = catalogue(found);
462
463 let backend = cat.backends.get("copilot").expect("backend present"); assert_eq!(backend.default, "gpt-5");
465 assert_eq!(backend.models.len(), 2);
466 assert_eq!(
467 backend.models.iter().filter(|m| m.default).count(),
468 1,
469 "exactly one model is the default"
470 );
471 assert!(
472 backend.models.iter().all(|m| m.available),
473 "availability is a boolean on every model, never absent"
474 );
475 }
476}