diff --git a/src/crates/core/src/agentic/tools/implementations/skill_tool.rs b/src/crates/core/src/agentic/tools/implementations/skill_tool.rs index 8b1a2786..6ce47aa1 100644 --- a/src/crates/core/src/agentic/tools/implementations/skill_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/skill_tool.rs @@ -10,7 +10,6 @@ use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; use log::debug; use serde_json::{json, Value}; -use std::path::Path; // Use skills module use super::skills::{get_skill_registry, SkillLocation}; @@ -56,12 +55,31 @@ Important: ) } - async fn build_description(&self, workspace_root: Option<&Path>) -> String { + async fn build_description_for_context( + &self, + context: Option<&ToolUseContext>, + ) -> String { let registry = get_skill_registry(); - let available_skills = match workspace_root { - Some(workspace_root) => { + let available_skills = match context { + Some(ctx) if ctx.is_remote() => { + if let Some(fs) = ctx.ws_fs() { + let root = ctx + .workspace + .as_ref() + .map(|w| w.root_path_string()) + .unwrap_or_default(); + registry + .get_enabled_skills_xml_for_remote_workspace(fs, &root) + .await + } else { + registry + .get_enabled_skills_xml_for_workspace(ctx.workspace_root()) + .await + } + } + Some(ctx) => { registry - .get_enabled_skills_xml_for_workspace(Some(workspace_root)) + .get_enabled_skills_xml_for_workspace(ctx.workspace_root()) .await } None => registry.get_enabled_skills_xml().await, @@ -78,19 +96,18 @@ impl Tool for SkillTool { } async fn description(&self) -> BitFunResult { - Ok(self.build_description(None).await) + Ok(self.build_description_for_context(None).await) } async fn description_with_context( &self, context: Option<&ToolUseContext>, ) -> BitFunResult { - let mut s = self - .build_description(context.and_then(|ctx| ctx.workspace_root())) - .await; - if context.map(|c| c.is_remote()).unwrap_or(false) { + let mut s = self.build_description_for_context(context).await; + if context.map(|c| c.is_remote()).unwrap_or(false) && context.and_then(|c| c.ws_fs()).is_none() + { s.push_str( - "\n\n**Remote workspace:** Project-level skills under `.bitfun/skills` on the **server** may not appear in the list above because this index is built from the local workspace view. Use **Read** / **Glob** on the remote tree if you need a skill file that is not listed.", + "\n\n**Remote workspace:** Project-level skills on the server could not be indexed (workspace I/O unavailable). Use **Read** / **Glob** on the remote tree if needed.", ); } Ok(s) @@ -170,9 +187,26 @@ impl Tool for SkillTool { // Find and load skill through registry let registry = get_skill_registry(); - let skill_data = registry - .find_and_load_skill_for_workspace(skill_name, context.workspace_root()) - .await?; + let skill_data = if context.is_remote() { + if let Some(ws_fs) = context.ws_fs() { + let root = context + .workspace + .as_ref() + .map(|w| w.root_path_string()) + .unwrap_or_default(); + registry + .find_and_load_skill_for_remote_workspace(skill_name, ws_fs, &root) + .await? + } else { + registry + .find_and_load_skill_for_workspace(skill_name, context.workspace_root()) + .await? + } + } else { + registry + .find_and_load_skill_for_workspace(skill_name, context.workspace_root()) + .await? + }; let location_str = match skill_data.location { SkillLocation::User => "user", diff --git a/src/crates/core/src/agentic/tools/implementations/skills/registry.rs b/src/crates/core/src/agentic/tools/implementations/skills/registry.rs index 53cc113c..70e24cb7 100644 --- a/src/crates/core/src/agentic/tools/implementations/skills/registry.rs +++ b/src/crates/core/src/agentic/tools/implementations/skills/registry.rs @@ -6,6 +6,7 @@ use super::builtin::ensure_builtin_skills_installed; use super::types::{SkillData, SkillInfo, SkillLocation}; +use crate::agentic::workspace::WorkspaceFileSystem; use crate::infrastructure::get_path_manager_arc; use crate::util::errors::{BitFunError, BitFunResult}; use log::{debug, error}; @@ -382,6 +383,97 @@ impl SkillRegistry { .collect() } + /// Remote SSH workspace: merge **client** user-level skills with **server** project skills (via SFTP). + pub async fn get_enabled_skills_xml_for_remote_workspace( + &self, + fs: &dyn WorkspaceFileSystem, + remote_root: &str, + ) -> Vec { + self.scan_skill_map_for_remote_workspace(fs, remote_root) + .await + .into_values() + .filter(|skill| skill.enabled) + .map(|skill| skill.to_xml_desc()) + .collect() + } + + async fn scan_skills_in_remote_dir( + fs: &dyn WorkspaceFileSystem, + dir: &str, + level: SkillLocation, + ) -> Vec { + let mut skills = Vec::new(); + let entries = match fs.read_dir(dir).await { + Ok(e) => e, + Err(_) => return skills, + }; + for e in entries { + if !e.is_dir || e.is_symlink { + continue; + } + let skill_md = format!("{}/SKILL.md", e.path.trim_end_matches('/')); + if !fs.is_file(&skill_md).await.unwrap_or(false) { + continue; + } + match fs.read_file_text(&skill_md).await { + Ok(content) => match SkillData::from_markdown( + e.path.clone(), + &content, + level, + false, + ) { + Ok(skill_data) => { + skills.push(SkillInfo { + name: skill_data.name, + description: skill_data.description, + path: skill_data.path, + level, + enabled: skill_data.enabled, + }); + } + Err(err) => { + error!("Failed to parse SKILL.md in {}: {}", e.path, err); + } + }, + Err(err) => { + debug!("Failed to read {}: {}", skill_md, err); + } + } + } + skills + } + + async fn scan_remote_project_skills( + fs: &dyn WorkspaceFileSystem, + remote_root: &str, + ) -> Vec { + let mut skills = Vec::new(); + let root = remote_root.trim_end_matches('/'); + for (parent, sub) in PROJECT_SKILL_SUBDIRS { + let skill_dir = format!("{}/{}/{}", root, parent, sub); + if !fs.is_dir(&skill_dir).await.unwrap_or(false) { + continue; + } + let mut part = + Self::scan_skills_in_remote_dir(fs, &skill_dir, SkillLocation::Project).await; + skills.append(&mut part); + } + skills + } + + /// User skills from this machine plus project skills from the remote workspace root. + pub async fn scan_skill_map_for_remote_workspace( + &self, + fs: &dyn WorkspaceFileSystem, + remote_root: &str, + ) -> HashMap { + let mut map = self.scan_skill_map_for_workspace(None).await; + for info in Self::scan_remote_project_skills(fs, remote_root).await { + map.insert(info.name.clone(), info); + } + map + } + pub async fn find_and_load_skill_for_workspace( &self, skill_name: &str, @@ -406,4 +498,32 @@ impl SkillRegistry { SkillData::from_markdown(info.path.clone(), &content, info.level, true) } + + /// Load skill when the workspace is remote: reads SKILL.md via [`WorkspaceFileSystem`]. + pub async fn find_and_load_skill_for_remote_workspace( + &self, + skill_name: &str, + fs: &dyn WorkspaceFileSystem, + remote_root: &str, + ) -> BitFunResult { + let map = self.scan_skill_map_for_remote_workspace(fs, remote_root).await; + let info = map + .get(skill_name) + .ok_or_else(|| BitFunError::tool(format!("Skill '{}' not found", skill_name)))?; + + if !info.enabled { + return Err(BitFunError::tool(format!( + "Skill '{}' is disabled", + skill_name + ))); + } + + let skill_md_path = format!("{}/SKILL.md", info.path.trim_end_matches('/')); + let content = fs + .read_file_text(&skill_md_path) + .await + .map_err(|e| BitFunError::tool(format!("Failed to read skill file: {}", e)))?; + + SkillData::from_markdown(info.path.clone(), &content, info.level, true) + } } diff --git a/src/crates/core/src/agentic/workspace.rs b/src/crates/core/src/agentic/workspace.rs index 360a0414..3f8b5cbb 100644 --- a/src/crates/core/src/agentic/workspace.rs +++ b/src/crates/core/src/agentic/workspace.rs @@ -95,6 +95,15 @@ impl WorkspaceBinding { // traits instead of checking is_remote themselves. // ============================================================ +/// One row from [`WorkspaceFileSystem::read_dir`] (POSIX paths when backend is remote SSH). +#[derive(Debug, Clone)] +pub struct WorkspaceDirEntry { + pub name: String, + pub path: String, + pub is_dir: bool, + pub is_symlink: bool, +} + /// Unified file system operations that work for both local and remote workspaces. #[async_trait] pub trait WorkspaceFileSystem: Send + Sync { @@ -104,6 +113,8 @@ pub trait WorkspaceFileSystem: Send + Sync { async fn exists(&self, path: &str) -> anyhow::Result; async fn is_file(&self, path: &str) -> anyhow::Result; async fn is_dir(&self, path: &str) -> anyhow::Result; + /// List immediate children (non-recursive). Symlinks may be included; callers often skip them. + async fn read_dir(&self, path: &str) -> anyhow::Result>; } /// Unified shell execution for both local and remote workspaces. @@ -180,6 +191,28 @@ impl WorkspaceFileSystem for LocalWorkspaceFs { Err(_) => Ok(false), } } + + async fn read_dir(&self, path: &str) -> anyhow::Result> { + let mut out = Vec::new(); + let mut rd = tokio::fs::read_dir(path).await?; + while let Ok(Some(entry)) = rd.next_entry().await { + let p = entry.path(); + let meta = tokio::fs::symlink_metadata(&p).await?; + if meta.file_type().is_symlink() { + continue; + } + let name = entry.file_name().to_string_lossy().to_string(); + let path_str = p.to_string_lossy().to_string(); + let is_dir = meta.is_dir(); + out.push(WorkspaceDirEntry { + name, + path: path_str, + is_dir, + is_symlink: false, + }); + } + Ok(out) + } } /// Local shell implementation of `WorkspaceShell`. @@ -271,6 +304,23 @@ impl WorkspaceFileSystem for RemoteWorkspaceFs { .await .map_err(|e| anyhow::anyhow!("{}", e)) } + + async fn read_dir(&self, path: &str) -> anyhow::Result> { + let entries = self + .file_service + .read_dir(&self.connection_id, path) + .await + .map_err(|e| anyhow::anyhow!("{}", e))?; + Ok(entries + .into_iter() + .map(|e| WorkspaceDirEntry { + name: e.name, + path: e.path, + is_dir: e.is_dir, + is_symlink: e.is_symlink, + }) + .collect()) + } } /// SSH-backed shell implementation.