Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
266 changes: 253 additions & 13 deletions src/apps/desktop/src/api/skill_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,13 @@ use tokio::time::{timeout, Duration};

use crate::api::app_state::AppState;
use bitfun_core::agentic::tools::implementations::skills::mode_overrides::{
get_disabled_mode_skills_from_document, load_disabled_user_mode_skills,
load_project_mode_skills_document_local, project_mode_skills_path_for_remote,
save_project_mode_skills_document_local, set_mode_skill_disabled_in_document,
set_user_mode_skill_disabled,
get_disabled_mode_skills_from_document, load_project_mode_skills_document_local,
load_user_mode_skill_overrides, project_mode_skills_path_for_remote,
save_project_mode_skills_document_local, set_disabled_mode_skills_in_document,
set_mode_skill_disabled_in_document, set_user_mode_skill_state,
};
use bitfun_core::agentic::tools::implementations::skills::{
default_profiles::{is_enabled_by_default_for_mode, is_skill_enabled_for_mode},
ModeSkillInfo, SkillData, SkillInfo, SkillLocation, SkillRegistry,
};
use bitfun_core::agentic::workspace::RemoteWorkspaceFs;
Expand Down Expand Up @@ -82,6 +83,14 @@ pub struct SkillMarketDownloadResponse {
pub output: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ReplaceModeSkillSelectionRequest {
pub mode_id: String,
pub enabled_skill_keys: Vec<String>,
pub workspace_path: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SkillMarketItem {
Expand Down Expand Up @@ -183,11 +192,9 @@ async fn get_mode_skill_infos_for_workspace_input(
workspace_path: Option<&str>,
) -> Result<Vec<ModeSkillInfo>, String> {
let all_skills = get_all_skills_for_workspace_input(state, registry, workspace_path).await?;
let disabled_user: HashSet<String> = load_disabled_user_mode_skills(mode_id)
let user_overrides = load_user_mode_skill_overrides(mode_id)
.await
.map_err(|e| format!("Failed to load user skill overrides: {}", e))?
.into_iter()
.collect();
.map_err(|e| format!("Failed to load user skill overrides: {}", e))?;

let (disabled_project, resolved_skills): (HashSet<String>, Vec<SkillInfo>) = if let Some((
remote_root,
Expand Down Expand Up @@ -254,10 +261,8 @@ async fn get_mode_skill_infos_for_workspace_input(
Ok(all_skills
.into_iter()
.map(|skill| {
let disabled_by_mode = match skill.level {
SkillLocation::User => disabled_user.contains(&skill.key),
SkillLocation::Project => disabled_project.contains(&skill.key),
};
let disabled_by_mode =
!is_skill_enabled_for_mode(&skill, mode_id, &user_overrides, &disabled_project);
let selected_for_runtime = resolved_keys.contains(&skill.key);

ModeSkillInfo {
Expand All @@ -269,6 +274,145 @@ async fn get_mode_skill_infos_for_workspace_input(
.collect())
}

fn normalize_skill_key_list(keys: Vec<String>) -> Vec<String> {
let mut seen = HashSet::new();
let mut normalized = Vec::new();

for key in keys {
let trimmed = key.trim();
if trimmed.is_empty() {
continue;
}

let owned = trimmed.to_string();
if seen.insert(owned.clone()) {
normalized.push(owned);
}
}

normalized
}

async fn persist_user_mode_skill_selection(
mode_id: &str,
all_skills: &[SkillInfo],
enabled_keys: &HashSet<String>,
) -> Result<(), String> {
let mut disabled_user_skills = Vec::new();
let mut enabled_user_skills = Vec::new();

for skill in all_skills
.iter()
.filter(|skill| skill.level == SkillLocation::User)
{
let should_enable = enabled_keys.contains(&skill.key);
let default_enabled = is_enabled_by_default_for_mode(skill, mode_id);

if default_enabled && !should_enable {
disabled_user_skills.push(skill.key.clone());
} else if !default_enabled && should_enable {
enabled_user_skills.push(skill.key.clone());
}
}

bitfun_core::service::config::mode_config_canonicalizer::persist_mode_config_from_value(
mode_id,
serde_json::json!({
"disabled_user_skills": normalize_skill_key_list(disabled_user_skills),
"enabled_user_skills": normalize_skill_key_list(enabled_user_skills),
}),
)
.await
.map_err(|e| format!("Failed to update user skill overrides: {}", e))
}

fn build_disabled_project_skill_keys(
all_skills: &[SkillInfo],
enabled_keys: &HashSet<String>,
) -> Vec<String> {
all_skills
.iter()
.filter(|skill| skill.level == SkillLocation::Project)
.filter(|skill| !enabled_keys.contains(&skill.key))
.map(|skill| skill.key.clone())
.collect()
}

async fn persist_project_mode_skill_selection_local(
mode_id: &str,
workspace_root: &Path,
disabled_project_skills: Vec<String>,
) -> Result<(), String> {
let mut document = load_project_mode_skills_document_local(workspace_root)
.await
.map_err(|e| format!("Failed to load project mode skills: {}", e))?;
set_disabled_mode_skills_in_document(&mut document, mode_id, disabled_project_skills)
.map_err(|e| format!("Failed to update project skill overrides: {}", e))?;
save_project_mode_skills_document_local(workspace_root, &document)
.await
.map_err(|e| format!("Failed to save project mode skills: {}", e))
}

async fn persist_project_mode_skill_selection_remote(
state: &State<'_, AppState>,
remote_root: &str,
entry: &RemoteWorkspaceEntry,
mode_id: &str,
disabled_project_skills: Vec<String>,
) -> Result<(), String> {
let remote_fs = state
.get_remote_file_service_async()
.await
.map_err(|e| format!("Remote file service not available: {}", e))?;
let config_path = project_mode_skills_path_for_remote(remote_root);
let mut document = if remote_fs
.exists(&entry.connection_id, &config_path)
.await
.map_err(|e| format!("Failed to check remote project skill overrides: {}", e))?
{
let content = remote_fs
.read_file(&entry.connection_id, &config_path)
.await
.map_err(|e| format!("Failed to read remote project skill overrides: {}", e))?;
let content = String::from_utf8(content)
.map_err(|e| format!("Remote project skill overrides are not valid UTF-8: {}", e))?;
serde_json::from_str::<Value>(&content)
.map_err(|e| format!("Invalid remote project skill overrides JSON: {}", e))?
} else {
serde_json::json!({})
};

set_disabled_mode_skills_in_document(&mut document, mode_id, disabled_project_skills)
.map_err(|e| format!("Failed to update remote project skill overrides: {}", e))?;

let config_dir = config_path
.rsplit_once('/')
.map(|(dir, _)| dir.to_string())
.ok_or_else(|| format!("Invalid remote project config path '{}'", config_path))?;

remote_fs
.create_dir_all(&entry.connection_id, &config_dir)
.await
.map_err(|e| {
format!(
"Failed to create remote project skill overrides directory: {}",
e
)
})?;
remote_fs
.write_file(
&entry.connection_id,
&config_path,
serde_json::to_vec_pretty(&document)
.map_err(|e| format!("Failed to serialize remote project skill overrides: {}", e))?
.as_slice(),
)
.await
.map_err(|e| format!("Failed to write remote project skill overrides: {}", e))?;

Ok(())
}

#[tauri::command]
pub async fn get_skill_configs(
state: State<'_, AppState>,
Expand Down Expand Up @@ -322,7 +466,34 @@ pub async fn set_mode_skill_disabled(
workspace_path: Option<String>,
) -> Result<String, String> {
if skill_key.starts_with("user::") {
set_user_mode_skill_disabled(&mode_id, &skill_key, disabled)
let registry = SkillRegistry::global();
let skill_info = if let Some((remote_root, entry)) =
resolve_remote_workspace(&state, workspace_path.as_deref()).await?
{
let remote_fs = state
.get_remote_file_service_async()
.await
.map_err(|e| format!("Remote file service not available: {}", e))?;
let remote_workspace_fs = RemoteWorkspaceFs::new(entry.connection_id, remote_fs);
registry
.find_skill_by_key_for_remote_workspace(
&remote_workspace_fs,
&remote_root,
&skill_key,
)
.await
} else {
registry
.find_skill_by_key_for_workspace(
&skill_key,
workspace_root_from_input(workspace_path.as_deref()).as_deref(),
)
.await
}
.ok_or_else(|| format!("Skill '{}' not found", skill_key))?;

let default_enabled = is_enabled_by_default_for_mode(&skill_info, &mode_id);
set_user_mode_skill_state(&mode_id, &skill_key, !disabled, default_enabled)
.await
.map_err(|e| format!("Failed to update user skill override: {}", e))?;
if let Err(e) = bitfun_core::service::config::reload_global_config().await {
Expand Down Expand Up @@ -417,6 +588,75 @@ pub async fn set_mode_skill_disabled(
))
}

#[tauri::command]
pub async fn replace_mode_skill_selection(
state: State<'_, AppState>,
request: ReplaceModeSkillSelectionRequest,
) -> Result<String, String> {
let registry = SkillRegistry::global();
let all_skills =
get_all_skills_for_workspace_input(&state, registry, request.workspace_path.as_deref())
.await?;

let enabled_skill_keys = normalize_skill_key_list(request.enabled_skill_keys);
let enabled_keys: HashSet<String> = enabled_skill_keys.iter().cloned().collect();
let known_keys: HashSet<String> = all_skills.iter().map(|skill| skill.key.clone()).collect();
let unknown_keys: Vec<String> = enabled_skill_keys
.iter()
.filter(|key| !known_keys.contains(*key))
.cloned()
.collect();
if !unknown_keys.is_empty() {
return Err(format!(
"Unknown skill keys for mode '{}': {}",
request.mode_id,
unknown_keys.join(", ")
));
}

persist_user_mode_skill_selection(&request.mode_id, &all_skills, &enabled_keys).await?;

let disabled_project_skills = normalize_skill_key_list(build_disabled_project_skill_keys(
&all_skills,
&enabled_keys,
));

if let Some((remote_root, entry)) =
resolve_remote_workspace(&state, request.workspace_path.as_deref()).await?
{
persist_project_mode_skill_selection_remote(
&state,
&remote_root,
&entry,
&request.mode_id,
disabled_project_skills,
)
.await?;
} else if let Some(workspace_root) =
workspace_root_from_input(request.workspace_path.as_deref())
{
persist_project_mode_skill_selection_local(
&request.mode_id,
&workspace_root,
disabled_project_skills,
)
.await?;
}

if let Err(e) = bitfun_core::service::config::reload_global_config().await {
log::warn!(
"Failed to reload global config after batch skill update: mode_id={}, error={}",
request.mode_id,
e
);
}

Ok(format!(
"Mode '{}' skill selection updated successfully",
request.mode_id
))
}

#[tauri::command]
pub async fn validate_skill_path(path: String) -> Result<SkillValidationResult, String> {
use std::path::Path;
Expand Down
1 change: 1 addition & 0 deletions src/apps/desktop/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,7 @@ pub async fn run() {
search_skill_market,
download_skill_market,
set_mode_skill_disabled,
replace_mode_skill_selection,
validate_skill_path,
add_skill,
delete_skill,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,46 @@ use crate::infrastructure::get_path_manager_arc;
use crate::util::errors::BitFunResult;
use include_dir::{include_dir, Dir};
use log::{debug, error};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use tokio::fs;

static BUILTIN_SKILLS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/builtin_skills");
static BUILTIN_SKILL_DIR_NAMES: OnceLock<HashSet<String>> = OnceLock::new();

fn collect_builtin_skill_dir_names() -> HashSet<String> {
BUILTIN_SKILLS_DIR
.dirs()
.filter_map(|dir| {
let rel = dir.path();
if rel.components().count() != 1 {
return None;
}

rel.file_name()
.and_then(|name| name.to_str())
.map(|name| name.to_string())
})
.collect()
}

pub fn builtin_skill_dir_names() -> &'static HashSet<String> {
BUILTIN_SKILL_DIR_NAMES.get_or_init(collect_builtin_skill_dir_names)
}

pub fn is_builtin_skill_dir_name(dir_name: &str) -> bool {
builtin_skill_dir_names().contains(dir_name)
}

pub fn builtin_skill_group_key(dir_name: &str) -> Option<&'static str> {
match dir_name {
"docx" | "pdf" | "pptx" | "xlsx" => Some("office"),
"find-skills" | "writing-skills" => Some("meta"),
"agent-browser" => Some("computer-use"),
_ => Some("superpowers"),
}
}

pub async fn ensure_builtin_skills_installed() -> BitFunResult<()> {
let pm = get_path_manager_arc();
Expand Down Expand Up @@ -124,3 +160,23 @@ async fn desired_file_content(
) -> BitFunResult<Vec<u8>> {
Ok(file.contents().to_vec())
}

#[cfg(test)]
mod tests {
use super::builtin_skill_group_key;

#[test]
fn builtin_skill_groups_match_expected_sets() {
assert_eq!(builtin_skill_group_key("docx"), Some("office"));
assert_eq!(builtin_skill_group_key("pdf"), Some("office"));
assert_eq!(builtin_skill_group_key("pptx"), Some("office"));
assert_eq!(builtin_skill_group_key("xlsx"), Some("office"));
assert_eq!(builtin_skill_group_key("find-skills"), Some("meta"));
assert_eq!(builtin_skill_group_key("writing-skills"), Some("meta"));
assert_eq!(builtin_skill_group_key("agent-browser"), Some("computer-use"));
assert_eq!(
builtin_skill_group_key("test-driven-development"),
Some("superpowers")
);
}
}
Loading
Loading