1use std::collections::HashMap;
11
12use chrono::Utc;
13use serde_json::{json, Value};
14
15use crate::backend::storage::store::AgentSkill;
16
17#[derive(Debug, Clone)]
19pub struct AgentConfigFile {
20 pub filename: String,
22 pub content: String,
24}
25
26pub fn build_config_files(
36 content_map: &HashMap<String, String>,
37 skills: &[AgentSkill],
38 agent_name: &str,
39 agent_id: &str,
40 agent_slug: &str,
41) -> Vec<AgentConfigFile> {
42 let mut files: Vec<AgentConfigFile> = Vec::new();
43
44 let mut template_vars: HashMap<String, String> = HashMap::new();
46 template_vars.insert("AGENT".to_string(), agent_name.to_string());
47 template_vars.insert("AGENT_DISPLAY".to_string(), agent_name.to_string());
48 template_vars.insert("AGENT_ID".to_string(), agent_id.to_string());
49 template_vars.insert("DATE".to_string(), Utc::now().format("%Y-%m-%d").to_string());
51 let mut claude_md_parts: Vec<String> = Vec::new();
58
59 if let Some(soul) = content_map.get("soul") {
60 claude_md_parts.push(expand_template(soul, &template_vars));
61 }
62 if let Some(agentmd) = content_map.get("agentmd") {
63 if !claude_md_parts.is_empty() {
64 claude_md_parts.push("\n---\n".to_string());
65 }
66 claude_md_parts.push(expand_template(agentmd, &template_vars));
67 }
68 if let Some(memory) = content_map.get("memory") {
69 claude_md_parts.push("\n# Memory\n".to_string());
70 claude_md_parts.push(memory.clone());
71 }
72
73 if !skills.is_empty() {
75 claude_md_parts.push("\n# Available Skills\n\n".to_string());
76 claude_md_parts.push("Use `/<trigger>` to invoke a skill.\n\n".to_string());
77 for skill in skills {
78 let trigger_part = if skill.trigger.is_empty() {
79 String::new()
80 } else {
81 format!(" (trigger: /{})", skill.trigger)
82 };
83 let desc_part = if skill.description.is_empty() {
84 String::new()
85 } else {
86 format!(" \u{2014} {}", skill.description)
87 };
88 claude_md_parts.push(format!("- **{}**{}{}\n", skill.name, trigger_part, desc_part));
89 }
90 }
91
92 if !claude_md_parts.is_empty() {
93 files.push(AgentConfigFile {
94 filename: "CLAUDE.md".to_string(),
95 content: claude_md_parts.join(""),
96 });
97 }
98
99 for skill in skills {
103 if !skill.trigger.is_empty() && !skill.content.is_empty() {
104 let content = expand_template(&skill.content, &template_vars);
105 files.push(AgentConfigFile {
106 filename: format!(".claude/commands/{}.md", skill.trigger),
107 content,
108 });
109 }
110 }
111
112 let user_hooks = content_map.get("hooks").map(|s| s.as_str());
123 let user_settings = content_map.get("settings").map(|s| s.as_str());
124 if let Some(settings_json) = build_settings_with_hooks(user_settings, user_hooks) {
125 files.push(AgentConfigFile {
126 filename: ".claude/settings.json".to_string(),
127 content: settings_json,
128 });
129 }
130
131 let mcp_content = content_map.get("mcp").map(|s| s.as_str());
138 if let Some(mcp_json) = build_mcp_config(mcp_content, agent_slug, "") {
139 files.push(AgentConfigFile {
140 filename: ".mcp.json".to_string(),
141 content: mcp_json,
142 });
143 }
144
145 files
146}
147
148pub fn build_config_files_with_bus(
154 content_map: &HashMap<String, String>,
155 skills: &[AgentSkill],
156 agent_name: &str,
157 agent_id: &str,
158 agent_bus_id: &str,
159 working_directory: &str,
160 agent_slug: &str,
161) -> Vec<AgentConfigFile> {
162 let mut files: Vec<AgentConfigFile> = Vec::new();
163
164 let mut template_vars: HashMap<String, String> = HashMap::new();
165 template_vars.insert("AGENT".to_string(), agent_name.to_string());
166 template_vars.insert("AGENT_DISPLAY".to_string(), agent_name.to_string());
167 template_vars.insert("AGENT_ID".to_string(), agent_id.to_string());
168 template_vars.insert("WORKING_DIR".to_string(), working_directory.to_string());
169 template_vars.insert("DATE".to_string(), Utc::now().format("%Y-%m-%d").to_string());
170
171 let mut claude_md_parts: Vec<String> = Vec::new();
173 if let Some(soul) = content_map.get("soul") {
174 claude_md_parts.push(expand_template(soul, &template_vars));
175 }
176 if let Some(agentmd) = content_map.get("agentmd") {
177 if !claude_md_parts.is_empty() {
178 claude_md_parts.push("\n---\n".to_string());
179 }
180 claude_md_parts.push(expand_template(agentmd, &template_vars));
181 }
182 if let Some(memory) = content_map.get("memory") {
183 claude_md_parts.push("\n# Memory\n".to_string());
184 claude_md_parts.push(memory.clone());
185 }
186 if !skills.is_empty() {
187 claude_md_parts.push("\n# Available Skills\n\n".to_string());
188 claude_md_parts.push("Use `/<trigger>` to invoke a skill.\n\n".to_string());
189 for skill in skills {
190 let trigger_part = if skill.trigger.is_empty() {
191 String::new()
192 } else {
193 format!(" (trigger: /{})", skill.trigger)
194 };
195 let desc_part = if skill.description.is_empty() {
196 String::new()
197 } else {
198 format!(" \u{2014} {}", skill.description)
199 };
200 claude_md_parts.push(format!("- **{}**{}{}\n", skill.name, trigger_part, desc_part));
201 }
202 }
203 if !claude_md_parts.is_empty() {
204 files.push(AgentConfigFile {
205 filename: "CLAUDE.md".to_string(),
206 content: claude_md_parts.join(""),
207 });
208 }
209
210 for skill in skills {
212 if !skill.trigger.is_empty() && !skill.content.is_empty() {
213 let content = expand_template(&skill.content, &template_vars);
214 files.push(AgentConfigFile {
215 filename: format!(".claude/commands/{}.md", skill.trigger),
216 content,
217 });
218 }
219 }
220
221 if let Some(hooks) = content_map.get("hooks") {
223 files.push(AgentConfigFile {
224 filename: ".claude/hooks.json".to_string(),
225 content: hooks.clone(),
226 });
227 }
228
229 let mcp_content = content_map.get("mcp").map(|s| s.as_str());
231 if let Some(mcp_json) = build_mcp_config(mcp_content, agent_slug, agent_bus_id) {
232 files.push(AgentConfigFile {
233 filename: ".mcp.json".to_string(),
234 content: mcp_json,
235 });
236 }
237
238 files
239}
240
241pub fn build_mcp_config(
253 user_mcp_content: Option<&str>,
254 agent_slug: &str,
255 agent_bus_id: &str,
256) -> Option<String> {
257 let mut env_map = serde_json::Map::new();
262 if !agent_slug.is_empty() {
263 env_map.insert("AGENTMUX_AGENT_ID".to_string(), json!(agent_slug));
264 }
265 if !agent_bus_id.is_empty() {
266 env_map.insert("AGENTMUX_AGENT_BUS_ID".to_string(), json!(agent_bus_id));
267 }
268
269 let agentmux_server = json!({
270 "type": "stdio",
271 "command": "agentmux-mcp",
272 "args": [],
273 "env": Value::Object(env_map),
274 });
275
276 let mut mcp_servers = serde_json::Map::new();
277 mcp_servers.insert("agentmux".to_string(), agentmux_server);
278
279 if let Some(raw) = user_mcp_content {
281 match serde_json::from_str::<Value>(raw) {
282 Ok(Value::Object(user_obj)) => {
283 if let Some(Value::Object(user_servers)) = user_obj.get("mcpServers") {
284 for (k, v) in user_servers {
285 mcp_servers.insert(k.clone(), v.clone());
286 }
287 }
288 }
289 Ok(_) => {
290 }
292 Err(_) => {
293 tracing::error!("agent_config: invalid MCP JSON in agent content, using auto-injected only");
295 }
296 }
297 }
298
299 let result = json!({ "mcpServers": Value::Object(mcp_servers) });
300 match serde_json::to_string_pretty(&result) {
301 Ok(s) => Some(s),
302 Err(e) => {
303 tracing::error!("agent_config: failed to serialize MCP config: {e}");
304 None
305 }
306 }
307}
308
309pub fn build_settings_with_hooks(
327 user_settings_content: Option<&str>,
328 user_hooks_content: Option<&str>,
329) -> Option<String> {
330 use serde_json::Value;
331 let agentmux_pretooluse = json!({
332 "matcher": "^(Bash|.*[Bb]ash.*)$",
333 "hooks": [
334 {
335 "type": "command",
336 "command": "agentmux-bashwrap hook"
337 }
338 ]
339 });
340 let mut hooks_obj = serde_json::Map::new();
341 let mut pretooluse_entries: Vec<Value> = Vec::new();
342
343 if let Some(raw) = user_hooks_content {
348 match serde_json::from_str::<Value>(raw) {
349 Ok(Value::Object(user_obj)) => {
350 for (k, v) in user_obj {
351 if k == "PreToolUse" {
352 if let Value::Array(arr) = v {
353 pretooluse_entries.extend(arr);
354 } else {
355 tracing::warn!(
356 "agent_config: user hooks.PreToolUse is not an array; dropped"
357 );
358 }
359 } else {
360 hooks_obj.insert(k, v);
361 }
362 }
363 }
364 Ok(other) => {
365 tracing::warn!(
366 kind = ?other,
367 "agent_config: user hooks top-level value is not an object; dropped"
368 );
369 }
370 Err(e) => {
371 tracing::warn!(
372 error = %e,
373 "agent_config: failed to parse user hooks JSON; dropped"
374 );
375 }
376 }
377 }
378 pretooluse_entries.push(agentmux_pretooluse);
381 hooks_obj.insert("PreToolUse".to_string(), Value::Array(pretooluse_entries));
382
383 let mut settings_obj = serde_json::Map::new();
387 if let Some(raw) = user_settings_content {
388 match serde_json::from_str::<Value>(raw) {
389 Ok(Value::Object(user_obj)) => {
390 for (k, v) in user_obj {
391 settings_obj.insert(k, v);
392 }
393 }
394 Ok(_other) => {
395 tracing::warn!(
396 "agent_config: user settings.json top-level is not an object; dropped"
397 );
398 }
399 Err(e) => {
400 tracing::warn!(
401 error = %e,
402 "agent_config: failed to parse user settings.json; dropped"
403 );
404 }
405 }
406 }
407 if let Some(Value::Object(existing_hooks)) = settings_obj.get("hooks").cloned() {
416 for (k, v) in existing_hooks {
417 if k == "PreToolUse" {
418 if let Value::Array(user_pretooluse) = v {
419 if let Some(Value::Array(ours)) = hooks_obj.remove("PreToolUse") {
422 let mut merged = user_pretooluse;
423 merged.extend(ours);
424 hooks_obj.insert("PreToolUse".to_string(), Value::Array(merged));
425 } else {
426 hooks_obj.insert("PreToolUse".to_string(), Value::Array(user_pretooluse));
427 }
428 } else {
429 tracing::warn!(
430 "agent_config: user settings.hooks.PreToolUse is not an array; dropped"
431 );
432 }
433 continue;
434 }
435 hooks_obj.entry(k).or_insert(v);
436 }
437 }
438 settings_obj.insert("hooks".to_string(), Value::Object(hooks_obj));
439
440 {
445 let bashwrap_allow = Value::String("Bash(agentmux-bashwrap *)".to_string());
449 let mut allow_arr = match settings_obj.get("permissions") {
450 Some(Value::Object(perms)) => match perms.get("allow") {
451 Some(Value::Array(arr)) => arr.clone(),
452 _ => Vec::new(),
453 },
454 _ => Vec::new(),
455 };
456 if !allow_arr.iter().any(|v| v == &bashwrap_allow) {
457 allow_arr.push(bashwrap_allow);
458 }
459 let mut perms_obj = match settings_obj.remove("permissions") {
460 Some(Value::Object(obj)) => obj,
461 _ => serde_json::Map::new(),
462 };
463 perms_obj.insert("allow".to_string(), Value::Array(allow_arr));
464 settings_obj.insert("permissions".to_string(), Value::Object(perms_obj));
465 }
466
467 match serde_json::to_string_pretty(&Value::Object(settings_obj)) {
468 Ok(s) => Some(s),
469 Err(e) => {
470 tracing::error!("agent_config: failed to serialize settings.json: {e}");
471 None
472 }
473 }
474}
475
476pub fn expand_template(content: &str, vars: &HashMap<String, String>) -> String {
483 let mut result = String::with_capacity(content.len());
486 let bytes = content.as_bytes();
487 let len = bytes.len();
488 let mut i = 0;
489
490 while i < len {
491 if i + 1 < len && bytes[i] == b'{' && bytes[i + 1] == b'{' {
493 if let Some(rel) = content[i + 2..].find("}}") {
495 let key = &content[i + 2..i + 2 + rel];
496 if key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') {
498 if let Some(val) = vars.get(key) {
499 result.push_str(val);
500 } else {
501 result.push_str(&content[i..i + 2 + rel + 2]);
503 }
504 i += 2 + rel + 2; continue;
506 }
507 }
508 }
509 let ch = content[i..].chars().next().unwrap();
514 result.push(ch);
515 i += ch.len_utf8();
516 }
517
518 result
519}
520
521#[cfg(test)]
526mod tests {
527 use super::*;
528
529 fn make_skill(name: &str, trigger: &str, description: &str, content: &str) -> AgentSkill {
530 AgentSkill {
531 id: format!("skill-{}", trigger),
532 agent_id: "agent-1".to_string(),
533 name: name.to_string(),
534 trigger: trigger.to_string(),
535 skill_type: "prompt".to_string(),
536 description: description.to_string(),
537 content: content.to_string(),
538 created_at: 0,
539 }
540 }
541
542 #[test]
543 fn test_expand_template_basic() {
544 let mut vars = HashMap::new();
545 vars.insert("AGENT".to_string(), "Aria".to_string());
546 vars.insert("DATE".to_string(), "2026-04-10".to_string());
547
548 let out = expand_template("Hello {{AGENT}}, today is {{DATE}}.", &vars);
549 assert_eq!(out, "Hello Aria, today is 2026-04-10.");
550 }
551
552 #[test]
553 fn test_expand_template_unknown_placeholder_preserved() {
554 let vars = HashMap::new();
555 let out = expand_template("Value: {{UNKNOWN}}", &vars);
556 assert_eq!(out, "Value: {{UNKNOWN}}");
557 }
558
559 #[test]
560 fn test_expand_template_empty_vars() {
561 let vars = HashMap::new();
562 let out = expand_template("No placeholders here.", &vars);
563 assert_eq!(out, "No placeholders here.");
564 }
565
566 #[test]
567 fn test_build_mcp_config_no_user_content() {
568 let result = build_mcp_config(None, "aria", "bus-42").unwrap();
569 let parsed: Value = serde_json::from_str(&result).unwrap();
570 let servers = &parsed["mcpServers"];
571 assert!(servers["agentmux"].is_object());
572 assert_eq!(servers["agentmux"]["command"], "agentmux-mcp");
573 assert_eq!(servers["agentmux"]["env"]["AGENTMUX_AGENT_ID"], "aria");
574 assert_eq!(servers["agentmux"]["env"]["AGENTMUX_AGENT_BUS_ID"], "bus-42");
575 }
576
577 #[test]
578 fn test_build_mcp_config_merges_user_servers() {
579 let user_mcp = r#"{"mcpServers": {"mytool": {"type": "stdio", "command": "mytool"}}}"#;
580 let result = build_mcp_config(Some(user_mcp), "aria", "").unwrap();
581 let parsed: Value = serde_json::from_str(&result).unwrap();
582 let servers = &parsed["mcpServers"];
583 assert!(servers["agentmux"].is_object());
584 assert!(servers["mytool"].is_object());
585 }
586
587 #[test]
588 fn test_build_mcp_config_invalid_user_json_uses_auto_injected() {
589 let result = build_mcp_config(Some("not json {{"), "aria", "").unwrap();
590 let parsed: Value = serde_json::from_str(&result).unwrap();
591 assert!(parsed["mcpServers"]["agentmux"].is_object());
592 }
593
594 #[test]
595 fn test_build_config_files_claude_md_assembled() {
596 let mut content_map = HashMap::new();
597 content_map.insert("soul".to_string(), "You are {{AGENT}}.".to_string());
598 content_map.insert("agentmd".to_string(), "## Instructions\nDo stuff.".to_string());
599
600 let files = build_config_files(&content_map, &[], "Aria", "agent-1", "aria");
601 let claude_md = files.iter().find(|f| f.filename == "CLAUDE.md").unwrap();
602 assert!(claude_md.content.contains("You are Aria."));
603 assert!(claude_md.content.contains("---"));
604 assert!(claude_md.content.contains("## Instructions"));
605 }
606
607 #[test]
608 fn test_build_config_files_skills_index_and_commands() {
609 let content_map = HashMap::new();
610 let skills = vec![
611 make_skill("Deploy", "deploy", "Deploy the app", "Run: deploy all"),
612 make_skill("Test", "test", "Run tests", "Run: test suite"),
613 ];
614
615 let files = build_config_files(&content_map, &skills, "Aria", "agent-1", "aria");
616
617 let claude_md = files.iter().find(|f| f.filename == "CLAUDE.md").unwrap();
619 assert!(claude_md.content.contains("Available Skills"));
620 assert!(claude_md.content.contains("/deploy"));
621 assert!(claude_md.content.contains("/test"));
622
623 assert!(files.iter().any(|f| f.filename == ".claude/commands/deploy.md"));
625 assert!(files.iter().any(|f| f.filename == ".claude/commands/test.md"));
626 }
627
628 #[test]
629 fn test_build_config_files_settings_merges_user_hooks() {
630 let mut content_map = HashMap::new();
637 content_map.insert(
638 "hooks".to_string(),
639 r#"{"PreToolUse":[{"matcher":"Read","hooks":[{"type":"command","command":"my-audit"}]}]}"#
640 .to_string(),
641 );
642 let files = build_config_files(&content_map, &[], "Aria", "agent-1", "aria");
643 let settings = files
644 .iter()
645 .find(|f| f.filename == ".claude/settings.json")
646 .expect("settings.json emitted");
647 let parsed: Value = serde_json::from_str(&settings.content).unwrap();
648 let pre_tool_use = parsed["hooks"]["PreToolUse"]
649 .as_array()
650 .expect("PreToolUse is an array");
651 assert!(
653 pre_tool_use
654 .iter()
655 .any(|e| e["matcher"].as_str() == Some("Read")),
656 "user-supplied PreToolUse:Read must survive the merge"
657 );
658 assert!(
659 pre_tool_use
660 .iter()
661 .any(|e| e["matcher"].as_str().unwrap_or("").contains("Bash")),
662 "auto-injected PreToolUse:Bash must still be present"
663 );
664 }
665
666 #[test]
667 fn test_build_config_files_mcp_written() {
668 let content_map = HashMap::new();
669 let files = build_config_files(&content_map, &[], "Aria", "agent-1", "aria");
670 let mcp = files.iter().find(|f| f.filename == ".mcp.json").unwrap();
671 let parsed: Value = serde_json::from_str(&mcp.content).unwrap();
672 assert!(parsed["mcpServers"]["agentmux"].is_object());
673 }
674}