feat(experts): add built-in expert skills with per-agent activation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
xintaofei
2026-04-10 15:17:51 +08:00
parent 64d4e9c903
commit 5b613daded
73 changed files with 11199 additions and 30 deletions

View File

@@ -392,16 +392,16 @@ async fn uninstall_npm_from_user_prefix(package_name: &str) -> Result<(), AcpErr
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SkillStorageKind {
pub(crate) enum SkillStorageKind {
SkillDirectoryOnly,
SkillDirectoryOrMarkdownFile,
}
#[derive(Debug, Clone)]
struct SkillStorageSpec {
kind: SkillStorageKind,
global_dirs: Vec<PathBuf>,
project_rel_dirs: Vec<&'static str>,
pub(crate) struct SkillStorageSpec {
pub kind: SkillStorageKind,
pub global_dirs: Vec<PathBuf>,
pub project_rel_dirs: Vec<&'static str>,
}
fn home_dir_or_default() -> PathBuf {
@@ -1204,7 +1204,7 @@ fn persist_agent_local_config_json(
Ok(())
}
fn skill_storage_spec(agent_type: AgentType) -> Option<SkillStorageSpec> {
pub(crate) fn skill_storage_spec(agent_type: AgentType) -> Option<SkillStorageSpec> {
match agent_type {
AgentType::ClaudeCode => Some(SkillStorageSpec {
kind: SkillStorageKind::SkillDirectoryOnly,
@@ -1263,7 +1263,7 @@ fn scope_rank(scope: AgentSkillScope) -> u8 {
}
}
fn validate_skill_id(raw: &str) -> Result<String, AcpError> {
pub(crate) fn validate_skill_id(raw: &str) -> Result<String, AcpError> {
let id = raw.trim();
if id.is_empty() {
return Err(AcpError::protocol("skill id cannot be empty"));
@@ -1287,7 +1287,7 @@ fn validate_skill_id(raw: &str) -> Result<String, AcpError> {
Ok(id.to_string())
}
fn scoped_skill_dirs(
pub(crate) fn scoped_skill_dirs(
agent_type: AgentType,
scope: AgentSkillScope,
workspace_path: Option<&str>,
@@ -1316,7 +1316,7 @@ fn scoped_skill_dirs(
}
}
fn preferred_scope_skill_dir(
pub(crate) fn preferred_scope_skill_dir(
agent_type: AgentType,
scope: AgentSkillScope,
workspace_path: Option<&str>,
@@ -1360,6 +1360,24 @@ fn skill_content_path(layout: AgentSkillLayout, skill_path: &Path) -> PathBuf {
}
}
/// Symlink-safe removal: if `path` is a symlink (to a file or directory),
/// only the link itself is removed. Otherwise directories are removed
/// recursively and files are unlinked. This prevents `remove_dir_all` from
/// accidentally wiping the contents of a symlink target — which is critical
/// for the Experts feature where agent skill dirs may contain symlinks into
/// the central `~/.codeg/skills/` store.
pub(crate) fn remove_skill_entry(path: &Path) -> std::io::Result<()> {
let meta = fs::symlink_metadata(path)?;
let file_type = meta.file_type();
if file_type.is_symlink() {
fs::remove_file(path)
} else if file_type.is_dir() {
fs::remove_dir_all(path)
} else {
fs::remove_file(path)
}
}
fn list_skills_from_dir(
scope: AgentSkillScope,
dir: &Path,
@@ -2726,12 +2744,7 @@ pub async fn acp_delete_agent_skill(
let skill = locate_existing_skill_across_dirs(&dirs, spec.kind, &id, scope)
.ok_or_else(|| AcpError::protocol(format!("skill not found: {id}")))?;
let skill_path = PathBuf::from(&skill.path);
if skill.layout == AgentSkillLayout::SkillDirectory {
fs::remove_dir_all(&skill_path)
.map_err(|e| AcpError::protocol(format!("failed to delete skill directory: {e}")))?;
} else {
fs::remove_file(&skill_path)
.map_err(|e| AcpError::protocol(format!("failed to delete skill file: {e}")))?;
}
remove_skill_entry(&skill_path)
.map_err(|e| AcpError::protocol(format!("failed to delete skill entry: {e}")))?;
Ok(())
}

View File

@@ -0,0 +1,880 @@
//! Built-in expert skills management.
//!
//! Experts are curated skills (from obra/superpowers) that codeg bundles
//! into its binary via `include_dir!`. On startup they are extracted to a
//! central directory `~/.codeg/skills/<id>/`. Users can then enable an
//! expert for any ACP agent by creating a symbolic link (or Windows
//! junction) from the agent's skill directory into the central copy.
//!
//! The central store is the single source of truth. Enabling/disabling is
//! purely "does a link exist in the agent's skill dir" — there is no
//! database state, and updates propagate automatically when codeg upgrades
//! and re-extracts the bundled files.
use std::collections::BTreeMap;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use chrono::Utc;
use include_dir::{include_dir, Dir, DirEntry};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use tokio::sync::Mutex;
use crate::commands::acp::{
preferred_scope_skill_dir, remove_skill_entry, scoped_skill_dirs, skill_storage_spec,
validate_skill_id,
};
use crate::acp::types::AgentSkillScope;
use crate::models::agent::AgentType;
// ─── Embedded bundle ────────────────────────────────────────────────────
static EXPERTS_BUNDLE: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/experts");
const CENTRAL_DIR_NAME: &str = ".codeg";
const CENTRAL_SKILLS_SUBDIR: &str = "skills";
const MANIFEST_FILE: &str = ".manifest.json";
const EXPERTS_TOML: &str = "experts.toml";
// ─── Error type ─────────────────────────────────────────────────────────
#[derive(Debug, thiserror::Error)]
pub enum ExpertsError {
#[error("expert not found: {0}")]
NotFound(String),
#[error("agent does not support skills: {0:?}")]
UnsupportedAgent(AgentType),
#[error("a real directory already exists at '{path}' — delete or rename it first")]
NameCollision { path: String },
#[error("a different link already exists at '{path}' (points to '{found}') — remove it first")]
ForeignLink { path: String, found: String },
#[error("io error: {0}")]
Io(String),
#[error("metadata error: {0}")]
Metadata(String),
#[error("central expert store is unavailable: {0}")]
CentralUnavailable(String),
}
impl Serialize for ExpertsError {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
impl From<io::Error> for ExpertsError {
fn from(err: io::Error) -> Self {
ExpertsError::Io(err.to_string())
}
}
// ─── Public types ───────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize)]
pub struct ExpertMetadata {
pub id: String,
pub category: String,
pub icon: Option<String>,
pub sort_order: i32,
pub display_name: BTreeMap<String, String>,
pub description: BTreeMap<String, String>,
pub bundled_hash: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct ExpertListItem {
pub metadata: ExpertMetadata,
pub installed_centrally: bool,
pub user_modified: bool,
pub central_path: String,
}
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ExpertLinkState {
NotLinked,
LinkedToCodeg,
LinkedElsewhere,
BlockedByRealDirectory,
Broken,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExpertInstallStatus {
pub expert_id: String,
pub agent_type: AgentType,
pub state: ExpertLinkState,
pub link_path: String,
pub target_path: Option<String>,
pub expected_target_path: String,
pub copy_mode: bool,
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct InstallReport {
pub installed_count: usize,
pub updated_count: usize,
pub pending_user_review: Vec<String>,
pub errors: Vec<String>,
}
// ─── Manifest ───────────────────────────────────────────────────────────
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct Manifest {
#[serde(default)]
codeg_version: String,
#[serde(default)]
installed_at: String,
#[serde(default)]
experts: BTreeMap<String, ManifestEntry>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct ManifestEntry {
#[serde(default)]
hash: String,
#[serde(default)]
installed_at: String,
#[serde(default)]
pending_user_review: bool,
}
// ─── Concurrency ────────────────────────────────────────────────────────
fn mutation_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
// ─── Paths ──────────────────────────────────────────────────────────────
fn home_dir_or_default() -> PathBuf {
dirs::home_dir().unwrap_or_else(|| PathBuf::from("."))
}
pub(crate) fn central_experts_dir() -> PathBuf {
home_dir_or_default()
.join(CENTRAL_DIR_NAME)
.join(CENTRAL_SKILLS_SUBDIR)
}
fn manifest_path() -> PathBuf {
central_experts_dir().join(MANIFEST_FILE)
}
fn expert_central_path(expert_id: &str) -> PathBuf {
central_experts_dir().join(expert_id)
}
fn agent_link_path(agent: AgentType, expert_id: &str) -> Result<PathBuf, ExpertsError> {
let dir = preferred_scope_skill_dir(agent, AgentSkillScope::Global, None)
.map_err(|_| ExpertsError::UnsupportedAgent(agent))?;
Ok(dir.join(expert_id))
}
// ─── Metadata loading ───────────────────────────────────────────────────
#[derive(Debug, Deserialize)]
struct ExpertsTomlRoot {
#[serde(default)]
expert: Vec<ExpertTomlEntry>,
}
#[derive(Debug, Deserialize)]
struct ExpertTomlEntry {
id: String,
category: String,
#[serde(default)]
icon: Option<String>,
#[serde(default)]
sort_order: i32,
#[serde(default)]
display_name: BTreeMap<String, String>,
#[serde(default)]
description: BTreeMap<String, String>,
}
fn bundled_metadata() -> &'static [ExpertMetadata] {
static METADATA: OnceLock<Vec<ExpertMetadata>> = OnceLock::new();
METADATA.get_or_init(|| match load_bundled_metadata_inner() {
Ok(list) => list,
Err(err) => {
eprintln!("[Experts] failed to load bundled metadata: {err}");
Vec::new()
}
})
}
fn load_bundled_metadata_inner() -> Result<Vec<ExpertMetadata>, ExpertsError> {
let toml_file = EXPERTS_BUNDLE
.get_file(EXPERTS_TOML)
.ok_or_else(|| ExpertsError::Metadata(format!("{EXPERTS_TOML} missing from bundle")))?;
let toml_str = toml_file
.contents_utf8()
.ok_or_else(|| ExpertsError::Metadata(format!("{EXPERTS_TOML} is not valid UTF-8")))?;
let root: ExpertsTomlRoot = toml::from_str(toml_str)
.map_err(|e| ExpertsError::Metadata(format!("failed to parse {EXPERTS_TOML}: {e}")))?;
let mut out = Vec::with_capacity(root.expert.len());
for entry in root.expert {
let bundled_hash = hash_bundled_expert(&entry.id)?;
out.push(ExpertMetadata {
id: entry.id,
category: entry.category,
icon: entry.icon,
sort_order: entry.sort_order,
display_name: entry.display_name,
description: entry.description,
bundled_hash,
});
}
out.sort_by(|a, b| {
a.sort_order
.cmp(&b.sort_order)
.then_with(|| a.id.cmp(&b.id))
});
Ok(out)
}
fn find_metadata(expert_id: &str) -> Result<&'static ExpertMetadata, ExpertsError> {
bundled_metadata()
.iter()
.find(|m| m.id == expert_id)
.ok_or_else(|| ExpertsError::NotFound(expert_id.to_string()))
}
// ─── Hashing ────────────────────────────────────────────────────────────
fn hash_bundled_expert(expert_id: &str) -> Result<String, ExpertsError> {
let skill_dir = format!("skills/{expert_id}");
let dir = EXPERTS_BUNDLE
.get_dir(&skill_dir)
.ok_or_else(|| ExpertsError::NotFound(expert_id.to_string()))?;
let mut files: Vec<(&str, &[u8])> = Vec::new();
collect_bundle_files(dir, &mut files);
files.sort_by_key(|(path, _)| *path);
let mut hasher = Sha256::new();
for (path, contents) in files {
hasher.update(path.as_bytes());
hasher.update(b"\0");
hasher.update(contents);
hasher.update(b"\0");
}
Ok(format!("{:x}", hasher.finalize()))
}
fn collect_bundle_files<'a>(dir: &'a Dir<'a>, out: &mut Vec<(&'a str, &'a [u8])>) {
for entry in dir.entries() {
match entry {
DirEntry::File(f) => {
let rel = f.path().to_str().unwrap_or("");
out.push((rel, f.contents()));
}
DirEntry::Dir(d) => collect_bundle_files(d, out),
}
}
}
fn hash_disk_directory(path: &Path) -> Result<String, ExpertsError> {
let mut files: Vec<(String, Vec<u8>)> = Vec::new();
collect_disk_files(path, path, &mut files)?;
files.sort_by(|a, b| a.0.cmp(&b.0));
let mut hasher = Sha256::new();
for (rel_path, contents) in files {
// Mirror the bundled hash format: relative path includes the
// leading `skills/<id>/` prefix from bundled view.
let logical = format!(
"skills/{}/{}",
path.file_name()
.and_then(|s| s.to_str())
.unwrap_or_default(),
rel_path
);
hasher.update(logical.as_bytes());
hasher.update(b"\0");
hasher.update(&contents);
hasher.update(b"\0");
}
Ok(format!("{:x}", hasher.finalize()))
}
fn collect_disk_files(
base: &Path,
current: &Path,
out: &mut Vec<(String, Vec<u8>)>,
) -> Result<(), ExpertsError> {
if !current.exists() {
return Ok(());
}
for entry in fs::read_dir(current)? {
let entry = entry?;
let file_type = entry.file_type()?;
let child = entry.path();
if file_type.is_dir() {
collect_disk_files(base, &child, out)?;
} else if file_type.is_file() {
let rel = child
.strip_prefix(base)
.map_err(|e| ExpertsError::Io(e.to_string()))?
.to_string_lossy()
.replace('\\', "/");
let contents = fs::read(&child)?;
out.push((rel, contents));
}
}
Ok(())
}
// ─── Manifest I/O ───────────────────────────────────────────────────────
fn load_manifest() -> Manifest {
let path = manifest_path();
match fs::read_to_string(&path) {
Ok(content) => serde_json::from_str::<Manifest>(&content).unwrap_or_default(),
Err(_) => Manifest::default(),
}
}
fn save_manifest(manifest: &Manifest) -> Result<(), ExpertsError> {
let path = manifest_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let serialized = serde_json::to_string_pretty(manifest)
.map_err(|e| ExpertsError::Metadata(format!("failed to serialize manifest: {e}")))?;
fs::write(&path, serialized)?;
Ok(())
}
// ─── Link operations ────────────────────────────────────────────────────
#[cfg(unix)]
fn create_link_raw(src: &Path, dst: &Path) -> io::Result<bool> {
std::os::unix::fs::symlink(src, dst).map(|_| false)
}
#[cfg(windows)]
fn create_link_raw(src: &Path, dst: &Path) -> io::Result<bool> {
match junction::create(src, dst) {
Ok(_) => Ok(false),
Err(junction_err) => {
// Fall back to recursive copy when junction creation is not
// possible (e.g. cross-volume, permission denied). Mark the
// returned status with copy_mode = true so the UI can warn
// the user that upgrades won't propagate automatically.
copy_dir_recursive(src, dst).map_err(|copy_err| {
io::Error::new(
io::ErrorKind::Other,
format!(
"junction failed ({junction_err}); copy fallback failed ({copy_err})"
),
)
})?;
Ok(true)
}
}
}
#[cfg(windows)]
fn copy_dir_recursive(src: &Path, dst: &Path) -> io::Result<()> {
fs::create_dir_all(dst)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let ft = entry.file_type()?;
let from = entry.path();
let to = dst.join(entry.file_name());
if ft.is_dir() {
copy_dir_recursive(&from, &to)?;
} else if ft.is_file() {
fs::copy(&from, &to)?;
}
}
Ok(())
}
fn read_link_target(path: &Path) -> Option<PathBuf> {
fs::read_link(path).ok()
}
fn path_is_symlink(path: &Path) -> bool {
fs::symlink_metadata(path)
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
}
/// On Windows a junction is *not* a symlink — it is a directory reparse
/// point. `symlink_metadata` reports it as a directory. So we also need to
/// ask the OS whether the directory is a reparse point.
#[cfg(windows)]
fn path_is_reparse_point(path: &Path) -> bool {
use std::os::windows::fs::MetadataExt;
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
fs::symlink_metadata(path)
.map(|m| m.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0)
.unwrap_or(false)
}
#[cfg(not(windows))]
fn path_is_reparse_point(_path: &Path) -> bool {
false
}
fn classify_link(link_path: &Path, expected_target: &Path) -> ExpertLinkState {
if !link_path.exists() && !path_is_symlink(link_path) {
return ExpertLinkState::NotLinked;
}
// Broken symlink detection: `exists()` returns false if the link
// dangles; but we already checked that. Re-check the metadata.
match fs::symlink_metadata(link_path) {
Ok(meta) => {
let is_link_like = meta.file_type().is_symlink() || path_is_reparse_point(link_path);
if !is_link_like {
return ExpertLinkState::BlockedByRealDirectory;
}
// Verify the target.
match read_link_target(link_path) {
Some(target) => {
// Normalize both sides by canonicalizing the expected
// target. The link target may be relative.
let target = if target.is_absolute() {
target
} else if let Some(parent) = link_path.parent() {
parent.join(target)
} else {
target
};
let canonical_target = fs::canonicalize(&target).ok();
let canonical_expected = fs::canonicalize(expected_target).ok();
match (canonical_target.as_ref(), canonical_expected.as_ref()) {
(Some(t), Some(e)) if t == e => ExpertLinkState::LinkedToCodeg,
(Some(_), Some(_)) => ExpertLinkState::LinkedElsewhere,
(None, _) => ExpertLinkState::Broken,
(_, None) => ExpertLinkState::LinkedElsewhere,
}
}
None => {
// On Windows junctions, `read_link` may fail but the
// directory still resolves. Fall back to canonical
// comparison via the path itself.
let canonical_link = fs::canonicalize(link_path).ok();
let canonical_expected = fs::canonicalize(expected_target).ok();
match (canonical_link, canonical_expected) {
(Some(t), Some(e)) if t == e => ExpertLinkState::LinkedToCodeg,
_ => ExpertLinkState::LinkedElsewhere,
}
}
}
}
Err(_) => ExpertLinkState::NotLinked,
}
}
// ─── Central store installation ────────────────────────────────────────
pub async fn ensure_central_experts_installed() -> InstallReport {
let _guard = mutation_lock().lock().await;
tokio::task::spawn_blocking(ensure_central_experts_installed_blocking)
.await
.unwrap_or_else(|e| {
let mut r = InstallReport::default();
r.errors.push(format!("join error: {e}"));
r
})
}
fn ensure_central_experts_installed_blocking() -> InstallReport {
let mut report = InstallReport::default();
let central = central_experts_dir();
if let Err(e) = fs::create_dir_all(&central) {
report
.errors
.push(format!("failed to create central dir: {e}"));
return report;
}
let mut manifest = load_manifest();
let meta_list = bundled_metadata();
for meta in meta_list {
match install_or_refresh_expert(meta, &mut manifest) {
Ok(InstallAction::Skipped) => {}
Ok(InstallAction::Installed) => {
report.installed_count += 1;
}
Ok(InstallAction::Updated) => {
report.updated_count += 1;
}
Ok(InstallAction::BackedUp) => {
report.updated_count += 1;
report.pending_user_review.push(meta.id.clone());
}
Err(e) => {
report
.errors
.push(format!("{}: {}", meta.id, e));
}
}
}
manifest.codeg_version = env!("CARGO_PKG_VERSION").to_string();
manifest.installed_at = Utc::now().to_rfc3339();
if let Err(e) = save_manifest(&manifest) {
report.errors.push(format!("save manifest: {e}"));
}
report
}
enum InstallAction {
Skipped,
Installed,
Updated,
BackedUp,
}
fn install_or_refresh_expert(
meta: &ExpertMetadata,
manifest: &mut Manifest,
) -> Result<InstallAction, ExpertsError> {
let central_path = expert_central_path(&meta.id);
let bundled_hash = &meta.bundled_hash;
let manifest_entry = manifest.experts.get(&meta.id).cloned().unwrap_or_default();
if central_path.exists() {
let on_disk_hash = hash_disk_directory(&central_path).unwrap_or_default();
if &on_disk_hash == bundled_hash {
// Up-to-date and pristine. Ensure manifest matches.
if manifest_entry.hash != *bundled_hash {
manifest.experts.insert(
meta.id.clone(),
ManifestEntry {
hash: bundled_hash.clone(),
installed_at: Utc::now().to_rfc3339(),
pending_user_review: false,
},
);
}
return Ok(InstallAction::Skipped);
}
// Content differs. Was the user the one who changed it, or is
// the bundle itself newer?
let user_modified = manifest_entry.hash.is_empty() || on_disk_hash != manifest_entry.hash;
if user_modified {
// Preserve user work: move aside, install fresh.
let backup_name = format!(
"{}.user-backup-{}",
meta.id,
Utc::now().format("%Y%m%d-%H%M%S")
);
let backup_path = central_experts_dir().join(backup_name);
fs::rename(&central_path, &backup_path)?;
extract_expert_to_disk(meta, &central_path)?;
manifest.experts.insert(
meta.id.clone(),
ManifestEntry {
hash: bundled_hash.clone(),
installed_at: Utc::now().to_rfc3339(),
pending_user_review: true,
},
);
return Ok(InstallAction::BackedUp);
}
// Pristine but outdated → overwrite.
remove_skill_entry(&central_path)
.map_err(|e| ExpertsError::Io(format!("remove stale expert: {e}")))?;
extract_expert_to_disk(meta, &central_path)?;
manifest.experts.insert(
meta.id.clone(),
ManifestEntry {
hash: bundled_hash.clone(),
installed_at: Utc::now().to_rfc3339(),
pending_user_review: false,
},
);
Ok(InstallAction::Updated)
} else {
extract_expert_to_disk(meta, &central_path)?;
manifest.experts.insert(
meta.id.clone(),
ManifestEntry {
hash: bundled_hash.clone(),
installed_at: Utc::now().to_rfc3339(),
pending_user_review: false,
},
);
Ok(InstallAction::Installed)
}
}
fn extract_expert_to_disk(meta: &ExpertMetadata, target: &Path) -> Result<(), ExpertsError> {
let skill_rel = format!("skills/{}", meta.id);
let dir = EXPERTS_BUNDLE
.get_dir(&skill_rel)
.ok_or_else(|| ExpertsError::NotFound(meta.id.clone()))?;
fs::create_dir_all(target)?;
extract_bundle_dir(dir, &skill_rel, target)?;
Ok(())
}
fn extract_bundle_dir(
dir: &Dir<'_>,
bundle_prefix: &str,
target: &Path,
) -> Result<(), ExpertsError> {
for entry in dir.entries() {
match entry {
DirEntry::File(f) => {
let rel = f
.path()
.to_str()
.ok_or_else(|| ExpertsError::Io("non-utf8 path in bundle".into()))?;
let rel_within =
rel.strip_prefix(bundle_prefix)
.and_then(|s| s.strip_prefix('/'))
.unwrap_or(rel);
let out_path = target.join(rel_within);
if let Some(parent) = out_path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&out_path, f.contents())?;
}
DirEntry::Dir(d) => {
extract_bundle_dir(d, bundle_prefix, target)?;
}
}
}
Ok(())
}
// ─── Commands: list / status ────────────────────────────────────────────
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn experts_list() -> Result<Vec<ExpertListItem>, ExpertsError> {
let meta_list = bundled_metadata().to_vec();
let manifest = load_manifest();
let mut out = Vec::with_capacity(meta_list.len());
for meta in meta_list {
let central_path = expert_central_path(&meta.id);
let installed_centrally = central_path.exists();
let user_modified = manifest
.experts
.get(&meta.id)
.map(|e| e.pending_user_review)
.unwrap_or(false);
out.push(ExpertListItem {
metadata: meta,
installed_centrally,
user_modified,
central_path: central_path.to_string_lossy().to_string(),
});
}
Ok(out)
}
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn experts_get_install_status(
expert_id: String,
) -> Result<Vec<ExpertInstallStatus>, ExpertsError> {
let expert_id = validate_skill_id(&expert_id)
.map_err(|e| ExpertsError::Metadata(e.to_string()))?;
let _ = find_metadata(&expert_id)?; // ensure it exists in the bundle
let expected = expert_central_path(&expert_id);
let agents = supported_agents();
let mut out = Vec::with_capacity(agents.len());
for agent in agents {
let link_path = match agent_link_path(agent, &expert_id) {
Ok(p) => p,
Err(_) => continue,
};
let state = classify_link(&link_path, &expected);
let target_path = read_link_target(&link_path).map(|p| p.to_string_lossy().to_string());
out.push(ExpertInstallStatus {
expert_id: expert_id.clone(),
agent_type: agent,
state,
link_path: link_path.to_string_lossy().to_string(),
target_path,
expected_target_path: expected.to_string_lossy().to_string(),
copy_mode: false,
});
}
Ok(out)
}
fn supported_agents() -> Vec<AgentType> {
const ALL: &[AgentType] = &[
AgentType::ClaudeCode,
AgentType::Codex,
AgentType::OpenCode,
AgentType::Gemini,
AgentType::OpenClaw,
AgentType::Cline,
];
ALL.iter()
.filter(|a| skill_storage_spec(**a).is_some())
.copied()
.collect()
}
// ─── Commands: link / unlink ────────────────────────────────────────────
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn experts_link_to_agent(
expert_id: String,
agent_type: AgentType,
) -> Result<ExpertInstallStatus, ExpertsError> {
let expert_id = validate_skill_id(&expert_id)
.map_err(|e| ExpertsError::Metadata(e.to_string()))?;
let _ = find_metadata(&expert_id)?;
let central = expert_central_path(&expert_id);
if !central.exists() {
return Err(ExpertsError::CentralUnavailable(format!(
"expert '{expert_id}' is not installed in central store"
)));
}
let _guard = mutation_lock().lock().await;
let link_path = agent_link_path(agent_type, &expert_id)?;
if let Some(parent) = link_path.parent() {
fs::create_dir_all(parent)?;
}
let mut copy_mode = false;
match create_link_raw(&central, &link_path) {
Ok(is_copy) => {
copy_mode = is_copy;
}
Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {
// Already exists — figure out what kind.
match classify_link(&link_path, &central) {
ExpertLinkState::LinkedToCodeg => {
// Idempotent success.
}
ExpertLinkState::BlockedByRealDirectory => {
return Err(ExpertsError::NameCollision {
path: link_path.to_string_lossy().to_string(),
});
}
ExpertLinkState::LinkedElsewhere | ExpertLinkState::Broken => {
let found = read_link_target(&link_path)
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| "<unknown>".into());
return Err(ExpertsError::ForeignLink {
path: link_path.to_string_lossy().to_string(),
found,
});
}
ExpertLinkState::NotLinked => {
// Shouldn't happen after AlreadyExists, but retry once.
create_link_raw(&central, &link_path).map_err(|e| ExpertsError::Io(format!(
"retry link failed: {e}"
)))?;
}
}
}
Err(err) => return Err(ExpertsError::Io(err.to_string())),
}
let state = classify_link(&link_path, &central);
let target_path = read_link_target(&link_path).map(|p| p.to_string_lossy().to_string());
Ok(ExpertInstallStatus {
expert_id: expert_id.clone(),
agent_type,
state,
link_path: link_path.to_string_lossy().to_string(),
target_path,
expected_target_path: central.to_string_lossy().to_string(),
copy_mode,
})
}
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn experts_unlink_from_agent(
expert_id: String,
agent_type: AgentType,
) -> Result<(), ExpertsError> {
let expert_id = validate_skill_id(&expert_id)
.map_err(|e| ExpertsError::Metadata(e.to_string()))?;
let _guard = mutation_lock().lock().await;
// Scan ALL global dirs for this agent to handle shared-dir agents
// (Codex and Cline both point at `~/.agents/skills/`). Remove the
// link wherever it is found.
let dirs = scoped_skill_dirs(agent_type, AgentSkillScope::Global, None)
.map_err(|_| ExpertsError::UnsupportedAgent(agent_type))?;
let central = expert_central_path(&expert_id);
let mut removed = false;
for dir in dirs {
let candidate = dir.join(&expert_id);
if !candidate.exists() && !path_is_symlink(&candidate) {
continue;
}
let state = classify_link(&candidate, &central);
if matches!(state, ExpertLinkState::LinkedToCodeg | ExpertLinkState::Broken) {
// Safe to remove a link to our central store or a broken link.
remove_skill_entry(&candidate)
.map_err(|e| ExpertsError::Io(format!("remove link {}: {e}", candidate.display())))?;
removed = true;
} else if state == ExpertLinkState::LinkedElsewhere {
return Err(ExpertsError::ForeignLink {
path: candidate.to_string_lossy().to_string(),
found: read_link_target(&candidate)
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| "<unknown>".into()),
});
} else if state == ExpertLinkState::BlockedByRealDirectory {
// Not ours; leave alone.
continue;
}
}
if !removed {
// It was already unlinked — treat as idempotent success.
}
Ok(())
}
// ─── Commands: read / open ──────────────────────────────────────────────
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn experts_read_content(expert_id: String) -> Result<String, ExpertsError> {
let expert_id = validate_skill_id(&expert_id)
.map_err(|e| ExpertsError::Metadata(e.to_string()))?;
let _ = find_metadata(&expert_id)?;
let path = expert_central_path(&expert_id).join("SKILL.md");
if !path.exists() {
// Fall back to bundled copy when central store isn't populated.
let bundled_rel = format!("skills/{expert_id}/SKILL.md");
if let Some(f) = EXPERTS_BUNDLE.get_file(&bundled_rel) {
if let Some(text) = f.contents_utf8() {
return Ok(text.to_string());
}
}
return Err(ExpertsError::CentralUnavailable(format!(
"expert '{expert_id}' has no SKILL.md on disk"
)));
}
let content = fs::read_to_string(&path)?;
Ok(content)
}
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn experts_open_central_dir() -> Result<String, ExpertsError> {
let dir = central_experts_dir();
fs::create_dir_all(&dir)?;
Ok(dir.to_string_lossy().to_string())
}

View File

@@ -1,6 +1,7 @@
pub mod acp;
pub mod chat_channel;
pub mod conversations;
pub mod experts;
pub mod folder_commands;
pub mod folders;
pub mod mcp;