1use axum::{
10 Json,
11 extract::{Path, State},
12 http::{HeaderMap, StatusCode},
13 response::{IntoResponse, Response},
14};
15use serde::{Deserialize, Serialize};
16use zeroclaw_runtime::skills::{
17 RemoveMode, ScaffoldOptions, ServiceError, SkillFrontmatter, SkillsService,
18};
19
20use super::AppState;
21use super::api::require_auth;
22
23#[derive(Debug, Serialize)]
26pub struct BundleEntry {
27 pub alias: String,
28 pub directory: String,
29 pub include: Vec<String>,
30 pub exclude: Vec<String>,
31}
32
33#[derive(Debug, Serialize)]
34pub struct BundlesResponse {
35 pub bundles: Vec<BundleEntry>,
36}
37
38#[derive(Debug, Serialize)]
39pub struct SkillEntry {
40 pub bundle: String,
41 pub name: String,
42 pub directory: String,
43 pub frontmatter: SkillFrontmatter,
44}
45
46#[derive(Debug, Serialize)]
47pub struct SkillsListResponse {
48 pub skills: Vec<SkillEntry>,
49}
50
51#[derive(Debug, Serialize)]
52pub struct SkillReadResponse {
53 pub bundle: String,
54 pub name: String,
55 pub frontmatter: SkillFrontmatter,
56 pub body: String,
57}
58
59#[derive(Debug, Deserialize)]
60pub struct SkillCreateBody {
61 pub name: String,
62 pub frontmatter: SkillFrontmatter,
63 #[serde(default)]
66 pub body: String,
67 #[serde(default)]
70 pub no_scaffold: bool,
71}
72
73#[derive(Debug, Deserialize)]
74pub struct SkillWriteBody {
75 pub frontmatter: SkillFrontmatter,
76 #[serde(default)]
77 pub body: String,
78}
79
80#[derive(Debug, Deserialize, Default)]
81pub struct DeleteQuery {
82 #[serde(default)]
85 pub purge: bool,
86}
87
88pub async fn handle_list_bundles(State(state): State<AppState>, headers: HeaderMap) -> Response {
92 if let Err(e) = require_auth(&state, &headers) {
93 return e.into_response();
94 }
95 let config = state.config.read().clone();
96 let install_root = config.install_root_dir();
97 let service = SkillsService::new(&config, install_root);
98
99 match service.list_bundles() {
100 Ok(bundles) => Json(BundlesResponse {
101 bundles: bundles
102 .into_iter()
103 .map(|b| BundleEntry {
104 alias: b.alias,
105 directory: b.directory.display().to_string(),
106 include: b.include,
107 exclude: b.exclude,
108 })
109 .collect(),
110 })
111 .into_response(),
112 Err(e) => service_error_response(e),
113 }
114}
115
116pub async fn handle_list_skills(
118 State(state): State<AppState>,
119 headers: HeaderMap,
120 Path(alias): Path<String>,
121) -> Response {
122 if let Err(e) = require_auth(&state, &headers) {
123 return e.into_response();
124 }
125 let config = state.config.read().clone();
126 let install_root = config.install_root_dir();
127 let service = SkillsService::new(&config, install_root);
128
129 match service.list_skills(Some(&alias)) {
130 Ok(skills) => Json(SkillsListResponse {
131 skills: skills
132 .into_iter()
133 .map(|s| SkillEntry {
134 bundle: s.r#ref.bundle().to_string(),
135 name: s.r#ref.name().to_string(),
136 directory: s.directory.display().to_string(),
137 frontmatter: s.frontmatter,
138 })
139 .collect(),
140 })
141 .into_response(),
142 Err(e) => service_error_response(e),
143 }
144}
145
146pub async fn handle_create_skill(
148 State(state): State<AppState>,
149 headers: HeaderMap,
150 Path(alias): Path<String>,
151 Json(body): Json<SkillCreateBody>,
152) -> Response {
153 if let Err(e) = require_auth(&state, &headers) {
154 return e.into_response();
155 }
156 let config = state.config.read().clone();
157 let install_root = config.install_root_dir();
158 let service = SkillsService::new(&config, install_root);
159
160 let target = match service.resolve_ref(&body.name, Some(&alias)) {
161 Ok(r) => r,
162 Err(e) => return service_error_response(e),
163 };
164 match service.scaffold_skill(
165 &target,
166 body.frontmatter,
167 ScaffoldOptions {
168 create_optional_subdirs: !body.no_scaffold,
169 body: body.body,
170 },
171 ) {
172 Ok(path) => (
173 StatusCode::CREATED,
174 Json(serde_json::json!({
175 "bundle": target.bundle(),
176 "name": target.name(),
177 "directory": path.display().to_string(),
178 })),
179 )
180 .into_response(),
181 Err(e) => service_error_response(e),
182 }
183}
184
185pub async fn handle_read_skill(
187 State(state): State<AppState>,
188 headers: HeaderMap,
189 Path((alias, name)): Path<(String, String)>,
190) -> Response {
191 if let Err(e) = require_auth(&state, &headers) {
192 return e.into_response();
193 }
194 let config = state.config.read().clone();
195 let install_root = config.install_root_dir();
196 let service = SkillsService::new(&config, install_root);
197
198 let target = match service.resolve_ref(&name, Some(&alias)) {
199 Ok(r) => r,
200 Err(e) => return service_error_response(e),
201 };
202 match service.read_skill(&target) {
203 Ok(doc) => Json(SkillReadResponse {
204 bundle: target.bundle().to_string(),
205 name: target.name().to_string(),
206 frontmatter: doc.frontmatter,
207 body: doc.body,
208 })
209 .into_response(),
210 Err(e) => service_error_response(e),
211 }
212}
213
214pub async fn handle_write_skill(
216 State(state): State<AppState>,
217 headers: HeaderMap,
218 Path((alias, name)): Path<(String, String)>,
219 Json(body): Json<SkillWriteBody>,
220) -> Response {
221 if let Err(e) = require_auth(&state, &headers) {
222 return e.into_response();
223 }
224 let config = state.config.read().clone();
225 let install_root = config.install_root_dir();
226 let service = SkillsService::new(&config, install_root);
227
228 let target = match service.resolve_ref(&name, Some(&alias)) {
229 Ok(r) => r,
230 Err(e) => return service_error_response(e),
231 };
232 let doc = zeroclaw_runtime::skills::SkillDocument {
233 frontmatter: body.frontmatter,
234 body: body.body,
235 };
236 match service.write_skill(&target, &doc) {
237 Ok(()) => StatusCode::NO_CONTENT.into_response(),
238 Err(e) => service_error_response(e),
239 }
240}
241
242pub async fn handle_delete_skill(
244 State(state): State<AppState>,
245 headers: HeaderMap,
246 Path((alias, name)): Path<(String, String)>,
247 axum::extract::Query(q): axum::extract::Query<DeleteQuery>,
248) -> Response {
249 if let Err(e) = require_auth(&state, &headers) {
250 return e.into_response();
251 }
252 let config = state.config.read().clone();
253 let install_root = config.install_root_dir();
254 let service = SkillsService::new(&config, install_root);
255
256 let target = match service.resolve_ref(&name, Some(&alias)) {
257 Ok(r) => r,
258 Err(e) => return service_error_response(e),
259 };
260 let mode = if q.purge {
261 RemoveMode::Purge
262 } else {
263 RemoveMode::Archive
264 };
265 match service.remove_skill(&target, mode) {
266 Ok(()) => StatusCode::NO_CONTENT.into_response(),
267 Err(e) => service_error_response(e),
268 }
269}
270
271fn service_error_response(err: ServiceError) -> Response {
274 let status = match &err {
275 ServiceError::Ref(_) => StatusCode::BAD_REQUEST,
276 ServiceError::Bundle(_) => StatusCode::BAD_REQUEST,
277 ServiceError::Scaffold(_) => StatusCode::BAD_REQUEST,
278 ServiceError::DocumentParse(_) => StatusCode::UNPROCESSABLE_ENTITY,
279 ServiceError::NotFound(_) => StatusCode::NOT_FOUND,
280 ServiceError::Io(_) => StatusCode::INTERNAL_SERVER_ERROR,
281 };
282 (
283 status,
284 Json(serde_json::json!({
285 "error": format!("{}", err),
286 })),
287 )
288 .into_response()
289}