fix: fix some displays and features

This commit is contained in:
2026-06-06 17:06:45 +02:00
parent 2332b7f815
commit 3be55795a6
31 changed files with 3118 additions and 30 deletions

View File

@ -14,6 +14,8 @@ use application::{
GitStatusInput, LaunchAgentInput, ListAgentsInput, ListLayoutsInput, LoadLayoutInput,
MutateLayoutInput, OpenProjectInput, ReadAgentContextInput, RenameLayoutInput,
SetActiveLayoutInput, SyncAgentWithTemplateInput, UpdateAgentContextInput,
AssignSkillToAgentInput, CreateSkillInput, DeleteSkillInput, ListSkillsInput,
UnassignSkillFromAgentInput, UpdateSkillInput,
};
use domain::ports::PtyHandle;
@ -31,8 +33,10 @@ use crate::dto::{
ReattachResultDto, RenameLayoutRequestDto, ResizeTerminalRequestDto, SaveProfileRequestDto,
SetActiveLayoutRequestDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
TemplateListDto, TerminalClosedDto, TerminalSessionDto, UpdateAgentContextRequestDto,
UpdateTemplateRequestDto, WriteTerminalRequestDto,
UpdateTemplateRequestDto, WriteTerminalRequestDto, parse_skill_id, AssignSkillRequestDto,
CreateSkillRequestDto, SkillDto, SkillListDto, UnassignSkillRequestDto, UpdateSkillRequestDto,
};
use domain::{SkillRef, SkillScope};
use crate::pty::{PtyBridge, PtyChunk};
use crate::state::AppState;
@ -1182,3 +1186,157 @@ pub async fn move_tab_to_new_window(
Ok(MoveTabResultDto::from(out))
}
// ---------------------------------------------------------------------------
// Skills (L12)
// ---------------------------------------------------------------------------
/// `create_skill` — create a skill in its scope's store.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for an empty name/content or malformed
/// project id, `NOT_FOUND` if the project is unknown, `STORE` on failure).
#[tauri::command]
pub async fn create_skill(
request: CreateSkillRequestDto,
state: State<'_, AppState>,
) -> Result<SkillDto, ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?;
state
.create_skill
.execute(CreateSkillInput {
name: request.name,
content: request.content,
scope: request.scope,
project_root: project.root,
})
.await
.map(SkillDto::from)
.map_err(ErrorDto::from)
}
/// `update_skill` — replace a skill's Markdown content.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for malformed ids or empty content,
/// `NOT_FOUND` if the project or skill is unknown, `STORE` on failure).
#[tauri::command]
pub async fn update_skill(
request: UpdateSkillRequestDto,
state: State<'_, AppState>,
) -> Result<SkillDto, ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?;
let skill_id = parse_skill_id(&request.skill_id)?;
state
.update_skill
.execute(UpdateSkillInput {
scope: request.scope,
skill_id,
content: request.content,
project_root: project.root,
})
.await
.map(SkillDto::from)
.map_err(ErrorDto::from)
}
/// `list_skills` — list the skills in one scope.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the
/// project is unknown, `STORE` on failure).
#[tauri::command]
pub async fn list_skills(
project_id: String,
scope: SkillScope,
state: State<'_, AppState>,
) -> Result<SkillListDto, ErrorDto> {
let project = resolve_project(&project_id, &state).await?;
state
.list_skills
.execute(ListSkillsInput {
scope,
project_root: project.root,
})
.await
.map(SkillListDto::from)
.map_err(ErrorDto::from)
}
/// `delete_skill` — remove a skill from its scope's store.
///
/// Agents that referenced it keep their `SkillRef`; injection simply skips the
/// now-absent skill.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for malformed ids, `NOT_FOUND` if the
/// project or skill is unknown, `STORE` on failure).
#[tauri::command]
pub async fn delete_skill(
project_id: String,
scope: SkillScope,
skill_id: String,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
let project = resolve_project(&project_id, &state).await?;
let id = parse_skill_id(&skill_id)?;
state
.delete_skill
.execute(DeleteSkillInput {
scope,
skill_id: id,
project_root: project.root,
})
.await
.map_err(ErrorDto::from)
}
/// `assign_skill_to_agent` — record a `SkillRef` in the agent's manifest entry.
/// Idempotent.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for malformed ids, `NOT_FOUND` if the
/// project or agent is unknown, `STORE` on failure).
#[tauri::command]
pub async fn assign_skill_to_agent(
request: AssignSkillRequestDto,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?;
let agent_id = parse_agent_id(&request.agent_id)?;
let skill_id = parse_skill_id(&request.skill_id)?;
state
.assign_skill
.execute(AssignSkillToAgentInput {
project,
agent_id,
skill: SkillRef::new(skill_id, request.scope),
})
.await
.map_err(ErrorDto::from)
}
/// `unassign_skill_from_agent` — drop a skill assignment from an agent.
/// Idempotent.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for malformed ids, `NOT_FOUND` if the
/// project or agent is unknown, `STORE` on failure).
#[tauri::command]
pub async fn unassign_skill_from_agent(
request: UnassignSkillRequestDto,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?;
let agent_id = parse_agent_id(&request.agent_id)?;
let skill_id = parse_skill_id(&request.skill_id)?;
state
.unassign_skill
.execute(UnassignSkillFromAgentInput {
project,
agent_id,
skill_id,
})
.await
.map_err(ErrorDto::from)
}

View File

@ -1342,3 +1342,107 @@ pub fn parse_tab_id(raw: &str) -> Result<TabId, ErrorDto> {
message: format!("invalid tab id: {raw}"),
})
}
// ---------------------------------------------------------------------------
// Skills (L12)
// ---------------------------------------------------------------------------
use application::{CreateSkillOutput, ListSkillsOutput, UpdateSkillOutput};
use domain::{Skill, SkillId, SkillScope};
/// A skill crossing the wire. [`Skill`] already serialises camelCase
/// (`id`, `name`, `contentMd`, `scope` as `"global"`/`"project"`), so we embed
/// it directly — the TS mirror matches this shape.
#[derive(Debug, Clone, Serialize)]
#[serde(transparent)]
pub struct SkillDto(pub Skill);
/// A list of skills (transparent array on the wire).
#[derive(Debug, Clone, Serialize)]
#[serde(transparent)]
pub struct SkillListDto(pub Vec<SkillDto>);
impl From<ListSkillsOutput> for SkillListDto {
fn from(out: ListSkillsOutput) -> Self {
Self(out.skills.into_iter().map(SkillDto).collect())
}
}
impl From<CreateSkillOutput> for SkillDto {
fn from(out: CreateSkillOutput) -> Self {
Self(out.skill)
}
}
impl From<UpdateSkillOutput> for SkillDto {
fn from(out: UpdateSkillOutput) -> Self {
Self(out.skill)
}
}
/// Request DTO for `create_skill`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateSkillRequestDto {
/// Owning project (resolved to a root; ignored on disk for `Global`).
pub project_id: String,
/// Display name.
pub name: String,
/// Initial Markdown content.
pub content: String,
/// Scope the skill is created in.
pub scope: SkillScope,
}
/// Request DTO for `update_skill`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateSkillRequestDto {
/// Owning project (resolved to a root; ignored on disk for `Global`).
pub project_id: String,
/// Id of the skill to update.
pub skill_id: String,
/// Scope the skill lives in.
pub scope: SkillScope,
/// New Markdown content.
pub content: String,
}
/// Request DTO for `assign_skill_to_agent`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AssignSkillRequestDto {
/// Owning project.
pub project_id: String,
/// Agent receiving the skill.
pub agent_id: String,
/// Skill to assign.
pub skill_id: String,
/// Scope of the skill (recorded alongside the ref).
pub scope: SkillScope,
}
/// Request DTO for `unassign_skill_from_agent`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UnassignSkillRequestDto {
/// Owning project.
pub project_id: String,
/// Agent losing the skill.
pub agent_id: String,
/// Skill to unassign.
pub skill_id: String,
}
/// Parses a skill-id string (UUID) coming from the frontend.
///
/// # Errors
/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID.
pub fn parse_skill_id(raw: &str) -> Result<SkillId, ErrorDto> {
uuid::Uuid::parse_str(raw)
.map(SkillId::from_uuid)
.map_err(|_| ErrorDto {
code: "INVALID".to_owned(),
message: format!("invalid skill id: {raw}"),
})
}

