1use std::collections::HashSet;
17use std::path::{Path, PathBuf};
18
19const MAX_IMPORT_DEPTH: usize = 5;
21
22#[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#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct StartupFile {
46 pub role: &'static str,
49 pub name: String,
51 pub path: PathBuf,
52 pub owner: Owner,
53 pub text: String,
55 pub count: Option<usize>,
57 pub contains: Vec<&'static str>,
59}
60
61pub 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 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
149fn 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 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
192fn 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
221fn 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
241fn 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 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}