Surface les skills assignés à un agent « à la MCP » : description sur l'entité Skill (+ effective_description fallback), section « # Skills disponibles » haute altitude dans le convention-file (mode MCP), outil read-only idea_skill_read (résolution projet→global), use case ReadSkill (port SkillStore existant), et câblage au composition root. Dump legacy du corps complet conservé en mode sans-MCP (zéro régression). Rétro-compat index.json (serde default). Tests : domain+application+infrastructure 1212 passed / 0 failed (23 ajoutés). Reste : T6 (champ description front) + T7 (e2e après rebuild AppImage). NB topologie : commit réalisé par l'orchestrateur car l'agent Git était injoignable (bug de livraison cold-start) ; à faire relire/rebaser par Git. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
402 lines
14 KiB
Rust
402 lines
14 KiB
Rust
//! [`FsSkillStore`] — file implementation of the [`SkillStore`] port
|
|
//! (ARCHITECTURE §14.2, L12).
|
|
//!
|
|
//! Skills are reusable, model-agnostic workflows. They live in **two isolated
|
|
//! scopes**, each backed by its own directory but the same on-disk shape
|
|
//! (mirroring [`crate::store::FsTemplateStore`]): a small JSON index carrying the
|
|
//! metadata needed to list without parsing every `.md`, plus the Markdown bodies
|
|
//! under `md/`:
|
|
//!
|
|
//! ```text
|
|
//! <app_data_dir>/skills/ # SkillScope::Global (shared across projects)
|
|
//! <project_root>/.ideai/skills/ # SkillScope::Project (travels with the code)
|
|
//! ├── index.json # { version, skills: [{ id, name, contentHash }] }
|
|
//! └── md/
|
|
//! └── <id>.md # a skill's Markdown content
|
|
//! ```
|
|
//!
|
|
//! The two scopes never bleed into one another: a `Project` skill is invisible to
|
|
//! a `Global` listing and vice-versa, because each resolves to a different
|
|
//! directory. All I/O goes through the [`FileSystem`] port, so the adapter is
|
|
//! location-neutral (a project hosted over SSH/WSL works unchanged) and
|
|
//! Tauri-agnostic.
|
|
//!
|
|
//! Like the template store, [`delete`](SkillStore::delete) drops the index row
|
|
//! and leaves the orphaned `md/<id>.md` on disk (the [`FileSystem`] port exposes
|
|
//! no remove); since listing is index-driven, the skill is effectively gone.
|
|
|
|
use std::hash::{Hash, Hasher};
|
|
use std::sync::Arc;
|
|
|
|
use async_trait::async_trait;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use domain::ids::SkillId;
|
|
use domain::markdown::MarkdownDoc;
|
|
use domain::ports::{FileSystem, RemotePath, SkillStore, StoreError};
|
|
use domain::project::ProjectPath;
|
|
use domain::skill::{Skill, SkillScope};
|
|
|
|
/// Directory (under app-data) holding the global skills store.
|
|
const GLOBAL_SKILLS_DIR: &str = "skills";
|
|
|
|
/// The `.ideai/` directory name inside a project root.
|
|
const IDEAI_DIR: &str = ".ideai";
|
|
|
|
/// Sub-path of the project-scoped skills store inside `.ideai/`.
|
|
const PROJECT_SKILLS_DIR: &str = "skills";
|
|
|
|
/// Index file name inside a skills dir.
|
|
const INDEX_FILE: &str = "index.json";
|
|
|
|
/// Current schema version of the index file.
|
|
const INDEX_VERSION: u32 = 1;
|
|
|
|
/// One metadata row in `index.json` (the `.md` content lives separately).
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct IndexEntry {
|
|
id: SkillId,
|
|
name: String,
|
|
/// One-line affordance description (feature « skills à la MCP »). `#[serde(default)]`
|
|
/// is **mandatory** for backward-compat: legacy `index.json` written before this
|
|
/// field existed deserialise with `None` instead of failing.
|
|
#[serde(default)]
|
|
description: Option<String>,
|
|
content_hash: String,
|
|
}
|
|
|
|
/// On-disk shape of a scope's `index.json`.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct IndexDoc {
|
|
version: u32,
|
|
skills: Vec<IndexEntry>,
|
|
}
|
|
|
|
impl Default for IndexDoc {
|
|
fn default() -> Self {
|
|
Self {
|
|
version: INDEX_VERSION,
|
|
skills: Vec::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A stable, dependency-free digest of Markdown content for out-of-app edit
|
|
/// detection — deterministic across runs and platforms (fixed-key hasher).
|
|
fn content_hash(md: &MarkdownDoc) -> String {
|
|
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
|
md.as_str().hash(&mut hasher);
|
|
format!("{:016x}", hasher.finish())
|
|
}
|
|
|
|
/// File-backed [`SkillStore`], composing a [`FileSystem`] port. Holds only the
|
|
/// machine-global app-data dir; the project root for [`SkillScope::Project`] is
|
|
/// supplied **per call**, so a single instance serves every open project
|
|
/// (mirroring [`crate::store::IdeaiContextStore`]).
|
|
#[derive(Clone)]
|
|
pub struct FsSkillStore {
|
|
fs: Arc<dyn FileSystem>,
|
|
app_data_dir: String,
|
|
}
|
|
|
|
impl FsSkillStore {
|
|
/// Builds the store from an injected [`FileSystem`] and the global app-data
|
|
/// dir (used for [`SkillScope::Global`]). Directories are created on first
|
|
/// write.
|
|
#[must_use]
|
|
pub fn new(fs: Arc<dyn FileSystem>, app_data_dir: impl Into<String>) -> Self {
|
|
Self {
|
|
fs,
|
|
app_data_dir: app_data_dir.into(),
|
|
}
|
|
}
|
|
|
|
/// Root directory of a scope's store. `root` is ignored for `Global`.
|
|
fn dir(&self, scope: SkillScope, root: &ProjectPath) -> String {
|
|
match scope {
|
|
SkillScope::Global => {
|
|
let base = self.app_data_dir.trim_end_matches(['/', '\\']);
|
|
format!("{base}/{GLOBAL_SKILLS_DIR}")
|
|
}
|
|
SkillScope::Project => {
|
|
let base = root.as_str().trim_end_matches(['/', '\\']);
|
|
format!("{base}/{IDEAI_DIR}/{PROJECT_SKILLS_DIR}")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `<scope-dir>/index.json`.
|
|
fn index_path(&self, scope: SkillScope, root: &ProjectPath) -> RemotePath {
|
|
RemotePath::new(format!("{}/{INDEX_FILE}", self.dir(scope, root)))
|
|
}
|
|
|
|
/// `<scope-dir>/md/<id>.md`.
|
|
fn md_path(&self, scope: SkillScope, root: &ProjectPath, id: SkillId) -> RemotePath {
|
|
RemotePath::new(format!("{}/md/{id}.md", self.dir(scope, root)))
|
|
}
|
|
|
|
/// Reads a scope's index, returning an empty default if absent.
|
|
async fn read_index(
|
|
&self,
|
|
scope: SkillScope,
|
|
root: &ProjectPath,
|
|
) -> Result<IndexDoc, StoreError> {
|
|
match self.fs.read(&self.index_path(scope, root)).await {
|
|
Ok(bytes) => {
|
|
serde_json::from_slice(&bytes).map_err(|e| StoreError::Serialization(e.to_string()))
|
|
}
|
|
Err(domain::ports::FsError::NotFound(_)) => Ok(IndexDoc::default()),
|
|
Err(e) => Err(StoreError::Io(e.to_string())),
|
|
}
|
|
}
|
|
|
|
/// Writes a scope's index, ensuring its directory exists.
|
|
async fn write_index(
|
|
&self,
|
|
scope: SkillScope,
|
|
root: &ProjectPath,
|
|
doc: &IndexDoc,
|
|
) -> Result<(), StoreError> {
|
|
self.fs
|
|
.create_dir_all(&RemotePath::new(self.dir(scope, root)))
|
|
.await
|
|
.map_err(|e| StoreError::Io(e.to_string()))?;
|
|
let bytes =
|
|
serde_json::to_vec_pretty(doc).map_err(|e| StoreError::Serialization(e.to_string()))?;
|
|
self.fs
|
|
.write(&self.index_path(scope, root), &bytes)
|
|
.await
|
|
.map_err(|e| StoreError::Io(e.to_string()))
|
|
}
|
|
|
|
/// Reconstructs the [`Skill`] for an index entry by reading its `.md`.
|
|
async fn load(
|
|
&self,
|
|
scope: SkillScope,
|
|
root: &ProjectPath,
|
|
entry: &IndexEntry,
|
|
) -> Result<Skill, StoreError> {
|
|
let bytes = self
|
|
.fs
|
|
.read(&self.md_path(scope, root, entry.id))
|
|
.await
|
|
.map_err(|e| match e {
|
|
domain::ports::FsError::NotFound(_) => StoreError::NotFound,
|
|
other => StoreError::Io(other.to_string()),
|
|
})?;
|
|
let content =
|
|
String::from_utf8(bytes).map_err(|e| StoreError::Serialization(e.to_string()))?;
|
|
Skill::new(
|
|
entry.id,
|
|
entry.name.clone(),
|
|
MarkdownDoc::new(content),
|
|
scope,
|
|
)
|
|
.map(|skill| skill.with_description(entry.description.clone()))
|
|
.map_err(|e| StoreError::Serialization(e.to_string()))
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl SkillStore for FsSkillStore {
|
|
async fn list(&self, scope: SkillScope, root: &ProjectPath) -> Result<Vec<Skill>, StoreError> {
|
|
let index = self.read_index(scope, root).await?;
|
|
let mut out = Vec::with_capacity(index.skills.len());
|
|
for entry in &index.skills {
|
|
out.push(self.load(scope, root, entry).await?);
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
async fn get(
|
|
&self,
|
|
scope: SkillScope,
|
|
root: &ProjectPath,
|
|
id: SkillId,
|
|
) -> Result<Skill, StoreError> {
|
|
let index = self.read_index(scope, root).await?;
|
|
let entry = index
|
|
.skills
|
|
.iter()
|
|
.find(|e| e.id == id)
|
|
.ok_or(StoreError::NotFound)?;
|
|
self.load(scope, root, entry).await
|
|
}
|
|
|
|
async fn save(&self, skill: &Skill, root: &ProjectPath) -> Result<(), StoreError> {
|
|
let scope = skill.scope;
|
|
// (1) Write the Markdown content.
|
|
self.fs
|
|
.create_dir_all(&RemotePath::new(format!("{}/md", self.dir(scope, root))))
|
|
.await
|
|
.map_err(|e| StoreError::Io(e.to_string()))?;
|
|
self.fs
|
|
.write(
|
|
&self.md_path(scope, root, skill.id),
|
|
skill.content_md.as_str().as_bytes(),
|
|
)
|
|
.await
|
|
.map_err(|e| StoreError::Io(e.to_string()))?;
|
|
|
|
// (2) Upsert the index metadata.
|
|
let mut index = self.read_index(scope, root).await?;
|
|
let row = IndexEntry {
|
|
id: skill.id,
|
|
name: skill.name.clone(),
|
|
description: skill.description.clone(),
|
|
content_hash: content_hash(&skill.content_md),
|
|
};
|
|
if let Some(slot) = index.skills.iter_mut().find(|e| e.id == skill.id) {
|
|
*slot = row;
|
|
} else {
|
|
index.skills.push(row);
|
|
}
|
|
self.write_index(scope, root, &index).await
|
|
}
|
|
|
|
async fn delete(
|
|
&self,
|
|
scope: SkillScope,
|
|
root: &ProjectPath,
|
|
id: SkillId,
|
|
) -> Result<(), StoreError> {
|
|
let mut index = self.read_index(scope, root).await?;
|
|
let before = index.skills.len();
|
|
index.skills.retain(|e| e.id != id);
|
|
if index.skills.len() == before {
|
|
return Err(StoreError::NotFound);
|
|
}
|
|
// The orphaned `md/<id>.md` is left on disk (no FileSystem delete); the
|
|
// index no longer references it, so it is effectively gone.
|
|
self.write_index(scope, root, &index).await
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::collections::HashMap;
|
|
use std::sync::Mutex;
|
|
|
|
use domain::ports::{DirEntry, FsError};
|
|
|
|
/// In-memory [`FileSystem`] mock (same minimal shape as the memory-store tests).
|
|
#[derive(Default)]
|
|
struct MemFs {
|
|
files: Mutex<HashMap<String, Vec<u8>>>,
|
|
}
|
|
|
|
impl MemFs {
|
|
fn arc() -> Arc<Self> {
|
|
Arc::new(Self::default())
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl FileSystem for MemFs {
|
|
async fn read(&self, path: &RemotePath) -> Result<Vec<u8>, FsError> {
|
|
self.files
|
|
.lock()
|
|
.unwrap()
|
|
.get(path.as_str())
|
|
.cloned()
|
|
.ok_or_else(|| FsError::NotFound(path.as_str().to_string()))
|
|
}
|
|
async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> {
|
|
self.files
|
|
.lock()
|
|
.unwrap()
|
|
.insert(path.as_str().to_string(), data.to_vec());
|
|
Ok(())
|
|
}
|
|
async fn exists(&self, path: &RemotePath) -> Result<bool, FsError> {
|
|
Ok(self.files.lock().unwrap().contains_key(path.as_str()))
|
|
}
|
|
async fn create_dir_all(&self, _path: &RemotePath) -> Result<(), FsError> {
|
|
Ok(())
|
|
}
|
|
async fn list(&self, _path: &RemotePath) -> Result<Vec<DirEntry>, FsError> {
|
|
Ok(Vec::new())
|
|
}
|
|
async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn root() -> ProjectPath {
|
|
ProjectPath::new("/proj").unwrap()
|
|
}
|
|
|
|
fn skill(id: u128, name: &str, body: &str) -> Skill {
|
|
Skill::new(
|
|
SkillId::from_uuid(uuid::Uuid::from_u128(id)),
|
|
name,
|
|
MarkdownDoc::new(body),
|
|
SkillScope::Global,
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn legacy_index_without_description_loads_with_none() {
|
|
// A legacy `index.json` written before the `description` field existed must
|
|
// deserialise (thanks to `#[serde(default)]`) and load with `description: None`.
|
|
let fs = MemFs::arc();
|
|
let id = uuid::Uuid::from_u128(42);
|
|
let legacy_index = format!(
|
|
r#"{{"version":1,"skills":[{{"id":"{id}","name":"legacy","contentHash":"abc"}}]}}"#
|
|
);
|
|
fs.write(
|
|
&RemotePath::new("/app/skills/index.json".to_owned()),
|
|
legacy_index.as_bytes(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
fs.write(
|
|
&RemotePath::new(format!("/app/skills/md/{id}.md")),
|
|
b"# legacy body",
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let store = FsSkillStore::new(fs, "/app");
|
|
let listed = store.list(SkillScope::Global, &root()).await.unwrap();
|
|
|
|
assert_eq!(listed.len(), 1);
|
|
assert_eq!(listed[0].name, "legacy");
|
|
assert_eq!(listed[0].description, None);
|
|
assert_eq!(listed[0].content_md.as_str(), "# legacy body");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn save_then_load_round_trips_description() {
|
|
let store = FsSkillStore::new(MemFs::arc(), "/app");
|
|
let s = skill(1, "refactor", "# Refactor\n\nbody")
|
|
.with_description(Some("Refactors code".to_owned()));
|
|
|
|
store.save(&s, &root()).await.unwrap();
|
|
|
|
let got = store.get(SkillScope::Global, &root(), s.id).await.unwrap();
|
|
assert_eq!(got.description.as_deref(), Some("Refactors code"));
|
|
assert_eq!(got, s);
|
|
|
|
// Also surfaces through `list`.
|
|
let listed = store.list(SkillScope::Global, &root()).await.unwrap();
|
|
assert_eq!(listed.len(), 1);
|
|
assert_eq!(listed[0].description.as_deref(), Some("Refactors code"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn save_without_description_loads_as_none() {
|
|
let store = FsSkillStore::new(MemFs::arc(), "/app");
|
|
let s = skill(2, "plain", "# Plain\n\nbody");
|
|
|
|
store.save(&s, &root()).await.unwrap();
|
|
|
|
let got = store.get(SkillScope::Global, &root(), s.id).await.unwrap();
|
|
assert_eq!(got.description, None);
|
|
}
|
|
}
|