View File

@ -119,6 +119,12 @@ pub fn run() {
commands::git_log,
commands::git_init,
commands::git_graph,
commands::create_skill,
commands::update_skill,
commands::list_skills,
commands::delete_skill,
commands::assign_skill_to_agent,
commands::unassign_skill_from_agent,
commands::move_tab_to_new_window,
])
.run(tauri::generate_context!())

View File

@ -13,18 +13,19 @@ use application::{
CreateAgentFromTemplate, CreateLayout, CreateProject, CreateTemplate, DeleteAgent,
DeleteLayout, DeleteProfile, DeleteTemplate, DetectAgentDrift, DetectProfiles, FirstRunState,
GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage,
HealthUseCase, LaunchAgent, ListAgents, ListLayouts, ListProfiles, ListProjects, ListTemplates,
LoadLayout, MoveTabToNewWindow, MutateLayout, OpenProject, OpenTerminal, ReadAgentContext,
ReferenceProfiles, RenameLayout, ResizeTerminal, SaveProfile, SetActiveLayout,
HealthUseCase, LaunchAgent, ListAgents, ListLayouts, ListProfiles, ListProjects, ListSkills,
ListTemplates, LoadLayout, MoveTabToNewWindow, MutateLayout, OpenProject, OpenTerminal,
ReadAgentContext, ReferenceProfiles, RenameLayout, ResizeTerminal, SaveProfile, SetActiveLayout,
AssignSkillToAgent, CreateSkill, DeleteSkill, UnassignSkillFromAgent, UpdateSkill,
SyncAgentWithTemplate, TerminalSessions, UpdateAgentContext, UpdateTemplate, WriteToTerminal,
};
use domain::ports::{
AgentContextStore, AgentRuntime, Clock, EventBus, FileSystem, GitPort, IdGenerator,
ProcessSpawner, ProfileStore, ProjectStore, PtyPort, TemplateStore,
ProcessSpawner, ProfileStore, ProjectStore, PtyPort, SkillStore, TemplateStore,
};
use infrastructure::{
CliAgentRuntime, FsProfileStore, FsProjectStore, FsTemplateStore, Git2Repository,
CliAgentRuntime, FsProfileStore, FsProjectStore, FsSkillStore, FsTemplateStore, Git2Repository,
IdeaiContextStore, LocalFileSystem, LocalProcessSpawner, PortablePtyAdapter, SystemClock,
TokioBroadcastEventBus, UuidGenerator,
};
@ -147,6 +148,19 @@ pub struct AppState {
pub git_init: Arc<GitInit>,
/// Return the commit graph for all local branches.
pub git_graph: Arc<GitGraph>,
// --- Skills (L12) ---
/// Create a skill in a scope's store.
pub create_skill: Arc<CreateSkill>,
/// Update a skill's content.
pub update_skill: Arc<UpdateSkill>,
/// List skills in a scope.
pub list_skills: Arc<ListSkills>,
/// Delete a skill from its scope's store.
pub delete_skill: Arc<DeleteSkill>,
/// Assign a skill to an agent (records a `SkillRef`).
pub assign_skill: Arc<AssignSkillToAgent>,
/// Unassign a skill from an agent.
pub unassign_skill: Arc<UnassignSkillFromAgent>,
}
impl AppState {
@ -287,6 +301,17 @@ impl AppState {
let contexts = Arc::new(IdeaiContextStore::new(Arc::clone(&fs_port)));
let contexts_port = Arc::clone(&contexts) as Arc<dyn AgentContextStore>;
// --- Skill store (L12) ---
// Global skills live in the machine-local app-data dir; project skills are
// resolved per call from each project's `.ideai/` (so one store serves all
// open projects). Shared by the skill use cases and the agent launcher
// (assigned-skill injection into the convention file, §14.2).
let skill_store = Arc::new(FsSkillStore::new(
Arc::clone(&fs_port),
app_data_dir.to_string_lossy().into_owned(),
));
let skill_store_port = Arc::clone(&skill_store) as Arc<dyn SkillStore>;
let create_agent = Arc::new(CreateAgentFromScratch::new(
Arc::clone(&contexts_port),
Arc::clone(&ids) as Arc<dyn IdGenerator>,
@ -307,6 +332,7 @@ impl AppState {
Arc::clone(&runtime_port),
Arc::clone(&fs_port),
Arc::clone(&pty_port),
Arc::clone(&skill_store_port),
Arc::clone(&terminal_sessions),
Arc::clone(&events_port),
));
@ -364,6 +390,25 @@ impl AppState {
let git_init = Arc::new(GitInit::new(Arc::clone(&git_port), Arc::clone(&events_port)));
let git_graph = Arc::new(GitGraph::new(Arc::clone(&git_port)));
// --- Skill use cases (L12) ---
// Reuse the skill store (built above for the launcher) and the shared
// agent context store for the agent↔skill assignment.
let create_skill = Arc::new(CreateSkill::new(
Arc::clone(&skill_store_port),
Arc::clone(&ids) as Arc<dyn IdGenerator>,
));
let update_skill = Arc::new(UpdateSkill::new(Arc::clone(&skill_store_port)));
let list_skills = Arc::new(ListSkills::new(Arc::clone(&skill_store_port)));
let delete_skill = Arc::new(DeleteSkill::new(Arc::clone(&skill_store_port)));
let assign_skill = Arc::new(AssignSkillToAgent::new(
Arc::clone(&contexts_port),
Arc::clone(&events_port),
));
let unassign_skill = Arc::new(UnassignSkillFromAgent::new(
Arc::clone(&contexts_port),
Arc::clone(&events_port),
));
// --- Windows (L10) ---
let move_tab = Arc::new(MoveTabToNewWindow::new(
Arc::clone(&store_port),
@ -422,6 +467,12 @@ impl AppState {
git_log,
git_init,
git_graph,
create_skill,
update_skill,
list_skills,
delete_skill,
assign_skill,
unassign_skill,
move_tab,
}
}

View File

@ -15,11 +15,11 @@ use std::sync::Arc;
use domain::ports::{
AgentContextStore, AgentRuntime, ContextInjectionPlan, EventBus, FileSystem, PreparedContext,
ProfileStore, PtyPort, RemotePath, SpawnSpec,
ProfileStore, PtyPort, RemotePath, SkillStore, SpawnSpec, StoreError,
};
use domain::{
Agent, AgentId, AgentManifest, AgentOrigin, DomainEvent, ManifestEntry, MarkdownDoc, NodeId,
Project, ProfileId, ProjectPath, PtySize, SessionKind, SessionStatus, TerminalSession,
Project, ProfileId, ProjectPath, PtySize, SessionKind, SessionStatus, Skill, TerminalSession,
};
use crate::error::AppError;
@ -350,6 +350,7 @@ pub struct LaunchAgent {
runtime: Arc<dyn AgentRuntime>,
fs: Arc<dyn FileSystem>,
pty: Arc<dyn PtyPort>,
skills: Arc<dyn SkillStore>,
sessions: Arc<TerminalSessions>,
events: Arc<dyn EventBus>,
}
@ -364,6 +365,7 @@ impl LaunchAgent {
runtime: Arc<dyn AgentRuntime>,
fs: Arc<dyn FileSystem>,
pty: Arc<dyn PtyPort>,
skills: Arc<dyn SkillStore>,
sessions: Arc<TerminalSessions>,
events: Arc<dyn EventBus>,
) -> Self {
@ -373,11 +375,35 @@ impl LaunchAgent {
runtime,
fs,
pty,
skills,
sessions,
events,
}
}
/// Resolves the Markdown bodies of an agent's assigned skills, in the
/// **manifest order** (deterministic). A skill that no longer exists in its
/// store (deleted out from under the assignment) is silently skipped — a
/// dangling [`domain::SkillRef`] must not block a launch.
///
/// # Errors
/// [`AppError::Store`] on any store failure other than a missing skill.
async fn resolve_skills(
&self,
agent: &Agent,
root: &ProjectPath,
) -> Result<Vec<Skill>, AppError> {
let mut out = Vec::with_capacity(agent.skills.len());
for skill_ref in &agent.skills {
match self.skills.get(skill_ref.scope, root, skill_ref.skill_id).await {
Ok(skill) => out.push(skill),
Err(StoreError::NotFound) => {}
Err(e) => return Err(e.into()),
}
}
Ok(out)
}
/// Executes the launch.
///
/// Step order is contractually significant (and unit-tested): resolve the
@ -444,8 +470,10 @@ impl LaunchAgent {
.runtime
.prepare_invocation(&profile, &prepared, &run_dir)?;
// 5. Apply the injection plan side effects *before* spawning.
self.apply_injection(&input.project, &agent.context_path, &content, &mut spec)
// 5. Resolve the agent's assigned skills (their `.md` bodies), then apply
// the injection plan side effects *before* spawning.
let skills = self.resolve_skills(&agent, &input.project.root).await?;
self.apply_injection(&input.project, &agent.context_path, &content, &skills, &mut spec)
.await?;
// 6. Spawn the PTY at the resolved cwd; adopt its session id everywhere.
@ -487,6 +515,7 @@ impl LaunchAgent {
project: &Project,
context_rel_path: &str,
content: &MarkdownDoc,
skills: &[Skill],
spec: &mut SpawnSpec,
) -> Result<(), AppError> {
match spec.context_plan.clone() {
@ -496,9 +525,10 @@ impl LaunchAgent {
// run directory — `spec.cwd` is that run dir, never the project
// root, so there is zero collision between agents. The document is
// composed: an absolute project-root header (so the agent knows
// where to operate, since its cwd is *not* the root) followed by
// the agent's persona `.md`.
let document = compose_convention_file(project.root.as_str(), content.as_str());
// where to operate, since its cwd is *not* the root), the agent's
// persona `.md`, then the bodies of its assigned skills (§14.2).
let document =
compose_convention_file(project.root.as_str(), content.as_str(), skills);
let path = RemotePath::new(join(&spec.cwd, &target));
self.fs.write(&path, document.as_bytes()).await?;
}
@ -537,13 +567,17 @@ fn agent_run_dir(root: &ProjectPath, agent_id: &AgentId) -> Result<ProjectPath,
/// Composes the convention file IdeA writes into an agent's run directory: an
/// absolute project-root header (the agent's cwd is the run dir, *not* the root,
/// so it must be told where to work) followed by the agent's persona `.md`.
/// so it must be told where to work), the agent's persona `.md`, then the bodies
/// of its assigned `skills` under a `# Skills` section (ARCHITECTURE §14.2).
///
/// Kept as a **pure** function (no I/O) so it is unit-testable in isolation, and
/// deliberately structured so future blocks (assigned skills, shared project
/// context — ARCHITECTURE §14.2) can be appended without touching the launcher.
/// Skills are emitted in the order given (the caller passes them in manifest
/// order, making the output deterministic); each is introduced by a `##` header
/// carrying its name. When `skills` is empty the section is omitted entirely, so
/// an agent with no skills gets exactly the previous document.
///
/// Kept as a **pure** function (no I/O) so it is unit-testable in isolation.
#[must_use]
pub(crate) fn compose_convention_file(project_root: &str, agent_md: &str) -> String {
pub(crate) fn compose_convention_file(project_root: &str, agent_md: &str, skills: &[Skill]) -> String {
let mut out = String::new();
out.push_str("# Project root\n\n");
out.push_str(project_root);
@ -554,6 +588,17 @@ pub(crate) fn compose_convention_file(project_root: &str, agent_md: &str) -> Str
);
out.push_str("---\n\n");
out.push_str(agent_md);
if !skills.is_empty() {
out.push_str("\n\n---\n\n# Skills\n");
for skill in skills {
out.push_str("\n## ");
out.push_str(&skill.name);
out.push_str("\n\n");
out.push_str(skill.content_md.as_str());
out.push('\n');
}
}
out
}
@ -610,7 +655,7 @@ mod tests {
#[test]
fn compose_convention_file_carries_root_then_persona() {
let doc = compose_convention_file("/abs/project/root", "# Persona\n\nDo things.");
let doc = compose_convention_file("/abs/project/root", "# Persona\n\nDo things.", &[]);
// Absolute project root present.
assert!(doc.contains("/abs/project/root"));
@ -621,5 +666,38 @@ mod tests {
let root_at = doc.find("/abs/project/root").unwrap();
let persona_at = doc.find("# Persona").unwrap();
assert!(root_at < persona_at, "root header must precede the persona");
// No skills ⇒ no Skills section.
assert!(!doc.contains("# Skills"));
}
#[test]
fn compose_convention_file_appends_assigned_skills_in_order() {
let s = |n: u128, name: &str, body: &str| {
Skill::new(
domain::SkillId::from_uuid(uuid::Uuid::from_u128(n)),
name,
MarkdownDoc::new(body),
domain::SkillScope::Global,
)
.unwrap()
};
let doc = compose_convention_file(
"/root",
"# Persona",
&[s(1, "refactor", "REFAC_BODY"), s(2, "review", "REVIEW_BODY")],
);
// Both skill bodies present, after the persona.
assert!(doc.contains("REFAC_BODY"));
assert!(doc.contains("REVIEW_BODY"));
let persona_at = doc.find("# Persona").unwrap();
let refac_at = doc.find("REFAC_BODY").unwrap();
let review_at = doc.find("REVIEW_BODY").unwrap();
assert!(persona_at < refac_at, "skills come after the persona");
// Deterministic order: first assigned skill precedes the second.
assert!(refac_at < review_at, "skills emitted in the given order");
// Skill names surface as sub-headers.
assert!(doc.contains("## refactor"));
assert!(doc.contains("## review"));
}
}

View File

@ -18,6 +18,7 @@ pub mod health;
pub mod layout;
pub mod project;
pub mod remote;
pub mod skill;
pub mod template;
pub mod terminal;
pub mod window;
@ -61,6 +62,12 @@ pub use template::{
SyncAgentWithTemplateInput, SyncAgentWithTemplateOutput, UpdateTemplate, UpdateTemplateInput,
UpdateTemplateOutput,
};
pub use skill::{
AssignSkillToAgent, AssignSkillToAgentInput, CreateSkill, CreateSkillInput, CreateSkillOutput,
DeleteSkill, DeleteSkillInput, ListSkills, ListSkillsInput, ListSkillsOutput,
UnassignSkillFromAgent, UnassignSkillFromAgentInput, UpdateSkill, UpdateSkillInput,
UpdateSkillOutput,
};
pub use terminal::{
CloseTerminal, CloseTerminalInput, CloseTerminalOutput, OpenTerminal, OpenTerminalInput,
OpenTerminalOutput, ResizeTerminal, ResizeTerminalInput, TerminalSessions, WriteToTerminal,

View File

@ -0,0 +1,21 @@
//! Skill use cases (ARCHITECTURE §14.2; L12).
//!
//! Skills are reusable, model-agnostic workflows (IdeA's universal equivalent of
//! a CLI slash-command). This module owns their CRUD across both scopes
//! ([`domain::skill::SkillScope`]) and the agent↔skill assignment that records a
//! [`domain::skill::SkillRef`] in the project manifest. The actual injection of
//! an assigned skill's body into the generated convention file happens at agent
//! activation (L6).
//!
//! Every use case talks only to ports ([`domain::ports::SkillStore`],
//! [`domain::ports::AgentContextStore`], [`domain::ports::IdGenerator`],
//! [`domain::ports::EventBus`]).
mod usecases;
pub use usecases::{
AssignSkillToAgent, AssignSkillToAgentInput, CreateSkill, CreateSkillInput, CreateSkillOutput,
DeleteSkill, DeleteSkillInput, ListSkills, ListSkillsInput, ListSkillsOutput,
UnassignSkillFromAgent, UnassignSkillFromAgentInput, UpdateSkill, UpdateSkillInput,
UpdateSkillOutput,
};

View File

@ -0,0 +1,339 @@
//! Skill use cases (ARCHITECTURE §14.2; L12).
//!
//! - **CRUD** in either scope: [`CreateSkill`], [`UpdateSkill`], [`DeleteSkill`],
//! [`ListSkills`].
//! - **Assignment**: [`AssignSkillToAgent`] / [`UnassignSkillFromAgent`] mutate
//! the project manifest entry's `skills` and announce
//! [`DomainEvent::SkillAssigned`]. Both are idempotent.
use std::sync::Arc;
use domain::ports::{AgentContextStore, EventBus, IdGenerator, SkillStore};
use domain::{
AgentId, AgentManifest, DomainEvent, MarkdownDoc, Project, ProjectPath, Skill, SkillId,
SkillRef, SkillScope,
};
use crate::error::AppError;
// ---------------------------------------------------------------------------
// CreateSkill
// ---------------------------------------------------------------------------
/// Input for [`CreateSkill::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateSkillInput {
/// Display name (also the `.md` stem on disk).
pub name: String,
/// Initial Markdown body.
pub content: String,
/// Scope the skill is created in (selects its backing store).
pub scope: SkillScope,
/// Active project root (used only for [`SkillScope::Project`]).
pub project_root: ProjectPath,
}
/// Output of [`CreateSkill::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateSkillOutput {
/// The created skill.
pub skill: Skill,
}
/// Creates a skill in the store of its [`SkillScope`].
pub struct CreateSkill {
skills: Arc<dyn SkillStore>,
ids: Arc<dyn IdGenerator>,
}
impl CreateSkill {
/// Builds the use case from its ports.
#[must_use]
pub fn new(skills: Arc<dyn SkillStore>, ids: Arc<dyn IdGenerator>) -> Self {
Self { skills, ids }
}
/// Executes creation.
///
/// # Errors
/// - [`AppError::Invalid`] if `name`/`content` is empty,
/// - [`AppError::Store`] on persistence failure.
pub async fn execute(&self, input: CreateSkillInput) -> Result<CreateSkillOutput, AppError> {
let id = SkillId::from_uuid(self.ids.new_uuid());
let skill = Skill::new(id, input.name, MarkdownDoc::new(input.content), input.scope)
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.skills.save(&skill, &input.project_root).await?;
Ok(CreateSkillOutput { skill })
}
}
// ---------------------------------------------------------------------------
// UpdateSkill
// ---------------------------------------------------------------------------
/// Input for [`UpdateSkill::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateSkillInput {
/// Scope the skill lives in.
pub scope: SkillScope,
/// Skill to update.
pub skill_id: SkillId,
/// New Markdown body.
pub content: String,
/// Active project root (used only for [`SkillScope::Project`]).
pub project_root: ProjectPath,
}
/// Output of [`UpdateSkill::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateSkillOutput {
/// The updated skill.
pub skill: Skill,
}
/// Replaces a skill's content (re-validating the non-empty invariant).
pub struct UpdateSkill {
skills: Arc<dyn SkillStore>,
}
impl UpdateSkill {
/// Builds the use case.
#[must_use]
pub fn new(skills: Arc<dyn SkillStore>) -> Self {
Self { skills }
}
/// Executes the update.
///
/// # Errors
/// - [`AppError::NotFound`] if the skill is unknown in that scope,
/// - [`AppError::Invalid`] if the new content is empty,
/// - [`AppError::Store`] on persistence failure.
pub async fn execute(&self, input: UpdateSkillInput) -> Result<UpdateSkillOutput, AppError> {
let current = self
.skills
.get(input.scope, &input.project_root, input.skill_id)
.await?;
let updated = current
.with_content(MarkdownDoc::new(input.content))
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.skills.save(&updated, &input.project_root).await?;
Ok(UpdateSkillOutput { skill: updated })
}
}
// ---------------------------------------------------------------------------
// ListSkills / DeleteSkill
// ---------------------------------------------------------------------------
/// Input for [`ListSkills::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListSkillsInput {
/// Scope to list.
pub scope: SkillScope,
/// Active project root (used only for [`SkillScope::Project`]).
pub project_root: ProjectPath,
}
/// Output of [`ListSkills::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListSkillsOutput {
/// All skills in the requested scope.
pub skills: Vec<Skill>,
}
/// Lists the skills in one scope.
pub struct ListSkills {
skills: Arc<dyn SkillStore>,
}
impl ListSkills {
/// Builds the use case.
#[must_use]
pub fn new(skills: Arc<dyn SkillStore>) -> Self {
Self { skills }
}
/// Lists skills in `input.scope`.
///
/// # Errors
/// [`AppError::Store`] on persistence failure.
pub async fn execute(&self, input: ListSkillsInput) -> Result<ListSkillsOutput, AppError> {
Ok(ListSkillsOutput {
skills: self.skills.list(input.scope, &input.project_root).await?,
})
}
}
/// Input for [`DeleteSkill::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeleteSkillInput {
/// Scope the skill lives in.
pub scope: SkillScope,
/// Skill to delete.
pub skill_id: SkillId,
/// Active project root (used only for [`SkillScope::Project`]).
pub project_root: ProjectPath,
}
/// Deletes a skill from its scope's store.
///
/// Agents that referenced it keep their [`SkillRef`]; the injection step simply
/// finds nothing to resolve for the now-absent skill and skips it.
pub struct DeleteSkill {
skills: Arc<dyn SkillStore>,
}
impl DeleteSkill {
/// Builds the use case.
#[must_use]
pub fn new(skills: Arc<dyn SkillStore>) -> Self {
Self { skills }
}
/// Deletes the skill.
///
/// # Errors
/// - [`AppError::NotFound`] if the skill is unknown in that scope,
/// - [`AppError::Store`] on persistence failure.
pub async fn execute(&self, input: DeleteSkillInput) -> Result<(), AppError> {
self.skills
.delete(input.scope, &input.project_root, input.skill_id)
.await?;
Ok(())
}
}
// ---------------------------------------------------------------------------
// AssignSkillToAgent / UnassignSkillFromAgent
// ---------------------------------------------------------------------------
/// Input for [`AssignSkillToAgent::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AssignSkillToAgentInput {
/// The owning project.
pub project: Project,
/// The agent receiving the skill.
pub agent_id: AgentId,
/// The skill to assign.
pub skill: SkillRef,
}
/// Assigns a skill to an agent by recording a [`SkillRef`] in its manifest entry.
/// Idempotent: re-assigning the same skill is a no-op (no duplicate).
pub struct AssignSkillToAgent {
contexts: Arc<dyn AgentContextStore>,
events: Arc<dyn EventBus>,
}
impl AssignSkillToAgent {
/// Builds the use case from its ports.
#[must_use]
pub fn new(contexts: Arc<dyn AgentContextStore>, events: Arc<dyn EventBus>) -> Self {
Self { contexts, events }
}
/// Executes the assignment.
///
/// # Errors
/// - [`AppError::NotFound`] if the agent is unknown to the project,
/// - [`AppError::Invalid`] if the resulting manifest is invalid,
/// - [`AppError::Store`] on persistence failure.
pub async fn execute(&self, input: AssignSkillToAgentInput) -> Result<(), AppError> {
let mut manifest = self.contexts.load_manifest(&input.project).await?;
let entry = manifest
.entries
.iter_mut()
.find(|e| e.agent_id == input.agent_id)
.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
// Mutate through the domain entity so the dedup invariant is enforced in
// one place, then fold the result back into the manifest entry.
let mut agent = entry.to_agent().map_err(|e| AppError::Invalid(e.to_string()))?;
let changed = agent.assign_skill(input.skill);
*entry = domain::ManifestEntry::from_agent(&agent);
if changed {
self.persist_and_announce(&input.project, manifest, input.agent_id, input.skill.skill_id, true)
.await?;
}
Ok(())
}
/// Saves the manifest and announces the assignment change.
async fn persist_and_announce(
&self,
project: &Project,
manifest: AgentManifest,
agent_id: AgentId,
skill_id: SkillId,
assigned: bool,
) -> Result<(), AppError> {
let manifest = AgentManifest::new(manifest.version, manifest.entries)
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.contexts.save_manifest(project, &manifest).await?;
self.events.publish(DomainEvent::SkillAssigned {
agent_id,
skill_id,
assigned,
});
Ok(())
}
}
/// Input for [`UnassignSkillFromAgent::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnassignSkillFromAgentInput {
/// The owning project.
pub project: Project,
/// The agent losing the skill.
pub agent_id: AgentId,
/// The skill to unassign.
pub skill_id: SkillId,
}
/// Removes a skill assignment from an agent. Idempotent: unassigning a skill the
/// agent does not carry is a no-op.
pub struct UnassignSkillFromAgent {
contexts: Arc<dyn AgentContextStore>,
events: Arc<dyn EventBus>,
}
impl UnassignSkillFromAgent {
/// Builds the use case from its ports.
#[must_use]
pub fn new(contexts: Arc<dyn AgentContextStore>, events: Arc<dyn EventBus>) -> Self {
Self { contexts, events }
}
/// Executes the un-assignment.
///
/// # Errors
/// - [`AppError::NotFound`] if the agent is unknown to the project,
/// - [`AppError::Invalid`] if the resulting manifest is invalid,
/// - [`AppError::Store`] on persistence failure.
pub async fn execute(&self, input: UnassignSkillFromAgentInput) -> Result<(), AppError> {
let mut manifest = self.contexts.load_manifest(&input.project).await?;
let entry = manifest
.entries
.iter_mut()
.find(|e| e.agent_id == input.agent_id)
.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
let mut agent = entry.to_agent().map_err(|e| AppError::Invalid(e.to_string()))?;
let changed = agent.unassign_skill(input.skill_id);
*entry = domain::ManifestEntry::from_agent(&agent);
if changed {
let manifest = AgentManifest::new(manifest.version, manifest.entries)
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.contexts.save_manifest(&input.project, &manifest).await?;
self.events.publish(DomainEvent::SkillAssigned {
agent_id: input.agent_id,
skill_id: input.skill_id,
assigned: false,
});
}
Ok(())
}
}

View File

@ -26,12 +26,13 @@ use domain::markdown::MarkdownDoc;
use domain::ports::{
AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
ExitStatus, FileSystem, FsError, IdGenerator, OutputStream, PreparedContext, ProfileStore,
PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SpawnSpec, StoreError,
PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SkillStore, SpawnSpec, StoreError,
};
use domain::profile::{AgentProfile, ContextInjection};
use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef;
use domain::{PtySize, SessionId};
use domain::skill::{Skill, SkillScope};
use domain::{PtySize, SessionId, SkillId, SkillRef};
use uuid::Uuid;
use application::{
@ -177,6 +178,49 @@ impl ProfileStore for FakeProfiles {
}
}
// ---------------------------------------------------------------------------
// FakeSkills (SkillStore) — an in-memory store seeded with a few skills
// ---------------------------------------------------------------------------
#[derive(Clone, Default)]
struct FakeSkills(Arc<Vec<Skill>>);
impl FakeSkills {
fn with(skills: Vec<Skill>) -> Self {
Self(Arc::new(skills))
}
}
#[async_trait]
impl SkillStore for FakeSkills {
async fn list(&self, scope: SkillScope, _root: &ProjectPath) -> Result<Vec<Skill>, StoreError> {
Ok(self.0.iter().filter(|s| s.scope == scope).cloned().collect())
}
async fn get(
&self,
scope: SkillScope,
_root: &ProjectPath,
id: SkillId,
) -> Result<Skill, StoreError> {
self.0
.iter()
.find(|s| s.scope == scope && s.id == id)
.cloned()
.ok_or(StoreError::NotFound)
}
async fn save(&self, _skill: &Skill, _root: &ProjectPath) -> Result<(), StoreError> {
Ok(())
}
async fn delete(
&self,
_scope: SkillScope,
_root: &ProjectPath,
_id: SkillId,
) -> Result<(), StoreError> {
Ok(())
}
}
// ---------------------------------------------------------------------------
// FakeRuntime (AgentRuntime) — records prepare + returns a configured plan
// ---------------------------------------------------------------------------
@ -577,6 +621,7 @@ fn launch_fixture(injection: ContextInjection, plan: Option<ContextInjectionPlan
Arc::new(runtime),
Arc::new(fs.clone()),
Arc::new(pty.clone()),
Arc::new(FakeSkills::default()),
Arc::clone(&sessions),
Arc::new(bus.clone()),
);
@ -695,6 +740,7 @@ async fn two_agents_same_root_get_distinct_run_dirs_no_collision() {
Arc::new(FakeRuntime::new(Arc::clone(&tr), plan)),
Arc::new(fs.clone()),
Arc::new(pty.clone()),
Arc::new(FakeSkills::default()),
Arc::clone(&sessions),
Arc::new(SpyBus::default()),
);
@ -730,6 +776,98 @@ async fn two_agents_same_root_get_distinct_run_dirs_no_collision() {
assert!(String::from_utf8(writes[1].1.clone()).unwrap().contains("# bravo"));
}
#[tokio::test]
async fn launch_conventionfile_injects_assigned_skills_in_order() {
// An agent with two assigned global skills; the generated convention file must
// carry both skill bodies, after the persona, in assignment order (§14.2).
let skill_id = |n: u128| SkillId::from_uuid(Uuid::from_u128(n));
let skill = |n: u128, name: &str, body: &str| {
Skill::new(skill_id(n), name, MarkdownDoc::new(body), SkillScope::Global).unwrap()
};
let mut agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
agent.assign_skill(SkillRef::new(skill_id(1), SkillScope::Global));
agent.assign_skill(SkillRef::new(skill_id(2), SkillScope::Global));
let contexts = FakeContexts::with_agent(&agent, "# persona");
let profiles = FakeProfiles::new(vec![profile(
pid(9),
ContextInjection::convention_file("CLAUDE.md").unwrap(),
)]);
let tr = trace();
let fs = FakeFs::new(Arc::clone(&tr));
let pty = FakePty::new(Arc::clone(&tr), sid(777));
let skills = FakeSkills::with(vec![
skill(1, "refactor", "REFAC_BODY"),
skill(2, "review", "REVIEW_BODY"),
]);
let launch = LaunchAgent::new(
Arc::new(contexts),
Arc::new(profiles),
Arc::new(FakeRuntime::new(
Arc::clone(&tr),
Some(ContextInjectionPlan::File {
target: "CLAUDE.md".to_owned(),
}),
)),
Arc::new(fs.clone()),
Arc::new(pty.clone()),
Arc::new(skills),
Arc::new(TerminalSessions::new()),
Arc::new(SpyBus::default()),
);
launch.execute(launch_input(agent.id)).await.unwrap();
let writes = fs.writes();
assert_eq!(writes.len(), 1);
let doc = String::from_utf8(writes[0].1.clone()).unwrap();
assert!(doc.contains("# persona"), "persona present: {doc}");
assert!(doc.contains("REFAC_BODY"), "first skill body present");
assert!(doc.contains("REVIEW_BODY"), "second skill body present");
// Deterministic order: persona before skills, skill 1 before skill 2.
let persona_at = doc.find("# persona").unwrap();
let refac_at = doc.find("REFAC_BODY").unwrap();
let review_at = doc.find("REVIEW_BODY").unwrap();
assert!(persona_at < refac_at && refac_at < review_at, "ordering: {doc}");
}
#[tokio::test]
async fn launch_skips_dangling_skill_ref_without_failing() {
// The agent references a skill that no longer exists in the store: launch must
// still succeed and simply omit it (no Skills section for a sole dangling ref).
let mut agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
agent.assign_skill(SkillRef::new(SkillId::from_uuid(Uuid::from_u128(99)), SkillScope::Global));
let contexts = FakeContexts::with_agent(&agent, "# persona");
let profiles = FakeProfiles::new(vec![profile(
pid(9),
ContextInjection::convention_file("CLAUDE.md").unwrap(),
)]);
let tr = trace();
let fs = FakeFs::new(Arc::clone(&tr));
let pty = FakePty::new(Arc::clone(&tr), sid(777));
let launch = LaunchAgent::new(
Arc::new(contexts),
Arc::new(profiles),
Arc::new(FakeRuntime::new(
Arc::clone(&tr),
Some(ContextInjectionPlan::File {
target: "CLAUDE.md".to_owned(),
}),
)),
Arc::new(fs.clone()),
Arc::new(pty.clone()),
Arc::new(FakeSkills::default()), // empty store ⇒ the ref is dangling
Arc::new(TerminalSessions::new()),
Arc::new(SpyBus::default()),
);
launch.execute(launch_input(agent.id)).await.expect("launch must succeed");
let doc = String::from_utf8(fs.writes()[0].1.clone()).unwrap();
assert!(!doc.contains("# Skills"), "no Skills section for a dangling ref: {doc}");
}
#[tokio::test]
async fn launch_stdin_strategy_pipes_context_after_spawn() {
let (launch, agent, fs, pty, _bus, _sessions, tr) =
@ -771,6 +909,7 @@ async fn launch_unknown_profile_is_not_found() {
Arc::new(FakeRuntime::new(Arc::clone(&tr), Some(ContextInjectionPlan::Stdin))),
Arc::new(FakeFs::new(Arc::clone(&tr))),
Arc::new(pty.clone()),
Arc::new(FakeSkills::default()),
Arc::new(TerminalSessions::new()),
Arc::new(SpyBus::default()),
);

View File

@ -0,0 +1,395 @@
//! L12 tests for the skill use cases with in-memory port fakes (no real
//! store/FS): CRUD across scopes (`CreateSkill`, `UpdateSkill`, `DeleteSkill`,
//! `ListSkills`) and the manifest-mutating assignment
//! (`AssignSkillToAgent` / `UnassignSkillFromAgent`), asserting the
//! `SkillAssigned` event and idempotence.
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use domain::events::DomainEvent;
use domain::ids::{AgentId, ProfileId, ProjectId, SkillId};
use domain::markdown::MarkdownDoc;
use domain::ports::{
AgentContextStore, EventBus, EventStream, IdGenerator, SkillStore, StoreError,
};
use domain::skill::{Skill, SkillScope};
use domain::{AgentManifest, ManifestEntry, Project, ProjectPath, RemoteRef, SkillRef};
use uuid::Uuid;
use application::{
AssignSkillToAgent, AssignSkillToAgentInput, CreateSkill, CreateSkillInput, DeleteSkill,
DeleteSkillInput, ListSkills, ListSkillsInput, UnassignSkillFromAgent,
UnassignSkillFromAgentInput, UpdateSkill, UpdateSkillInput,
};
// ---------------------------------------------------------------------------
// Fakes
// ---------------------------------------------------------------------------
/// In-memory skill store keyed by `(scope, id)`, honouring scope isolation.
#[derive(Clone, Default)]
struct FakeSkills(Arc<Mutex<Vec<Skill>>>);
#[async_trait]
impl SkillStore for FakeSkills {
async fn list(&self, scope: SkillScope, _root: &ProjectPath) -> Result<Vec<Skill>, StoreError> {
Ok(self
.0
.lock()
.unwrap()
.iter()
.filter(|s| s.scope == scope)
.cloned()
.collect())
}
async fn get(
&self,
scope: SkillScope,
_root: &ProjectPath,
id: SkillId,
) -> Result<Skill, StoreError> {
self.0
.lock()
.unwrap()
.iter()
.find(|s| s.scope == scope && s.id == id)
.cloned()
.ok_or(StoreError::NotFound)
}
async fn save(&self, skill: &Skill, _root: &ProjectPath) -> Result<(), StoreError> {
let mut v = self.0.lock().unwrap();
if let Some(slot) = v.iter_mut().find(|s| s.scope == skill.scope && s.id == skill.id) {
*slot = skill.clone();
} else {
v.push(skill.clone());
}
Ok(())
}
async fn delete(
&self,
scope: SkillScope,
_root: &ProjectPath,
id: SkillId,
) -> Result<(), StoreError> {
let mut v = self.0.lock().unwrap();
let before = v.len();
v.retain(|s| !(s.scope == scope && s.id == id));
if v.len() == before {
return Err(StoreError::NotFound);
}
Ok(())
}
}
#[derive(Clone)]
struct FakeContexts(Arc<Mutex<AgentManifest>>);
impl FakeContexts {
fn new(entries: Vec<ManifestEntry>) -> Self {
Self(Arc::new(Mutex::new(AgentManifest { version: 1, entries })))
}
fn manifest(&self) -> AgentManifest {
self.0.lock().unwrap().clone()
}
}
#[async_trait]
impl AgentContextStore for FakeContexts {
async fn read_context(
&self,
_p: &Project,
_agent: &AgentId,
) -> Result<MarkdownDoc, StoreError> {
Err(StoreError::NotFound)
}
async fn write_context(
&self,
_p: &Project,
_agent: &AgentId,
_md: &MarkdownDoc,
) -> Result<(), StoreError> {
Ok(())
}
async fn load_manifest(&self, _p: &Project) -> Result<AgentManifest, StoreError> {
Ok(self.manifest())
}
async fn save_manifest(&self, _p: &Project, m: &AgentManifest) -> Result<(), StoreError> {
*self.0.lock().unwrap() = m.clone();
Ok(())
}
}
#[derive(Default, Clone)]
struct SpyBus(Arc<Mutex<Vec<DomainEvent>>>);
impl SpyBus {
fn events(&self) -> Vec<DomainEvent> {
self.0.lock().unwrap().clone()
}
}
impl EventBus for SpyBus {
fn publish(&self, event: DomainEvent) {
self.0.lock().unwrap().push(event);
}
fn subscribe(&self) -> EventStream {
Box::new(std::iter::empty())
}
}
struct SeqIds(Mutex<u128>);
impl SeqIds {
fn new() -> Self {
Self(Mutex::new(1))
}
}
impl IdGenerator for SeqIds {
fn new_uuid(&self) -> Uuid {
let mut n = self.0.lock().unwrap();
let id = Uuid::from_u128(*n);
*n += 1;
id
}
}
// ---------------------------------------------------------------------------
// Builders
// ---------------------------------------------------------------------------
fn pid(n: u128) -> ProfileId {
ProfileId::from_uuid(Uuid::from_u128(n))
}
fn aid(n: u128) -> AgentId {
AgentId::from_uuid(Uuid::from_u128(n))
}
fn sid(n: u128) -> SkillId {
SkillId::from_uuid(Uuid::from_u128(n))
}
fn root() -> ProjectPath {
ProjectPath::new("/home/me/demo").unwrap()
}
fn project() -> Project {
Project::new(
ProjectId::from_uuid(Uuid::from_u128(1000)),
"demo",
root(),
RemoteRef::local(),
1_700_000_000_000,
)
.unwrap()
}
fn scratch_entry(agent: AgentId) -> ManifestEntry {
ManifestEntry::new(agent, "A", "agents/a.md", pid(1), None, false, None).unwrap()
}
// ---------------------------------------------------------------------------
// CRUD
// ---------------------------------------------------------------------------
#[tokio::test]
async fn create_skill_persists_in_its_scope() {
let store = FakeSkills::default();
let out = CreateSkill::new(Arc::new(store.clone()), Arc::new(SeqIds::new()))
.execute(CreateSkillInput {
name: "refactor".to_owned(),
content: "# body".to_owned(),
scope: SkillScope::Project,
project_root: root(),
})
.await
.unwrap();
assert_eq!(out.skill.scope, SkillScope::Project);
assert_eq!(
store.list(SkillScope::Project, &root()).await.unwrap().len(),
1
);
assert!(store
.list(SkillScope::Global, &root())
.await
.unwrap()
.is_empty());
}
#[tokio::test]
async fn create_skill_rejects_empty_content() {
let store = FakeSkills::default();
let err = CreateSkill::new(Arc::new(store), Arc::new(SeqIds::new()))
.execute(CreateSkillInput {
name: "k".to_owned(),
content: String::new(),
scope: SkillScope::Global,
project_root: root(),
})
.await
.unwrap_err();
assert_eq!(err.code(), "INVALID");
}
#[tokio::test]
async fn update_skill_replaces_content() {
let store = FakeSkills::default();
store
.save(
&Skill::new(sid(1), "k", MarkdownDoc::new("v1"), SkillScope::Global).unwrap(),
&root(),
)
.await
.unwrap();
let out = UpdateSkill::new(Arc::new(store.clone()))
.execute(UpdateSkillInput {
scope: SkillScope::Global,
skill_id: sid(1),
content: "v2".to_owned(),
project_root: root(),
})
.await
.unwrap();
assert_eq!(out.skill.content_md.as_str(), "v2");
}
#[tokio::test]
async fn update_unknown_skill_is_not_found() {
let store = FakeSkills::default();
let err = UpdateSkill::new(Arc::new(store))
.execute(UpdateSkillInput {
scope: SkillScope::Global,
skill_id: sid(404),
content: "x".to_owned(),
project_root: root(),
})
.await
.unwrap_err();
assert_eq!(err.code(), "NOT_FOUND");
}
#[tokio::test]
async fn list_is_scope_filtered() {
let store = FakeSkills::default();
store
.save(
&Skill::new(sid(1), "g", MarkdownDoc::new("x"), SkillScope::Global).unwrap(),
&root(),
)
.await
.unwrap();
store
.save(
&Skill::new(sid(2), "p", MarkdownDoc::new("x"), SkillScope::Project).unwrap(),
&root(),
)
.await
.unwrap();
let listed = ListSkills::new(Arc::new(store))
.execute(ListSkillsInput {
scope: SkillScope::Global,
project_root: root(),
})
.await
.unwrap();
assert_eq!(listed.skills.len(), 1);
assert_eq!(listed.skills[0].id, sid(1));
}
#[tokio::test]
async fn delete_then_delete_is_not_found() {
let store = FakeSkills::default();
store
.save(
&Skill::new(sid(1), "k", MarkdownDoc::new("x"), SkillScope::Project).unwrap(),
&root(),
)
.await
.unwrap();
let uc = DeleteSkill::new(Arc::new(store));
uc.execute(DeleteSkillInput {
scope: SkillScope::Project,
skill_id: sid(1),
project_root: root(),
})
.await
.unwrap();
let err = uc
.execute(DeleteSkillInput {
scope: SkillScope::Project,
skill_id: sid(1),
project_root: root(),
})
.await
.unwrap_err();
assert_eq!(err.code(), "NOT_FOUND");
}
// ---------------------------------------------------------------------------
// Assignment
// ---------------------------------------------------------------------------
fn assigned_event(events: &[DomainEvent]) -> Vec<(SkillId, bool)> {
events
.iter()
.filter_map(|e| match e {
DomainEvent::SkillAssigned {
skill_id, assigned, ..
} => Some((*skill_id, *assigned)),
_ => None,
})
.collect()
}
#[tokio::test]
async fn assign_mutates_manifest_emits_event_and_is_idempotent() {
let contexts = FakeContexts::new(vec![scratch_entry(aid(1))]);
let bus = SpyBus::default();
let uc = AssignSkillToAgent::new(Arc::new(contexts.clone()), Arc::new(bus.clone()));
let input = AssignSkillToAgentInput {
project: project(),
agent_id: aid(1),
skill: SkillRef::new(sid(9), SkillScope::Global),
};
uc.execute(input.clone()).await.unwrap();
// Re-assigning the same skill is a no-op (dedup).
uc.execute(input).await.unwrap();
let entry = &contexts.manifest().entries[0];
assert_eq!(entry.skills.len(), 1, "no duplicate assignment");
assert_eq!(entry.skills[0].skill_id, sid(9));
assert_eq!(
assigned_event(&bus.events()),
vec![(sid(9), true)],
"exactly one assign event despite double execute"
);
}
#[tokio::test]
async fn unassign_removes_and_emits_false_then_is_noop() {
let mut entry = scratch_entry(aid(1));
entry.skills.push(SkillRef::new(sid(9), SkillScope::Global));
let contexts = FakeContexts::new(vec![entry]);
let bus = SpyBus::default();
let uc = UnassignSkillFromAgent::new(Arc::new(contexts.clone()), Arc::new(bus.clone()));
let input = UnassignSkillFromAgentInput {
project: project(),
agent_id: aid(1),
skill_id: sid(9),
};
uc.execute(input.clone()).await.unwrap();
uc.execute(input).await.unwrap(); // already gone → no-op
assert!(contexts.manifest().entries[0].skills.is_empty());
assert_eq!(assigned_event(&bus.events()), vec![(sid(9), false)]);
}
#[tokio::test]
async fn assign_to_unknown_agent_is_not_found() {
let contexts = FakeContexts::new(vec![]);
let bus = SpyBus::default();
let err = AssignSkillToAgent::new(Arc::new(contexts), Arc::new(bus))
.execute(AssignSkillToAgentInput {
project: project(),
agent_id: aid(404),
skill: SkillRef::new(sid(9), SkillScope::Global),
})
.await
.unwrap_err();
assert_eq!(err.code(), "NOT_FOUND");
}

View File

@ -34,6 +34,7 @@ use crate::markdown::MarkdownDoc;
use crate::profile::AgentProfile;
use crate::project::{Project, ProjectPath};
use crate::remote::RemoteKind;
use crate::skill::{Skill, SkillScope};
use crate::template::AgentTemplate;
use crate::terminal::PtySize;
@ -209,7 +210,7 @@ pub enum FsError {
}
/// Errors from persistence stores ([`TemplateStore`], [`ProjectStore`],
/// [`AgentContextStore`]).
/// [`AgentContextStore`], [`SkillStore`]).
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum StoreError {
/// The requested item was not found.
@ -460,6 +461,57 @@ pub trait TemplateStore: Send + Sync {
async fn delete(&self, id: crate::ids::TemplateId) -> Result<(), StoreError>;
}
/// CRUD for [`Skill`]s across both scopes ([`SkillScope::Global`] in the IDE
/// store, [`SkillScope::Project`] under `.ideai/skills/`), per ARCHITECTURE
/// §14.2 and L12.
///
/// The two scopes are **isolated**: a skill saved as `Project` never surfaces in
/// a `Global` listing and vice-versa. Each call carries the [`SkillScope`]
/// explicitly (or via the [`Skill`] for [`save`](Self::save)) so the adapter
/// resolves the right backing location.
///
/// `root` identifies the project whose `.ideai/skills/` to use for
/// [`SkillScope::Project`]; it is **ignored** for [`SkillScope::Global`] (which
/// lives in the machine-global IDE store). Passing the root per call — rather
/// than baking it into the adapter — keeps a single store instance correct
/// across every open project (mirroring [`AgentContextStore`]).
#[async_trait]
pub trait SkillStore: Send + Sync {
/// Lists all skills in `scope` (for `root`'s project when project-scoped).
///
/// # Errors
/// [`StoreError`] on failure.
async fn list(&self, scope: SkillScope, root: &ProjectPath) -> Result<Vec<Skill>, StoreError>;
/// Gets a skill by id within `scope`.
///
/// # Errors
/// [`StoreError::NotFound`] if absent in that scope.
async fn get(
&self,
scope: SkillScope,
root: &ProjectPath,
id: crate::ids::SkillId,
) -> Result<Skill, StoreError>;
/// Saves (creates or replaces by id) a skill in its own [`Skill::scope`].
///
/// # Errors
/// [`StoreError`] on failure.
async fn save(&self, skill: &Skill, root: &ProjectPath) -> Result<(), StoreError>;
/// Deletes a skill by id within `scope`.
///
/// # Errors
/// [`StoreError::NotFound`] if absent in that scope.
async fn delete(
&self,
scope: SkillScope,
root: &ProjectPath,
id: crate::ids::SkillId,
) -> Result<(), StoreError>;
}
/// Persistence of the known-projects registry and the workspace.
#[async_trait]
pub trait ProjectStore: Send + Sync {

View File

@ -32,4 +32,6 @@ pub use process::LocalProcessSpawner;
pub use pty::PortablePtyAdapter;
pub use remote::{remote_host, LocalHost};
pub use runtime::CliAgentRuntime;
pub use store::{FsProfileStore, FsProjectStore, FsTemplateStore, IdeaiContextStore};
pub use store::{
FsProfileStore, FsProjectStore, FsSkillStore, FsTemplateStore, IdeaiContextStore,
};

View File

@ -7,9 +7,11 @@
mod context;
mod profile;
mod project;
mod skill;
mod template;
pub use context::IdeaiContextStore;
pub use profile::FsProfileStore;
pub use project::FsProjectStore;
pub use skill::FsSkillStore;
pub use template::FsTemplateStore;

View File

@ -0,0 +1,263 @@
//! [`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,
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_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(),
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
}
}

View File

@ -0,0 +1,211 @@
//! L12 integration tests for [`FsSkillStore`] against a real temp directory and a
//! real [`LocalFileSystem`]: md + `index.json` round-trip in **both** scopes,
//! scope isolation (a `Project` skill never appears under `Global` and vice
//! versa), upsert, delete, tolerant reads, and the on-disk layout.
use std::path::PathBuf;
use std::sync::Arc;
use domain::ids::SkillId;
use domain::markdown::MarkdownDoc;
use domain::ports::{FileSystem, RemotePath, SkillStore, StoreError};
use domain::project::ProjectPath;
use domain::skill::{Skill, SkillScope};
use infrastructure::{FsSkillStore, LocalFileSystem};
use uuid::Uuid;
/// A unique scratch directory under the OS temp dir, cleaned up on drop. It plays
/// **both** roles: its own path is the global app-data dir, and a `project/`
/// child is the project root (so the two scopes resolve to disjoint subtrees).
struct TempDir(PathBuf);
impl TempDir {
fn new() -> Self {
let p = std::env::temp_dir().join(format!("idea-l12-skill-{}", Uuid::new_v4()));
std::fs::create_dir_all(&p).unwrap();
Self(p)
}
fn app_data_dir(&self) -> String {
self.0.to_string_lossy().into_owned()
}
fn project_root(&self) -> ProjectPath {
ProjectPath::new(self.0.join("project").to_string_lossy().into_owned()).unwrap()
}
fn child(&self, rel: &str) -> RemotePath {
RemotePath::new(self.0.join(rel).to_string_lossy().into_owned())
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn store(tmp: &TempDir) -> FsSkillStore {
let fs: Arc<dyn FileSystem> = Arc::new(LocalFileSystem::new());
FsSkillStore::new(fs, tmp.app_data_dir())
}
fn sid(n: u128) -> SkillId {
SkillId::from_uuid(Uuid::from_u128(n))
}
fn skill(id: SkillId, name: &str, content: &str, scope: SkillScope) -> Skill {
Skill::new(id, name, MarkdownDoc::new(content), scope).unwrap()
}
#[tokio::test]
async fn missing_index_lists_empty_in_both_scopes() {
let tmp = TempDir::new();
let s = store(&tmp);
let root = tmp.project_root();
assert!(s.list(SkillScope::Global, &root).await.unwrap().is_empty());
assert!(s.list(SkillScope::Project, &root).await.unwrap().is_empty());
}
#[tokio::test]
async fn global_roundtrip_save_get_list() {
let tmp = TempDir::new();
let s = store(&tmp);
let root = tmp.project_root();
let sk = skill(sid(1), "refactor", "# Refactor workflow", SkillScope::Global);
s.save(&sk, &root).await.unwrap();
assert_eq!(s.get(SkillScope::Global, &root, sid(1)).await.unwrap(), sk);
assert_eq!(
s.list(SkillScope::Global, &root).await.unwrap(),
vec![sk.clone()]
);
// The Markdown landed under the global skills dir.
let fs = LocalFileSystem::new();
let bytes = fs
.read(&tmp.child(&format!("skills/md/{}.md", sid(1))))
.await
.unwrap();
assert_eq!(String::from_utf8(bytes).unwrap(), sk.content_md.as_str());
}
#[tokio::test]
async fn project_roundtrip_lands_under_ideai() {
let tmp = TempDir::new();
let s = store(&tmp);
let root = tmp.project_root();
let sk = skill(sid(1), "review", "# Review workflow", SkillScope::Project);
s.save(&sk, &root).await.unwrap();
assert_eq!(s.get(SkillScope::Project, &root, sid(1)).await.unwrap(), sk);
let fs = LocalFileSystem::new();
let bytes = fs
.read(&tmp.child(&format!("project/.ideai/skills/md/{}.md", sid(1))))
.await
.unwrap();
assert_eq!(String::from_utf8(bytes).unwrap(), sk.content_md.as_str());
}
#[tokio::test]
async fn scopes_are_isolated() {
let tmp = TempDir::new();
let s = store(&tmp);
let root = tmp.project_root();
s.save(&skill(sid(1), "g", "global body", SkillScope::Global), &root)
.await
.unwrap();
s.save(
&skill(sid(2), "p", "project body", SkillScope::Project),
&root,
)
.await
.unwrap();
// A global skill never surfaces in the project scope and vice-versa.
let globals = s.list(SkillScope::Global, &root).await.unwrap();
let projects = s.list(SkillScope::Project, &root).await.unwrap();
assert_eq!(globals.len(), 1);
assert_eq!(projects.len(), 1);
assert_eq!(globals[0].id, sid(1));
assert_eq!(projects[0].id, sid(2));
assert!(matches!(
s.get(SkillScope::Global, &root, sid(2)).await.unwrap_err(),
StoreError::NotFound
));
assert!(matches!(
s.get(SkillScope::Project, &root, sid(1)).await.unwrap_err(),
StoreError::NotFound
));
}
#[tokio::test]
async fn save_upserts_content() {
let tmp = TempDir::new();
let s = store(&tmp);
let root = tmp.project_root();
s.save(&skill(sid(1), "k", "v1", SkillScope::Global), &root)
.await
.unwrap();
s.save(&skill(sid(1), "k", "v2", SkillScope::Global), &root)
.await
.unwrap();
assert_eq!(
s.get(SkillScope::Global, &root, sid(1))
.await
.unwrap()
.content_md
.as_str(),
"v2"
);
assert_eq!(
s.list(SkillScope::Global, &root).await.unwrap().len(),
1,
"upsert, not append"
);
}
#[tokio::test]
async fn delete_removes_from_index_and_is_not_found_twice() {
let tmp = TempDir::new();
let s = store(&tmp);
let root = tmp.project_root();
s.save(&skill(sid(1), "k", "x", SkillScope::Project), &root)
.await
.unwrap();
s.delete(SkillScope::Project, &root, sid(1)).await.unwrap();
assert!(s.list(SkillScope::Project, &root).await.unwrap().is_empty());
assert!(matches!(
s.delete(SkillScope::Project, &root, sid(1))
.await
.unwrap_err(),
StoreError::NotFound
));
}
#[tokio::test]
async fn index_is_camelcase_with_content_hash() {
let tmp = TempDir::new();
let s = store(&tmp);
let root = tmp.project_root();
s.save(&skill(sid(1), "refactor", "hello", SkillScope::Global), &root)
.await
.unwrap();
let fs = LocalFileSystem::new();
let bytes = fs.read(&tmp.child("skills/index.json")).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let entry = &json.get("skills").unwrap().as_array().unwrap()[0];
assert_eq!(entry.get("name").and_then(|v| v.as_str()), Some("refactor"));
assert!(
entry.get("contentHash").is_some(),
"camelCase contentHash present"
);
assert!(
entry.get("content_hash").is_none(),
"no snake_case leak"
);
}