1use std::collections::HashMap;
11
12use chrono::Utc;
13use serde_json::{json, Value};
14
15use crate::backend::storage::wstore::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) -> Vec<AgentConfigFile> {
41 let mut files: Vec<AgentConfigFile> = Vec::new();
42
43 let mut template_vars: HashMap<String, String> = HashMap::new();
45 template_vars.insert("AGENT".to_string(), agent_name.to_string());
46 template_vars.insert("AGENT_DISPLAY".to_string(), agent_name.to_string());
47 template_vars.insert("AGENT_ID".to_string(), agent_id.to_string());
48 template_vars.insert("DATE".to_string(), Utc::now().format("%Y-%m-%d").to_string());
50 let mut claude_md_parts: Vec<String> = Vec::new();
57
58 if let Some(soul) = content_map.get("soul") {
59 claude_md_parts.push(expand_template(soul, &template_vars));
60 }
61 if let Some(agentmd) = content_map.get("agentmd") {
62 if !claude_md_parts.is_empty() {
63 claude_md_parts.push("\n---\n".to_string());
64 }
65 claude_md_parts.push(expand_template(agentmd, &template_vars));
66 }
67 if let Some(memory) = content_map.get("memory") {
68 claude_md_parts.push("\n# Memory\n".to_string());
69 claude_md_parts.push(memory.clone());
70 }
71
72 if !skills.is_empty() {
74 claude_md_parts.push("\n# Available Skills\n\n".to_string());
75 claude_md_parts.push("Use `/<trigger>` to invoke a skill.\n\n".to_string());
76 for skill in skills {
77 let trigger_part = if skill.trigger.is_empty() {
78 String::new()
79 } else {
80 format!(" (trigger: /{})", skill.trigger)
81 };
82 let desc_part = if skill.description.is_empty() {
83 String::new()
84 } else {
85 format!(" \u{2014} {}", skill.description)
86 };
87 claude_md_parts.push(format!("- **{}**{}{}\n", skill.name, trigger_part, desc_part));
88 }
89 }
90
91 if !claude_md_parts.is_empty() {
92 files.push(AgentConfigFile {
93 filename: "CLAUDE.md".to_string(),
94 content: claude_md_parts.join(""),
95 });
96 }
97
98 for skill in skills {
102 if !skill.trigger.is_empty() && !skill.content.is_empty() {
103 let content = expand_template(&skill.content, &template_vars);
104 files.push(AgentConfigFile {
105 filename: format!(".claude/commands/{}.md", skill.trigger),
106 content,
107 });
108 }
109 }
110
111 let user_hooks = content_map.get("hooks").map(|s| s.as_str());
122 let user_settings = content_map.get("settings").map(|s| s.as_str());
123 if let Some(settings_json) = build_settings_with_hooks(user_settings, user_hooks) {
124 files.push(AgentConfigFile {
125 filename: ".claude/settings.json".to_string(),
126 content: settings_json,
127 });
128 }
129
130 let mcp_content = content_map.get("mcp").map(|s| s.as_str());
137 if let Some(mcp_json) = build_mcp_config(mcp_content, agent_name, "") {
138 files.push(AgentConfigFile {
139 filename: ".mcp.json".to_string(),
140 content: mcp_json,
141 });
142 }
143
144 files
145}
146
147pub fn build_config_files_with_bus(
153 content_map: &HashMap<String, String>,
154 skills: &[AgentSkill],
155 agent_name: &str,
156 agent_id: &str,
157 agent_bus_id: &str,
158 working_directory: &str,
159) -> Vec<AgentConfigFile> {
160 let mut files: Vec<AgentConfigFile> = Vec::new();
161
162 let mut template_vars: HashMap<String, String> = HashMap::new();
163 template_vars.insert("AGENT".to_string(), agent_name.to_string());
164 template_vars.insert("AGENT_DISPLAY".to_string(), agent_name.to_string());
165 template_vars.insert("AGENT_ID".to_string(), agent_id.to_string());
166 template_vars.insert("WORKING_DIR".to_string(), working_directory.to_string());
167 template_vars.insert("DATE".to_string(), Utc::now().format("%Y-%m-%d").to_string());
168
169 let mut claude_md_parts: Vec<String> = Vec::new();
171 if let Some(soul) = content_map.get("soul") {
172 claude_md_parts.push(expand_template(soul, &template_vars));
173 }
174 if let Some(agentmd) = content_map.get("agentmd") {
175 if !claude_md_parts.is_empty() {
176 claude_md_parts.push("\n---\n".to_string());
177 }
178 claude_md_parts.push(expand_template(agentmd, &template_vars));
179 }
180 if let Some(memory) = content_map.get("memory") {
181 claude_md_parts.push("\n# Memory\n".to_string());
182 claude_md_parts.push(memory.clone());
183 }
184 if !skills.is_empty() {
185 claude_md_parts.push("\n# Available Skills\n\n".to_string());
186 claude_md_parts.push("Use `/<trigger>` to invoke a skill.\n\n".to_string());
187 for skill in skills {
188 let trigger_part = if skill.trigger.is_empty() {
189 String::new()
190 } else {
191 format!(" (trigger: /{})", skill.trigger)
192 };
193 let desc_part = if skill.description.is_empty() {
194 String::new()
195 } else {
196 format!(" \u{2014} {}", skill.description)
197 };
198 claude_md_parts.push(format!("- **{}**{}{}\n", skill.name, trigger_part, desc_part));
199 }
200 }
201 if !claude_md_parts.is_empty() {
202 files.push(AgentConfigFile {
203 filename: "CLAUDE.md".to_string(),
204 content: claude_md_parts.join(""),
205 });
206 }
207
208 for skill in skills {
210 if !skill.trigger.is_empty() && !skill.content.is_empty() {
211 let content = expand_template(&skill.content, &template_vars);
212 files.push(AgentConfigFile {
213 filename: format!(".claude/commands/{}.md", skill.trigger),
214 content,
215 });
216 }
217 }
218
219 if let Some(hooks) = content_map.get("hooks") {
221 files.push(AgentConfigFile {
222 filename: ".claude/hooks.json".to_string(),
223 content: hooks.clone(),
224 });
225 }
226
227 let mcp_content = content_map.get("mcp").map(|s| s.as_str());
229 if let Some(mcp_json) = build_mcp_config(mcp_content, agent_name, agent_bus_id) {
230 files.push(AgentConfigFile {
231 filename: ".mcp.json".to_string(),
232 content: mcp_json,
233 });
234 }
235
236 files
237}
238
239pub fn build_mcp_config(
251 user_mcp_content: Option<&str>,
252 agent_name: &str,
253 agent_bus_id: &str,
254) -> Option<String> {
255 let mut env_map = serde_json::Map::new();
257 if !agent_name.is_empty() {
258 env_map.insert("AGENTMUX_AGENT_ID".to_string(), json!(agent_name));
259 }
260 if !agent_bus_id.is_empty() {
261 env_map.insert("AGENTMUX_AGENT_BUS_ID".to_string(), json!(agent_bus_id));
262 }
263
264 let agentmux_server = json!({
265 "type": "stdio",
266 "command": "agentmux-mcp",
267 "args": [],
268 "env": Value::Object(env_map),
269 });
270
271 let mut mcp_servers = serde_json::Map::new();
272 mcp_servers.insert("agentmux".to_string(), agentmux_server);
273
274 if let Some(raw) = user_mcp_content {
276 match serde_json::from_str::<Value>(raw) {
277 Ok(Value::Object(user_obj)) => {
278 if let Some(Value::Object(user_servers)) = user_obj.get("mcpServers") {
279 for (k, v) in user_servers {
280 mcp_servers.insert(k.clone(), v.clone());
281 }
282 }
283 }
284 Ok(_) => {
285 }
287 Err(_) => {
288 tracing::error!("agent_config: invalid MCP JSON in agent content, using auto-injected only");
290 }
291 }
292 }
293
294 let result = json!({ "mcpServers": Value::Object(mcp_servers) });
295 match serde_json::to_string_pretty(&result) {
296 Ok(s) => Some(s),
297 Err(e) => {
298 tracing::error!("agent_config: failed to serialize MCP config: {e}");
299 None
300 }
301 }
302}
303
304pub fn build_settings_with_hooks(
322 user_settings_content: Option<&str>,
323 user_hooks_content: Option<&str>,
324) -> Option<String> {
325 use serde_json::Value;
326 let agentmux_pretooluse = json!({
327 "matcher": "^(Bash|.*[Bb]ash.*)$",
328 "hooks": [
329 {
330 "type": "command",
331 "command": "agentmux-bashwrap hook"
332 }
333 ]
334 });
335 let mut hooks_obj = serde_json::Map::new();
336 let mut pretooluse_entries: Vec<Value> = Vec::new();
337
338 if let Some(raw) = user_hooks_content {
343 match serde_json::from_str::<Value>(raw) {
344 Ok(Value::Object(user_obj)) => {
345 for (k, v) in user_obj {
346 if k == "PreToolUse" {
347 if let Value::Array(arr) = v {
348 pretooluse_entries.extend(arr);
349 } else {
350 tracing::warn!(
351 "agent_config: user hooks.PreToolUse is not an array; dropped"
352 );
353 }
354 } else {
355 hooks_obj.insert(k, v);
356 }
357 }
358 }
359 Ok(other) => {
360 tracing::warn!(
361 kind = ?other,
362 "agent_config: user hooks top-level value is not an object; dropped"
363 );
364 }
365 Err(e) => {
366 tracing::warn!(
367 error = %e,
368 "agent_config: failed to parse user hooks JSON; dropped"
369 );
370 }
371 }
372 }
373 pretooluse_entries.push(agentmux_pretooluse);
376 hooks_obj.insert("PreToolUse".to_string(), Value::Array(pretooluse_entries));
377
378 let mut settings_obj = serde_json::Map::new();
382 if let Some(raw) = user_settings_content {
383 match serde_json::from_str::<Value>(raw) {
384 Ok(Value::Object(user_obj)) => {
385 for (k, v) in user_obj {
386 settings_obj.insert(k, v);
387 }
388 }
389 Ok(_other) => {
390 tracing::warn!(
391 "agent_config: user settings.json top-level is not an object; dropped"
392 );
393 }
394 Err(e) => {
395 tracing::warn!(
396 error = %e,
397 "agent_config: failed to parse user settings.json; dropped"
398 );
399 }
400 }
401 }
402 if let Some(Value::Object(existing_hooks)) = settings_obj.get("hooks").cloned() {
411 for (k, v) in existing_hooks {
412 if k == "PreToolUse" {
413 if let Value::Array(user_pretooluse) = v {
414 if let Some(Value::Array(ours)) = hooks_obj.remove("PreToolUse") {
417 let mut merged = user_pretooluse;
418 merged.extend(ours);
419 hooks_obj.insert("PreToolUse".to_string(), Value::Array(merged));
420 } else {
421 hooks_obj.insert("PreToolUse".to_string(), Value::Array(user_pretooluse));
422 }
423 } else {
424 tracing::warn!(
425 "agent_config: user settings.hooks.PreToolUse is not an array; dropped"
426 );
427 }
428 continue;
429 }
430 hooks_obj.entry(k).or_insert(v);
431 }
432 }
433 settings_obj.insert("hooks".to_string(), Value::Object(hooks_obj));
434
435 match serde_json::to_string_pretty(&Value::Object(settings_obj)) {
436 Ok(s) => Some(s),
437 Err(e) => {
438 tracing::error!("agent_config: failed to serialize settings.json: {e}");
439 None
440 }
441 }
442}
443
444pub fn expand_template(content: &str, vars: &HashMap<String, String>) -> String {
451 let mut result = String::with_capacity(content.len());
454 let bytes = content.as_bytes();
455 let len = bytes.len();
456 let mut i = 0;
457
458 while i < len {
459 if i + 1 < len && bytes[i] == b'{' && bytes[i + 1] == b'{' {
461 if let Some(rel) = content[i + 2..].find("}}") {
463 let key = &content[i + 2..i + 2 + rel];
464 if key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') {
466 if let Some(val) = vars.get(key) {
467 result.push_str(val);
468 } else {
469 result.push_str(&content[i..i + 2 + rel + 2]);
471 }
472 i += 2 + rel + 2; continue;
474 }
475 }
476 }
477 let ch = content[i..].chars().next().unwrap();
482 result.push(ch);
483 i += ch.len_utf8();
484 }
485
486 result
487}
488
489#[cfg(test)]
494mod tests {
495 use super::*;
496
497 fn make_skill(name: &str, trigger: &str, description: &str, content: &str) -> AgentSkill {
498 AgentSkill {
499 id: format!("skill-{}", trigger),
500 agent_id: "agent-1".to_string(),
501 name: name.to_string(),
502 trigger: trigger.to_string(),
503 skill_type: "prompt".to_string(),
504 description: description.to_string(),
505 content: content.to_string(),
506 created_at: 0,
507 }
508 }
509
510 #[test]
511 fn test_expand_template_basic() {
512 let mut vars = HashMap::new();
513 vars.insert("AGENT".to_string(), "Aria".to_string());
514 vars.insert("DATE".to_string(), "2026-04-10".to_string());
515
516 let out = expand_template("Hello {{AGENT}}, today is {{DATE}}.", &vars);
517 assert_eq!(out, "Hello Aria, today is 2026-04-10.");
518 }
519
520 #[test]
521 fn test_expand_template_unknown_placeholder_preserved() {
522 let vars = HashMap::new();
523 let out = expand_template("Value: {{UNKNOWN}}", &vars);
524 assert_eq!(out, "Value: {{UNKNOWN}}");
525 }
526
527 #[test]
528 fn test_expand_template_empty_vars() {
529 let vars = HashMap::new();
530 let out = expand_template("No placeholders here.", &vars);
531 assert_eq!(out, "No placeholders here.");
532 }
533
534 #[test]
535 fn test_build_mcp_config_no_user_content() {
536 let result = build_mcp_config(None, "Aria", "bus-42").unwrap();
537 let parsed: Value = serde_json::from_str(&result).unwrap();
538 let servers = &parsed["mcpServers"];
539 assert!(servers["agentmux"].is_object());
540 assert_eq!(servers["agentmux"]["command"], "agentmux-mcp");
541 assert_eq!(servers["agentmux"]["env"]["AGENTMUX_AGENT_ID"], "Aria");
542 assert_eq!(servers["agentmux"]["env"]["AGENTMUX_AGENT_BUS_ID"], "bus-42");
543 }
544
545 #[test]
546 fn test_build_mcp_config_merges_user_servers() {
547 let user_mcp = r#"{"mcpServers": {"mytool": {"type": "stdio", "command": "mytool"}}}"#;
548 let result = build_mcp_config(Some(user_mcp), "Aria", "").unwrap();
549 let parsed: Value = serde_json::from_str(&result).unwrap();
550 let servers = &parsed["mcpServers"];
551 assert!(servers["agentmux"].is_object());
552 assert!(servers["mytool"].is_object());
553 }
554
555 #[test]
556 fn test_build_mcp_config_invalid_user_json_uses_auto_injected() {
557 let result = build_mcp_config(Some("not json {{"), "Aria", "").unwrap();
558 let parsed: Value = serde_json::from_str(&result).unwrap();
559 assert!(parsed["mcpServers"]["agentmux"].is_object());
560 }
561
562 #[test]
563 fn test_build_config_files_claude_md_assembled() {
564 let mut content_map = HashMap::new();
565 content_map.insert("soul".to_string(), "You are {{AGENT}}.".to_string());
566 content_map.insert("agentmd".to_string(), "## Instructions\nDo stuff.".to_string());
567
568 let files = build_config_files(&content_map, &[], "Aria", "agent-1");
569 let claude_md = files.iter().find(|f| f.filename == "CLAUDE.md").unwrap();
570 assert!(claude_md.content.contains("You are Aria."));
571 assert!(claude_md.content.contains("---"));
572 assert!(claude_md.content.contains("## Instructions"));
573 }
574
575 #[test]
576 fn test_build_config_files_skills_index_and_commands() {
577 let content_map = HashMap::new();
578 let skills = vec![
579 make_skill("Deploy", "deploy", "Deploy the app", "Run: deploy all"),
580 make_skill("Test", "test", "Run tests", "Run: test suite"),
581 ];
582
583 let files = build_config_files(&content_map, &skills, "Aria", "agent-1");
584
585 let claude_md = files.iter().find(|f| f.filename == "CLAUDE.md").unwrap();
587 assert!(claude_md.content.contains("Available Skills"));
588 assert!(claude_md.content.contains("/deploy"));
589 assert!(claude_md.content.contains("/test"));
590
591 assert!(files.iter().any(|f| f.filename == ".claude/commands/deploy.md"));
593 assert!(files.iter().any(|f| f.filename == ".claude/commands/test.md"));
594 }
595
596 #[test]
597 fn test_build_config_files_settings_merges_user_hooks() {
598 let mut content_map = HashMap::new();
605 content_map.insert(
606 "hooks".to_string(),
607 r#"{"PreToolUse":[{"matcher":"Read","hooks":[{"type":"command","command":"my-audit"}]}]}"#
608 .to_string(),
609 );
610 let files = build_config_files(&content_map, &[], "Aria", "agent-1");
611 let settings = files
612 .iter()
613 .find(|f| f.filename == ".claude/settings.json")
614 .expect("settings.json emitted");
615 let parsed: Value = serde_json::from_str(&settings.content).unwrap();
616 let pre_tool_use = parsed["hooks"]["PreToolUse"]
617 .as_array()
618 .expect("PreToolUse is an array");
619 assert!(
621 pre_tool_use
622 .iter()
623 .any(|e| e["matcher"].as_str() == Some("Read")),
624 "user-supplied PreToolUse:Read must survive the merge"
625 );
626 assert!(
627 pre_tool_use
628 .iter()
629 .any(|e| e["matcher"].as_str().unwrap_or("").contains("Bash")),
630 "auto-injected PreToolUse:Bash must still be present"
631 );
632 }
633
634 #[test]
635 fn test_build_config_files_mcp_written() {
636 let content_map = HashMap::new();
637 let files = build_config_files(&content_map, &[], "Aria", "agent-1");
638 let mcp = files.iter().find(|f| f.filename == ".mcp.json").unwrap();
639 let parsed: Value = serde_json::from_str(&mcp.content).unwrap();
640 assert!(parsed["mcpServers"]["agentmux"].is_object());
641 }
642}