Skip to content

Commit 5ae1e22

Browse files
authored
Merge pull request #337 from wsp1911/main
feat(skills): support per-mode skill overrides by stable skill key
2 parents 44a5cbb + 548ff21 commit 5ae1e22

File tree

22 files changed

+1424
-716
lines changed

22 files changed

+1424
-716
lines changed

src/apps/desktop/src/api/skill_api.rs

Lines changed: 312 additions & 43 deletions
Large diffs are not rendered by default.

src/apps/desktop/src/lib.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -412,10 +412,11 @@ pub async fn run() {
412412
list_agent_tool_names,
413413
update_subagent_config,
414414
get_skill_configs,
415+
get_mode_skill_configs,
415416
list_skill_market,
416417
search_skill_market,
417418
download_skill_market,
418-
set_skill_enabled,
419+
set_mode_skill_disabled,
419420
validate_skill_path,
420421
add_skill,
421422
delete_skill,

src/crates/core/src/agentic/tools/implementations/skill_tool.rs

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -69,20 +69,32 @@ Important:
6969
.map(|w| w.root_path_string())
7070
.unwrap_or_default();
7171
registry
72-
.get_enabled_skills_xml_for_remote_workspace(fs, &root)
72+
.get_resolved_skills_xml_for_remote_workspace(
73+
fs,
74+
&root,
75+
ctx.agent_type.as_deref(),
76+
)
7377
.await
7478
} else {
7579
registry
76-
.get_enabled_skills_xml_for_workspace(ctx.workspace_root())
80+
.get_resolved_skills_xml_for_workspace(
81+
ctx.workspace_root(),
82+
ctx.agent_type.as_deref(),
83+
)
7784
.await
7885
}
7986
}
8087
Some(ctx) => {
8188
registry
82-
.get_enabled_skills_xml_for_workspace(ctx.workspace_root())
89+
.get_resolved_skills_xml_for_workspace(
90+
ctx.workspace_root(),
91+
ctx.agent_type.as_deref(),
92+
)
8393
.await
8494
}
85-
None => registry.get_enabled_skills_xml().await,
95+
None => registry
96+
.get_resolved_skills_xml_for_workspace(None, None)
97+
.await,
8698
};
8799

88100
self.render_description(available_skills.join("\n"))
@@ -195,16 +207,29 @@ impl Tool for SkillTool {
195207
.map(|w| w.root_path_string())
196208
.unwrap_or_default();
197209
registry
198-
.find_and_load_skill_for_remote_workspace(skill_name, ws_fs, &root)
210+
.find_and_load_skill_for_remote_workspace(
211+
skill_name,
212+
ws_fs,
213+
&root,
214+
context.agent_type.as_deref(),
215+
)
199216
.await?
200217
} else {
201218
registry
202-
.find_and_load_skill_for_workspace(skill_name, context.workspace_root())
219+
.find_and_load_skill_for_workspace(
220+
skill_name,
221+
context.workspace_root(),
222+
context.agent_type.as_deref(),
223+
)
203224
.await?
204225
}
205226
} else {
206227
registry
207-
.find_and_load_skill_for_workspace(skill_name, context.workspace_root())
228+
.find_and_load_skill_for_workspace(
229+
skill_name,
230+
context.workspace_root(),
231+
context.agent_type.as_deref(),
232+
)
208233
.await?
209234
};
210235

src/crates/core/src/agentic/tools/implementations/skills/builtin.rs

Lines changed: 2 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,8 @@
55
66
use crate::infrastructure::get_path_manager_arc;
77
use crate::util::errors::BitFunResult;
8-
use crate::util::front_matter_markdown::FrontMatterMarkdown;
98
use include_dir::{include_dir, Dir};
109
use log::{debug, error};
11-
use serde_yaml::Value;
1210
use std::path::{Path, PathBuf};
1311
use tokio::fs;
1412

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

