Change Codex skill storage to use only ~/.codex/skills/ instead of ~/.agents/skills/, and never disable the expert skills button so users can always access the empty-state hint. Also fix stale expert list in conversation window after linking in the separate settings window by re-fetching on window focus. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
921 lines
32 KiB
Rust
921 lines
32 KiB
Rust
//! 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(¢ral) {
|
|
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(¢ral_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(¢ral_path, &backup_path)?;
|
|
extract_expert_to_disk(meta, ¢ral_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(¢ral_path)
|
|
.map_err(|e| ExpertsError::Io(format!("remove stale expert: {e}")))?;
|
|
extract_expert_to_disk(meta, ¢ral_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, ¢ral_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_list_for_agent(
|
|
agent_type: AgentType,
|
|
) -> Result<Vec<ExpertListItem>, ExpertsError> {
|
|
let _ = skill_storage_spec(agent_type)
|
|
.ok_or(ExpertsError::UnsupportedAgent(agent_type))?;
|
|
|
|
let dirs = scoped_skill_dirs(agent_type, AgentSkillScope::Global, None)
|
|
.map_err(|_| ExpertsError::UnsupportedAgent(agent_type))?;
|
|
|
|
let meta_list = bundled_metadata().to_vec();
|
|
let manifest = load_manifest();
|
|
let mut out = Vec::new();
|
|
|
|
for meta in meta_list {
|
|
let central_path = expert_central_path(&meta.id);
|
|
let is_linked = dirs.iter().any(|dir| {
|
|
let candidate = dir.join(&meta.id);
|
|
classify_link(&candidate, ¢ral_path) == ExpertLinkState::LinkedToCodeg
|
|
});
|
|
if !is_linked {
|
|
continue;
|
|
}
|
|
|
|
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(¢ral, &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, ¢ral) {
|
|
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(¢ral, &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, ¢ral);
|
|
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.
|
|
// 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, ¢ral);
|
|
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())
|
|
}
|