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::store::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    agent_slug: &str,
41) -> Vec<AgentConfigFile> {
42    let mut files: Vec<AgentConfigFile> = Vec::new();
43
44    // Template variables for {{}} substitution
45    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    // DATE in YYYY-MM-DD format, UTC
50    template_vars.insert("DATE".to_string(), Utc::now().format("%Y-%m-%d").to_string());
51    // WORKING_DIR is not available in this signature; leave it empty for callers
52    // that don't pass it — expansion will leave {{WORKING_DIR}} intact if absent.
53
54    // ----------------------------------------------------------------
55    // Build CLAUDE.md: Soul + AgentMD + Memory + Skills index
56    // ----------------------------------------------------------------
57    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    // Append skill index with trigger references
74    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    // ----------------------------------------------------------------
100    // Write each skill as a slash command: .claude/commands/{trigger}.md
101    // ----------------------------------------------------------------
102    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    // ----------------------------------------------------------------
113    // Write .claude/hooks.json — always includes a PreToolUse:Bash
114    // entry pointing at `agentmux-bashwrap hook` so the streaming
115    // wrapper is invoked for every Bash tool call. User-provided
116    // hooks (from content_map["hooks"]) are merged on top, with the
117    // user's entries winning on key collisions, EXCEPT that our
118    // PreToolUse entries are always appended to any user
119    // PreToolUse array so streaming stays on regardless. See
120    // docs/specs/SPEC_STREAMING_BASH_RUNNER_2026_05_11.md §5.
121    // ----------------------------------------------------------------
122    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    // ----------------------------------------------------------------
132    // Build .mcp.json with auto-injected AgentMux MCP server
133    // ----------------------------------------------------------------
134    // agent_bus_id is not in the function signature; callers that have it
135    // should call build_mcp_config directly and push the result themselves,
136    // or use the variant below.
137    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
148/// Build the list of config files with a known `agent_bus_id`.
149///
150/// Same as [`build_config_files`] but also accepts an `agent_bus_id` so the
151/// MCP server entry can include `AGENTMUX_AGENT_BUS_ID`.  Prefer this overload
152/// when the caller has the full `AgentDefinition` available.
153pub 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    // CLAUDE.md
172    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    // Skill slash commands
211    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    // Hooks
222    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    // MCP — use full bus_id variant
230    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
241/// Build `.mcp.json` content with the auto-injected AgentMux MCP server entry.
242///
243/// The AgentMux server is always present as `mcpServers.agentmux`.
244/// If `user_mcp_content` is `Some`, its `mcpServers` entries are merged on top
245/// (user entries win over the auto-injected entry if the key collides).
246/// If the user content is not valid JSON the auto-injected-only config is
247/// returned and no error is propagated (mirrors TS behavior).
248///
249/// Returns `None` only if serialization unexpectedly fails (should never happen).
250///
251/// Mirrors `buildMcpConfig()` in `frontend/app/view/agent/agent-model.ts`.
252pub fn build_mcp_config(
253    user_mcp_content: Option<&str>,
254    agent_slug: &str,
255    agent_bus_id: &str,
256) -> Option<String> {
257    // Auto-injected AgentMux MCP server entry.
258    // agent_slug must be the pre-computed stable role slug (e.g. "korp"),
259    // NOT the display name — callers are responsible for passing the right
260    // value so renamed agents always advertise the same routing ID.
261    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    // Merge user-provided MCP config if present
280    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                // User content parsed but isn't an object — skip merge silently
291            }
292            Err(_) => {
293                // Invalid JSON in agent content — keep auto-injected only (mirrors TS behavior)
294                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
309/// Build `.claude/settings.json` content with the auto-injected
310/// PreToolUse Bash hook (under the `"hooks"` key) that redirects
311/// Bash invocations into the streaming wrapper
312/// (`agentmux-bashwrap exec`). User-supplied settings.json (from
313/// the agent's `content_map["settings"]`) is parsed and merged at
314/// the top level; user-supplied legacy hooks content (from
315/// `content_map["hooks"]`) is merged into `settings.hooks`.
316///
317/// **File location matters.** Claude Code reads project hooks from
318/// `<project>/.claude/settings.json` under the `"hooks"` key.
319/// A standalone `.claude/hooks.json` is NOT a Claude Code
320/// discovery location — that was the v0.33.804 streaming-bug root
321/// cause: the file was written but Claude never read it, so the
322/// PreToolUse hook never fired and live streaming silently failed.
323///
324/// See `docs/specs/SPEC_STREAMING_BASH_RUNNER_2026_05_11.md` §5
325/// and Claude Code docs: https://code.claude.com/docs/en/hooks.md
326pub 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    // Start with user hooks if present + parseable. Parse failures or
344    // non-Object top-levels are logged at WARN so the diagnostic trail
345    // surfaces — silent swallowing made user hooks disappear with no
346    // signal (reagent P2 on PR #809).
347    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    // Append our entry last so user matchers (deny rules etc.) get a chance to
379    // short-circuit before our rewrite.
380    pretooluse_entries.push(agentmux_pretooluse);
381    hooks_obj.insert("PreToolUse".to_string(), Value::Array(pretooluse_entries));
382
383    // Build the settings.json object: start from user-supplied settings.json
384    // (if any), then overlay our hooks key. User keys other than `hooks`
385    // pass through unchanged.
386    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    // Merge: any existing hooks key from user settings is merged with our
408    // additions. For PreToolUse specifically, user matchers from
409    // settings.json are PREPENDED (not dropped) so they short-circuit
410    // before our auto-injected agentmux-bashwrap entry — same ordering
411    // rule we apply to legacy content_map["hooks"] PreToolUse entries.
412    // For other event types (PostToolUse, Stop, etc.) we keep user's
413    // entries verbatim. Reagent P1 on PR #813 (the `continue` was a
414    // silent drop — caught a real merge bug).
415    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                    // Prepend user PreToolUse so their matchers run
420                    // first; our auto-injected entry stays last.
421                    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    // Claude Code requires the bashwrap exec command (produced by the hook rewrite)
441    // to be in permissions.allow — otherwise it raises a permissions error and the
442    // agent cannot run any bash commands. Merge with any user-supplied allow list
443    // rather than overwriting it.
444    {
445        // Space before * enforces a command-name boundary: matches
446        // "agentmux-bashwrap <args>" only, not other executables that
447        // happen to share the prefix (e.g. agentmux-bashwrapXYZ).
448        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
476/// Replace `{{VARIABLE}}` placeholders in `content` with values from `vars`.
477///
478/// Placeholders that have no corresponding key in `vars` are left unchanged
479/// (the original `{{VARIABLE}}` text is preserved).
480///
481/// Mirrors `expandTemplate()` in `frontend/app/view/agent/agent-model.ts`.
482pub fn expand_template(content: &str, vars: &HashMap<String, String>) -> String {
483    // Hand-rolled replacement to avoid pulling in a regex dependency.
484    // Scans for `{{`, extracts the key name up to `}}`, and substitutes.
485    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        // Look for '{{'
492        if i + 1 < len && bytes[i] == b'{' && bytes[i + 1] == b'{' {
493            // Find closing '}}'
494            if let Some(rel) = content[i + 2..].find("}}") {
495                let key = &content[i + 2..i + 2 + rel];
496                // Only substitute if key is a simple word (alphanumeric + underscore)
497                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                        // No match — preserve the original placeholder
502                        result.push_str(&content[i..i + 2 + rel + 2]);
503                    }
504                    i += 2 + rel + 2; // skip past '}}'
505                    continue;
506                }
507            }
508        }
509        // Not a placeholder start — copy character verbatim
510        // Safety: i is always on a valid char boundary because we only advance
511        // by 1 when not inside a placeholder, and UTF-8 single-byte characters
512        // are the only ones we index directly.
513        let ch = content[i..].chars().next().unwrap();
514        result.push(ch);
515        i += ch.len_utf8();
516    }
517
518    result
519}
520
521// ============================================================
522// Tests
523// ============================================================
524
525#[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        // CLAUDE.md should have the skills index
618        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        // Individual skill command files
624        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        // PR #813 moved hooks from `.claude/hooks.json` (a Claude Code
631        // dead-letter path) to `.claude/settings.json` under the
632        // `"hooks"` key. This test exercises the merge path: user
633        // PreToolUse entries must be PREPENDED (not silently
634        // dropped) to the auto-injected bashwrap entry so streaming
635        // stays on while user-supplied gates fire first.
636        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        // User's "Read" matcher prepended first, then our Bash matcher.
652        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}