agentmux_srv\backend/
agent_config.rs

1// Copyright 2024-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Pure config-building logic for agent definitions.
5//!
6//! Ports the `buildConfigFiles`, `buildMcpConfig`, and `expandTemplate`
7//! functions from `frontend/app/view/agent/agent-model.ts`.
8//! All functions are pure — no I/O, no async.
9
10use std::collections::HashMap;
11
12use chrono::Utc;
13use serde_json::{json, Value};
14
15use crate::backend::storage::wstore::AgentSkill;
16
17/// A single file to be written to the agent working directory.
18#[derive(Debug, Clone)]
19pub struct AgentConfigFile {
20    /// Path relative to the agent working directory (e.g. `"CLAUDE.md"`, `".mcp.json"`).
21    pub filename: String,
22    /// UTF-8 file content.
23    pub content: String,
24}
25
26/// Build the list of config files to write to the agent working directory.
27///
28/// Assembles `CLAUDE.md` from `soul` + `agentmd` + `memory` + skills index,
29/// writes each skill as a slash command under `.claude/commands/<trigger>.md`,
30/// writes `.claude/hooks.json` if a `hooks` content entry is present,
31/// auto-injects the AgentMux MCP server entry, and applies `{{VARIABLE}}`
32/// template substitution throughout.
33///
34/// Mirrors `buildConfigFiles()` in `frontend/app/view/agent/agent-model.ts`.
35pub 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    // Template variables for {{}} substitution
44    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    // DATE in YYYY-MM-DD format, UTC
49    template_vars.insert("DATE".to_string(), Utc::now().format("%Y-%m-%d").to_string());
50    // WORKING_DIR is not available in this signature; leave it empty for callers
51    // that don't pass it — expansion will leave {{WORKING_DIR}} intact if absent.
52
53    // ----------------------------------------------------------------
54    // Build CLAUDE.md: Soul + AgentMD + Memory + Skills index
55    // ----------------------------------------------------------------
56    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    // Append skill index with trigger references
73    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    // ----------------------------------------------------------------
99    // Write each skill as a slash command: .claude/commands/{trigger}.md
100    // ----------------------------------------------------------------
101    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    // ----------------------------------------------------------------
112    // Write .claude/hooks.json — always includes a PreToolUse:Bash
113    // entry pointing at `agentmux-bashwrap hook` so the streaming
114    // wrapper is invoked for every Bash tool call. User-provided
115    // hooks (from content_map["hooks"]) are merged on top, with the
116    // user's entries winning on key collisions, EXCEPT that our
117    // PreToolUse entries are always appended to any user
118    // PreToolUse array so streaming stays on regardless. See
119    // docs/specs/SPEC_STREAMING_BASH_RUNNER_2026_05_11.md §5.
120    // ----------------------------------------------------------------
121    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    // ----------------------------------------------------------------
131    // Build .mcp.json with auto-injected AgentMux MCP server
132    // ----------------------------------------------------------------
133    // agent_bus_id is not in the function signature; callers that have it
134    // should call build_mcp_config directly and push the result themselves,
135    // or use the variant below.
136    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
147/// Build the list of config files with a known `agent_bus_id`.
148///
149/// Same as [`build_config_files`] but also accepts an `agent_bus_id` so the
150/// MCP server entry can include `AGENTMUX_AGENT_BUS_ID`.  Prefer this overload
151/// when the caller has the full `AgentDefinition` available.
152pub 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    // CLAUDE.md
170    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    // Skill slash commands
209    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    // Hooks
220    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    // MCP — use full bus_id variant
228    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
239/// Build `.mcp.json` content with the auto-injected AgentMux MCP server entry.
240///
241/// The AgentMux server is always present as `mcpServers.agentmux`.
242/// If `user_mcp_content` is `Some`, its `mcpServers` entries are merged on top
243/// (user entries win over the auto-injected entry if the key collides).
244/// If the user content is not valid JSON the auto-injected-only config is
245/// returned and no error is propagated (mirrors TS behavior).
246///
247/// Returns `None` only if serialization unexpectedly fails (should never happen).
248///
249/// Mirrors `buildMcpConfig()` in `frontend/app/view/agent/agent-model.ts`.
250pub fn build_mcp_config(
251    user_mcp_content: Option<&str>,
252    agent_name: &str,
253    agent_bus_id: &str,
254) -> Option<String> {
255    // Auto-injected AgentMux MCP server entry
256    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    // Merge user-provided MCP config if present
275    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                // User content parsed but isn't an object — skip merge silently
286            }
287            Err(_) => {
288                // Invalid JSON in agent content — keep auto-injected only (mirrors TS behavior)
289                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
304/// Build `.claude/settings.json` content with the auto-injected
305/// PreToolUse Bash hook (under the `"hooks"` key) that redirects
306/// Bash invocations into the streaming wrapper
307/// (`agentmux-bashwrap exec`). User-supplied settings.json (from
308/// the agent's `content_map["settings"]`) is parsed and merged at
309/// the top level; user-supplied legacy hooks content (from
310/// `content_map["hooks"]`) is merged into `settings.hooks`.
311///
312/// **File location matters.** Claude Code reads project hooks from
313/// `<project>/.claude/settings.json` under the `"hooks"` key.
314/// A standalone `.claude/hooks.json` is NOT a Claude Code
315/// discovery location — that was the v0.33.804 streaming-bug root
316/// cause: the file was written but Claude never read it, so the
317/// PreToolUse hook never fired and live streaming silently failed.
318///
319/// See `docs/specs/SPEC_STREAMING_BASH_RUNNER_2026_05_11.md` §5
320/// and Claude Code docs: https://code.claude.com/docs/en/hooks.md
321pub 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    // Start with user hooks if present + parseable. Parse failures or
339    // non-Object top-levels are logged at WARN so the diagnostic trail
340    // surfaces — silent swallowing made user hooks disappear with no
341    // signal (reagent P2 on PR #809).
342    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    // Append our entry last so user matchers (deny rules etc.) get a chance to
374    // short-circuit before our rewrite.
375    pretooluse_entries.push(agentmux_pretooluse);
376    hooks_obj.insert("PreToolUse".to_string(), Value::Array(pretooluse_entries));
377
378    // Build the settings.json object: start from user-supplied settings.json
379    // (if any), then overlay our hooks key. User keys other than `hooks`
380    // pass through unchanged.
381    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    // Merge: any existing hooks key from user settings is merged with our
403    // additions. For PreToolUse specifically, user matchers from
404    // settings.json are PREPENDED (not dropped) so they short-circuit
405    // before our auto-injected agentmux-bashwrap entry — same ordering
406    // rule we apply to legacy content_map["hooks"] PreToolUse entries.
407    // For other event types (PostToolUse, Stop, etc.) we keep user's
408    // entries verbatim. Reagent P1 on PR #813 (the `continue` was a
409    // silent drop — caught a real merge bug).
410    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                    // Prepend user PreToolUse so their matchers run
415                    // first; our auto-injected entry stays last.
416                    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
444/// Replace `{{VARIABLE}}` placeholders in `content` with values from `vars`.
445///
446/// Placeholders that have no corresponding key in `vars` are left unchanged
447/// (the original `{{VARIABLE}}` text is preserved).
448///
449/// Mirrors `expandTemplate()` in `frontend/app/view/agent/agent-model.ts`.
450pub fn expand_template(content: &str, vars: &HashMap<String, String>) -> String {
451    // Hand-rolled replacement to avoid pulling in a regex dependency.
452    // Scans for `{{`, extracts the key name up to `}}`, and substitutes.
453    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        // Look for '{{'
460        if i + 1 < len && bytes[i] == b'{' && bytes[i + 1] == b'{' {
461            // Find closing '}}'
462            if let Some(rel) = content[i + 2..].find("}}") {
463                let key = &content[i + 2..i + 2 + rel];
464                // Only substitute if key is a simple word (alphanumeric + underscore)
465                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                        // No match — preserve the original placeholder
470                        result.push_str(&content[i..i + 2 + rel + 2]);
471                    }
472                    i += 2 + rel + 2; // skip past '}}'
473                    continue;
474                }
475            }
476        }
477        // Not a placeholder start — copy character verbatim
478        // Safety: i is always on a valid char boundary because we only advance
479        // by 1 when not inside a placeholder, and UTF-8 single-byte characters
480        // are the only ones we index directly.
481        let ch = content[i..].chars().next().unwrap();
482        result.push(ch);
483        i += ch.len_utf8();
484    }
485
486    result
487}
488
489// ============================================================
490// Tests
491// ============================================================
492
493#[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        // CLAUDE.md should have the skills index
586        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        // Individual skill command files
592        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        // PR #813 moved hooks from `.claude/hooks.json` (a Claude Code
599        // dead-letter path) to `.claude/settings.json` under the
600        // `"hooks"` key. This test exercises the merge path: user
601        // PreToolUse entries must be PREPENDED (not silently
602        // dropped) to the auto-injected bashwrap entry so streaming
603        // stays on while user-supplied gates fire first.
604        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        // User's "Read" matcher prepended first, then our Bash matcher.
620        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}