Skip to main content

nomoreide_core/agent_profiles/
mod.rs

1//! Saved bundles of MCP servers, skills, and plugins.
2//!
3//! A profile is a directory under the user's config holding a `profile.json`,
4//! and this module is the CRUD over that tree. What a profile *contains* is
5//! deliberately kept as documents rather than as a typed model of every field:
6//! the tool layer has already validated the shape a caller sent, and the store
7//! only has to hand back exactly what it was given. Modelling it twice would
8//! give the two layers a chance to disagree.
9
10mod apply;
11mod credentials;
12pub mod debug_setup;
13pub mod publication;
14mod registry;
15mod snapshot;
16mod store;
17mod transfer;
18
19use crate::agent_env::{Json, OrderedMap};
20use serde::{Deserialize, Serialize};
21
22pub use apply::{apply, Applied, ApplyOutcome, ApplyPreview};
23pub use credentials::Credential;
24pub use registry::{
25    auth, config as registry_config, install, list_public_profiles, publish, register_github,
26    InstallOutcome, PublishOutcome, PublishRequest,
27};
28pub use snapshot::{refresh, snapshot};
29pub use store::profiles_root;
30pub use transfer::{export, import, ExportOutcome, ImportOutcome};
31
32/// One saved profile, as its own file holds it.
33#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
34#[serde(rename_all = "camelCase")]
35pub struct Profile {
36    pub name: String,
37    /// Absent rather than empty when the profile was created without one.
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub description: Option<String>,
40    /// Which agent a snapshot was taken from. Absent on a profile that was
41    /// built by hand rather than captured.
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub source_agent: Option<String>,
44    #[serde(default)]
45    pub mcps: OrderedMap<Json>,
46    #[serde(default)]
47    pub skills: Vec<Json>,
48    #[serde(default)]
49    pub plugins: Vec<Json>,
50}
51
52/// A profile as a listing shows it: what it holds, not what is in it.
53#[derive(Debug, Clone, Serialize, PartialEq)]
54#[serde(rename_all = "camelCase")]
55pub struct ProfileSummary {
56    pub name: String,
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub description: Option<String>,
59    /// Carried into the listing, not only into the profile: which agent a
60    /// bundle came from is how anyone tells two snapshots apart.
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub source_agent: Option<String>,
63    pub mcp_count: usize,
64    pub skill_count: usize,
65    pub plugin_count: usize,
66    /// When the profile was last written. Read from the file rather than
67    /// stored in it, so an edit made by any means keeps it honest.
68    pub updated_at: String,
69    /// Where this profile came from, when it came from the registry.
70    ///
71    /// Absent for a profile made here. Serialised after `updatedAt` because
72    /// the reference spreads it in at that point.
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub registry: Option<serde_json::Value>,
75}
76
77#[derive(Debug, Clone, Serialize, PartialEq)]
78#[serde(rename_all = "camelCase")]
79pub struct DeleteOutcome {
80    pub ok: bool,
81    pub deleted: String,
82}
83
84#[derive(Debug, Clone, Serialize, PartialEq)]
85#[serde(rename_all = "camelCase")]
86pub struct CopyOutcome {
87    pub ok: bool,
88    pub copied_mcps: Vec<String>,
89    pub copied_skills: Vec<String>,
90    pub copied_plugins: Vec<String>,
91}
92
93/// What a profile may be called.
94///
95/// The name is also a directory name, so this is a safety boundary as much as
96/// a validation rule: anything that could climb out of the profile root — a
97/// slash, a leading dot-dot — is refused here rather than sanitised later.
98fn valid_name(name: &str) -> bool {
99    let mut characters = name.chars();
100    let first = characters.next();
101    first.is_some_and(|c| c.is_ascii_alphanumeric())
102        && characters.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
103}
104
105/// The name a caller supplied, trimmed, if it is one this store will hold.
106///
107/// The padding is taken off *before* the check and the trimmed name is what is
108/// used from then on — so `"  x  "` is the profile `x`, and a name that is only
109/// spaces is refused as the empty string it trims down to rather than as the
110/// spaces it arrived as.
111pub(super) fn check_name(name: &str) -> Result<String, String> {
112    let trimmed = name.trim();
113    if valid_name(trimmed) {
114        return Ok(trimmed.to_string());
115    }
116    Err(format!(
117        "Invalid profile name \"{trimmed}\". Use letters, numbers, \".\", \"_\", or \"-\"."
118    ))
119}
120
121fn not_found(name: &str) -> String {
122    format!("Profile \"{name}\" not found.")
123}
124
125/// Every profile, newest first.
126pub fn list() -> Result<Vec<ProfileSummary>, String> {
127    let mut summaries = store::summaries()?;
128    // Most recently written first, so a listing leads with what the user was
129    // last working on. Ties break by name, because two profiles written in the
130    // same millisecond would otherwise come back in directory order.
131    summaries.sort_by(|left, right| {
132        right
133            .1
134            .cmp(&left.1)
135            .then_with(|| left.0.name.cmp(&right.0.name))
136    });
137    Ok(summaries.into_iter().map(|(summary, _)| summary).collect())
138}
139
140/// Read a profile back.
141///
142/// The *asked-for* name is not checked, only the stored one. A profile that
143/// only [`import`] could have created — its name is whatever the archive or
144/// the `as` argument said — is still found, and is refused for what is in it
145/// rather than for what it was called.
146pub fn get(name: &str) -> Result<Profile, String> {
147    store::load(name)?.ok_or_else(|| not_found(name))
148}
149
150pub fn create(name: &str, description: Option<&str>) -> Result<Profile, String> {
151    let name = check_name(name)?;
152    if store::exists(&name) {
153        return Err(format!("Profile \"{name}\" already exists."));
154    }
155    let profile = Profile {
156        name: name.clone(),
157        description: description.map(str::to_string),
158        ..Profile::default()
159    };
160    store::save(&profile)?;
161    Ok(profile)
162}
163
164/// Change only what was named. Every field is optional, and one left out is
165/// left alone rather than cleared — an update that sends only a description
166/// must not empty the profile.
167pub fn update(
168    name: &str,
169    description: Option<&str>,
170    mcps: Option<OrderedMap<Json>>,
171    skills: Option<Vec<Json>>,
172    plugins: Option<Vec<Json>>,
173) -> Result<Profile, String> {
174    let mut profile = get(name)?;
175    if let Some(description) = description {
176        profile.description = Some(description.to_string());
177    }
178    if let Some(mcps) = mcps {
179        profile.mcps = canonical_servers(&mcps);
180    }
181    if let Some(skills) = skills {
182        profile.skills = skills;
183    }
184    if let Some(plugins) = plugins {
185        profile.plugins = plugins;
186    }
187    store::save(&profile)?;
188    Ok(profile)
189}
190
191/// Every server rebuilt in the shape a profile stores.
192///
193/// A caller's own field order does not survive, and neither do fields the
194/// other kind of server uses — a `remote` sent with a `command` is stored
195/// without one. Two profiles holding the same servers are then the same file,
196/// however the callers that built them happened to spell it.
197fn canonical_servers(mcps: &OrderedMap<Json>) -> OrderedMap<Json> {
198    let mut out = OrderedMap::new();
199    for (name, entry) in mcps.iter() {
200        out.insert(name.to_string(), canonical_server(entry));
201    }
202    out
203}
204
205fn canonical_server(entry: &Json) -> Json {
206    let Some(fields) = entry.as_object() else {
207        return entry.clone();
208    };
209    let mut server = OrderedMap::new();
210    let carry = |server: &mut OrderedMap<Json>, key: &str| {
211        if let Some(value) = fields.get(key) {
212            server.insert(key.to_string(), value.clone());
213        }
214    };
215    let remote = matches!(fields.get("kind"), Some(Json::String(kind)) if kind == "remote");
216    carry(&mut server, "kind");
217    if remote {
218        for key in ["transport", "url", "headers", "env"] {
219            carry(&mut server, key);
220        }
221    } else {
222        for key in ["command", "args", "env"] {
223            carry(&mut server, key);
224        }
225    }
226    Json::Object(server)
227}
228
229pub fn delete(name: &str) -> Result<DeleteOutcome, String> {
230    if !store::exists(name) {
231        return Err(not_found(name));
232    }
233    store::remove(name)?;
234    Ok(DeleteOutcome {
235        ok: true,
236        deleted: name.to_string(),
237    })
238}
239
240/// Copy named items from one profile into another.
241///
242/// All or nothing: every item is looked up before anything is written, so a
243/// request naming one item that does not exist leaves the target untouched
244/// rather than half-copied.
245pub fn copy_items(
246    from: &str,
247    to: &str,
248    mcps: &[String],
249    skills: &[String],
250    plugins: &[String],
251) -> Result<CopyOutcome, String> {
252    let source = get(from)?;
253    let mut target = get(to)?;
254
255    let taken_mcps = mcps
256        .iter()
257        .map(|key| {
258            source
259                .mcps
260                .get(key)
261                .cloned()
262                .map(|entry| (key.clone(), entry))
263                .ok_or_else(|| format!("MCP \"{key}\" not found in profile \"{from}\"."))
264        })
265        .collect::<Result<Vec<_>, _>>()?;
266    let taken_skills = pick(&source.skills, skills, "Skill", from)?;
267    let taken_plugins = pick(&source.plugins, plugins, "Plugin", from)?;
268
269    for (key, entry) in &taken_mcps {
270        target.mcps.set(key.clone(), entry.clone());
271    }
272    replace_by_name(&mut target.skills, &taken_skills);
273    replace_by_name(&mut target.plugins, &taken_plugins);
274    store::save(&target)?;
275
276    Ok(CopyOutcome {
277        ok: true,
278        copied_mcps: taken_mcps.into_iter().map(|(key, _)| key).collect(),
279        copied_skills: skills.to_vec(),
280        // A plugin is reported by its id rather than its name — the name alone
281        // does not say which agent it came from, and two agents may each have
282        // one called the same thing.
283        copied_plugins: taken_plugins.iter().map(apply::plugin_id).collect(),
284    })
285}
286
287/// The named entries of a list, in the order they were asked for.
288fn pick(
289    available: &[Json],
290    wanted: &[String],
291    label: &str,
292    from: &str,
293) -> Result<Vec<Json>, String> {
294    wanted
295        .iter()
296        .map(|name| {
297            available
298                .iter()
299                .find(|entry| entry_name(entry) == Some(name.as_str()))
300                .cloned()
301                .ok_or_else(|| format!("{label} \"{name}\" not found in profile \"{from}\"."))
302        })
303        .collect()
304}
305
306fn entry_name(entry: &Json) -> Option<&str> {
307    entry.as_object()?.get("name")?.as_str()
308}
309
310/// Add each entry, replacing one of the same name rather than duplicating it.
311fn replace_by_name(target: &mut Vec<Json>, incoming: &[Json]) {
312    for entry in incoming {
313        match target
314            .iter_mut()
315            .find(|existing| entry_name(existing) == entry_name(entry))
316        {
317            Some(existing) => *existing = entry.clone(),
318            None => target.push(entry.clone()),
319        }
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    #[test]
328    fn a_name_may_not_climb_out_of_the_profile_root() {
329        for refused in [
330            "../escape",
331            "a/b",
332            "-nope",
333            ".hidden",
334            "",
335            "with space",
336            "a\\\\b",
337        ] {
338            assert!(!valid_name(refused), "{refused} should be refused");
339        }
340        for accepted in ["alpha", "a", "A1", "a.b_c-d", "9lives"] {
341            assert!(valid_name(accepted), "{accepted} should be accepted");
342        }
343    }
344
345    #[test]
346    fn the_refusal_names_what_was_wrong_with_it() {
347        assert_eq!(
348            check_name("../escape").unwrap_err(),
349            "Invalid profile name \"../escape\". Use letters, numbers, \".\", \"_\", or \"-\"."
350        );
351    }
352}