123121
async fn desired_file_content(
124122
file: &include_dir::File<'_>,
125-
dest_path: &Path,
123+
_dest_path: &Path,
126124
) -> BitFunResult<Vec<u8>> {
127-
let source = file.contents();
128-
if !is_skill_markdown(file.path()) {
129-
return Ok(source.to_vec());
130-
}
131-
132-
let source_text = match std::str::from_utf8(source) {
133-
Ok(v) => v,
134-
Err(_) => return Ok(source.to_vec()),
135-
};
136-
137-
let enabled = if let Ok(existing) = fs::read_to_string(dest_path).await {
138-
// Preserve user-selected state when file already exists.
139-
extract_enabled_flag(&existing).unwrap_or(true)
140-
} else {
141-
// On first install, respect bundled default (if present), otherwise enable by default.
142-
extract_enabled_flag(source_text).unwrap_or(true)
143-
};
144-
145-
let merged = merge_skill_markdown_enabled(source_text, enabled)?;
146-
Ok(merged.into_bytes())
147-
}
148-
149-
fn is_skill_markdown(path: &Path) -> bool {
150-
path.file_name()
151-
.and_then(|n| n.to_str())
152-
.map(|n| n.eq_ignore_ascii_case("SKILL.md"))
153-
.unwrap_or(false)
154-
}
155-
156-
fn extract_enabled_flag(markdown: &str) -> Option<bool> {
157-
let (metadata, _) = FrontMatterMarkdown::load_str(markdown).ok()?;
158-
metadata.get("enabled").and_then(|v| v.as_bool())
159-
}
160-
161-
fn merge_skill_markdown_enabled(markdown: &str, enabled: bool) -> BitFunResult<String> {
162-
let (mut metadata, body) = FrontMatterMarkdown::load_str(markdown)
163-
.map_err(|e| crate::util::errors::BitFunError::tool(format!("Invalid SKILL.md: {}", e)))?;
164-
165-
let map = metadata.as_mapping_mut().ok_or_else(|| {
166-
crate::util::errors::BitFunError::tool(
167-
"Invalid SKILL.md: metadata is not a mapping".to_string(),
168-
)
169-
})?;
170-
171-
if enabled {
172-
map.remove(&Value::String("enabled".to_string()));
173-
} else {
174-
map.insert(Value::String("enabled".to_string()), Value::Bool(false));
175-
}
176-
177-
let yaml = serde_yaml::to_string(&metadata).map_err(|e| {
178-
crate::util::errors::BitFunError::tool(format!("Failed to serialize SKILL.md: {}", e))
179-
})?;
180-
Ok(format!(
181-
"---\n{}\n---\n\n{}",
182-
yaml.trim_end(),
183-
body.trim_start()
184-
))
125+
Ok(file.contents().to_vec())
185126
}

src/crates/core/src/agentic/tools/implementations/skills/mod.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@
33
//! Provides Skill registry, loading, and configuration management functionality
44
55
pub mod builtin;
6+
pub mod mode_overrides;
67
pub mod registry;
78
pub mod types;
89

910
pub use registry::SkillRegistry;
10-
pub use types::{SkillData, SkillInfo, SkillLocation};
11+
pub use types::{ModeSkillInfo, SkillData, SkillInfo, SkillLocation};
1112

