Skip to main content

fastskill_core/http/handlers/
skills.rs

1//! Skills CRUD endpoint handlers
2
3use crate::http::errors::{HttpError, HttpResult};
4use crate::http::handlers::AppState;
5use crate::http::models::*;
6use axum::{
7    extract::{Path, State},
8    Json,
9};
10use validator::Validate;
11
12#[derive(serde::Deserialize)]
13#[serde(rename_all = "camelCase")]
14pub struct UpgradeRequest {
15    skill_id: Option<String>,
16}
17
18fn skill_metadata_json(skill: &crate::core::skill_manager::SkillDefinition) -> serde_json::Value {
19    let source_type = skill
20        .source_type
21        .as_ref()
22        .map(|t| serde_json::to_value(t).unwrap_or(serde_json::Value::Null));
23    serde_json::json!({
24        "id": skill.id,
25        "name": skill.name,
26        "description": skill.description,
27        "version": skill.version,
28        "author": skill.author,
29        "enabled": skill.enabled,
30        "created_at": skill.created_at.to_rfc3339(),
31        "updated_at": skill.updated_at.to_rfc3339(),
32        "skill_file": skill.skill_file,
33        "reference_files": skill.reference_files,
34        "script_files": skill.script_files,
35        "asset_files": skill.asset_files,
36        "source_url": skill.source_url,
37        "source_type": source_type
38    })
39}
40
41/// GET /api/skills - List all skills
42pub async fn list_skills(
43    State(state): State<AppState>,
44) -> HttpResult<axum::Json<ApiResponse<SkillsListResponse>>> {
45    let skills = state.service.skill_manager().list_skills(None).await?;
46
47    let skill_responses: Vec<SkillResponse> = skills
48        .clone()
49        .into_iter()
50        .map(|skill| SkillResponse {
51            id: skill.id.to_string(),
52            name: skill.name.clone(),
53            description: skill.description.clone(),
54            metadata: skill_metadata_json(&skill),
55            created_at: Some(skill.created_at.to_rfc3339()),
56            updated_at: Some(skill.updated_at.to_rfc3339()),
57        })
58        .collect();
59
60    let response = SkillsListResponse {
61        skills: skill_responses,
62        count: skills.len(),
63        total: skills.len(),
64    };
65
66    Ok(axum::Json(ApiResponse::success(response)))
67}
68
69/// GET /api/skills/{id} - Get skill details
70pub async fn get_skill(
71    State(state): State<AppState>,
72    Path(skill_id): Path<String>,
73) -> HttpResult<axum::Json<ApiResponse<SkillResponse>>> {
74    // Check permissions
75
76    let skills = state.service.skill_manager().list_skills(None).await?;
77    let skill_id_parsed = crate::core::service::SkillId::new(skill_id.clone())
78        .map_err(|_| HttpError::BadRequest("Invalid skill ID format".to_string()))?;
79    let skill = skills
80        .into_iter()
81        .find(|s| s.id == skill_id_parsed)
82        .ok_or_else(|| HttpError::NotFound(format!("Skill not found: {}", skill_id)))?;
83
84    let metadata = skill_metadata_json(&skill);
85
86    let response = SkillResponse {
87        id: skill.id.to_string(),
88        name: skill.name.clone(),
89        description: skill.description.clone(),
90        metadata,
91        created_at: Some(skill.created_at.to_rfc3339()),
92        updated_at: Some(skill.updated_at.to_rfc3339()),
93    };
94
95    Ok(axum::Json(ApiResponse::success(response)))
96}
97
98/// POST /api/skills - Create new skill
99pub async fn create_skill(
100    State(_state): State<AppState>,
101    Json(_request): Json<SkillRequest>,
102) -> HttpResult<axum::Json<ApiResponse<SkillResponse>>> {
103    // Check permissions (write access required)
104
105    // Validate request
106    _request.validate().map_err(|e| {
107        HttpError::ValidationError(
108            e.field_errors()
109                .into_iter()
110                .map(|(field, errors)| {
111                    (
112                        field.to_string(),
113                        errors
114                            .iter()
115                            .map(|e| e.message.clone().unwrap_or_default().to_string())
116                            .collect(),
117                    )
118                })
119                .collect(),
120        )
121    })?;
122
123    // Create skill definition
124    let _skill_def = serde_json::json!({
125        "id": format!("skill_{}", chrono::Utc::now().timestamp()),
126        "name": _request.name,
127        "description": _request.description,
128    });
129
130    // Register skill (this would need to be implemented in the service)
131    // For now, return not implemented
132    Err(HttpError::InternalServerError(
133        "Skill creation not yet implemented".to_string(),
134    ))
135}
136
137/// PUT /api/skills/{id} - Update skill
138pub async fn update_skill(
139    State(_state): State<AppState>,
140    Path(_skill_id): Path<String>,
141    Json(request): Json<SkillRequest>,
142) -> HttpResult<axum::Json<ApiResponse<SkillResponse>>> {
143    // Check permissions
144
145    // Validate request
146    request.validate().map_err(|e| {
147        HttpError::ValidationError(
148            e.field_errors()
149                .into_iter()
150                .map(|(field, errors)| {
151                    (
152                        field.to_string(),
153                        errors
154                            .iter()
155                            .map(|e| e.message.clone().unwrap_or_default().to_string())
156                            .collect(),
157                    )
158                })
159                .collect(),
160        )
161    })?;
162
163    // Update skill (not implemented yet)
164    Err(HttpError::InternalServerError(
165        "Skill update not yet implemented".to_string(),
166    ))
167}
168
169/// DELETE /api/skills/{id} - Delete skill (remove from manifest and storage, unregister)
170pub async fn delete_skill(
171    State(state): State<AppState>,
172    Path(skill_id): Path<String>,
173) -> HttpResult<axum::Json<ApiResponse<serde_json::Value>>> {
174    let skill_id_parsed = crate::core::service::SkillId::new(skill_id.clone())
175        .map_err(|_| HttpError::BadRequest("Invalid skill ID format".to_string()))?;
176
177    let skills = state.service.skill_manager().list_skills(None).await?;
178    let skill = skills
179        .into_iter()
180        .find(|s| s.id == skill_id_parsed)
181        .ok_or_else(|| HttpError::NotFound(format!("Skill not found: {}", skill_id)))?;
182
183    let project_path = &state.project_file_path;
184    let lock_path = if let Some(parent) = project_path.parent() {
185        let safe_parent = if parent.exists() {
186            parent.canonicalize().map_err(|e| {
187                HttpError::InternalServerError(format!("Failed to resolve parent path: {}", e))
188            })?
189        } else {
190            parent.to_path_buf()
191        };
192        safe_parent.join("skills.lock")
193    } else {
194        std::path::PathBuf::from("skills.lock")
195    };
196
197    if project_path.exists() {
198        let mut project = crate::core::manifest::SkillProjectToml::load_from_file(project_path)
199            .map_err(|e| {
200                HttpError::InternalServerError(format!("Failed to load project: {}", e))
201            })?;
202        if let Some(ref mut deps) = project.dependencies {
203            deps.dependencies.remove(&skill_id);
204        }
205        project.save_to_file(project_path).map_err(|e| {
206            HttpError::InternalServerError(format!("Failed to save project: {}", e))
207        })?;
208        if lock_path.exists() {
209            let mut lock =
210                crate::core::lock::SkillsLock::load_from_file(&lock_path).map_err(|e| {
211                    HttpError::InternalServerError(format!("Failed to load lock: {}", e))
212                })?;
213            lock.remove_skill(&skill_id);
214            lock.save_to_file(&lock_path).map_err(|e| {
215                HttpError::InternalServerError(format!("Failed to save lock: {}", e))
216            })?;
217        }
218    }
219
220    let skill_dir = skill.skill_file.parent().ok_or_else(|| {
221        HttpError::InternalServerError("Skill file has no parent dir".to_string())
222    })?;
223    if skill_dir.exists() {
224        tokio::fs::remove_dir_all(skill_dir).await.map_err(|e| {
225            HttpError::InternalServerError(format!("Failed to remove skill dir: {}", e))
226        })?;
227    }
228
229    state
230        .service
231        .skill_manager()
232        .unregister_skill(&skill_id_parsed)
233        .await
234        .map_err(|e| HttpError::InternalServerError(e.to_string()))?;
235
236    Ok(axum::Json(ApiResponse::success(serde_json::json!({
237        "message": "Skill removed"
238    }))))
239}
240
241/// POST /api/skills/upgrade - Upgrade one or all skills from manifest (shells out to fastskill update)
242pub async fn upgrade_skills(
243    State(state): State<AppState>,
244    Json(payload): Json<Option<UpgradeRequest>>,
245) -> HttpResult<axum::Json<ApiResponse<serde_json::Value>>> {
246    let project_path = state.project_file_path.clone();
247    let filter_id = payload
248        .and_then(|p| p.skill_id)
249        .filter(|s| !s.is_empty() && s != "all");
250
251    let output = tokio::task::spawn_blocking(move || {
252        let exe = std::env::current_exe().map_err(|e| {
253            HttpError::InternalServerError(format!("Failed to get executable path: {}", e))
254        })?;
255        let mut cmd = std::process::Command::new(exe);
256        cmd.arg("update");
257        if let Some(ref id) = filter_id {
258            cmd.arg(id);
259        }
260        if let Some(parent) = project_path.parent() {
261            cmd.current_dir(parent);
262        }
263        let output = cmd
264            .output()
265            .map_err(|e| HttpError::InternalServerError(format!("Failed to run update: {}", e)))?;
266        Ok::<_, HttpError>(output)
267    })
268    .await
269    .map_err(|e| HttpError::InternalServerError(format!("Upgrade task failed: {}", e)))??;
270
271    if !output.status.success() {
272        let stderr = String::from_utf8_lossy(&output.stderr);
273        return Err(HttpError::InternalServerError(format!(
274            "Upgrade failed: {}",
275            stderr.trim()
276        )));
277    }
278
279    Ok(axum::Json(ApiResponse::success(serde_json::json!({
280        "message": "Upgrade completed",
281        "upgraded": true
282    }))))
283}