use anyhow::Context as _;
use std::io::IsTerminal as _;
pub(crate) use super::guided_autostart::github_host;
use super::guided_launch::launch_new_session_and_attach;
use super::managed::filter_live_sessions;
use crate::formatters::info_box::DaemonInfo;
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum PickerDecision {
Resume(usize),
LaunchNew,
Quit,
Unrecognised,
}
pub(crate) async fn run_guided_default(client: &reqwest::Client, url: &str) -> anyhow::Result<()> {
if let Some(result) = super::guided_inplace::try_inplace_relaunch(client, url).await {
return result;
}
let cwd = std::env::current_dir().context("cannot resolve current directory")?;
let workdir = cwd.to_string_lossy().to_string();
eprintln!("tm: no subcommand — using guided default for {workdir}");
super::managed_root::try_register_alias(&cwd);
let mut effective_url = url.to_string();
if let Some((source_id, workspace, git_root)) = derive_project(&cwd) {
let repo_url = git_root.to_string_lossy().to_string();
if let Some(r) = try_show_picker(
client,
&effective_url,
&source_id,
&workspace,
&repo_url,
&cwd,
)
.await
{
return r;
}
eprintln!("tm: daemon not running — starting it…");
match super::guided_autostart::ensure_daemon_started(client, &effective_url).await {
Ok(new_url) => {
effective_url = new_url;
if let Some(r) = try_show_picker(
client,
&effective_url,
&source_id,
&workspace,
&repo_url,
&cwd,
)
.await
{
return r;
}
eprintln!("tm: daemon started but sessions still unreachable; falling back");
}
Err(e) => eprintln!("tm: auto-start failed ({e}); falling back to offline mode"),
}
}
fallback_protected(client, &effective_url, &cwd).await
}
async fn try_show_picker(
client: &reqwest::Client,
url: &str,
source_id: &str,
workspace: &std::path::Path,
repo_url: &str,
cwd: &std::path::Path,
) -> Option<anyhow::Result<()>> {
let sessions = filter_live_sessions(list_project_sessions(client, url, source_id).await.ok()?);
if !tty_gate(
std::io::stdin().is_terminal(),
source_id,
workspace,
&sessions,
) {
return Some(Ok(()));
}
let daemon = DaemonInfo::from_lock_file_with_probe().with_count(sessions.len());
crate::formatters::banner::print_daily_banner(&cwd.to_string_lossy(), &daemon);
Some(run_tty_picker(client, url, repo_url, source_id, sessions).await)
}
pub(crate) fn derive_project(
cwd: &std::path::Path,
) -> Option<(String, std::path::PathBuf, std::path::PathBuf)> {
use trusty_mpm::daemon::managed_routes::inproject;
let git_root = find_git_root(cwd)?;
let origin_url = inproject::get_origin_url(&git_root).filter(|u| is_github_remote(u))?;
let gh = trusty_common::github_path::parse_github_path(&origin_url)?;
let source_id = format!("{}/{}", gh.owner, gh.repo);
let workspace = inproject::base_clone_path(&gh.owner, &gh.repo);
Some((source_id, workspace, git_root))
}
async fn list_project_sessions(
client: &reqwest::Client,
base_url: &str,
source_id: &str,
) -> anyhow::Result<Vec<trusty_mpm::client::ManagedSessionSummary>> {
let resp = client
.get(format!("{base_url}/api/v1/sessions/managed"))
.query(&[("source_id", source_id)])
.send()
.await?
.error_for_status()?;
let body: trusty_mpm::client::ManagedListResponse = resp.json().await?;
Ok(body.sessions)
}
pub(crate) fn tty_gate(
is_tty: bool,
source_id: &str,
workspace: &std::path::Path,
sessions: &[trusty_mpm::client::ManagedSessionSummary],
) -> bool {
print_project_context(source_id, workspace, sessions);
if !is_tty {
print_non_tty_hint(source_id, sessions);
}
is_tty
}
pub(crate) fn print_project_context(
source_id: &str,
workspace: &std::path::Path,
sessions: &[trusty_mpm::client::ManagedSessionSummary],
) {
eprintln!("tm: project: {source_id}");
eprintln!(
"tm: workspace: {} (live checkout is NOT touched)",
workspace.display()
);
if sessions.is_empty() {
eprintln!("tm: sessions: (none)");
} else {
eprintln!("tm: sessions:");
for (i, s) in sessions.iter().enumerate() {
let activity = s.last_activity_at.as_deref().unwrap_or("—");
eprintln!(
"tm: [{}] {} state={} last={activity}",
i + 1,
s.name,
s.state
);
}
}
}
pub(crate) fn print_non_tty_hint(
source_id: &str,
sessions: &[trusty_mpm::client::ManagedSessionSummary],
) {
eprintln!("tm: (stdin is not a TTY — run `tm` from an interactive terminal to use the picker)");
if sessions.is_empty() {
eprintln!("tm: to launch a new session: start the daemon and run `tm` from a TTY");
} else {
let n = sessions.len();
eprintln!("tm: {n} session(s) found for {source_id}");
eprintln!("tm: to resume: tmux attach-session -t {}", sessions[0].name);
eprintln!("tm: to launch: run `tm` from an interactive terminal");
}
}
pub(crate) fn parse_picker_choice(line: &str, session_count: usize) -> PickerDecision {
let choice = line.trim();
if choice.eq_ignore_ascii_case("q") {
return PickerDecision::Quit;
}
if choice.is_empty() {
return if session_count > 0 {
PickerDecision::Resume(0)
} else {
PickerDecision::LaunchNew
};
}
if let Ok(n) = choice.parse::<usize>() {
if n >= 1 && n <= session_count {
return PickerDecision::Resume(n - 1);
}
if n == session_count + 1 {
return PickerDecision::LaunchNew;
}
}
PickerDecision::Unrecognised
}
async fn run_tty_picker(
client: &reqwest::Client,
url: &str,
repo_url: &str,
source_id: &str,
mut sessions: Vec<trusty_mpm::client::ManagedSessionSummary>,
) -> anyhow::Result<()> {
loop {
eprintln!();
let new_idx = sessions.len() + 1;
if sessions.is_empty() {
eprintln!("tm: [Enter] launch new session");
eprintln!("tm: [q] quit");
} else {
for (i, s) in sessions.iter().enumerate() {
let stopped = matches!(s.state.as_str(), "stopped" | "errored");
let verb = if stopped { "restart" } else { "resume" };
eprintln!("tm: [{}] {} {} ({})", i + 1, verb, s.name, s.state);
}
eprintln!("tm: [{new_idx}] launch new session");
eprintln!("tm: [q] quit");
eprintln!("tm: default: [1] resume/restart most recent");
}
eprint!("tm: > ");
let mut line = String::new();
let n = std::io::stdin()
.read_line(&mut line)
.context("failed to read choice from stdin")?;
if n == 0 {
break;
}
match parse_picker_choice(&line, sessions.len()) {
PickerDecision::Quit => {
eprintln!("tm: quit.");
break;
}
PickerDecision::Resume(i) => {
super::guided_resume::resume_guided_session(client, url, &sessions[i]).await?
}
PickerDecision::LaunchNew => {
launch_new_session_and_attach(client, url, repo_url).await?
}
PickerDecision::Unrecognised => {
eprintln!("tm: unrecognised choice '{}'; quitting.", line.trim());
break;
}
}
let r = list_project_sessions(client, url, source_id).await?;
sessions = filter_live_sessions(r);
}
Ok(())
}
pub(crate) fn non_github_refusal_message(remote_desc: &str) -> String {
format!(
"tm: not auto-managing this project — `tm` auto-manages GitHub repositories only.\n\
tm: detected non-GitHub remote: {remote_desc}.\n\
tm: your live checkout was not touched. \
(Use an explicit `tm` subcommand to work here manually.)"
)
}
pub(crate) fn is_github_remote(url: &str) -> bool {
let lower = url.to_ascii_lowercase();
let host = github_host(&lower);
host == "github.com" || host.starts_with("github-") || host.starts_with("github_")
}
fn find_git_root(cwd: &std::path::Path) -> Option<std::path::PathBuf> {
let out = std::process::Command::new("git")
.arg("-C")
.arg(cwd)
.args(["rev-parse", "--show-toplevel"])
.output()
.ok()
.filter(|o| o.status.success())?;
let root = String::from_utf8_lossy(&out.stdout);
let root = root.trim();
(!root.is_empty()).then_some(std::path::PathBuf::from(root))
}
pub(crate) async fn fallback_protected(
client: &reqwest::Client,
url: &str,
cwd: &std::path::Path,
) -> anyhow::Result<()> {
if let Some(git_root) = find_git_root(cwd) {
let origin_url = trusty_mpm::daemon::managed_routes::inproject::get_origin_url(&git_root);
if let Some(ref raw_url) = origin_url
&& is_github_remote(raw_url)
{
return redirect_to_managed_clone(client, url, cwd, raw_url).await;
}
let remote_desc = origin_url.as_deref().unwrap_or("(no remote configured)");
eprintln!("{}", non_github_refusal_message(remote_desc));
anyhow::bail!(
"not auto-managing: live git checkout at '{}' is a non-GitHub repository — \
auto-managed clones require a GitHub remote; \
use an explicit `tm` subcommand to work here manually",
git_root.display()
);
}
eprintln!("{}", super::misc::NON_GIT_FALLBACK_HINT);
Ok(())
}
async fn redirect_to_managed_clone(
client: &reqwest::Client,
url: &str,
cwd: &std::path::Path,
origin_url: &str,
) -> anyhow::Result<()> {
use trusty_mpm::daemon::managed_routes::inproject;
use trusty_mpm::session_manager::ManagedSessionId;
let Some(gh) = trusty_common::github_path::parse_github_path(origin_url) else {
eprintln!(
"tm: cannot determine GitHub project from remote URL '{origin_url}'.\n\
Start the daemon first with `tm start`, then run `tm` again."
);
anyhow::bail!(
"daemon unreachable: cannot parse GitHub remote URL as owner/repo — run `tm start` first"
);
};
let base = inproject::base_clone_path(&gh.owner, &gh.repo);
eprintln!(
"tm: daemon unreachable — redirecting to protected managed clone\n\
tm: base clone: {}",
base.display()
);
if let Err(e) = inproject::ensure_base_clone(origin_url, &base) {
eprintln!(
"tm: could not set up base clone for {}/{}: {e}\n\
Start the daemon first with `tm start`, then run `tm` again.",
gh.owner, gh.repo
);
anyhow::bail!("failed to set up managed base clone: {e}");
}
let session_id = ManagedSessionId::new();
let worktree = match inproject::create_session_worktree(&base, &session_id.to_string()) {
Ok(p) => p,
Err(e) => {
eprintln!(
"tm: could not create per-session worktree: {e}\n\
Start the daemon first with `tm start`, then run `tm` again."
);
anyhow::bail!("failed to create session worktree: {e}");
}
};
eprintln!(
"tm: launching in protected workspace (live checkout at {} is untouched)\n\
tm: session worktree: {}",
cwd.display(),
worktree.display()
);
let dir = worktree.to_string_lossy().to_string();
super::launch::launch(client, url, Some(dir), None).await
}