1213
/// Get global Skill registry instance
1314
pub fn get_skill_registry() -> &'static SkillRegistry {
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
//! Mode-specific skill override helpers.
2+
3+
use crate::agentic::workspace::WorkspaceFileSystem;
4+
use crate::infrastructure::get_path_manager_arc;
5+
use crate::service::config::global::GlobalConfigManager;
6+
use crate::service::config::mode_config_canonicalizer::persist_mode_config_from_value;
7+
use crate::service::config::types::ModeConfig;
8+
use crate::util::errors::{BitFunError, BitFunResult};
9+
use serde_json::{json, Map, Value};
10+
use std::collections::{HashMap, HashSet};
11+
use std::path::Path;
12+
13+
const PROJECT_MODE_SKILLS_FILE_NAME: &str = "mode_skills.json";
14+
const DISABLED_SKILLS_KEY: &str = "disabled_skills";
15+
16+
fn dedupe_skill_keys(keys: Vec<String>) -> Vec<String> {
17+
let mut seen = HashSet::new();
18+
let mut normalized = Vec::new();
19+
20+
for key in keys {
21+
let trimmed = key.trim();
22+
if trimmed.is_empty() {
23+
continue;
24+
}
25+
let owned = trimmed.to_string();
26+
if seen.insert(owned.clone()) {
27+
normalized.push(owned);
28+
}
29+
}
30+
31+
normalized
32+
}
33+
34+
pub async fn load_disabled_user_mode_skills(mode_id: &str) -> BitFunResult<Vec<String>> {
35+
let config_service = GlobalConfigManager::get_service().await?;
36+
let stored_configs: HashMap<String, ModeConfig> = config_service
37+
.get_config(Some("ai.mode_configs"))
38+
.await
39+
.unwrap_or_default();
40+
41+
Ok(dedupe_skill_keys(
42+
stored_configs
43+
.get(mode_id)
44+
.map(|config| config.disabled_user_skills.clone())
45+
.unwrap_or_default(),
46+
))
47+
}
48+
49+
pub async fn set_user_mode_skill_disabled(
50+
mode_id: &str,
51+
skill_key: &str,
52+
disabled: bool,
53+
) -> BitFunResult<Vec<String>> {
54+
let mut next = load_disabled_user_mode_skills(mode_id).await?;
55+
if disabled {
56+
next.push(skill_key.to_string());
57+
next = dedupe_skill_keys(next);
58+
} else {
59+
next.retain(|value| value != skill_key);
60+
}
61+
62+
persist_mode_config_from_value(mode_id, json!({ "disabled_user_skills": next })).await?;
63+
load_disabled_user_mode_skills(mode_id).await
64+
}
65+
66+
pub fn project_mode_skills_path_for_remote(remote_root: &str) -> String {
67+
format!(
68+
"{}/.bitfun/config/{}",
69+
remote_root.trim_end_matches('/'),
70+
PROJECT_MODE_SKILLS_FILE_NAME
71+
)
72+
}
73+
74+
fn normalize_project_document_value(value: Value) -> Value {
75+
match value {
76+
Value::Object(_) => value,
77+
_ => Value::Object(Map::new()),
78+
}
79+
}
80+
81+
fn mode_skills_object_mut(document: &mut Value) -> BitFunResult<&mut Map<String, Value>> {
82+
if !document.is_object() {
83+
*document = Value::Object(Map::new());
84+
}
85+
86+
document
87+
.as_object_mut()
88+
.ok_or_else(|| BitFunError::config("Project mode skills must be a JSON object".to_string()))
89+
}
90+
91+
fn mode_skills_object(document: &Value) -> Option<&Map<String, Value>> {
92+
document.as_object()
93+
}
94+
95+
pub fn get_disabled_mode_skills_from_document(document: &Value, mode_id: &str) -> Vec<String> {
96+
let Some(mode_object) = mode_skills_object(document)
97+
.and_then(|map| map.get(mode_id))
98+
.and_then(Value::as_object)
99+
else {
100+
return Vec::new();
101+
};
102+
103+
let keys = mode_object
104+
.get(DISABLED_SKILLS_KEY)
105+
.cloned()
106+
.and_then(|value| serde_json::from_value::<Vec<String>>(value).ok())
107+
.unwrap_or_default();
108+
109+
dedupe_skill_keys(keys)
110+
}
111+
112+
pub fn set_mode_skill_disabled_in_document(
113+
document: &mut Value,
114+
mode_id: &str,
115+
skill_key: &str,
116+
disabled: bool,
117+
) -> BitFunResult<Vec<String>> {
118+
let mode_skills = mode_skills_object_mut(document)?;
119+
let mode_entry = mode_skills
120+
.entry(mode_id.to_string())
121+
.or_insert_with(|| Value::Object(Map::new()));
122+
123+
if !mode_entry.is_object() {
124+
*mode_entry = Value::Object(Map::new());
125+
}
126+
127+
let mode_object = mode_entry
128+
.as_object_mut()
129+
.ok_or_else(|| BitFunError::config("Mode skills entry must be a JSON object".to_string()))?;
130+
131+
let current = mode_object
132+
.get(DISABLED_SKILLS_KEY)
133+
.cloned()
134+
.and_then(|value| serde_json::from_value::<Vec<String>>(value).ok())
135+
.unwrap_or_default();
136+
137+
let mut next = dedupe_skill_keys(current);
138+
if disabled {
139+
next.push(skill_key.to_string());
140+
next = dedupe_skill_keys(next);
141+
} else {
142+
next.retain(|value| value != skill_key);
143+
}
144+
145+
if next.is_empty() {
146+
mode_object.remove(DISABLED_SKILLS_KEY);
147+
} else {
148+
mode_object.insert(
149+
DISABLED_SKILLS_KEY.to_string(),
150+
serde_json::to_value(&next)?,
151+
);
152+
}
153+
154+
if mode_object.is_empty() {
155+
mode_skills.remove(mode_id);
156+
}
157+
158+
Ok(next)
159+
}
160+
161+
pub async fn load_project_mode_skills_document_local(
162+
workspace_root: &Path,
163+
) -> BitFunResult<Value> {
164+
let path = get_path_manager_arc().project_mode_skills_file(workspace_root);
165+
match tokio::fs::read_to_string(&path).await {
166+
Ok(content) => Ok(normalize_project_document_value(serde_json::from_str(&content)?)),
167+
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
168+
Ok(Value::Object(Map::new()))
169+
}
170+
Err(error) => Err(BitFunError::config(format!(
171+
"Failed to read project skill overrides file '{}': {}",
172+
path.display(),
173+
error
174+
))),
175+
}
176+
}
177+
178+
pub async fn save_project_mode_skills_document_local(
179+
workspace_root: &Path,
180+
document: &Value,
181+
) -> BitFunResult<()> {
182+
let path = get_path_manager_arc().project_mode_skills_file(workspace_root);
183+
if let Some(parent) = path.parent() {
184+
tokio::fs::create_dir_all(parent).await?;
185+
}
186+
tokio::fs::write(&path, serde_json::to_vec_pretty(document)?).await?;
187+
Ok(())
188+
}
189+
190+
pub async fn load_disabled_mode_skills_local(
191+
workspace_root: &Path,
192+
mode_id: &str,
193+
) -> BitFunResult<Vec<String>> {
194+
let document = load_project_mode_skills_document_local(workspace_root).await?;
195+
Ok(get_disabled_mode_skills_from_document(&document, mode_id))
196+
}
197+
198+
pub async fn load_disabled_mode_skills_remote(
199+
fs: &dyn WorkspaceFileSystem,
200+
remote_root: &str,
201+
mode_id: &str,
202+
) -> BitFunResult<Vec<String>> {
203+
let path = project_mode_skills_path_for_remote(remote_root);
204+
let exists = fs.exists(&path).await.unwrap_or(false);
205+
if !exists {
206+
return Ok(Vec::new());
207+
}
208+
209+
let content = fs
210+
.read_file_text(&path)
211+
.await
212+
.map_err(|error| BitFunError::config(format!(
213+
"Failed to read remote project skill overrides: {}",
214+
error
215+
)))?;
216+
let document = normalize_project_document_value(serde_json::from_str(&content)?);
217+
Ok(get_disabled_mode_skills_from_document(&document, mode_id))
218+
}

0 commit comments

Comments
 (0)