diff --git a/src/config/install_remote.rs b/src/config/install_remote.rs index a3e2f13..f29c71a 100644 --- a/src/config/install_remote.rs +++ b/src/config/install_remote.rs @@ -1,17 +1,44 @@ use anyhow::{Context, Result, bail}; +use inquire::Select; use std::ffi::{OsStr, OsString}; use std::fs; use std::path::{Path, PathBuf}; -use crate::config::InstallFilter; +use crate::config::{InstallFilter, paths}; +use crate::function::Language; use crate::utils; +use crate::utils::IS_STDOUT_TERMINAL; pub fn install_remote(git_url: &str, filter: Option, force: bool) -> Result<()> { let (url, reference) = parse_url_with_ref(git_url)?; let temp = clone_to_temp(&url, reference.as_deref())?; println!("Cloned {git_url} to {}", temp.path().display()); - print_repo_tree(temp.path())?; - let _ = (force, filter); + + let layout = scan_remote_layout(temp.path())?; + let layout = apply_filter(layout, filter); + + if layout.is_empty() { + println!( + "No recognized assets found in {git_url}. Expected one or more of: \ + agents/, roles/, macros/, functions/tools/, functions/mcp.json" + ); + return Ok(()); + } + + let plan = plan_changes(&layout)?; + + if !plan.files.is_empty() { + print_plan_summary(&plan); + apply_plan(&plan, force)?; + } + + if plan.skipped_mcp_json.is_some() { + println!( + "\nNote: functions/mcp.json detected but MCP merge is not yet wired up \ + (Step 3 of the install-remote rollout)." + ); + } + Ok(()) } @@ -158,32 +185,429 @@ fn run_git(args: Vec) -> Result<()> { Ok(()) } -fn print_repo_tree(root: &Path) -> Result<()> { - println!("Repository contents ({}):", root.display()); - print_children(root, "") +#[derive(Default)] +struct RemoteLayout { + agents: Option, + roles: Option, + macros: Option, + functions_tools: Option, + mcp_json: Option, } -fn print_children(dir: &Path, prefix: &str) -> Result<()> { - let mut entries: Vec<_> = fs::read_dir(dir) - .with_context(|| format!("failed to read {}", dir.display()))? - .filter_map(|e| e.ok()) - .filter(|e| e.file_name() != OsStr::new(".git")) - .collect(); - entries.sort_by_key(|e| e.file_name()); +impl RemoteLayout { + fn is_empty(&self) -> bool { + self.agents.is_none() + && self.roles.is_none() + && self.macros.is_none() + && self.functions_tools.is_none() + && self.mcp_json.is_none() + } +} - let n = entries.len(); - for (i, entry) in entries.iter().enumerate() { - let is_last = i == n - 1; - let connector = if is_last { "└── " } else { "├── " }; - let name = entry.file_name().to_string_lossy().to_string(); - println!("{prefix}{connector}{name}"); +fn scan_remote_layout(root: &Path) -> Result { + let mut layout = RemoteLayout::default(); - if entry.file_type()?.is_dir() { - let extension = if is_last { " " } else { "│ " }; - let new_prefix = format!("{prefix}{extension}"); - print_children(&entry.path(), &new_prefix)?; + let agents = root.join("agents"); + if agents.is_dir() { + layout.agents = Some(agents); + } + let roles = root.join("roles"); + if roles.is_dir() { + layout.roles = Some(roles); + } + let macros = root.join("macros"); + if macros.is_dir() { + layout.macros = Some(macros); + } + let functions = root.join("functions"); + if functions.is_dir() { + let tools = functions.join("tools"); + if tools.is_dir() { + layout.functions_tools = Some(tools); + } + let mcp = functions.join("mcp.json"); + if mcp.is_file() { + layout.mcp_json = Some(mcp); } } + + Ok(layout) +} + +fn apply_filter(mut layout: RemoteLayout, filter: Option) -> RemoteLayout { + let Some(filter) = filter else { + return layout; + }; + match filter { + InstallFilter::Agents => RemoteLayout { + agents: layout.agents.take(), + ..RemoteLayout::default() + }, + InstallFilter::Roles => RemoteLayout { + roles: layout.roles.take(), + ..RemoteLayout::default() + }, + InstallFilter::Macros => RemoteLayout { + macros: layout.macros.take(), + ..RemoteLayout::default() + }, + InstallFilter::Functions => RemoteLayout { + functions_tools: layout.functions_tools.take(), + ..RemoteLayout::default() + }, + InstallFilter::McpConfig => RemoteLayout { + mcp_json: layout.mcp_json.take(), + ..RemoteLayout::default() + }, + } +} + +fn walk_files(root: &Path) -> Result> { + let mut out = Vec::new(); + walk_files_inner(root, &mut out)?; + Ok(out) +} + +fn walk_files_inner(dir: &Path, out: &mut Vec) -> Result<()> { + for entry in fs::read_dir(dir).with_context(|| format!("failed to read {}", dir.display()))? { + let entry = entry?; + let file_type = entry.file_type()?; + let name = entry.file_name(); + + if file_type.is_symlink() { + bail!( + "Symlink not allowed in remote install source: {}", + entry.path().display() + ); + } + if name == OsStr::new(".git") { + continue; + } + if name == OsStr::new("..") { + bail!( + "Path traversal '..' not allowed: {}", + entry.path().display() + ); + } + + let path = entry.path(); + if file_type.is_dir() { + walk_files_inner(&path, out)?; + } else if file_type.is_file() { + out.push(path); + } + } + Ok(()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TopCategory { + Agents, + Roles, + Macros, + FunctionsTools, +} + +impl TopCategory { + fn label(&self) -> &'static str { + match self { + TopCategory::Agents => "agents", + TopCategory::Roles => "roles", + TopCategory::Macros => "macros", + TopCategory::FunctionsTools => "functions/tools", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PlannedKind { + New, + Identical, + Conflict, +} + +struct PlannedFile { + src: PathBuf, + dst: PathBuf, + kind: PlannedKind, + top_category: TopCategory, +} + +struct InstallPlan { + files: Vec, + skipped_mcp_json: Option<(PathBuf, PathBuf)>, +} + +fn plan_changes(layout: &RemoteLayout) -> Result { + let mut files = Vec::new(); + + if let Some(src_dir) = &layout.agents { + plan_dir_into( + src_dir, + &paths::agents_data_dir(), + TopCategory::Agents, + &mut files, + )?; + } + if let Some(src_dir) = &layout.roles { + plan_dir_into(src_dir, &paths::roles_dir(), TopCategory::Roles, &mut files)?; + } + if let Some(src_dir) = &layout.macros { + plan_dir_into( + src_dir, + &paths::macros_dir(), + TopCategory::Macros, + &mut files, + )?; + } + if let Some(src_dir) = &layout.functions_tools { + plan_dir_into( + src_dir, + &paths::functions_dir().join("tools"), + TopCategory::FunctionsTools, + &mut files, + )?; + } + + let skipped_mcp_json = layout + .mcp_json + .as_ref() + .map(|src| (src.clone(), paths::mcp_config_file())); + + Ok(InstallPlan { + files, + skipped_mcp_json, + }) +} + +fn plan_dir_into( + src_dir: &Path, + dst_dir: &Path, + category: TopCategory, + out: &mut Vec, +) -> Result<()> { + for src in walk_files(src_dir)? { + let rel = src + .strip_prefix(src_dir) + .expect("walk_files only returns paths under src_dir"); + let dst = dst_dir.join(rel); + let kind = classify_file(&src, &dst)?; + out.push(PlannedFile { + src, + dst, + kind, + top_category: category, + }); + } + Ok(()) +} + +fn classify_file(src: &Path, dst: &Path) -> Result { + if !dst.exists() { + return Ok(PlannedKind::New); + } + if files_equal(src, dst)? { + Ok(PlannedKind::Identical) + } else { + Ok(PlannedKind::Conflict) + } +} + +const LARGE_FILE_THRESHOLD: u64 = 8 * 1024 * 1024; + +fn files_equal(a: &Path, b: &Path) -> Result { + let a_meta = fs::metadata(a).with_context(|| format!("stat {}", a.display()))?; + let b_meta = fs::metadata(b).with_context(|| format!("stat {}", b.display()))?; + if a_meta.len() != b_meta.len() { + return Ok(false); + } + if a_meta.len() > LARGE_FILE_THRESHOLD { + files_equal_streaming(a, b) + } else { + let a_bytes = fs::read(a).with_context(|| format!("read {}", a.display()))?; + let b_bytes = fs::read(b).with_context(|| format!("read {}", b.display()))?; + Ok(a_bytes == b_bytes) + } +} + +fn files_equal_streaming(a: &Path, b: &Path) -> Result { + use std::io::Read; + let mut fa = fs::File::open(a).with_context(|| format!("open {}", a.display()))?; + let mut fb = fs::File::open(b).with_context(|| format!("open {}", b.display()))?; + let mut buf_a = [0u8; 8192]; + let mut buf_b = [0u8; 8192]; + loop { + let na = fa.read(&mut buf_a)?; + let nb = fb.read(&mut buf_b)?; + if na != nb { + return Ok(false); + } + if na == 0 { + return Ok(true); + } + if buf_a[..na] != buf_b[..nb] { + return Ok(false); + } + } +} + +fn print_plan_summary(plan: &InstallPlan) { + println!("Plan:"); + for cat in [ + TopCategory::Agents, + TopCategory::Roles, + TopCategory::Macros, + TopCategory::FunctionsTools, + ] { + let new_ = count_kind(plan, cat, PlannedKind::New); + let identical = count_kind(plan, cat, PlannedKind::Identical); + let conflict = count_kind(plan, cat, PlannedKind::Conflict); + if new_ + identical + conflict > 0 { + println!( + " {:<16} new={new_} identical={identical} conflict={conflict}", + cat.label() + ); + } + } +} + +fn count_kind(plan: &InstallPlan, cat: TopCategory, kind: PlannedKind) -> usize { + plan.files + .iter() + .filter(|p| p.top_category == cat && p.kind == kind) + .count() +} + +#[derive(Debug, Clone, Copy, PartialEq)] +enum StickyMode { + None, + KeepAll, + ReplaceAll, +} + +enum ConflictAction { + Keep, + Replace, +} + +struct ApplyReport { + new_count: usize, + identical_count: usize, + replaced_count: usize, + kept_count: usize, +} + +fn apply_plan(plan: &InstallPlan, force: bool) -> Result { + let mut report = ApplyReport { + new_count: 0, + identical_count: 0, + replaced_count: 0, + kept_count: 0, + }; + let mut sticky = if force { + StickyMode::ReplaceAll + } else { + StickyMode::None + }; + + for planned in &plan.files { + match planned.kind { + PlannedKind::New => { + write_file(&planned.src, &planned.dst)?; + report.new_count += 1; + } + PlannedKind::Identical => { + report.identical_count += 1; + } + PlannedKind::Conflict => match resolve_conflict(planned, &mut sticky)? { + ConflictAction::Keep => report.kept_count += 1, + ConflictAction::Replace => { + write_file(&planned.src, &planned.dst)?; + report.replaced_count += 1; + } + }, + } + } + + println!( + "\nInstalled: {} new, {} replaced, {} kept, {} identical.", + report.new_count, report.replaced_count, report.kept_count, report.identical_count + ); + + Ok(report) +} + +fn resolve_conflict(planned: &PlannedFile, sticky: &mut StickyMode) -> Result { + match *sticky { + StickyMode::KeepAll => return Ok(ConflictAction::Keep), + StickyMode::ReplaceAll => return Ok(ConflictAction::Replace), + StickyMode::None => {} + } + + if !*IS_STDOUT_TERMINAL { + bail!( + "Refusing to overwrite local file {} non-interactively. \ + Re-run with --install-force or in a terminal.", + planned.dst.display() + ); + } + + let prompt = format!( + "Conflict at {} (category: {})", + planned.dst.display(), + planned.top_category.label() + ); + let choice = Select::new( + &prompt, + vec!["keep", "replace", "keep-all", "replace-all", "abort"], + ) + .prompt() + .with_context(|| "failed to read conflict choice")?; + + match choice { + "keep" => Ok(ConflictAction::Keep), + "replace" => Ok(ConflictAction::Replace), + "keep-all" => { + *sticky = StickyMode::KeepAll; + Ok(ConflictAction::Keep) + } + "replace-all" => { + *sticky = StickyMode::ReplaceAll; + Ok(ConflictAction::Replace) + } + "abort" => bail!("Aborted by user."), + _ => unreachable!("inquire::Select returned an unexpected option"), + } +} + +fn write_file(src: &Path, dst: &Path) -> Result<()> { + if let Some(parent) = dst.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create directory {}", parent.display()))?; + } + fs::copy(src, dst) + .with_context(|| format!("failed to copy {} to {}", src.display(), dst.display()))?; + set_executable_bit_if_script(dst)?; + Ok(()) +} + +#[cfg(unix)] +fn set_executable_bit_if_script(path: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + let Some(ext) = path.extension().and_then(OsStr::to_str) else { + return Ok(()); + }; + if Language::from_extension(ext) == Language::Unsupported { + return Ok(()); + } + fs::set_permissions(path, fs::Permissions::from_mode(0o755)) + .with_context(|| format!("chmod {}", path.display()))?; + Ok(()) +} + +#[cfg(not(unix))] +fn set_executable_bit_if_script(_path: &Path) -> Result<()> { Ok(()) } @@ -252,4 +676,189 @@ mod tests { assert!(parse_url_with_ref("https://github.com/foo/bar.git#$inject").is_err()); assert!(parse_url_with_ref("https://github.com/foo/bar.git#;rm -rf /").is_err()); } + + fn touch(p: &Path) { + if let Some(parent) = p.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(p, b"").unwrap(); + } + + fn fresh_temp_dir(name: &str) -> PathBuf { + let dir = utils::temp_file(name, ""); + fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn remote_layout_is_empty_when_default() { + assert!(RemoteLayout::default().is_empty()); + } + + #[test] + fn remote_layout_is_not_empty_when_any_field_set() { + let l = RemoteLayout { + agents: Some(PathBuf::from("/x")), + ..RemoteLayout::default() + }; + assert!(!l.is_empty()); + } + + #[test] + fn apply_filter_none_passes_through() { + let l = RemoteLayout { + agents: Some(PathBuf::from("a")), + roles: Some(PathBuf::from("r")), + macros: Some(PathBuf::from("m")), + functions_tools: Some(PathBuf::from("f")), + mcp_json: Some(PathBuf::from("j")), + }; + let out = apply_filter(l, None); + assert!(out.agents.is_some() && out.roles.is_some() && out.macros.is_some()); + assert!(out.functions_tools.is_some() && out.mcp_json.is_some()); + } + + #[test] + fn apply_filter_functions_keeps_only_tools_not_mcp() { + let l = RemoteLayout { + agents: Some(PathBuf::from("a")), + roles: None, + macros: None, + functions_tools: Some(PathBuf::from("f")), + mcp_json: Some(PathBuf::from("j")), + }; + let out = apply_filter(l, Some(InstallFilter::Functions)); + assert!(out.agents.is_none()); + assert_eq!(out.functions_tools, Some(PathBuf::from("f"))); + assert!(out.mcp_json.is_none()); + } + + #[test] + fn apply_filter_mcp_config_keeps_only_mcp_json() { + let l = RemoteLayout { + agents: Some(PathBuf::from("a")), + roles: None, + macros: None, + functions_tools: Some(PathBuf::from("f")), + mcp_json: Some(PathBuf::from("j")), + }; + let out = apply_filter(l, Some(InstallFilter::McpConfig)); + assert!(out.agents.is_none() && out.functions_tools.is_none()); + assert_eq!(out.mcp_json, Some(PathBuf::from("j"))); + } + + #[test] + fn apply_filter_roles_keeps_only_roles() { + let l = RemoteLayout { + agents: Some(PathBuf::from("a")), + roles: Some(PathBuf::from("r")), + macros: Some(PathBuf::from("m")), + functions_tools: Some(PathBuf::from("f")), + mcp_json: Some(PathBuf::from("j")), + }; + let out = apply_filter(l, Some(InstallFilter::Roles)); + assert_eq!(out.roles, Some(PathBuf::from("r"))); + assert!(out.agents.is_none() && out.macros.is_none()); + assert!(out.functions_tools.is_none() && out.mcp_json.is_none()); + } + + #[test] + fn walk_files_skips_dot_git_and_collects_regular_files() { + let root = fresh_temp_dir("walk-test-"); + touch(&root.join("a.txt")); + touch(&root.join("sub/b.txt")); + touch(&root.join(".git/HEAD")); + touch(&root.join(".git/objects/pack/foo")); + + let mut files = walk_files(&root).unwrap(); + files.sort(); + let rels: Vec<_> = files + .iter() + .map(|p| p.strip_prefix(&root).unwrap().to_owned()) + .collect(); + + assert_eq!( + rels, + vec![PathBuf::from("a.txt"), PathBuf::from("sub/b.txt")] + ); + let _ = fs::remove_dir_all(&root); + } + + #[cfg(unix)] + #[test] + fn walk_files_rejects_symlink() { + let root = fresh_temp_dir("walk-symlink-test-"); + touch(&root.join("real.txt")); + std::os::unix::fs::symlink(root.join("real.txt"), root.join("link.txt")).unwrap(); + + let err = walk_files(&root).unwrap_err(); + assert!( + err.to_string().contains("Symlink not allowed"), + "got error: {err}" + ); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn scan_remote_layout_finds_known_subdirs() { + let root = fresh_temp_dir("scan-test-"); + fs::create_dir_all(root.join("agents/sample")).unwrap(); + fs::create_dir_all(root.join("roles")).unwrap(); + fs::create_dir_all(root.join("macros")).unwrap(); + fs::create_dir_all(root.join("functions/tools")).unwrap(); + touch(&root.join("functions/mcp.json")); + touch(&root.join("README.md")); + + let layout = scan_remote_layout(&root).unwrap(); + assert!(layout.agents.is_some()); + assert!(layout.roles.is_some()); + assert!(layout.macros.is_some()); + assert!(layout.functions_tools.is_some()); + assert!(layout.mcp_json.is_some()); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn scan_remote_layout_ignores_unrelated_files() { + let root = fresh_temp_dir("scan-unrelated-"); + fs::create_dir_all(root.join("docs")).unwrap(); + touch(&root.join("docs/intro.md")); + touch(&root.join("README.md")); + + let layout = scan_remote_layout(&root).unwrap(); + assert!(layout.is_empty()); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn classify_file_new_when_dst_missing() { + let dir = fresh_temp_dir("classify-new-"); + let src = dir.join("src"); + fs::write(&src, b"hello").unwrap(); + let dst = dir.join("dst"); + assert_eq!(classify_file(&src, &dst).unwrap(), PlannedKind::New); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn classify_file_identical_when_bytes_match() { + let dir = fresh_temp_dir("classify-identical-"); + let src = dir.join("src"); + let dst = dir.join("dst"); + fs::write(&src, b"same bytes").unwrap(); + fs::write(&dst, b"same bytes").unwrap(); + assert_eq!(classify_file(&src, &dst).unwrap(), PlannedKind::Identical); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn classify_file_conflict_when_bytes_differ() { + let dir = fresh_temp_dir("classify-conflict-"); + let src = dir.join("src"); + let dst = dir.join("dst"); + fs::write(&src, b"version A").unwrap(); + fs::write(&dst, b"version B").unwrap(); + assert_eq!(classify_file(&src, &dst).unwrap(), PlannedKind::Conflict); + let _ = fs::remove_dir_all(&dir); + } } diff --git a/src/function/mod.rs b/src/function/mod.rs index 0fab009..e9d0ec4 100644 --- a/src/function/mod.rs +++ b/src/function/mod.rs @@ -60,7 +60,13 @@ pub enum Language { impl From<&String> for Language { fn from(s: &String) -> Self { - match s.to_lowercase().as_str() { + Language::from_extension(s) + } +} + +impl Language { + pub fn from_extension(ext: &str) -> Self { + match ext.to_lowercase().as_str() { "sh" => Language::Bash, "py" => Language::Python, "ts" => Language::TypeScript,