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
355 changes: 312 additions & 43 deletions src/apps/desktop/src/api/skill_api.rs

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion src/apps/desktop/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -412,10 +412,11 @@ pub async fn run() {
list_agent_tool_names,
update_subagent_config,
get_skill_configs,
get_mode_skill_configs,
list_skill_market,
search_skill_market,
download_skill_market,
set_skill_enabled,
set_mode_skill_disabled,
validate_skill_path,
add_skill,
delete_skill,
Expand Down
39 changes: 32 additions & 7 deletions src/crates/core/src/agentic/tools/implementations/skill_tool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,20 +69,32 @@ Important:
.map(|w| w.root_path_string())
.unwrap_or_default();
registry
.get_enabled_skills_xml_for_remote_workspace(fs, &root)
.get_resolved_skills_xml_for_remote_workspace(
fs,
&root,
ctx.agent_type.as_deref(),
)
.await
} else {
registry
.get_enabled_skills_xml_for_workspace(ctx.workspace_root())
.get_resolved_skills_xml_for_workspace(
ctx.workspace_root(),
ctx.agent_type.as_deref(),
)
.await
}
}
Some(ctx) => {
registry
.get_enabled_skills_xml_for_workspace(ctx.workspace_root())
.get_resolved_skills_xml_for_workspace(
ctx.workspace_root(),
ctx.agent_type.as_deref(),
)
.await
}
None => registry.get_enabled_skills_xml().await,
None => registry
.get_resolved_skills_xml_for_workspace(None, None)
.await,
};

