Skip to main content

zeroclaw_gateway/
api_skills.rs

1//! HTTP adapter over `zeroclaw_runtime::skills::SkillsService`.
2//!
3//! Thin handlers — every endpoint translates request shape → `SkillsService`
4//! call → response shape. No filesystem logic, no validation, no error
5//! mapping that isn't already encoded in `SkillsService`. The dashboard,
6//! the CLI (`zeroclaw skills add/edit/bundle ...`), and the future TUI all
7//! reach the same canonical implementation through their respective surface.
8
9use 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// ── Request / response shapes ───────────────────────────────────────
24
25#[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    /// Initial markdown body. When empty, the service writes a default
64    /// `# <Title>` heading derived from the skill name.
65    #[serde(default)]
66    pub body: String,
67    /// Skip scaffolding the optional `scripts/`, `references/`, `assets/`
68    /// subdirs. Defaults to `false` (create them).
69    #[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    /// When `true`, hard-delete the skill instead of archiving. Defaults to
83    /// `false` — same as `RemoveMode::Archive`.
84    #[serde(default)]
85    pub purge: bool,
86}
87
88// ── Handlers ────────────────────────────────────────────────────────
89
90/// `GET /api/skills/bundles`
91pub 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
116/// `GET /api/skills/bundles/:alias/skills`
117pub 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
146/// `POST /api/skills/bundles/:alias/skills`
147pub 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
185/// `GET /api/skills/bundles/:alias/skills/:name`
186pub 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
214/// `PUT /api/skills/bundles/:alias/skills/:name`
215pub 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
242/// `DELETE /api/skills/bundles/:alias/skills/:name?purge=true`
243pub 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
271// ── Error mapping ───────────────────────────────────────────────────
272
273fn 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}