nomoreide_core/agent_profiles/
mod.rs1mod 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#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
34#[serde(rename_all = "camelCase")]
35pub struct Profile {
36 pub name: String,
37 #[serde(skip_serializing_if = "Option::is_none")]
39 pub description: Option<String>,
40 #[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#[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 #[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 pub updated_at: String,
69 #[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
93fn 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
105pub(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
125pub fn list() -> Result<Vec<ProfileSummary>, String> {
127 let mut summaries = store::summaries()?;
128 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
140pub 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
164pub 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
191fn 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
240pub 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 copied_plugins: taken_plugins.iter().map(apply::plugin_id).collect(),
284 })
285}
286
287fn 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
310fn 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}