use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use anyhow::Context;
use super::load::load_alias;
use super::registry::ManagedRegistry;
pub fn build_launch_command_configured(
repo_path: &Path,
claude_config_dir: &Path,
api_key: Option<&str>,
mcp_config: Option<&Path>,
alternate_screen: bool,
) -> Command {
let mut cmd = Command::new("claude");
cmd.current_dir(repo_path);
crate::core::claude_env_scrub::scrub_command(&mut cmd);
crate::core::alt_screen::apply_configured_to_command(&mut cmd, alternate_screen);
cmd.env("CLAUDE_CONFIG_DIR", claude_config_dir);
cmd.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
cmd.arg(crate::core::model_inject::PERMISSION_MODE_FLAG);
cmd.args(crate::core::session_mcp_scope::mcp_config_argv(mcp_config));
if let Some(key) = api_key
&& !key.trim().is_empty()
{
cmd.arg("--bare");
}
cmd
}
pub fn build_launch_command_with_prompt(
repo_path: &Path,
claude_config_dir: &Path,
api_key: Option<&str>,
mcp_config: Option<&Path>,
prompt_file: &Path,
alternate_screen: bool,
) -> Command {
let mut cmd = build_launch_command_configured(
repo_path,
claude_config_dir,
api_key,
mcp_config,
alternate_screen,
);
cmd.arg("--append-system-prompt-file").arg(prompt_file);
cmd
}
fn run_launch_command(
config_root: Option<&Path>,
repo_path: &Path,
claude_config_dir: &Path,
api_key: Option<&str>,
mcp_config: Option<&Path>,
prompt_file: &Path,
) -> Command {
build_launch_command_with_prompt(
repo_path,
claude_config_dir,
api_key,
mcp_config,
prompt_file,
crate::core::alt_screen::configured_alternate_screen_in(config_root),
)
}
pub fn build_launch_command(
repo_path: &Path,
claude_config_dir: &Path,
api_key: Option<&str>,
mcp_config: Option<&Path>,
) -> Command {
build_launch_command_configured(
repo_path,
claude_config_dir,
api_key,
mcp_config,
crate::core::alt_screen::configured_alternate_screen(),
)
}
pub fn build_login_command(claude_config_dir: &Path) -> Command {
let mut cmd = Command::new("claude");
cmd.args(["auth", "login"]);
cmd.env("CLAUDE_CONFIG_DIR", claude_config_dir);
cmd.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
cmd
}
#[derive(Debug, PartialEq, Eq)]
pub enum AuthState {
ApiKey,
CredentialsFile,
Unknown,
}
pub fn check_credentials(claude_config_dir: &Path, api_key: Option<&str>) -> AuthState {
if let Some(key) = api_key
&& !key.trim().is_empty()
{
return AuthState::ApiKey;
}
if claude_config_dir.join(".credentials.json").exists() {
return AuthState::CredentialsFile;
}
AuthState::Unknown
}
pub fn run_alias(alias: &str, managed_root: &Path, claude_config_dir: &Path) -> anyhow::Result<()> {
let repo_path = load_alias(alias, managed_root, claude_config_dir)
.with_context(|| format!("failed to load alias '{alias}'"))?;
let api_key = std::env::var("ANTHROPIC_API_KEY").ok();
match check_credentials(claude_config_dir, api_key.as_deref()) {
AuthState::ApiKey => {
}
AuthState::CredentialsFile => {
}
AuthState::Unknown => {
eprintln!(
"hint: no ANTHROPIC_API_KEY and no .credentials.json found in {}.\n\
If you haven't run `tm login` yet, do so once to authenticate\n\
managed sessions on your Claude Max/Pro plan.\n\
Alternatively, export ANTHROPIC_API_KEY to use the API-key path.",
claude_config_dir.display()
);
}
}
let mcp_config = crate::core::session_mcp_scope::provision(&repo_path, claude_config_dir)
.context("failed to compose the session-scoped MCP config")?;
let content = crate::core::content_source::framework_content_for(&repo_path)
.context("cannot compose the PM instructions")?;
let cli = crate::core::session_launch::cli_launch(&content, &repo_path, None);
let prompt_file = crate::core::model_inject::write_pm_prompt_file_in(
&std::env::temp_dir(),
&cli.prompt,
&repo_path,
"run",
)?;
let mut cmd = run_launch_command(
crate::core::alt_screen::operator_config_root().as_deref(),
&repo_path,
claude_config_dir,
api_key.as_deref(),
Some(&mcp_config),
&prompt_file,
);
let (stamp_name, stamp_value) = crate::core::session_profile::launch_env(cli.profile);
cmd.env(stamp_name, stamp_value);
crate::core::spawn_disclaim::disclaimed_status(&mut cmd)
.context("failed to spawn 'claude'; is it installed and on PATH?")?;
Ok(())
}
pub fn resolve_repo_path(alias: &str, managed_root: &Path) -> anyhow::Result<PathBuf> {
let registry = ManagedRegistry::load(managed_root)
.with_context(|| format!("failed to load registry from {}", managed_root.display()))?;
registry
.get(alias)
.with_context(|| format!("alias '{alias}' is not registered"))?;
let repo = managed_root.join("projects").join(alias).join("repo");
let marker = repo.join(".trusty-mpm").join("managed.toml");
if !marker.exists() {
anyhow::bail!("alias '{alias}' is registered but not loaded; run `tm load {alias}` first");
}
Ok(repo)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_build_launch_command_carries_the_mcp_config_flag_and_not_strict() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("repo");
let cfg = tmp.path().join("claude-config");
let composed = tmp.path().join("state").join("session-mcp").join("ab.json");
let cmd = build_launch_command_configured(&repo, &cfg, None, Some(&composed), false);
let args: Vec<String> = cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert!(
!args.iter().any(|a| a == "--strict-mcp-config"),
"the launch line must never narrow the user scope: {args:?}"
);
let pos = args
.iter()
.position(|a| a == "--mcp-config")
.expect("--mcp-config must be present");
assert_eq!(
args[pos + 1],
composed.display().to_string(),
"the flag must name the file the caller composed, unquoted for exec"
);
}
#[test]
fn test_build_launch_command_omits_the_mcp_config_flag_without_a_file() {
let tmp = TempDir::new().unwrap();
let cmd = build_launch_command_configured(
&tmp.path().join("repo"),
&tmp.path().join("claude-config"),
None,
None,
false,
);
let args: Vec<String> = cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert!(
!args
.iter()
.any(|a| a == "--strict-mcp-config" || a == "--mcp-config"),
"no composed file means no flag naming one: {args:?}"
);
}
#[test]
fn test_build_launch_command_sets_env_and_cwd() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("repo");
let cfg = tmp.path().join("claude-config");
let cmd = build_launch_command_configured(&repo, &cfg, None, None, false);
assert_eq!(cmd.get_program(), "claude");
assert_eq!(cmd.get_current_dir(), Some(repo.as_path()));
let env_vars: Vec<_> = cmd.get_envs().collect();
let claude_cfg_var = env_vars
.iter()
.find(|(k, _)| *k == std::ffi::OsStr::new("CLAUDE_CONFIG_DIR"));
assert!(
claude_cfg_var.is_some(),
"CLAUDE_CONFIG_DIR should be set in the command env"
);
if let Some((_, val)) = claude_cfg_var {
assert_eq!(*val, Some(cfg.as_os_str()));
}
}
#[test]
fn test_build_launch_command_adds_bare_with_api_key() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("repo");
let cfg = tmp.path().join("claude-config");
let cmd =
build_launch_command_configured(&repo, &cfg, Some("sk-ant-test-key"), None, false);
let args: Vec<_> = cmd.get_args().collect();
assert!(
args.iter().any(|a| a == &std::ffi::OsStr::new("--bare")),
"expected --bare in args when api_key is set; got {args:?}"
);
}
#[test]
fn test_build_launch_command_no_bare_without_api_key() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("repo");
let cfg = tmp.path().join("claude-config");
let cmd = build_launch_command_configured(&repo, &cfg, None, None, false);
let args: Vec<_> = cmd.get_args().collect();
assert!(
!args.iter().any(|a| a == &std::ffi::OsStr::new("--bare")),
"--bare must not be present when api_key is None; got {args:?}"
);
let cmd2 = build_launch_command_configured(&repo, &cfg, Some(""), None, false);
let args2: Vec<_> = cmd2.get_args().collect();
assert!(
!args2.iter().any(|a| a == &std::ffi::OsStr::new("--bare")),
"--bare must not be present for empty api_key; got {args2:?}"
);
let cmd3 = build_launch_command_configured(&repo, &cfg, Some(" "), None, false);
let args3: Vec<_> = cmd3.get_args().collect();
assert!(
!args3.iter().any(|a| a == &std::ffi::OsStr::new("--bare")),
"--bare must not be present for whitespace-only key; got {args3:?}"
);
}
#[test]
fn test_build_launch_command_includes_bypass_permissions() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("repo");
let cfg = tmp.path().join("claude-config");
let cmd = build_launch_command_configured(&repo, &cfg, None, None, false);
let args: Vec<_> = cmd.get_args().collect();
assert!(
args.iter()
.any(|a| *a == std::ffi::OsStr::new("--dangerously-skip-permissions")),
"expected --dangerously-skip-permissions in args (no key); got {args:?}"
);
let cmd2 =
build_launch_command_configured(&repo, &cfg, Some("sk-ant-test-key"), None, false);
let args2: Vec<_> = cmd2.get_args().collect();
assert!(
args2
.iter()
.any(|a| *a == std::ffi::OsStr::new("--dangerously-skip-permissions")),
"expected --dangerously-skip-permissions with api key; got {args2:?}"
);
assert!(
args2.iter().any(|a| *a == std::ffi::OsStr::new("--bare")),
"expected --bare to co-exist with bypass flag; got {args2:?}"
);
}
#[test]
fn test_build_launch_command_scrubs_inherited_session_markers() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("repo");
let cfg = tmp.path().join("claude-config");
let cmd = build_launch_command_configured(&repo, &cfg, None, None, false);
let envs: Vec<(String, Option<String>)> = cmd
.get_envs()
.map(|(k, v)| {
(
k.to_string_lossy().into_owned(),
v.map(|v| v.to_string_lossy().into_owned()),
)
})
.collect();
for marker in [
"CLAUDE_CODE_CHILD_SESSION",
"CLAUDE_CODE_SESSION_ID",
"CLAUDECODE",
"CLAUDE_PID",
"CLAUDE_EFFORT",
"CLAUDE_CODE_EXECPATH",
] {
assert!(
envs.contains(&(marker.to_owned(), None)),
"{marker} must be REMOVED (None) for the `tm run` session: {envs:?}"
);
}
assert!(
envs.contains(&(
"CLAUDE_CONFIG_DIR".to_owned(),
Some(cfg.display().to_string())
)),
"CLAUDE_CONFIG_DIR must survive the scrub as a set value: {envs:?}"
);
}
#[test]
fn test_build_launch_command_never_passes_on_the_profile_stamp() {
let tmp = TempDir::new().unwrap();
let cmd = build_launch_command_configured(tmp.path(), tmp.path(), None, None, false);
assert!(
cmd.get_envs()
.any(|(k, v)| k == "TRUSTY_MPM_SESSION_PROFILE" && v.is_none()),
"the profile stamp must be REMOVED for the `tm run` session"
);
}
#[test]
fn run_launch_command_carries_the_prompt_file() {
let tmp = TempDir::new().unwrap();
let prompt = tmp.path().join("pm prompt.txt");
let cmd = run_launch_command(
None,
&tmp.path().join("repo"),
&tmp.path().join("cfg"),
None,
None,
&prompt,
);
let args: Vec<String> = cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
let pos = args
.iter()
.position(|a| a == "--append-system-prompt-file")
.expect("tm run must carry --append-system-prompt-file");
assert_eq!(args[pos + 1], prompt.display().to_string(), "{args:?}");
}
#[test]
fn run_launch_command_follows_the_configured_renderer() {
use crate::core::alt_screen::ALT_SCREEN_ENV_VAR;
let tmp = TempDir::new().unwrap();
for (alternate_screen, want) in [(true, "0"), (false, "1")] {
let root = TempDir::new().unwrap();
std::fs::write(
root.path().join("config.yaml"),
format!("tmux:\n alternate_screen: {alternate_screen}\n"),
)
.unwrap();
let cmd = run_launch_command(
Some(root.path()),
&tmp.path().join("repo"),
&tmp.path().join("cfg"),
None,
None,
&tmp.path().join("p.txt"),
);
let carried = cmd
.get_envs()
.find(|(k, _)| *k == ALT_SCREEN_ENV_VAR)
.and_then(|(_, v)| v.map(|v| v.to_string_lossy().into_owned()));
assert_eq!(
carried.as_deref(),
Some(want),
"alternate_screen={alternate_screen}"
);
}
}
#[test]
fn test_build_launch_command_assigns_the_configured_renderer() {
use crate::core::alt_screen::ALT_SCREEN_ENV_VAR;
let tmp = TempDir::new().unwrap();
for (alternate_screen, want) in [(true, "0"), (false, "1")] {
let cmd = build_launch_command_configured(
&tmp.path().join("repo"),
&tmp.path().join("cfg"),
None,
None,
alternate_screen,
);
let carried = cmd
.get_envs()
.find(|(k, _)| *k == ALT_SCREEN_ENV_VAR)
.and_then(|(_, v)| v.map(|v| v.to_string_lossy().into_owned()));
assert_eq!(
carried.as_deref(),
Some(want),
"alternate_screen={alternate_screen}"
);
}
}
#[test]
fn test_build_launch_command_defaults_the_mouse_capture_off() {
use crate::core::alt_screen::{MOUSE_DEFAULT, MOUSE_ENV_VAR};
let tmp = TempDir::new().unwrap();
let cmd = build_launch_command_configured(
&tmp.path().join("repo"),
&tmp.path().join("cfg"),
None,
None,
false,
);
let carried_by_the_launch = std::env::var_os(MOUSE_ENV_VAR).is_some();
let provisioned = cmd.get_envs().any(|(k, v)| {
k == MOUSE_ENV_VAR && v.is_some_and(|v| v == std::ffi::OsStr::new(MOUSE_DEFAULT))
});
assert_eq!(
provisioned, !carried_by_the_launch,
"tm run must provision {MOUSE_ENV_VAR}={MOUSE_DEFAULT} when the \
launch carries no value, and leave an operator value untouched when it does"
);
}
#[test]
fn test_build_login_command_sets_env_and_invocation() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().join("claude-config");
let cmd = build_login_command(&cfg);
assert_eq!(cmd.get_program(), "claude");
let args: Vec<_> = cmd.get_args().collect();
assert_eq!(
args,
vec![std::ffi::OsStr::new("auth"), std::ffi::OsStr::new("login")],
"expected [auth, login], got {args:?}"
);
let env_vars: Vec<_> = cmd.get_envs().collect();
let cfg_var = env_vars
.iter()
.find(|(k, _)| *k == std::ffi::OsStr::new("CLAUDE_CONFIG_DIR"));
assert!(
cfg_var.is_some(),
"CLAUDE_CONFIG_DIR must be set on the login command"
);
if let Some((_, val)) = cfg_var {
assert_eq!(*val, Some(cfg.as_os_str()));
}
}
#[test]
fn test_check_credentials_with_key_param() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().to_path_buf();
assert_eq!(
check_credentials(&cfg, Some("sk-test-key")),
AuthState::ApiKey
);
assert_eq!(check_credentials(&cfg, Some("")), AuthState::Unknown);
assert_eq!(check_credentials(&cfg, Some(" ")), AuthState::Unknown);
assert_eq!(check_credentials(&cfg, None), AuthState::Unknown);
}
#[test]
fn test_check_credentials_with_file() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().to_path_buf();
std::fs::write(cfg.join(".credentials.json"), r#"{"token":"t"}"#).unwrap();
assert_eq!(check_credentials(&cfg, None), AuthState::CredentialsFile);
assert_eq!(
check_credentials(&cfg, Some("sk-also-valid")),
AuthState::ApiKey
);
}
}