use trusty_mpm::client::DaemonClient;
use trusty_mpm::client::http_client::projects::{ProjectStatusWire, RegisterProjectArgs};
use trusty_mpm::project::Project;
use trusty_mpm::project_config::{self, ConfigEdit};
use crate::cli::ConfigAction;
pub(crate) struct RegisterInput {
pub name: String,
pub repo_url: String,
pub default_branch: Option<String>,
pub description: Option<String>,
pub tags: Vec<String>,
pub stack_hint: Option<String>,
pub gh_user: Option<String>,
pub gh_account: Option<String>,
}
fn daemon(client: &reqwest::Client, url: &str) -> DaemonClient {
DaemonClient::with_client(client.clone(), url.to_string())
}
pub(crate) async fn list(
client: &reqwest::Client,
url: &str,
json: bool,
tag: Option<String>,
) -> anyhow::Result<()> {
let projects = daemon(client, url)
.registry_list_projects(tag.as_deref())
.await?;
if json {
println!("{}", serde_json::to_string_pretty(&projects)?);
return Ok(());
}
if projects.is_empty() {
println!("no projects registered");
return Ok(());
}
for p in &projects {
println!("{}", render_project_line(p));
}
Ok(())
}
pub(crate) async fn register(
client: &reqwest::Client,
url: &str,
input: RegisterInput,
) -> anyhow::Result<()> {
let args = RegisterProjectArgs {
name: input.name,
repo_url: input.repo_url,
default_branch: input.default_branch,
description: input.description,
tags: if input.tags.is_empty() {
None
} else {
Some(input.tags)
},
stack_hint: input.stack_hint,
gh_user: input.gh_user,
gh_account: input.gh_account,
worktree: None,
};
let project = daemon(client, url).registry_register_project(&args).await?;
println!(
"registered project '{}' ({} @ {})",
project.name, project.repo_url, project.default_branch
);
Ok(())
}
pub(crate) async fn show(
client: &reqwest::Client,
url: &str,
name: &str,
json: bool,
) -> anyhow::Result<()> {
let daemon = daemon(client, url);
let project = daemon.registry_get_project(name).await?;
let sessions = match daemon.fleet_managed_sessions().await {
Ok(groups) => groups
.into_iter()
.find(|g| g.project_name == project.name)
.map(|g| g.sessions)
.unwrap_or_default(),
Err(e) => {
eprintln!("warning: could not read sessions: {e}");
Vec::new()
}
};
if json {
let out = serde_json::json!({
"project": project,
"sessions": sessions.iter().map(|s| serde_json::json!({
"id": s.id, "name": s.name, "state": s.state,
"branch": s.branch, "task": s.task,
})).collect::<Vec<_>>(),
});
println!("{}", serde_json::to_string_pretty(&out)?);
return Ok(());
}
println!("{}", render_project_line(&project));
if let Some(desc) = &project.description {
println!(" description: {desc}");
}
if !project.tags.is_empty() {
println!(" tags: {}", project.tags.join(", "));
}
if let Some(gh) = &project.gh_user {
println!(" gh_user: {gh}");
}
if let Some(gh) = &project.gh_account {
println!(" gh_account: {gh}");
}
println!(
" sessions ({}) [read-only โ mutate via `tm sessions <verb>`]:",
sessions.len()
);
if sessions.is_empty() {
println!(" (none)");
} else {
for s in &sessions {
let branch = s.branch.as_deref().unwrap_or("-");
println!(" {} {} [{}] {}", s.id, s.name, s.state, branch);
}
}
Ok(())
}
pub(crate) async fn status(
client: &reqwest::Client,
url: &str,
name: &str,
json: bool,
) -> anyhow::Result<()> {
let status = daemon(client, url).project_status(name).await?;
if json {
let out = serde_json::json!({
"project_name": status.project_name,
"repo_url": status.repo_url,
"sessions": {
"provisioning": status.sessions.provisioning,
"active": status.sessions.active,
"stopped": status.sessions.stopped,
"errored": status.sessions.errored,
"decommissioned": status.sessions.decommissioned,
"total": status.sessions.total,
},
"last_activity_at": status.last_activity_at,
"config": {
"gh_user_set": status.config.gh_user_set,
"github_binding_set": status.config.github_binding_set,
"gh_account_set": status.config.gh_account_set,
},
});
println!("{}", serde_json::to_string_pretty(&out)?);
return Ok(());
}
for line in render_status(&status) {
println!("{line}");
}
Ok(())
}
pub(crate) async fn config(
client: &reqwest::Client,
url: &str,
name: &str,
json: bool,
action: Option<ConfigAction>,
) -> anyhow::Result<()> {
match action {
None => config_view(client, url, name, json).await,
Some(ConfigAction::Set {
field: crate::cli::SettableConfigField::Worktree,
value,
}) => {
let normalized = normalize_bool_flag(&value)?;
config_apply(
client,
url,
name,
ConfigEdit::Set(project_config::ConfigField::Worktree, normalized),
)
.await
}
Some(ConfigAction::Set { field, value }) => {
config_apply(client, url, name, ConfigEdit::Set(field.into(), value)).await
}
Some(ConfigAction::Unset { field }) => {
config_apply(client, url, name, ConfigEdit::Unset(field.into())).await
}
Some(ConfigAction::Tags { add, remove }) => {
if add.is_empty() && remove.is_empty() {
anyhow::bail!("tags: provide --add and/or --remove");
}
config_apply(client, url, name, ConfigEdit::Tags { add, remove }).await
}
}
}
async fn config_view(
client: &reqwest::Client,
url: &str,
name: &str,
json: bool,
) -> anyhow::Result<()> {
let project = daemon(client, url).registry_get_project(name).await?;
if json {
println!("{}", serde_json::to_string_pretty(&project)?);
return Ok(());
}
println!("{}", render_config_view(&project));
Ok(())
}
async fn config_apply(
client: &reqwest::Client,
url: &str,
name: &str,
edit: ConfigEdit,
) -> anyhow::Result<()> {
let args = project_config::build_patch_args(&edit);
let project = daemon(client, url)
.registry_patch_project(name, &args)
.await?;
println!("updated project '{}'", project.name);
println!("{}", render_config_view(&project));
Ok(())
}
fn render_config_view(p: &Project) -> String {
let lines = [
format!("{} ({})", p.name, p.repo_url),
format!(" default_branch: {}", p.default_branch),
format!(
" description: {}",
p.description.as_deref().unwrap_or("(unset)")
),
format!(
" tags: {}",
if p.tags.is_empty() {
"(none)".to_string()
} else {
p.tags.join(", ")
}
),
format!(
" stack_hint: {}",
p.stack_hint.as_deref().unwrap_or("(unset)")
),
format!(" gh_user: {}", p.gh_user.as_deref().unwrap_or("(unset)")),
format!(
" gh_account: {}",
p.gh_account.as_deref().unwrap_or("(unset)")
),
format!(
" worktree: {}",
if p.worktree_enabled() {
"true (isolated per-session worktree)"
} else {
"false (launch on main โ #3455)"
}
),
];
lines.join("\n")
}
fn normalize_bool_flag(value: &str) -> anyhow::Result<String> {
match value.trim().to_ascii_lowercase().as_str() {
"true" => Ok("true".to_string()),
"false" => Ok("false".to_string()),
other => anyhow::bail!("worktree: expected 'true' or 'false', got {other:?}"),
}
}
fn render_project_line(p: &Project) -> String {
format!("{}\t{}\t({})", p.name, p.repo_url, p.default_branch)
}
fn render_status(s: &ProjectStatusWire) -> Vec<String> {
let c = &s.sessions;
let activity = s
.last_activity_at
.map(|t| t.to_rfc3339())
.unwrap_or_else(|| "never".to_string());
vec![
format!("{} ({})", s.project_name, s.repo_url),
format!(
" sessions: {} total ({} active, {} provisioning, {} stopped, {} errored, {} decommissioned)",
c.total, c.active, c.provisioning, c.stopped, c.errored, c.decommissioned
),
format!(" last activity: {activity}"),
format!(
" config: gh_user {}, gh_account {}, gh-binding {}",
if s.config.gh_user_set { "set" } else { "unset" },
if s.config.gh_account_set {
"set"
} else {
"unset"
},
if s.config.github_binding_set {
"set"
} else {
"unset"
}
),
]
}
#[cfg(test)]
mod tests {
use super::*;
use trusty_mpm::client::http_client::projects::{
ProjectConfigFlagsWire, SessionStateCountsWire,
};
fn project() -> Project {
Project {
name: "widget".into(),
repo_url: "https://github.com/acme/widget".into(),
default_branch: "main".into(),
stack_hint: None,
tags: vec![],
description: None,
gh_user: None,
gh_account: None,
github: None,
commit_name: None,
commit_email: None,
worktree: None,
}
}
#[test]
fn render_project_line_basic() {
let line = render_project_line(&project());
assert!(line.contains("widget"));
assert!(line.contains("https://github.com/acme/widget"));
assert!(line.contains("main"));
}
#[test]
fn normalize_bool_flag_accepts_true_false_case_insensitive() {
assert_eq!(normalize_bool_flag("true").unwrap(), "true");
assert_eq!(normalize_bool_flag("FALSE").unwrap(), "false");
assert_eq!(normalize_bool_flag(" True ").unwrap(), "true");
}
#[test]
fn normalize_bool_flag_rejects_other_values() {
assert!(normalize_bool_flag("yes").is_err());
assert!(normalize_bool_flag("1").is_err());
assert!(normalize_bool_flag("").is_err());
}
#[test]
fn render_config_view_shows_worktree_status() {
let mut p = project();
assert!(
render_config_view(&p).contains("worktree: true"),
"default (unset) worktree must render as the true/isolated default"
);
p.worktree = Some(false);
assert!(
render_config_view(&p).contains("worktree: false"),
"an explicit opt-out must render as false"
);
}
#[test]
fn render_status_line_counts() {
let s = ProjectStatusWire {
project_name: "widget".into(),
repo_url: "https://github.com/acme/widget".into(),
sessions: SessionStateCountsWire {
provisioning: 1,
active: 2,
stopped: 0,
errored: 1,
decommissioned: 0,
total: 4,
},
last_activity_at: Some(
chrono::DateTime::parse_from_rfc3339("2026-07-10T12:00:00Z")
.unwrap()
.with_timezone(&chrono::Utc),
),
config: ProjectConfigFlagsWire {
gh_user_set: true,
github_binding_set: false,
gh_account_set: true,
},
};
let lines = render_status(&s);
assert!(lines[0].contains("widget"));
assert!(lines[1].contains("4 total"));
assert!(lines[1].contains("2 active"));
assert!(lines[2].contains("2026-07-10"));
assert!(lines[3].contains("gh_user set"));
assert!(lines[3].contains("gh_account set"));
}
#[test]
fn render_config_view_shows_every_field() {
let mut p = project();
p.description = Some("the widget".to_string());
p.tags = vec!["backend".to_string()];
p.stack_hint = Some("rust".to_string());
p.gh_user = Some("acme-bot".to_string());
p.gh_account = Some("acme-bot".to_string());
let rendered = render_config_view(&p);
assert!(rendered.contains("default_branch: main"));
assert!(rendered.contains("description: the widget"));
assert!(rendered.contains("tags: backend"));
assert!(rendered.contains("stack_hint: rust"));
assert!(rendered.contains("gh_user: acme-bot"));
assert!(rendered.contains("gh_account: acme-bot"));
}
#[test]
fn render_config_view_shows_unset_placeholders() {
let rendered = render_config_view(&project());
assert!(rendered.contains("description: (unset)"));
assert!(rendered.contains("tags: (none)"));
assert!(rendered.contains("stack_hint: (unset)"));
assert!(rendered.contains("gh_user: (unset)"));
assert!(rendered.contains("gh_account: (unset)"));
}
#[test]
fn render_status_no_activity() {
let s = ProjectStatusWire {
project_name: "widget".into(),
repo_url: "u".into(),
sessions: SessionStateCountsWire::default(),
last_activity_at: None,
config: ProjectConfigFlagsWire::default(),
};
let lines = render_status(&s);
assert!(lines[2].contains("never"));
assert!(lines[3].contains("gh_user unset"));
}
}