use serde::{Deserialize, Serialize}; use crate::worker::WorkspaceClient; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum SkillDiagnosticSeverity { Error, Warning, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct SkillDiagnostic { pub severity: SkillDiagnosticSeverity, pub code: String, pub message: String, /// Path-free authority/provenance label such as `builtin:foo` or `workspace:foo`. #[serde(skip_serializing_if = "Option::is_none")] pub source: Option, } impl SkillDiagnostic { pub fn error( code: impl Into, message: impl Into, source: Option, ) -> Self { Self { severity: SkillDiagnosticSeverity::Error, code: code.into(), message: message.into(), source, } } pub fn warning( code: impl Into, message: impl Into, source: Option, ) -> Self { Self { severity: SkillDiagnosticSeverity::Warning, code: code.into(), message: message.into(), source, } } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum SkillSourceKind { Builtin, Workspace, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct SkillProvenance { pub kind: SkillSourceKind, /// Stable path-free id: `builtin:` or `workspace:`. pub id: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct SkillResourceRef { pub kind: String, /// Skill-relative resource name/path. Never an absolute filesystem path. pub name: String, pub supported: bool, #[serde(skip_serializing_if = "Option::is_none")] pub diagnostic: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct SkillCatalogEntry { pub name: String, pub description: String, pub provenance: SkillProvenance, #[serde(default)] pub overrides: Vec, #[serde(default)] pub diagnostics: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct SkillCatalogResponse { /// Authority label for diagnostics; callers must not interpret it as a path. pub authority: String, #[serde(default)] pub entries: Vec, #[serde(default)] pub diagnostics: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct SkillDetailResponse { pub name: String, pub description: String, pub provenance: SkillProvenance, #[serde(default)] pub overrides: Vec, #[serde(default)] pub diagnostics: Vec, /// Full SKILL.md contents. This is intentionally omitted from catalog responses. pub body: String, #[serde(default)] pub allowed_tools: Vec, /// Explicitly documents that allowed-tools is parsed only as an experimental hint. pub allowed_tools_status: String, #[serde(default)] pub resources: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct SkillActivationResponse { pub name: String, pub provenance: SkillProvenance, #[serde(default)] pub diagnostics: Vec, /// Full SKILL.md contents to append to Worker history on explicit activation. pub body: String, } #[derive(Debug, thiserror::Error)] pub enum SkillClientError { #[error("workspace client is unavailable: {0}")] Unavailable(String), #[error("workspace client kind `{0}` does not expose direct Skill HTTP operations")] UnsupportedClient(String), #[error("Skill request failed: {0}")] Request(#[from] reqwest::Error), #[error("Skill API response JSON is invalid: {0}")] Json(#[from] serde_json::Error), #[error("Skill API returned HTTP {status}: {body}")] Http { status: reqwest::StatusCode, body: String, }, #[error("invalid Skill API base URL: {0}")] InvalidBaseUrl(String), } impl WorkspaceClient { pub fn list_skills(&self) -> Result { self.get_skill_json("skills") } pub fn read_skill(&self, name: &str) -> Result { self.get_skill_json(&format!("skills/{name}")) } pub fn activate_skill(&self, name: &str) -> Result { self.get_skill_json(&format!("skills/{name}/activate")) } fn get_skill_json Deserialize<'de>>( &self, path: &str, ) -> Result { let Self::Http { workspace_id, base_url, } = self else { return match self { Self::Available { kind } => Err(SkillClientError::UnsupportedClient(kind.clone())), Self::Unavailable { reason } => Err(SkillClientError::Unavailable(reason.clone())), Self::Http { .. } => unreachable!(), }; }; if base_url.trim().is_empty() { return Err(SkillClientError::InvalidBaseUrl(base_url.clone())); } let base = base_url.trim_end_matches('/'); let url = format!("{base}/api/w/{workspace_id}/{path}"); let response = reqwest::blocking::Client::new().get(url).send()?; let status = response.status(); let body = response.text()?; if !status.is_success() { return Err(SkillClientError::Http { status, body }); } Ok(serde_json::from_str(&body)?) } } #[cfg(test)] mod tests { use std::io::{BufRead, BufReader, Write}; use std::net::TcpListener; use std::thread; use super::*; #[test] fn http_workspace_client_fetches_skill_catalog_from_backend_endpoint() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let addr = listener.local_addr().unwrap(); let handle = thread::spawn(move || { let (mut stream, _) = listener.accept().unwrap(); let mut reader = BufReader::new(stream.try_clone().unwrap()); let mut request_line = String::new(); reader.read_line(&mut request_line).unwrap(); assert!(request_line.starts_with("GET /api/w/ws-1/skills HTTP/1.1")); loop { let mut line = String::new(); reader.read_line(&mut line).unwrap(); if line == "\r\n" || line.is_empty() { break; } } let body = serde_json::json!({ "authority": "workspace-backend-skills-v0", "entries": [{ "name": "triage-errors", "description": "Use when triaging errors.", "provenance": { "kind": "workspace", "id": "workspace:triage-errors" }, "overrides": [], "diagnostics": [] }], "diagnostics": [] }) .to_string(); write!( stream, "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}", body.len(), body ) .unwrap(); }); let client = WorkspaceClient::http("ws-1", format!("http://{addr}")); let catalog = client.list_skills().unwrap(); assert_eq!(catalog.entries[0].name, "triage-errors"); assert_eq!(catalog.entries[0].provenance.id, "workspace:triage-errors"); handle.join().unwrap(); } }