use std::io::{Cursor, Write}; use axum::{ Json, body::Body, http::{ HeaderValue, StatusCode, header::{CONTENT_DISPOSITION, CONTENT_TYPE}, }, response::{IntoResponse, Response}, }; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use zip::{ZipWriter, write::SimpleFileOptions}; use crate::http_error::AppError; const SKILL_ROOT: &str = "genarrative-external-editor-api"; const SKILL_FILES: [(&str, &str); 7] = [ ( "SKILL.md", include_str!("../../../../.codex/skills/genarrative-external-editor-api/SKILL.md"), ), ( "references/capability-routing.md", include_str!( "../../../../.codex/skills/genarrative-external-editor-api/references/capability-routing.md" ), ), ( "references/api-operations.md", include_str!( "../../../../.codex/skills/genarrative-external-editor-api/references/api-operations.md" ), ), ( "references/authentication-and-safety.md", include_str!( "../../../../.codex/skills/genarrative-external-editor-api/references/authentication-and-safety.md" ), ), ( "references/requests-and-outputs.md", include_str!( "../../../../.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md" ), ), ( "scripts/genarrative_external_api.py", include_str!( "../../../../.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py" ), ), ( "agents/openai.yaml", include_str!( "../../../../.codex/skills/genarrative-external-editor-api/agents/openai.yaml" ), ), ]; pub async fn get_external_skill_entry() -> Response { let mut response = Body::from(SKILL_FILES[0].1).into_response(); response.headers_mut().insert( CONTENT_TYPE, HeaderValue::from_static("text/markdown; charset=utf-8"), ); response } pub async fn download_external_skill_archive() -> Result { let bytes = build_external_skill_archive()?; let mut response = Body::from(bytes).into_response(); response .headers_mut() .insert(CONTENT_TYPE, HeaderValue::from_static("application/zip")); response.headers_mut().insert( CONTENT_DISPOSITION, HeaderValue::from_static( "attachment; filename=\"genarrative-external-editor-api.skill.zip\"", ), ); Ok(response) } pub async fn get_external_agent_integration_manifest() -> Result, AppError> { let archive = build_external_skill_archive()?; let sha256 = format!("{:x}", Sha256::digest(&archive)); Ok(Json(json!({ "name": SKILL_ROOT, "version": env!("CARGO_PKG_VERSION"), "mcp": { "transport": "streamable-http", "url": "/api/external/v1/mcp", "authentication": "bearer-api-key", "credentialSetup": { "action": "CONFIGURE_BEARER_API_KEY", "authorizationValueFormat": "Bearer ", "navigationLabel": "开发者 API Key", "guide": "/api/external/v1/skill/SKILL.md" } }, "openapi": "/api/external/v1/openapi.json", "skill": { "entry": "/api/external/v1/skill/SKILL.md", "archive": "/api/external/v1/skill.zip", "archiveSha256": sha256, "files": SKILL_FILES.map(|(path, _)| format!("{SKILL_ROOT}/{path}")), } }))) } fn build_external_skill_archive() -> Result, AppError> { let cursor = Cursor::new(Vec::new()); let mut archive = ZipWriter::new(cursor); let options = SimpleFileOptions::default().unix_permissions(0o644); for (path, contents) in SKILL_FILES { archive .start_file(format!("{SKILL_ROOT}/{path}"), options) .map_err(skill_archive_error)?; archive .write_all(contents.as_bytes()) .map_err(skill_archive_error)?; } archive .finish() .map(Cursor::into_inner) .map_err(skill_archive_error) } fn skill_archive_error(error: impl std::fmt::Display) -> AppError { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": "external-skill-archive", "message": format!("构建外部 Skill 包失败:{error}"), })) } #[cfg(test)] mod tests { use super::*; #[test] fn archive_contains_complete_skill_bundle() { let bytes = build_external_skill_archive().expect("skill archive should build"); let mut archive = zip::ZipArchive::new(Cursor::new(bytes)).expect("archive should parse"); assert_eq!(archive.len(), SKILL_FILES.len()); for (path, contents) in SKILL_FILES { let name = format!("{SKILL_ROOT}/{path}"); let mut file = archive.by_name(&name).expect("skill file should exist"); let mut actual = String::new(); std::io::Read::read_to_string(&mut file, &mut actual).expect("skill file should read"); assert_eq!(actual, contents); } } #[tokio::test] async fn integration_manifest_matches_complete_skill_archive() { let bytes = build_external_skill_archive().expect("skill archive should build"); let Json(manifest) = get_external_agent_integration_manifest() .await .expect("integration manifest should build"); let expected_files = SKILL_FILES .map(|(path, _)| format!("{SKILL_ROOT}/{path}")) .to_vec(); assert_eq!(manifest["skill"]["files"], json!(expected_files)); assert_eq!( manifest["mcp"]["credentialSetup"], json!({ "action": "CONFIGURE_BEARER_API_KEY", "authorizationValueFormat": "Bearer ", "navigationLabel": "开发者 API Key", "guide": "/api/external/v1/skill/SKILL.md" }) ); assert_eq!( manifest["skill"]["archiveSha256"], json!(format!("{:x}", Sha256::digest(&bytes))) ); } }