From 7b7ec434dfc88081728d3eacbd786e4821defbee Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 10:33:07 -0700 Subject: [PATCH] feat: add ecc2 package manager harness env --- ecc2/src/session/manager.rs | 168 +++++++++++++++++++++++++++++++++++- 1 file changed, 167 insertions(+), 1 deletion(-) diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index 9f1bc164..d8fd1e55 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -3228,6 +3228,10 @@ fn apply_shared_harness_runtime_env( command.env("CLAUDE_SESSION_ID", session_id); command.env("CLAUDE_PROJECT_DIR", working_dir); command.env("CLAUDE_CODE_ENTRYPOINT", "cli"); + if let Some(package_manager) = resolve_project_package_manager(working_dir) { + command.env("CLAUDE_PACKAGE_MANAGER", package_manager); + command.env("CLAUDE_CODE_PACKAGE_MANAGER", package_manager); + } if let Some(model) = profile.and_then(|profile| profile.model.as_ref()) { command.env("CLAUDE_MODEL", model); } @@ -3259,6 +3263,75 @@ fn is_ecc_plugin_root(candidate: &Path) -> bool { candidate.join("scripts/lib/utils.js").is_file() && candidate.join("hooks/hooks.json").is_file() } +fn resolve_project_package_manager(working_dir: &Path) -> Option<&'static str> { + if let Ok(package_manager) = std::env::var("CLAUDE_PACKAGE_MANAGER") { + if let Some(package_manager) = normalize_package_manager_name(&package_manager) { + return Some(package_manager); + } + } + + read_package_manager_from_json( + &working_dir.join(".claude").join("package-manager.json"), + "packageManager", + ) + .or_else(|| read_package_manager_from_package_json(&working_dir.join("package.json"))) + .or_else(|| detect_package_manager_from_lockfile(working_dir)) + .or_else(|| { + dirs::home_dir().and_then(|home_dir| { + read_package_manager_from_json( + &home_dir.join(".claude").join("package-manager.json"), + "packageManager", + ) + }) + }) + .or(Some("npm")) +} + +fn read_package_manager_from_json(path: &Path, field_name: &str) -> Option<&'static str> { + let content = std::fs::read_to_string(path).ok()?; + let value: serde_json::Value = serde_json::from_str(&content).ok()?; + value + .get(field_name) + .and_then(|value| value.as_str()) + .and_then(normalize_package_manager_name) +} + +fn read_package_manager_from_package_json(path: &Path) -> Option<&'static str> { + let package_manager = read_package_manager_from_json(path, "packageManager")?; + Some(package_manager) +} + +fn detect_package_manager_from_lockfile(working_dir: &Path) -> Option<&'static str> { + [ + ("pnpm", "pnpm-lock.yaml"), + ("bun", "bun.lockb"), + ("yarn", "yarn.lock"), + ("npm", "package-lock.json"), + ] + .into_iter() + .find_map(|(package_manager, lockfile)| { + working_dir + .join(lockfile) + .is_file() + .then_some(package_manager) + }) +} + +fn normalize_package_manager_name(package_manager: &str) -> Option<&'static str> { + let canonical = package_manager + .split('@') + .next() + .unwrap_or(package_manager) + .trim(); + match canonical { + "npm" => Some("npm"), + "pnpm" => Some("pnpm"), + "yarn" => Some("yarn"), + "bun" => Some("bun"), + _ => None, + } +} + fn normalize_task_for_harness( harness: HarnessKind, task: &str, @@ -4576,6 +4649,69 @@ mod tests { ); } + #[test] + fn build_agent_command_exports_detected_package_manager_env_from_lockfile() -> Result<()> { + let tempdir = TestDir::new("manager-package-manager-lockfile")?; + let repo_root = tempdir.path().join("repo"); + fs::create_dir_all(&repo_root)?; + write_package_manager_project_files(&repo_root, None, Some("pnpm-lock.yaml"), None)?; + + let cfg = Config::default(); + let command = build_agent_command( + &cfg, + "codex", + Path::new("codex"), + "inspect dependency graph", + "sess-pnpm", + &repo_root, + None, + ); + let envs = command_env_map(&command); + assert_eq!( + envs.get("CLAUDE_PACKAGE_MANAGER"), + Some(&"pnpm".to_string()) + ); + assert_eq!( + envs.get("CLAUDE_CODE_PACKAGE_MANAGER"), + Some(&"pnpm".to_string()) + ); + Ok(()) + } + + #[test] + fn build_agent_command_prefers_project_package_manager_config_over_lockfile() -> Result<()> { + let tempdir = TestDir::new("manager-package-manager-config")?; + let repo_root = tempdir.path().join("repo"); + fs::create_dir_all(&repo_root)?; + write_package_manager_project_files( + &repo_root, + Some("pnpm@9.0.0"), + Some("package-lock.json"), + Some("yarn"), + )?; + + let cfg = Config::default(); + let command = build_agent_command( + &cfg, + "codex", + Path::new("codex"), + "inspect dependency graph", + "sess-yarn", + &repo_root, + None, + ); + let envs = command_env_map(&command); + assert_eq!( + envs.get("CLAUDE_PACKAGE_MANAGER"), + Some(&"yarn".to_string()) + ); + assert_eq!( + envs.get("CLAUDE_CODE_PACKAGE_MANAGER"), + Some(&"yarn".to_string()) + ); + Ok(()) + } + #[test] fn build_session_record_canonicalizes_known_agent_aliases() -> Result<()> { let tempdir = TestDir::new("manager-canonical-agent-type")?; @@ -4868,7 +5004,7 @@ mod tests { let script_path = root.join("fake-claude.sh"); let log_path = root.join("fake-claude.log"); let script = format!( - "#!/usr/bin/env python3\nimport os\nimport pathlib\nimport signal\nimport sys\nimport time\n\nlog_path = pathlib.Path(r\"{}\")\nlog_path.write_text(os.getcwd() + \"\\n\", encoding=\"utf-8\")\nwith log_path.open(\"a\", encoding=\"utf-8\") as handle:\n handle.write(\" \".join(sys.argv[1:]) + \"\\n\")\n handle.write(\"ECC_SESSION_ID=\" + os.environ.get(\"ECC_SESSION_ID\", \"\") + \"\\n\")\n handle.write(\"CLAUDE_SESSION_ID=\" + os.environ.get(\"CLAUDE_SESSION_ID\", \"\") + \"\\n\")\n handle.write(\"CLAUDE_PROJECT_DIR=\" + os.environ.get(\"CLAUDE_PROJECT_DIR\", \"\") + \"\\n\")\n handle.write(\"CLAUDE_CODE_ENTRYPOINT=\" + os.environ.get(\"CLAUDE_CODE_ENTRYPOINT\", \"\") + \"\\n\")\n handle.write(\"CLAUDE_PLUGIN_ROOT=\" + os.environ.get(\"CLAUDE_PLUGIN_ROOT\", \"\") + \"\\n\")\n handle.write(\"ECC_HARNESS=\" + os.environ.get(\"ECC_HARNESS\", \"\") + \"\\n\")\n\ndef handle_term(signum, frame):\n raise SystemExit(0)\n\nsignal.signal(signal.SIGTERM, handle_term)\nwhile True:\n time.sleep(0.1)\n", + "#!/usr/bin/env python3\nimport os\nimport pathlib\nimport signal\nimport sys\nimport time\n\nlog_path = pathlib.Path(r\"{}\")\nlog_path.write_text(os.getcwd() + \"\\n\", encoding=\"utf-8\")\nwith log_path.open(\"a\", encoding=\"utf-8\") as handle:\n handle.write(\" \".join(sys.argv[1:]) + \"\\n\")\n handle.write(\"ECC_SESSION_ID=\" + os.environ.get(\"ECC_SESSION_ID\", \"\") + \"\\n\")\n handle.write(\"CLAUDE_SESSION_ID=\" + os.environ.get(\"CLAUDE_SESSION_ID\", \"\") + \"\\n\")\n handle.write(\"CLAUDE_PROJECT_DIR=\" + os.environ.get(\"CLAUDE_PROJECT_DIR\", \"\") + \"\\n\")\n handle.write(\"CLAUDE_CODE_ENTRYPOINT=\" + os.environ.get(\"CLAUDE_CODE_ENTRYPOINT\", \"\") + \"\\n\")\n handle.write(\"CLAUDE_PACKAGE_MANAGER=\" + os.environ.get(\"CLAUDE_PACKAGE_MANAGER\", \"\") + \"\\n\")\n handle.write(\"CLAUDE_CODE_PACKAGE_MANAGER=\" + os.environ.get(\"CLAUDE_CODE_PACKAGE_MANAGER\", \"\") + \"\\n\")\n handle.write(\"CLAUDE_PLUGIN_ROOT=\" + os.environ.get(\"CLAUDE_PLUGIN_ROOT\", \"\") + \"\\n\")\n handle.write(\"ECC_HARNESS=\" + os.environ.get(\"ECC_HARNESS\", \"\") + \"\\n\")\n\ndef handle_term(signum, frame):\n raise SystemExit(0)\n\nsignal.signal(signal.SIGTERM, handle_term)\nwhile True:\n time.sleep(0.1)\n", log_path.display() ); @@ -4911,11 +5047,39 @@ mod tests { .collect() } + fn write_package_manager_project_files( + repo_root: &Path, + package_manager_field: Option<&str>, + lockfile_name: Option<&str>, + project_config_package_manager: Option<&str>, + ) -> Result<()> { + let package_json = match package_manager_field { + Some(package_manager_field) => format!( + "{{\"name\":\"ecc-smoke\",\"packageManager\":\"{package_manager_field}\"}}\n" + ), + None => "{\"name\":\"ecc-smoke\"}\n".to_string(), + }; + fs::write(repo_root.join("package.json"), package_json)?; + if let Some(lockfile_name) = lockfile_name { + fs::write(repo_root.join(lockfile_name), "lockfile\n")?; + } + if let Some(project_config_package_manager) = project_config_package_manager { + let claude_dir = repo_root.join(".claude"); + fs::create_dir_all(&claude_dir)?; + fs::write( + claude_dir.join("package-manager.json"), + format!("{{\"packageManager\":\"{project_config_package_manager}\"}}\n"), + )?; + } + Ok(()) + } + #[tokio::test(flavor = "current_thread")] async fn create_session_spawns_process_and_marks_session_running() -> Result<()> { let tempdir = TestDir::new("manager-create-session")?; let repo_root = tempdir.path().join("repo"); init_git_repo(&repo_root)?; + write_package_manager_project_files(&repo_root, None, Some("pnpm-lock.yaml"), None)?; let cfg = build_config(tempdir.path()); let db = StateStore::open(&cfg.db_path)?; @@ -4952,6 +5116,8 @@ mod tests { repo_root.to_string_lossy() ))); assert!(log.contains("CLAUDE_CODE_ENTRYPOINT=cli")); + assert!(log.contains("CLAUDE_PACKAGE_MANAGER=pnpm")); + assert!(log.contains("CLAUDE_CODE_PACKAGE_MANAGER=pnpm")); assert!(log.contains("ECC_HARNESS=claude")); stop_session_with_options(&db, &session_id, false).await?;