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;
16use zeroclaw_runtime::rpc::types::{
17    SkillBundleEntry, SkillListEntry, SkillsBundlesResult, SkillsListResult, SkillsReadResult,
18};
19use zeroclaw_runtime::skills::{
20    RemoveMode, ScaffoldOptions, ServiceError, SkillFrontmatter, SkillsService,
21};
22
23use super::AppState;
24use super::api::require_auth;
25
26// ── HTTP-specific request shapes (not shared) ───────────────────────
27
28#[derive(Debug, Deserialize)]
29pub struct SkillCreateBody {
30    pub name: String,
31    pub frontmatter: SkillFrontmatter,
32    /// Initial markdown body. When empty, the service writes a default
33    /// `# <Title>` heading derived from the skill name.
34    #[serde(default)]
35    pub body: String,
36    /// Skip scaffolding the optional `scripts/`, `references/`, `assets/`
37    /// subdirs. Defaults to `false` (create them).
38    #[serde(default)]
39    pub no_scaffold: bool,
40}
41
42#[derive(Debug, Deserialize)]
43pub struct SkillWriteBody {
44    pub frontmatter: SkillFrontmatter,
45    #[serde(default)]
46    pub body: String,
47}
48
49#[derive(Debug, Deserialize, Default)]
50pub struct DeleteQuery {
51    /// When `true`, hard-delete the skill instead of archiving. Defaults to
52    /// `false` — same as `RemoveMode::Archive`.
53    #[serde(default)]
54    pub purge: bool,
55}
56
57// ── Handlers ────────────────────────────────────────────────────────
58
59/// `GET /api/skills/bundles`
60pub async fn handle_list_bundles(State(state): State<AppState>, headers: HeaderMap) -> Response {
61    if let Err(e) = require_auth(&state, &headers) {
62        return e.into_response();
63    }
64    let config = state.config.read().clone();
65    let install_root = config.install_root_dir();
66    let service = SkillsService::new(&config, install_root);
67
68    match service.list_bundles() {
69        Ok(bundles) => Json(SkillsBundlesResult {
70            bundles: bundles
71                .into_iter()
72                .map(|b| SkillBundleEntry {
73                    alias: b.alias,
74                    directory: b.directory.display().to_string(),
75                    include: b.include,
76                    exclude: b.exclude,
77                })
78                .collect(),
79        })
80        .into_response(),
81        Err(e) => service_error_response(e),
82    }
83}
84
85/// `GET /api/skills/bundles/:alias/skills`
86pub async fn handle_list_skills(
87    State(state): State<AppState>,
88    headers: HeaderMap,
89    Path(alias): Path<String>,
90) -> Response {
91    if let Err(e) = require_auth(&state, &headers) {
92        return e.into_response();
93    }
94    let config = state.config.read().clone();
95    let install_root = config.install_root_dir();
96    let service = SkillsService::new(&config, install_root);
97
98    match service.list_skills(Some(&alias)) {
99        Ok(skills) => Json(SkillsListResult {
100            skills: skills
101                .into_iter()
102                .map(|s| SkillListEntry {
103                    bundle: s.r#ref.bundle().to_string(),
104                    name: s.r#ref.name().to_string(),
105                    directory: s.directory.display().to_string(),
106                    frontmatter: s.frontmatter,
107                })
108                .collect(),
109        })
110        .into_response(),
111        Err(e) => service_error_response(e),
112    }
113}
114
115/// `POST /api/skills/bundles/:alias/skills`
116pub async fn handle_create_skill(
117    State(state): State<AppState>,
118    headers: HeaderMap,
119    Path(alias): Path<String>,
120    Json(body): Json<SkillCreateBody>,
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    let target = match service.resolve_ref(&body.name, Some(&alias)) {
130        Ok(r) => r,
131        Err(e) => return service_error_response(e),
132    };
133    match service.scaffold_skill(
134        &target,
135        body.frontmatter,
136        ScaffoldOptions {
137            create_optional_subdirs: !body.no_scaffold,
138            body: body.body,
139        },
140    ) {
141        Ok(path) => (
142            StatusCode::CREATED,
143            Json(serde_json::json!({
144                "bundle": target.bundle(),
145                "name": target.name(),
146                "directory": path.display().to_string(),
147            })),
148        )
149            .into_response(),
150        Err(e) => service_error_response(e),
151    }
152}
153
154/// `GET /api/skills/bundles/:alias/skills/:name`
155pub async fn handle_read_skill(
156    State(state): State<AppState>,
157    headers: HeaderMap,
158    Path((alias, name)): Path<(String, String)>,
159) -> Response {
160    if let Err(e) = require_auth(&state, &headers) {
161        return e.into_response();
162    }
163    let config = state.config.read().clone();
164    let install_root = config.install_root_dir();
165    let service = SkillsService::new(&config, install_root);
166
167    let target = match service.resolve_ref(&name, Some(&alias)) {
168        Ok(r) => r,
169        Err(e) => return service_error_response(e),
170    };
171    match service.read_skill(&target) {
172        Ok(doc) => Json(SkillsReadResult {
173            bundle: target.bundle().to_string(),
174            name: target.name().to_string(),
175            frontmatter: doc.frontmatter,
176            body: doc.body,
177        })
178        .into_response(),
179        Err(e) => service_error_response(e),
180    }
181}
182
183/// `PUT /api/skills/bundles/:alias/skills/:name`
184pub async fn handle_write_skill(
185    State(state): State<AppState>,
186    headers: HeaderMap,
187    Path((alias, name)): Path<(String, String)>,
188    Json(body): Json<SkillWriteBody>,
189) -> Response {
190    if let Err(e) = require_auth(&state, &headers) {
191        return e.into_response();
192    }
193    let config = state.config.read().clone();
194    let install_root = config.install_root_dir();
195    let service = SkillsService::new(&config, install_root);
196
197    let target = match service.resolve_ref(&name, Some(&alias)) {
198        Ok(r) => r,
199        Err(e) => return service_error_response(e),
200    };
201    let doc = zeroclaw_runtime::skills::SkillDocument {
202        frontmatter: body.frontmatter,
203        body: body.body,
204    };
205    match service.write_skill(&target, &doc) {
206        Ok(()) => StatusCode::NO_CONTENT.into_response(),
207        Err(e) => service_error_response(e),
208    }
209}
210
211/// `DELETE /api/skills/bundles/:alias/skills/:name?purge=true`
212pub async fn handle_delete_skill(
213    State(state): State<AppState>,
214    headers: HeaderMap,
215    Path((alias, name)): Path<(String, String)>,
216    axum::extract::Query(q): axum::extract::Query<DeleteQuery>,
217) -> Response {
218    if let Err(e) = require_auth(&state, &headers) {
219        return e.into_response();
220    }
221    let config = state.config.read().clone();
222    let install_root = config.install_root_dir();
223    let service = SkillsService::new(&config, install_root);
224
225    let target = match service.resolve_ref(&name, Some(&alias)) {
226        Ok(r) => r,
227        Err(e) => return service_error_response(e),
228    };
229    let mode = if q.purge {
230        RemoveMode::Purge
231    } else {
232        RemoveMode::Archive
233    };
234    match service.remove_skill(&target, mode) {
235        Ok(()) => StatusCode::NO_CONTENT.into_response(),
236        Err(e) => service_error_response(e),
237    }
238}
239
240// ── Error mapping ───────────────────────────────────────────────────
241
242fn service_error_response(err: ServiceError) -> Response {
243    let status = match &err {
244        ServiceError::Ref(_) => StatusCode::BAD_REQUEST,
245        ServiceError::Bundle(_) => StatusCode::BAD_REQUEST,
246        ServiceError::Scaffold(_) => StatusCode::BAD_REQUEST,
247        ServiceError::DocumentParse(_) => StatusCode::UNPROCESSABLE_ENTITY,
248        ServiceError::NotFound(_) => StatusCode::NOT_FOUND,
249        ServiceError::Io(_) => StatusCode::INTERNAL_SERVER_ERROR,
250    };
251    (
252        status,
253        Json(serde_json::json!({
254            "error": format!("{}", err),
255        })),
256    )
257        .into_response()
258}