self.render_description(available_skills.join("\n"))
Expand Down Expand Up @@ -195,16 +207,29 @@ impl Tool for SkillTool {
.map(|w| w.root_path_string())
.unwrap_or_default();
registry
.find_and_load_skill_for_remote_workspace(skill_name, ws_fs, &root)
.find_and_load_skill_for_remote_workspace(
skill_name,
ws_fs,
&root,
context.agent_type.as_deref(),
)
.await?
} else {
registry
.find_and_load_skill_for_workspace(skill_name, context.workspace_root())
.find_and_load_skill_for_workspace(
skill_name,
context.workspace_root(),
context.agent_type.as_deref(),
)
.await?
}
} else {
registry
.find_and_load_skill_for_workspace(skill_name, context.workspace_root())
.find_and_load_skill_for_workspace(
skill_name,
context.workspace_root(),
context.agent_type.as_deref(),
)
.await?
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,8 @@

use crate::infrastructure::get_path_manager_arc;
use crate::util::errors::BitFunResult;
use crate::util::front_matter_markdown::FrontMatterMarkdown;
use include_dir::{include_dir, Dir};
use log::{debug, error};
use serde_yaml::Value;
use std::path::{Path, PathBuf};
use tokio::fs;

Expand Down Expand Up @@ -122,64 +120,7 @@ fn safe_join(root: &Path, relative: &Path) -> BitFunResult<PathBuf> {

async fn desired_file_content(
file: &include_dir::File<'_>,
dest_path: &Path,
_dest_path: &Path,
) -> BitFunResult<Vec<u8>> {
let source = file.contents();
if !is_skill_markdown(file.path()) {
return Ok(source.to_vec());
}

let source_text = match std::str::from_utf8(source) {
Ok(v) => v,
Err(_) => return Ok(source.to_vec()),
};

let enabled = if let Ok(existing) = fs::read_to_string(dest_path).await {
// Preserve user-selected state when file already exists.
extract_enabled_flag(&existing).unwrap_or(true)
} else {
// On first install, respect bundled default (if present), otherwise enable by default.
extract_enabled_flag(source_text).unwrap_or(true)
};

let merged = merge_skill_markdown_enabled(source_text, enabled)?;
Ok(merged.into_bytes())
}

fn is_skill_markdown(path: &Path) -> bool {
path.file_name()
.and_then(|n| n.to_str())
.map(|n| n.eq_ignore_ascii_case("SKILL.md"))
.unwrap_or(false)
}

fn extract_enabled_flag(markdown: &str) -> Option<bool> {
let (metadata, _) = FrontMatterMarkdown::load_str(markdown).ok()?;
metadata.get("enabled").and_then(|v| v.as_bool())
}

fn merge_skill_markdown_enabled(markdown: &str, enabled: bool) -> BitFunResult<String> {
let (mut metadata, body) = FrontMatterMarkdown::load_str(markdown)
.map_err(|e| crate::util::errors::BitFunError::tool(format!("Invalid SKILL.md: {}", e)))?;

let map = metadata.as_mapping_mut().ok_or_else(|| {
crate::util::errors::BitFunError::tool(
"Invalid SKILL.md: metadata is not a mapping".to_string(),
)
})?;

if enabled {
map.remove(&Value::String("enabled".to_string()));
} else {
map.insert(Value::String("enabled".to_string()), Value::Bool(false));
}

let yaml = serde_yaml::to_string(&metadata).map_err(|e| {
crate::util::errors::BitFunError::tool(format!("Failed to serialize SKILL.md: {}", e))
})?;
Ok(format!(
"---\n{}\n---\n\n{}",
yaml.trim_end(),
body.trim_start()
))
Ok(file.contents().to_vec())
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@
//! Provides Skill registry, loading, and configuration management functionality

pub mod builtin;
pub mod mode_overrides;
pub mod registry;
pub mod types;

pub use registry::SkillRegistry;
pub use types::{SkillData, SkillInfo, SkillLocation};
pub use types::{ModeSkillInfo, SkillData, SkillInfo, SkillLocation};

/// Get global Skill registry instance
pub fn get_skill_registry() -> &'static SkillRegistry {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
//! Mode-specific skill override helpers.

use crate::agentic::workspace::WorkspaceFileSystem;
use crate::infrastructure::get_path_manager_arc;
use crate::service::config::global::GlobalConfigManager;
use crate::service::config::mode_config_canonicalizer::persist_mode_config_from_value;
use crate::service::config::types::ModeConfig;
use crate::util::errors::{BitFunError, BitFunResult};
use serde_json::{json, Map, Value};
use std::collections::{HashMap, HashSet};
use std::path::Path;

const PROJECT_MODE_SKILLS_FILE_NAME: &str = "mode_skills.json";
const DISABLED_SKILLS_KEY: &str = "disabled_skills";

fn dedupe_skill_keys(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
}

pub async fn load_disabled_user_mode_skills(mode_id: &str) -> BitFunResult<Vec<String>> {
let config_service = GlobalConfigManager::get_service().await?;
let stored_configs: HashMap<String, ModeConfig> = config_service
.get_config(Some("ai.mode_configs"))
.await
.unwrap_or_default();

Ok(dedupe_skill_keys(
stored_configs
.get(mode_id)
.map(|config| config.disabled_user_skills.clone())
.unwrap_or_default(),
))
}

pub async fn set_user_mode_skill_disabled(
mode_id: &str,
skill_key: &str,
disabled: bool,
) -> BitFunResult<Vec<String>> {
let mut next = load_disabled_user_mode_skills(mode_id).await?;
if disabled {
next.push(skill_key.to_string());
next = dedupe_skill_keys(next);
} else {
next.retain(|value| value != skill_key);
}

persist_mode_config_from_value(mode_id, json!({ "disabled_user_skills": next })).await?;
load_disabled_user_mode_skills(mode_id).await
}

pub fn project_mode_skills_path_for_remote(remote_root: &str) -> String {
format!(
"{}/.bitfun/config/{}",
remote_root.trim_end_matches('/'),
PROJECT_MODE_SKILLS_FILE_NAME
)
}

fn normalize_project_document_value(value: Value) -> Value {
match value {
Value::Object(_) => value,
_ => Value::Object(Map::new()),
}
}

fn mode_skills_object_mut(document: &mut Value) -> BitFunResult<&mut Map<String, Value>> {
if !document.is_object() {
*document = Value::Object(Map::new());
}

document
.as_object_mut()
.ok_or_else(|| BitFunError::config("Project mode skills must be a JSON object".to_string()))
}

fn mode_skills_object(document: &Value) -> Option<&Map<String, Value>> {
document.as_object()
}

pub fn get_disabled_mode_skills_from_document(document: &Value, mode_id: &str) -> Vec<String> {
let Some(mode_object) = mode_skills_object(document)
.and_then(|map| map.get(mode_id))
.and_then(Value::as_object)
else {
return Vec::new();
};

let keys = mode_object
.get(DISABLED_SKILLS_KEY)
.cloned()
.and_then(|value| serde_json::from_value::<Vec<String>>(value).ok())
.unwrap_or_default();

dedupe_skill_keys(keys)
}

pub fn set_mode_skill_disabled_in_document(
document: &mut Value,
mode_id: &str,
skill_key: &str,
disabled: bool,
) -> BitFunResult<Vec<String>> {
let mode_skills = mode_skills_object_mut(document)?;
let mode_entry = mode_skills
.entry(mode_id.to_string())
.or_insert_with(|| Value::Object(Map::new()));

if !mode_entry.is_object() {
*mode_entry = Value::Object(Map::new());
}

let mode_object = mode_entry
.as_object_mut()
.ok_or_else(|| BitFunError::config("Mode skills entry must be a JSON object".to_string()))?;

let current = mode_object
.get(DISABLED_SKILLS_KEY)
.cloned()
.and_then(|value| serde_json::from_value::<Vec<String>>(value).ok())
.unwrap_or_default();

let mut next = dedupe_skill_keys(current);
if disabled {
next.push(skill_key.to_string());
next = dedupe_skill_keys(next);
} else {
next.retain(|value| value != skill_key);
}

if next.is_empty() {
mode_object.remove(DISABLED_SKILLS_KEY);
} else {
mode_object.insert(
DISABLED_SKILLS_KEY.to_string(),
serde_json::to_value(&next)?,
);
}

if mode_object.is_empty() {
mode_skills.remove(mode_id);
}

Ok(next)
}

pub async fn load_project_mode_skills_document_local(
workspace_root: &Path,
) -> BitFunResult<Value> {
let path = get_path_manager_arc().project_mode_skills_file(workspace_root);
match tokio::fs::read_to_string(&path).await {
Ok(content) => Ok(normalize_project_document_value(serde_json::from_str(&content)?)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
Ok(Value::Object(Map::new()))
}
Err(error) => Err(BitFunError::config(format!(
"Failed to read project skill overrides file '{}': {}",
path.display(),
error
))),
}
}

pub async fn save_project_mode_skills_document_local(
workspace_root: &Path,
document: &Value,
) -> BitFunResult<()> {
let path = get_path_manager_arc().project_mode_skills_file(workspace_root);
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
tokio::fs::write(&path, serde_json::to_vec_pretty(document)?).await?;
Ok(())
}

pub async fn load_disabled_mode_skills_local(
workspace_root: &Path,
mode_id: &str,
) -> BitFunResult<Vec<String>> {
let document = load_project_mode_skills_document_local(workspace_root).await?;
Ok(get_disabled_mode_skills_from_document(&document, mode_id))
}

pub async fn load_disabled_mode_skills_remote(
fs: &dyn WorkspaceFileSystem,
remote_root: &str,
mode_id: &str,
) -> BitFunResult<Vec<String>> {
let path = project_mode_skills_path_for_remote(remote_root);
let exists = fs.exists(&path).await.unwrap_or(false);
if !exists {
return Ok(Vec::new());
}

let content = fs
.read_file_text(&path)
.await
.map_err(|error| BitFunError::config(format!(
"Failed to read remote project skill overrides: {}",
error
)))?;
let document = normalize_project_document_value(serde_json::from_str(&content)?);
Ok(get_disabled_mode_skills_from_document(&document, mode_id))
}
Loading
Loading