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>
252 lines
9.2 KiB
Rust
252 lines
9.2 KiB
Rust
//! Skill entity — reusable, model-agnostic workflows assignable to agents.
|
|
//!
|
|
//! A [`Skill`] is IdeA's universal equivalent of a CLI's slash-command, but
|
|
//! without any dependency on a particular model's `/command` syntax
|
|
//! (ARCHITECTURE §14.2). Assigned skills are injected as plain text into the
|
|
//! agent's generated convention file at activation — there is no proprietary
|
|
//! CLI mechanism involved.
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::error::DomainError;
|
|
use crate::ids::SkillId;
|
|
use crate::markdown::MarkdownDoc;
|
|
|
|
/// Where a skill lives, which also selects the store used to resolve it.
|
|
///
|
|
/// - [`SkillScope::Global`] skills are stored in the global IDE store
|
|
/// (`<app_data>/IdeA/skills/`) and reusable across projects.
|
|
/// - [`SkillScope::Project`] skills are stored under `.ideai/skills/` and are
|
|
/// specific to one project.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub enum SkillScope {
|
|
/// Reusable across projects (global IDE store).
|
|
Global,
|
|
/// Specific to a single project (`.ideai/skills/`).
|
|
Project,
|
|
}
|
|
|
|
/// A reusable workflow assignable to one or more agents.
|
|
///
|
|
/// Invariants enforced here:
|
|
/// - `name` non-empty,
|
|
/// - `content_md` non-empty (an empty skill carries no behaviour).
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct Skill {
|
|
/// Stable identifier.
|
|
pub id: SkillId,
|
|
/// Display name (also used as the `.md` file stem on disk).
|
|
pub name: String,
|
|
/// Optional one-line description surfaced as an *affordance* at high altitude
|
|
/// in an agent's convention file (« à la MCP »), so the agent knows the skill
|
|
/// exists and what it does without reading the whole body. `None`/empty ⇒
|
|
/// [`Skill::effective_description`] falls back to the first line of the body.
|
|
#[serde(default)]
|
|
pub description: Option<String>,
|
|
/// Markdown body — the workflow injected into an agent's convention file.
|
|
pub content_md: MarkdownDoc,
|
|
/// Scope (selects the backing store).
|
|
pub scope: SkillScope,
|
|
}
|
|
|
|
impl Skill {
|
|
/// Builds a validated skill.
|
|
///
|
|
/// # Errors
|
|
/// - [`DomainError::EmptyField`] if `name` is empty,
|
|
/// - [`DomainError::EmptyField`] if `content_md` is empty.
|
|
pub fn new(
|
|
id: SkillId,
|
|
name: impl Into<String>,
|
|
content_md: MarkdownDoc,
|
|
scope: SkillScope,
|
|
) -> Result<Self, DomainError> {
|
|
let name = name.into();
|
|
crate::validation::non_empty(&name, "skill.name")?;
|
|
if content_md.is_empty() {
|
|
return Err(DomainError::EmptyField {
|
|
field: "skill.content_md",
|
|
});
|
|
}
|
|
Ok(Self {
|
|
id,
|
|
name,
|
|
description: None,
|
|
content_md,
|
|
scope,
|
|
})
|
|
}
|
|
|
|
/// Returns this skill with its one-line [`Skill::description`] set (builder).
|
|
///
|
|
/// Kept separate from [`Skill::new`] so every existing call site is untouched
|
|
/// (the field is purely additive). An empty/whitespace description is treated
|
|
/// as absent by [`Skill::effective_description`], so no normalisation here.
|
|
#[must_use]
|
|
pub fn with_description(mut self, description: Option<String>) -> Self {
|
|
self.description = description;
|
|
self
|
|
}
|
|
|
|
/// The description to surface for this skill, with a deterministic fallback.
|
|
///
|
|
/// Returns [`Skill::description`] when present and non-blank (trimmed);
|
|
/// otherwise the **first non-empty line** of the body, stripped of any leading
|
|
/// `#` (Markdown heading marker) and trimmed. When the body has no usable line
|
|
/// either, returns an **empty `String`** (chosen over `Option` so callers can
|
|
/// format it unconditionally — an empty affordance line is harmless).
|
|
#[must_use]
|
|
pub fn effective_description(&self) -> String {
|
|
if let Some(desc) = &self.description {
|
|
let trimmed = desc.trim();
|
|
if !trimmed.is_empty() {
|
|
return trimmed.to_owned();
|
|
}
|
|
}
|
|
self.content_md
|
|
.as_str()
|
|
.lines()
|
|
.map(str::trim)
|
|
.find(|line| !line.is_empty())
|
|
.map(|line| line.trim_start_matches('#').trim().to_owned())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// Returns a copy of this skill with replaced content, re-validating the
|
|
/// non-empty invariant and **preserving** the [`Skill::description`].
|
|
///
|
|
/// # Errors
|
|
/// [`DomainError::EmptyField`] if `content_md` is empty.
|
|
pub fn with_content(&self, content_md: MarkdownDoc) -> Result<Self, DomainError> {
|
|
Ok(
|
|
Skill::new(self.id, self.name.clone(), content_md, self.scope)?
|
|
.with_description(self.description.clone()),
|
|
)
|
|
}
|
|
}
|
|
|
|
/// A reference from an agent to one assigned skill.
|
|
///
|
|
/// Stored in the [`crate::agent::ManifestEntry`]: an agent carries 0..N of these.
|
|
/// The `scope` is kept alongside the id so the application layer knows which
|
|
/// store to resolve the skill from without a global lookup.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SkillRef {
|
|
/// The assigned skill.
|
|
pub skill_id: SkillId,
|
|
/// Scope of the assigned skill (selects its store).
|
|
pub scope: SkillScope,
|
|
}
|
|
|
|
impl SkillRef {
|
|
/// Builds a reference to an assigned skill.
|
|
#[must_use]
|
|
pub const fn new(skill_id: SkillId, scope: SkillScope) -> Self {
|
|
Self { skill_id, scope }
|
|
}
|
|
}
|
|
|
|
impl From<&Skill> for SkillRef {
|
|
fn from(skill: &Skill) -> Self {
|
|
Self::new(skill.id, skill.scope)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::ids::SkillId;
|
|
|
|
fn skill(body: &str) -> Skill {
|
|
Skill::new(
|
|
SkillId::from_uuid(uuid::Uuid::from_u128(1)),
|
|
"demo",
|
|
MarkdownDoc::new(body),
|
|
SkillScope::Global,
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
// -- effective_description --------------------------------------------
|
|
|
|
#[test]
|
|
fn effective_description_returns_explicit_description_trimmed() {
|
|
// (a) explicit non-empty description ⇒ returned trimmed.
|
|
let s = skill("# Heading\n\nbody").with_description(Some(" Refactors code ".to_owned()));
|
|
assert_eq!(s.effective_description(), "Refactors code");
|
|
}
|
|
|
|
#[test]
|
|
fn effective_description_falls_back_to_first_body_line_without_hash() {
|
|
// (b) description None ⇒ first non-empty body line, `#` stripped & trimmed.
|
|
let s = skill("\n\n# My Skill Title \n\nrest of body");
|
|
assert_eq!(s.effective_description(), "My Skill Title");
|
|
}
|
|
|
|
#[test]
|
|
fn effective_description_first_line_without_heading_marker() {
|
|
// (b') first non-empty line that is not a heading is returned verbatim (trimmed).
|
|
let s = skill(" just a plain first line\nsecond");
|
|
assert_eq!(s.effective_description(), "just a plain first line");
|
|
}
|
|
|
|
#[test]
|
|
fn effective_description_blank_body_yields_empty_string() {
|
|
// (c) body that is only whitespace ⇒ empty String.
|
|
let s = skill(" \n\t\n ");
|
|
assert_eq!(s.effective_description(), "");
|
|
}
|
|
|
|
#[test]
|
|
fn effective_description_blank_description_falls_back_to_body() {
|
|
// (d) description Some("") / whitespace-only ⇒ treated as absent ⇒ body fallback.
|
|
let empty = skill("# Body Heading").with_description(Some(String::new()));
|
|
assert_eq!(empty.effective_description(), "Body Heading");
|
|
|
|
let spaces = skill("# Body Heading").with_description(Some(" \t ".to_owned()));
|
|
assert_eq!(spaces.effective_description(), "Body Heading");
|
|
}
|
|
|
|
// -- with_content preserves description -------------------------------
|
|
|
|
#[test]
|
|
fn with_content_preserves_description() {
|
|
let s = skill("old body").with_description(Some("kept".to_owned()));
|
|
let updated = s.with_content(MarkdownDoc::new("new body")).unwrap();
|
|
assert_eq!(updated.description.as_deref(), Some("kept"));
|
|
assert_eq!(updated.content_md.as_str(), "new body");
|
|
}
|
|
|
|
#[test]
|
|
fn with_content_rejects_empty_body() {
|
|
let s = skill("old body");
|
|
assert!(s.with_content(MarkdownDoc::new("")).is_err());
|
|
}
|
|
|
|
// -- serde: description is additive / defaults to None ----------------
|
|
|
|
#[test]
|
|
fn deserialize_legacy_skill_without_description_defaults_to_none() {
|
|
// A skill JSON written before `description` existed must deserialise with
|
|
// `description: None` thanks to `#[serde(default)]`.
|
|
let id = uuid::Uuid::from_u128(7);
|
|
let json =
|
|
format!(r##"{{"id":"{id}","name":"legacy","contentMd":"# body","scope":"global"}}"##);
|
|
let parsed: Skill = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(parsed.description, None);
|
|
assert_eq!(parsed.name, "legacy");
|
|
}
|
|
|
|
#[test]
|
|
fn serde_round_trip_with_description() {
|
|
let original = skill("# body").with_description(Some("affordance".to_owned()));
|
|
let json = serde_json::to_string(&original).unwrap();
|
|
let back: Skill = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(back, original);
|
|
assert_eq!(back.description.as_deref(), Some("affordance"));
|
|
}
|
|
}
|