Skip to main content

agentmux_srv/backend/
startup_files.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The files a Claude Code session reads by itself at startup, listed for the
5//! "Given to the agent" card
6//! (SPEC_LAUNCH_CONTEXT_WORKSPACE_RULE_AND_STARTUP_FILES_2026_09_30.md §4,
7//! phase LC2). Before this the card showed only what the SessionStart hook
8//! carried, while the CLI had also loaded the user's `CLAUDE.md`, every
9//! `CLAUDE.md` up the folder tree (the hand-maintained
10//! `~/.agentmux/agents/CLAUDE.md` among them), their `@imports`, the skill
11//! listing and the MCP servers.
12//!
13//! This mirrors Claude Code's documented loading rules: it is what the CLI
14//! *should* have read, not proof that it did.
15
16use std::collections::HashSet;
17use std::path::{Path, PathBuf};
18
19/// Claude Code follows `@imports` this many hops deep.
20const MAX_IMPORT_DEPTH: usize = 5;
21
22/// Who writes a file: AgentMux, the user (a file in the agent's own
23/// workspace that AgentMux doesn't manage), or someone else (outside the
24/// workspace and not written by AgentMux, e.g. the hand-maintained shared
25/// `agents/CLAUDE.md`).
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Owner {
28    Agentmux,
29    User,
30    External,
31}
32
33impl Owner {
34    pub fn as_str(self) -> &'static str {
35        match self {
36            Self::Agentmux => "agentmux",
37            Self::User => "user",
38            Self::External => "external",
39        }
40    }
41}
42
43/// One startup item.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct StartupFile {
46    /// `user_instructions`, `instructions`, `instructions_import`, `skills`
47    /// or `mcp_servers`.
48    pub role: &'static str,
49    /// Shown in the card: `~/…` for a path under the home dir.
50    pub name: String,
51    pub path: PathBuf,
52    pub owner: Owner,
53    /// The text as read; empty for the skills and MCP items, which list.
54    pub text: String,
55    /// For the skills and MCP items: how many are listed.
56    pub count: Option<usize>,
57    /// AgentMux sections the file carries: `global_memory`, `skills_index`.
58    pub contains: Vec<&'static str>,
59}
60
61/// Claude's startup files for a session in `cwd`, in the order the CLI loads
62/// them: the user's `CLAUDE.md` (in `config_dir`, else `~/.claude`), then
63/// each folder from the filesystem root down to `cwd` (its `CLAUDE.md`,
64/// `.claude/CLAUDE.md`, `CLAUDE.local.md`), each file followed by what it
65/// imports; then the skill listing and the MCP servers of `cwd`.
66pub fn claude_startup_files(cwd: &Path, config_dir: Option<&Path>, home: Option<&Path>) -> Vec<StartupFile> {
67    let mut out = Vec::new();
68    let mut seen = HashSet::new();
69    let user_md = config_dir
70        .map(Path::to_path_buf)
71        .or_else(|| home.map(|h| h.join(".claude")))
72        .map(|d| d.join("CLAUDE.md"));
73    if let Some(p) = user_md {
74        add_instructions(&p, "user_instructions", cwd, home, 0, &mut seen, &mut out);
75    }
76    let mut dirs: Vec<&Path> = cwd.ancestors().collect();
77    // "Up to, but not including, the root directory."
78    dirs.retain(|d| d.parent().is_some());
79    dirs.reverse();
80    for dir in dirs {
81        for name in ["CLAUDE.md", ".claude/CLAUDE.md", "CLAUDE.local.md"] {
82            add_instructions(&dir.join(name), "instructions", cwd, home, 0, &mut seen, &mut out);
83        }
84    }
85    if let Some(skills) = skills_item(cwd, home) {
86        out.push(skills);
87    }
88    if let Some(mcp) = mcp_item(cwd, home) {
89        out.push(mcp);
90    }
91    out
92}
93
94fn add_instructions(
95    path: &Path,
96    role: &'static str,
97    cwd: &Path,
98    home: Option<&Path>,
99    depth: usize,
100    seen: &mut HashSet<PathBuf>,
101    out: &mut Vec<StartupFile>,
102) {
103    let Ok(text) = std::fs::read_to_string(path) else { return };
104    let key = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
105    if !seen.insert(key) {
106        return;
107    }
108    let owner = owner_of(path, &text, cwd);
109    let contains = contains(&text);
110    let imports = if depth < MAX_IMPORT_DEPTH { imports_of(&text, path, home) } else { Vec::new() };
111    out.push(StartupFile {
112        role,
113        name: display_name(path, home),
114        path: path.to_path_buf(),
115        owner,
116        text,
117        count: None,
118        contains,
119    });
120    for import in imports {
121        add_instructions(&import, "instructions_import", cwd, home, depth + 1, seen, out);
122    }
123}
124
125fn owner_of(path: &Path, text: &str, cwd: &Path) -> Owner {
126    let agentmux_written = text.contains(crate::backend::agent_config::CLAUDE_MD_MANAGED_MARKER)
127        || text.contains("AgentMux: intentionally empty")
128        || path == cwd.join(crate::backend::agent_config::AGENTMUX_MEMORY_FILENAME);
129    if agentmux_written {
130        Owner::Agentmux
131    } else if path.starts_with(cwd) {
132        Owner::User
133    } else {
134        Owner::External
135    }
136}
137
138fn contains(text: &str) -> Vec<&'static str> {
139    let mut c = Vec::new();
140    if text.contains("# [AgentMux System] ") || text.contains("# [Workspace] ") {
141        c.push("global_memory");
142    }
143    if text.starts_with("# Available Skills") || text.contains("\n# Available Skills") {
144        c.push("skills_index");
145    }
146    c
147}
148
149/// `@path` imports in `text`, resolved against the importing file's folder
150/// (`~/` against the home dir), skipping fenced and inline code, which
151/// Claude Code doesn't evaluate. Only files that exist.
152fn imports_of(text: &str, file: &Path, home: Option<&Path>) -> Vec<PathBuf> {
153    let base = file.parent().unwrap_or(Path::new("."));
154    let mut out = Vec::new();
155    let mut fenced = false;
156    for line in text.lines() {
157        if line.trim_start().starts_with("```") {
158            fenced = !fenced;
159            continue;
160        }
161        if fenced {
162            continue;
163        }
164        // Odd segments between backticks are inline code.
165        for (i, segment) in line.split('`').enumerate() {
166            if i % 2 == 1 {
167                continue;
168            }
169            for token in segment.split_whitespace() {
170                let Some(raw) = token.strip_prefix('@') else { continue };
171                let raw = raw.trim_end_matches(|c: char| matches!(c, '.' | ',' | ';' | ':' | ')' | ']'));
172                if raw.is_empty() || raw.contains("://") {
173                    continue;
174                }
175                let path = match (raw.strip_prefix("~/"), home) {
176                    (Some(rest), Some(h)) => h.join(rest),
177                    (Some(_), None) => continue,
178                    (None, _) => {
179                        let p = Path::new(raw);
180                        if p.is_absolute() { p.to_path_buf() } else { base.join(p) }
181                    }
182                };
183                if path.is_file() {
184                    out.push(path);
185                }
186            }
187        }
188    }
189    out
190}
191
192/// The skill listing Claude Code builds from `.claude/commands/*.md` and
193/// `.claude/skills/*/SKILL.md`: one item with a count, no text (the CLI lists
194/// each by name and description).
195fn skills_item(cwd: &Path, home: Option<&Path>) -> Option<StartupFile> {
196    let mut count = 0usize;
197    if let Ok(entries) = std::fs::read_dir(cwd.join(".claude/commands")) {
198        count += entries
199            .flatten()
200            .filter(|e| e.path().extension().is_some_and(|x| x == "md"))
201            .count();
202    }
203    if let Ok(entries) = std::fs::read_dir(cwd.join(".claude/skills")) {
204        count += entries.flatten().filter(|e| e.path().join("SKILL.md").is_file()).count();
205    }
206    (count > 0).then(|| StartupFile {
207        role: "skills",
208        name: format!("Skills ({count}) in {}", display_name(&cwd.join(".claude"), home)),
209        path: cwd.join(".claude"),
210        owner: if cwd.join(crate::backend::agent_config::MANAGED_SKILL_FILES_MANIFEST).is_file() {
211            Owner::Agentmux
212        } else {
213            Owner::User
214        },
215        text: String::new(),
216        count: Some(count),
217        contains: Vec::new(),
218    })
219}
220
221/// The MCP servers in `cwd/.mcp.json`, by name only: the file carries the
222/// agent's signing keys, so its text never leaves this function.
223fn mcp_item(cwd: &Path, home: Option<&Path>) -> Option<StartupFile> {
224    let path = cwd.join(".mcp.json");
225    let json: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&path).ok()?).ok()?;
226    let names: Vec<&str> = json.get("mcpServers")?.as_object()?.keys().map(String::as_str).collect();
227    if names.is_empty() {
228        return None;
229    }
230    Some(StartupFile {
231        role: "mcp_servers",
232        name: format!("MCP servers: {} ({})", names.join(", "), display_name(&path, home)),
233        path,
234        owner: if names.contains(&"agentmux") { Owner::Agentmux } else { Owner::User },
235        text: String::new(),
236        count: Some(names.len()),
237        contains: Vec::new(),
238    })
239}
240
241/// `~/x/y` for a path under `home`, else the path; forward slashes.
242fn display_name(path: &Path, home: Option<&Path>) -> String {
243    let shown = match home.and_then(|h| path.strip_prefix(h).ok()) {
244        Some(rest) => format!("~/{}", rest.display()),
245        None => path.display().to_string(),
246    };
247    shown.replace('\\', "/")
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    fn write(p: &Path, text: &str) {
255        std::fs::create_dir_all(p.parent().unwrap()).unwrap();
256        std::fs::write(p, text).unwrap();
257    }
258
259    /// A tree like a real agent's: home/.agentmux/agents/CLAUDE.md (shared,
260    /// hand-maintained), the agent's own foreign CLAUDE.md importing
261    /// AGENTMUX_MEMORY.md, a config-dir CLAUDE.md, skills and .mcp.json.
262    fn agent_tree() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
263        let t = tempfile::tempdir().unwrap();
264        let home = t.path().join("home");
265        let agents = home.join(".agentmux/agents");
266        let cwd = agents.join("clamk-0612a");
267        let config = home.join(".agentmux/channels/c/identities/x/claude");
268        write(&agents.join("CLAUDE.md"), "# Jekt rules\n");
269        write(&cwd.join("CLAUDE.md"), "# Available Skills\n\n@.claude/AGENTMUX_MEMORY.md\n");
270        write(
271            &cwd.join(".claude/AGENTMUX_MEMORY.md"),
272            "# Memory\n# [AgentMux System] App API\n\n# Available Skills\n",
273        );
274        write(&config.join("CLAUDE.md"), "AgentMux: intentionally empty.\n");
275        write(&cwd.join(".claude/commands/tdd.md"), "x");
276        write(&cwd.join(".claude/commands/code-review.md"), "x");
277        write(&cwd.join(".claude/skills/run/SKILL.md"), "x");
278        write(&cwd.join(".claude/.agentmux-managed-skill-files.json"), "[]");
279        write(
280            &cwd.join(".mcp.json"),
281            r#"{"mcpServers":{"agentmux":{"env":{"AGENTMUX_JEKT_KEY":"secret"}}}}"#,
282        );
283        (t, home, cwd, config)
284    }
285
286    #[test]
287    fn lists_what_claude_loads_in_order_with_owners() {
288        let (_t, home, cwd, config) = agent_tree();
289        let files = claude_startup_files(&cwd, Some(&config), Some(&home));
290        let got: Vec<(&str, &str, &str)> = files.iter().map(|f| (f.role, f.name.as_str(), f.owner.as_str())).collect();
291        assert_eq!(
292            got,
293            [
294                ("user_instructions", "~/.agentmux/channels/c/identities/x/claude/CLAUDE.md", "agentmux"),
295                ("instructions", "~/.agentmux/agents/CLAUDE.md", "external"),
296                ("instructions", "~/.agentmux/agents/clamk-0612a/CLAUDE.md", "user"),
297                ("instructions_import", "~/.agentmux/agents/clamk-0612a/.claude/AGENTMUX_MEMORY.md", "agentmux"),
298                ("skills", "Skills (3) in ~/.agentmux/agents/clamk-0612a/.claude", "agentmux"),
299                ("mcp_servers", "MCP servers: agentmux (~/.agentmux/agents/clamk-0612a/.mcp.json)", "agentmux"),
300            ]
301        );
302        let memory = &files[3];
303        assert_eq!(memory.contains, ["global_memory", "skills_index"]);
304        assert_eq!(files[2].contains, ["skills_index"]);
305    }
306
307    #[test]
308    fn the_mcp_item_never_carries_the_file() {
309        let (_t, home, cwd, config) = agent_tree();
310        let files = claude_startup_files(&cwd, Some(&config), Some(&home));
311        let mcp = files.iter().find(|f| f.role == "mcp_servers").unwrap();
312        assert!(mcp.text.is_empty());
313        assert_eq!(mcp.count, Some(1));
314        assert!(!format!("{mcp:?}").contains("secret"));
315    }
316
317    #[test]
318    fn imports_resolve_relative_and_home_skip_code_and_stop_at_cycles() {
319        let t = tempfile::tempdir().unwrap();
320        let home = t.path().join("home");
321        let cwd = t.path().join("ws");
322        write(
323            &cwd.join("CLAUDE.md"),
324            "See @docs/a.md and @~/notes.md.\n`@docs/in-code.md`\n```\n@docs/fenced.md\n```\n@missing.md\n",
325        );
326        write(&cwd.join("docs/a.md"), "@../CLAUDE.md back again\n");
327        write(&cwd.join("docs/in-code.md"), "x");
328        write(&cwd.join("docs/fenced.md"), "x");
329        write(&home.join("notes.md"), "notes");
330        let files = claude_startup_files(&cwd, None, Some(&home));
331        let names: Vec<&str> = files.iter().map(|f| f.name.as_str()).collect();
332        let a = cwd.join("docs/a.md").display().to_string().replace('\\', "/");
333        let root_md = cwd.join("CLAUDE.md").display().to_string().replace('\\', "/");
334        assert_eq!(names, [root_md.as_str(), a.as_str(), "~/notes.md"]);
335        assert!(files[1..].iter().all(|f| f.role == "instructions_import"));
336    }
337
338    #[test]
339    fn nothing_there_lists_nothing() {
340        let t = tempfile::tempdir().unwrap();
341        assert!(claude_startup_files(&t.path().join("ws"), None, Some(&t.path().join("home"))).is_empty());
342    }
343}