feat(skills-catalog): implement caching, curated sources, git operations, and skill installation
This commit is contained in:
@@ -191,6 +191,48 @@ fn sanitize_settings_update(payload: &Value) -> Value {
|
||||
result_obj.insert("typographySizes".to_string(), sanitized);
|
||||
}
|
||||
}
|
||||
|
||||
// Skill catalogs (array of objects)
|
||||
if let Some(Value::Array(arr)) = obj.get("skillCatalogs") {
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
let mut catalogs: Vec<Value> = vec![];
|
||||
|
||||
for entry in arr {
|
||||
let Some(obj) = entry.as_object() else { continue };
|
||||
|
||||
let id = obj.get("id").and_then(|v| v.as_str()).unwrap_or("").trim();
|
||||
let label = obj.get("label").and_then(|v| v.as_str()).unwrap_or("").trim();
|
||||
let source = obj.get("source").and_then(|v| v.as_str()).unwrap_or("").trim();
|
||||
let subpath = obj.get("subpath").and_then(|v| v.as_str()).unwrap_or("").trim();
|
||||
let git_identity_id = obj.get("gitIdentityId").and_then(|v| v.as_str()).unwrap_or("").trim();
|
||||
|
||||
if id.is_empty() || label.is_empty() || source.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if seen.contains(id) {
|
||||
continue;
|
||||
}
|
||||
seen.insert(id.to_string());
|
||||
|
||||
let mut catalog = serde_json::Map::new();
|
||||
catalog.insert("id".to_string(), json!(id));
|
||||
catalog.insert("label".to_string(), json!(label));
|
||||
catalog.insert("source".to_string(), json!(source));
|
||||
if !subpath.is_empty() {
|
||||
catalog.insert("subpath".to_string(), json!(subpath));
|
||||
}
|
||||
if !git_identity_id.is_empty() {
|
||||
catalog.insert("gitIdentityId".to_string(), json!(git_identity_id));
|
||||
}
|
||||
|
||||
catalogs.push(Value::Object(catalog));
|
||||
}
|
||||
|
||||
if !catalogs.is_empty() {
|
||||
result_obj.insert("skillCatalogs".to_string(), Value::Array(catalogs));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
|
||||
@@ -9,6 +9,7 @@ mod opencode_config;
|
||||
mod opencode_manager;
|
||||
mod window_state;
|
||||
mod path_utils;
|
||||
mod skills_catalog;
|
||||
|
||||
use std::{collections::HashMap, path::PathBuf, sync::Arc, time::{Duration, Instant}};
|
||||
|
||||
@@ -1797,6 +1798,102 @@ async fn handle_config_routes(
|
||||
return handle_command_route(&state, method, req, trimmed.to_string()).await;
|
||||
}
|
||||
|
||||
// Skills catalog routes (must be checked before /api/config/skills/:name)
|
||||
if path == "/api/config/skills/catalog" && method == Method::GET {
|
||||
let refresh = req
|
||||
.uri()
|
||||
.query()
|
||||
.map(|q| q.contains("refresh=true"))
|
||||
.unwrap_or(false);
|
||||
|
||||
let working_directory = state.opencode.get_working_directory();
|
||||
let payload = skills_catalog::get_catalog(&working_directory, refresh).await;
|
||||
return Ok(json_response(StatusCode::OK, payload));
|
||||
}
|
||||
|
||||
if path == "/api/config/skills/scan" && method == Method::POST {
|
||||
let payload_map = match parse_request_payload(req).await {
|
||||
Ok(data) => data,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
|
||||
let payload_value = serde_json::Value::Object(payload_map.into_iter().collect());
|
||||
let scan_request = match serde_json::from_value::<skills_catalog::SkillsScanRequest>(payload_value) {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
return Ok(json_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
skills_catalog::SkillsRepoScanResponse {
|
||||
ok: false,
|
||||
items: None,
|
||||
error: Some(skills_catalog::SkillsRepoError {
|
||||
kind: "invalidSource".to_string(),
|
||||
message: "Malformed scan request".to_string(),
|
||||
ssh_only: None,
|
||||
identities: None,
|
||||
conflicts: None,
|
||||
}),
|
||||
},
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let response = skills_catalog::scan_repository(scan_request).await;
|
||||
let status = if response.ok {
|
||||
StatusCode::OK
|
||||
} else if response.error.as_ref().map(|e| e.kind.as_str()) == Some("authRequired") {
|
||||
StatusCode::UNAUTHORIZED
|
||||
} else {
|
||||
StatusCode::BAD_REQUEST
|
||||
};
|
||||
|
||||
return Ok(json_response(status, response));
|
||||
}
|
||||
|
||||
if path == "/api/config/skills/install" && method == Method::POST {
|
||||
let payload_map = match parse_request_payload(req).await {
|
||||
Ok(data) => data,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
|
||||
let payload_value = serde_json::Value::Object(payload_map.into_iter().collect());
|
||||
let install_request = match serde_json::from_value::<skills_catalog::SkillsInstallRequest>(payload_value) {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
return Ok(json_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
skills_catalog::SkillsInstallResponse {
|
||||
ok: false,
|
||||
installed: None,
|
||||
skipped: None,
|
||||
error: Some(skills_catalog::SkillsRepoError {
|
||||
kind: "invalidSource".to_string(),
|
||||
message: "Malformed install request".to_string(),
|
||||
ssh_only: None,
|
||||
identities: None,
|
||||
conflicts: None,
|
||||
}),
|
||||
},
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let working_directory = state.opencode.get_working_directory();
|
||||
let response = skills_catalog::install_skills(&working_directory, install_request).await;
|
||||
|
||||
let status = if response.ok {
|
||||
StatusCode::OK
|
||||
} else if response.error.as_ref().map(|e| e.kind.as_str()) == Some("conflicts") {
|
||||
StatusCode::CONFLICT
|
||||
} else if response.error.as_ref().map(|e| e.kind.as_str()) == Some("authRequired") {
|
||||
StatusCode::UNAUTHORIZED
|
||||
} else {
|
||||
StatusCode::BAD_REQUEST
|
||||
};
|
||||
|
||||
return Ok(json_response(status, response));
|
||||
}
|
||||
|
||||
// Handle skill routes: /api/config/skills and /api/config/skills/:name
|
||||
if path == "/api/config/skills" && method == Method::GET {
|
||||
return handle_skill_list_route(&state).await;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user