use super::AppState;
use super::api::require_auth;
use super::api_agents::build_kumiho_client;
use super::kumiho_client::{ItemResponse, KumihoError, RevisionResponse, slugify};
use axum::{
extract::{Path, Query, State},
http::{HeaderMap, StatusCode},
response::{IntoResponse, Json},
};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::OnceLock;
use std::time::Instant;
struct SkillCache {
skills: Vec<SkillResponse>,
include_deprecated: bool,
fetched_at: Instant,
}
static SKILL_CACHE: OnceLock<Mutex<Option<SkillCache>>> = OnceLock::new();
const CACHE_TTL_SECS: u64 = 30;
fn get_cached_skills(include_deprecated: bool) -> Option<Vec<SkillResponse>> {
let lock = SKILL_CACHE.get_or_init(|| Mutex::new(None));
let cache = lock.lock();
if let Some(ref c) = *cache {
if c.include_deprecated == include_deprecated
&& c.fetched_at.elapsed().as_secs() < CACHE_TTL_SECS
{
return Some(c.skills.clone());
}
}
None
}
fn set_cached_skills(skills: &[SkillResponse], include_deprecated: bool) {
let lock = SKILL_CACHE.get_or_init(|| Mutex::new(None));
let mut cache = lock.lock();
*cache = Some(SkillCache {
skills: skills.to_vec(),
include_deprecated,
fetched_at: Instant::now(),
});
}
pub fn invalidate_skill_cache() {
if let Some(lock) = SKILL_CACHE.get() {
let mut cache = lock.lock();
*cache = None;
}
}
const SKILL_SPACE_NAME: &str = "Skills";
const SKILL_ARTIFACT_NAME: &str = "SKILL.md";
const SKILL_KIND: &str = "skilldef";
const SKILLS_DIR: &str = ".construct/workspace/skills";
fn skill_project(state: &AppState) -> String {
state.config.lock().kumiho.memory_project.clone()
}
fn skill_space_path(state: &AppState) -> String {
format!("/{}/{}", skill_project(state), SKILL_SPACE_NAME)
}
#[derive(Deserialize)]
pub struct SkillListQuery {
#[serde(default)]
pub include_deprecated: bool,
pub q: Option<String>,
pub page: Option<u32>,
pub per_page: Option<u32>,
}
#[derive(Deserialize)]
pub struct CreateSkillBody {
pub name: String,
pub description: String,
pub content: String,
pub domain: String,
#[serde(default)]
pub tags: Option<Vec<String>>,
}
#[derive(Deserialize)]
pub struct DeprecateBody {
pub kref: String,
pub deprecated: bool,
}
#[derive(Serialize, Clone)]
pub struct SkillResponse {
pub kref: String,
pub name: String,
pub item_name: String,
pub deprecated: bool,
pub created_at: Option<String>,
pub description: String,
pub content: String,
pub domain: String,
pub tags: Vec<String>,
pub revision_number: i32,
}
fn kumiho_err(e: KumihoError) -> (StatusCode, Json<serde_json::Value>) {
match &e {
KumihoError::Unreachable(_) => (
StatusCode::SERVICE_UNAVAILABLE,
Json(serde_json::json!({ "error": format!("Kumiho service unavailable: {e}") })),
),
KumihoError::Api { status, body } => {
let code = if *status == 401 || *status == 403 {
StatusCode::BAD_GATEWAY
} else {
StatusCode::from_u16(*status).unwrap_or(StatusCode::BAD_GATEWAY)
};
(
code,
Json(serde_json::json!({ "error": format!("Kumiho upstream: {body}") })),
)
}
KumihoError::Decode(msg) => (
StatusCode::BAD_GATEWAY,
Json(serde_json::json!({ "error": format!("Bad response from Kumiho: {msg}") })),
),
}
}
fn skill_revision_metadata(body: &CreateSkillBody) -> HashMap<String, String> {
let mut meta = HashMap::new();
meta.insert("description".to_string(), body.description.clone());
meta.insert("domain".to_string(), body.domain.clone());
meta.insert("created_by".to_string(), "construct-dashboard".to_string());
if let Some(ref tags) = body.tags {
if !tags.is_empty() {
meta.insert("tags".to_string(), tags.join(","));
}
}
meta
}
fn to_skill_response(
item: &ItemResponse,
rev: Option<&RevisionResponse>,
artifact_content: Option<&str>,
) -> SkillResponse {
let meta = rev.map(|r| &r.metadata);
let get = |key: &str| -> String { meta.and_then(|m| m.get(key)).cloned().unwrap_or_default() };
let tags_str = get("tags");
let tags: Vec<String> = if tags_str.is_empty() {
Vec::new()
} else {
tags_str.split(',').map(|s| s.trim().to_string()).collect()
};
let content = match artifact_content {
Some(c) => c.to_string(),
None => get("content"),
};
SkillResponse {
kref: item.kref.clone(),
name: item.item_name.clone(),
item_name: item.item_name.clone(),
deprecated: item.deprecated,
created_at: item.created_at.clone(),
description: get("description"),
content,
domain: get("domain"),
tags,
revision_number: rev.map(|r| r.number).unwrap_or(0),
}
}
const BATCH_CHUNK_SIZE: usize = 20;
async fn enrich_items(
client: &super::kumiho_client::KumihoClient,
items: Vec<ItemResponse>,
) -> Vec<SkillResponse> {
if items.is_empty() {
return Vec::new();
}
let krefs: Vec<String> = items.iter().map(|i| i.kref.clone()).collect();
let mut set = tokio::task::JoinSet::new();
for chunk in krefs.chunks(BATCH_CHUNK_SIZE) {
let chunk_vec: Vec<String> = chunk.to_vec();
let c = client.clone();
set.spawn(async move { c.batch_get_revisions(&chunk_vec, "published").await });
}
let mut rev_map: std::collections::HashMap<String, RevisionResponse> =
std::collections::HashMap::new();
while let Some(res) = set.join_next().await {
if let Ok(Ok(batch)) = res {
rev_map.extend(batch);
}
}
let missing: Vec<String> = krefs
.iter()
.filter(|k| !rev_map.contains_key(*k))
.cloned()
.collect();
if !missing.is_empty() {
if let Ok(latest_map) = client.batch_get_revisions(&missing, "latest").await {
rev_map.extend(latest_map);
}
}
items
.iter()
.map(|item| {
let rev = rev_map.get(&item.kref);
let mut skill = to_skill_response(item, rev, None);
if skill.content.len() > 200 {
skill.content = format!("{}...", &skill.content[..200]);
}
skill
})
.collect()
}
fn skills_dir() -> std::path::PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
std::path::PathBuf::from(home).join(SKILLS_DIR)
}
async fn fetch_skill_content(
client: &super::kumiho_client::KumihoClient,
rev: &RevisionResponse,
) -> String {
if let Ok(artifacts) = client.get_artifacts(&rev.kref).await {
for art in &artifacts {
if art.name == SKILL_ARTIFACT_NAME {
let location = &art.location;
let file_path = if let Some(path) = location.strip_prefix("file://") {
Some(std::path::PathBuf::from(path))
} else if location.starts_with('/') {
Some(std::path::PathBuf::from(location))
} else {
None
};
if let Some(ref path) = file_path {
if let Ok(content) = tokio::fs::read_to_string(path).await {
return content;
}
}
if let Some(content) = art.metadata.get("content") {
return content.clone();
}
}
}
}
rev.metadata.get("content").cloned().unwrap_or_default()
}
async fn store_skill_artifact(
client: &super::kumiho_client::KumihoClient,
revision_kref: &str,
_item_kref: &str,
slug: &str,
content: &str,
) -> std::result::Result<(), KumihoError> {
let dir = skills_dir();
let _ = tokio::fs::create_dir_all(&dir).await;
let file_path = dir.join(format!("{slug}.md"));
let location = format!("file://{}", file_path.display());
tokio::fs::write(&file_path, content)
.await
.map_err(|e| KumihoError::Decode(format!("Failed to write skill file: {e}")))?;
let metadata = HashMap::new();
client
.create_artifact(revision_kref, SKILL_ARTIFACT_NAME, &location, metadata)
.await?;
Ok(())
}
pub async fn handle_list_skills(
State(state): State<AppState>,
headers: HeaderMap,
Query(query): Query<SkillListQuery>,
) -> impl IntoResponse {
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
if query.q.is_none() {
if let Some(cached) = get_cached_skills(query.include_deprecated) {
let total_count = cached.len() as u32;
let per_page = query.per_page.unwrap_or(9).min(50).max(1);
let page = query.page.unwrap_or(1).max(1);
let skip = ((page - 1) * per_page) as usize;
let skills: Vec<_> = cached
.into_iter()
.skip(skip)
.take(per_page as usize)
.collect();
return Json(serde_json::json!({
"skills": skills,
"total_count": total_count,
"page": page,
"per_page": per_page,
}))
.into_response();
}
}
let client = build_kumiho_client(&state);
let project_name = skill_project(&state);
let space_path = skill_space_path(&state);
if let Some(ref q) = query.q {
let items_result = client
.search_items(q, &project_name, SKILL_KIND, query.include_deprecated)
.await
.map(|results| results.into_iter().map(|sr| sr.item).collect::<Vec<_>>());
return match items_result {
Ok(items) => {
let skills = enrich_items(&client, items).await;
let total_count = skills.len() as u32;
let per_page = query.per_page.unwrap_or(9).min(50).max(1);
let page = query.page.unwrap_or(1).max(1);
let skip = ((page - 1) * per_page) as usize;
let skills: Vec<_> = skills
.into_iter()
.skip(skip)
.take(per_page as usize)
.collect();
Json(serde_json::json!({
"skills": skills,
"total_count": total_count,
"page": page,
"per_page": per_page,
}))
.into_response()
}
Err(ref e) if matches!(e, KumihoError::Api { status: 404, .. }) => Json(
serde_json::json!({ "skills": [], "total_count": 0, "page": 1, "per_page": 9 }),
)
.into_response(),
Err(e) => kumiho_err(e).into_response(),
};
}
let include_deprecated = query.include_deprecated;
let items: Vec<ItemResponse> = match client.list_items(&space_path, include_deprecated).await {
Ok(items) => items,
Err(_) => {
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut fallback_items: Vec<ItemResponse> = Vec::new();
for filter in &["a", "d"] {
if let Ok(batch) = client
.list_items_filtered(&space_path, filter, include_deprecated)
.await
{
for item in batch {
if seen.insert(item.kref.clone()) {
fallback_items.push(item);
}
}
}
}
fallback_items
}
};
if items.is_empty() {
let _ = client.ensure_project(&project_name).await;
let _ = client.ensure_space(&project_name, SKILL_SPACE_NAME).await;
}
let skills = enrich_items(&client, items).await;
set_cached_skills(&skills, query.include_deprecated);
let total_count = skills.len() as u32;
let per_page = query.per_page.unwrap_or(9).min(50).max(1);
let page = query.page.unwrap_or(1).max(1);
let skip = ((page - 1) * per_page) as usize;
let skills: Vec<_> = skills
.into_iter()
.skip(skip)
.take(per_page as usize)
.collect();
Json(serde_json::json!({
"skills": skills,
"total_count": total_count,
"page": page,
"per_page": per_page,
}))
.into_response()
}
pub async fn handle_get_skill(
State(state): State<AppState>,
headers: HeaderMap,
Path(kref): Path<String>,
) -> impl IntoResponse {
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
let kref = format!("kref://{kref}");
let client = build_kumiho_client(&state);
let rev = match client.get_published_or_latest(&kref).await {
Ok(rev) => rev,
Err(e) => return kumiho_err(e).into_response(),
};
let content = fetch_skill_content(&client, &rev).await;
let item_name = kref
.rsplit('/')
.next()
.unwrap_or_default()
.trim_end_matches(".skilldef")
.trim_end_matches(".skill")
.to_string();
let item = ItemResponse {
kref: kref.clone(),
name: item_name.clone(),
item_name,
kind: SKILL_KIND.to_string(),
deprecated: false,
created_at: None,
metadata: HashMap::new(),
};
let skill = to_skill_response(&item, Some(&rev), Some(&content));
Json(serde_json::json!({ "skill": skill })).into_response()
}
pub async fn handle_create_skill(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<CreateSkillBody>,
) -> impl IntoResponse {
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
let client = build_kumiho_client(&state);
let project_name = skill_project(&state);
let space_path = skill_space_path(&state);
if let Err(e) = client.ensure_project(&project_name).await {
return kumiho_err(e).into_response();
}
if let Err(e) = client.ensure_space(&project_name, SKILL_SPACE_NAME).await {
return kumiho_err(e).into_response();
}
let slug = slugify(&body.name);
let item = match client
.create_item(&space_path, &slug, SKILL_KIND, HashMap::new())
.await
{
Ok(item) => item,
Err(e) => return kumiho_err(e).into_response(),
};
let metadata = skill_revision_metadata(&body);
let rev = match client.create_revision(&item.kref, metadata).await {
Ok(rev) => rev,
Err(e) => return kumiho_err(e).into_response(),
};
if let Err(e) = store_skill_artifact(&client, &rev.kref, &item.kref, &slug, &body.content).await
{
tracing::warn!("Failed to create SKILL.md artifact for {}: {e}", item.kref);
}
let _ = client.tag_revision(&rev.kref, "published").await;
invalidate_skill_cache();
let skill = to_skill_response(&item, Some(&rev), Some(&body.content));
(
StatusCode::CREATED,
Json(serde_json::json!({ "skill": skill })),
)
.into_response()
}
pub async fn handle_update_skill(
State(state): State<AppState>,
headers: HeaderMap,
Path(kref): Path<String>,
Json(body): Json<CreateSkillBody>,
) -> impl IntoResponse {
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
let kref = format!("kref://{kref}");
let client = build_kumiho_client(&state);
let slug = kref
.rsplit('/')
.next()
.unwrap_or_default()
.trim_end_matches(".skilldef")
.trim_end_matches(".skill")
.to_string();
let metadata = skill_revision_metadata(&body);
let rev = match client.create_revision(&kref, metadata).await {
Ok(rev) => rev,
Err(e) => return kumiho_err(e).into_response(),
};
if let Err(e) = store_skill_artifact(&client, &rev.kref, &kref, &slug, &body.content).await {
tracing::warn!("Failed to create SKILL.md artifact for {kref}: {e}");
}
let _ = client.tag_revision(&rev.kref, "published").await;
invalidate_skill_cache();
let item = ItemResponse {
kref: kref.clone(),
name: body.name.clone(),
item_name: body.name.clone(),
kind: SKILL_KIND.to_string(),
deprecated: false,
created_at: None,
metadata: HashMap::new(),
};
let skill = to_skill_response(&item, Some(&rev), Some(&body.content));
Json(serde_json::json!({ "skill": skill })).into_response()
}
pub async fn handle_deprecate_skill(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<DeprecateBody>,
) -> impl IntoResponse {
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
let kref = body.kref.clone();
let client = build_kumiho_client(&state);
match client.deprecate_item(&kref, body.deprecated).await {
Ok(item) => {
invalidate_skill_cache();
let rev = client.get_published_or_latest(&kref).await.ok();
let skill = to_skill_response(&item, rev.as_ref(), None);
Json(serde_json::json!({ "skill": skill })).into_response()
}
Err(e) => kumiho_err(e).into_response(),
}
}
pub async fn handle_delete_skill(
State(state): State<AppState>,
headers: HeaderMap,
Path(kref): Path<String>,
) -> impl IntoResponse {
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
let kref = format!("kref://{kref}");
let client = build_kumiho_client(&state);
match client.delete_item(&kref).await {
Ok(()) => {
invalidate_skill_cache();
StatusCode::NO_CONTENT.into_response()
}
Err(e) => kumiho_err(e).into_response(),
}
}