mod config;
pub(crate) mod launch;
mod poller;
mod preflight;
mod register;
mod seed;
mod session_name;
pub(crate) mod status;
use std::path::{Path, PathBuf};
use anyhow::{Context, bail};
use trusty_mpm::core::project_config::PROJECT_CONFIG_FILE;
use self::config::Edit;
use self::session_name::SessionNames;
use crate::cli::FleetAction;
pub(crate) use self::launch::ARCHITECT_SESSION;
pub(crate) use self::status::status;
pub(crate) const DEFAULT_DIR: &str = "trusty-mpm-projects/architect";
pub(crate) async fn run(action: FleetAction) -> anyhow::Result<()> {
let home = dirs::home_dir().context("cannot resolve the home directory")?;
match action {
FleetAction::Init {
dir,
no_launch,
session,
} => {
let session = session.as_deref().map(SessionNames::new).transpose()?;
let dir = resolve_dir(dir.as_deref(), &home)?;
let mut report = init_with_session(&dir, &home, !no_launch, TMUX, session.as_ref())?;
if let Some(reg) = report.registration.take() {
report.steps.push(register::register(®).await);
}
print!("{}", report.render());
if report.failed() {
bail!("tm fleet init failed; see the FAILED step above");
}
Ok(())
}
FleetAction::Status { dir, json, session } => {
let session = session.as_deref().map(SessionNames::new).transpose()?;
let dir = resolve_dir(dir.as_deref(), &home)?;
let mut report = status(&dir, &home, TMUX, session.as_ref());
report.this_session = Some(status::this_session_check(
&report.dir,
status::this_session_env(),
trusty_mpm::core::config::MpmConfig::load_default,
));
if json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
print!("{}", report.render());
}
if !report.complete {
bail!("the Architect is not fully set up in {}", dir.display());
}
Ok(())
}
}
}
pub(crate) fn resolve_dir(dir: Option<&str>, home: &Path) -> anyhow::Result<PathBuf> {
if let Some(dir) = dir {
return std::path::absolute(dir).with_context(|| format!("invalid --dir {dir:?}"));
}
let path = user_config_path(home);
let recorded = read_or_empty(&path)
.and_then(|raw| config::parse_user_config(&raw, &path))
.map(|(_, typed)| config::architects(&typed))
.unwrap_or_default();
match recorded.as_slice() {
[] => Ok(home.join(DEFAULT_DIR)),
[one] => Ok(one.clone()),
many => bail!(
"{} records more than one Architect in `[supervisor] projects` ({}); pass --dir",
path.display(),
many.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", ")
),
}
}
#[derive(Clone, Copy)]
pub(crate) struct Probe {
pub(crate) pane: fn(&str) -> launch::PaneState,
pub(crate) stamp: fn(&str) -> Option<String>,
pub(crate) claude: fn(&str) -> Option<u32>,
}
pub(crate) const TMUX: Probe = Probe {
pane: launch::pane_state,
stamp: launch::launch_stamp,
claude: launch::claude_pid,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Step {
Changed(String),
Unchanged(String),
Skipped(String),
Failed(String),
}
#[derive(Debug, Default)]
pub(crate) struct InitReport {
pub(crate) steps: Vec<Step>,
pub(crate) unbound: bool,
pub(crate) registration: Option<trusty_mpm::session_manager::SupervisorRegistration>,
}
impl InitReport {
pub(crate) fn changed(&self) -> bool {
self.steps.iter().any(|s| matches!(s, Step::Changed(_)))
}
pub(crate) fn failed(&self) -> bool {
self.steps.iter().any(|s| matches!(s, Step::Failed(_)))
}
pub(crate) fn render(&self) -> String {
let mut out = String::new();
for step in &self.steps {
let (mark, text) = match step {
Step::Changed(t) => ("done ", t),
Step::Unchanged(t) => ("unchanged", t),
Step::Skipped(t) => ("skipped ", t),
Step::Failed(t) => ("FAILED ", t),
};
out.push_str(&format!(" {mark} {text}\n"));
}
out.push_str(if self.failed() {
"The Architect is not fully set up: fix the FAILED step and run `tm fleet init` again.\n"
} else if self.unbound {
"Architect set up (NOT bound: anchor writes will be denied; see the warning above). \
Check it with `tm fleet status`.\n"
} else if self.changed() {
"Architect set up. Check it with `tm fleet status`.\n"
} else {
"Nothing changed: the Architect is already set up.\n"
});
out
}
}
#[cfg(test)]
pub(crate) fn init(
dir: &Path,
home: &Path,
launch: bool,
probe: Probe,
) -> anyhow::Result<InitReport> {
init_with_session(dir, home, launch, probe, None)
}
pub(crate) fn init_with_session(
dir: &Path,
home: &Path,
launch: bool,
probe: Probe,
session: Option<&SessionNames>,
) -> anyhow::Result<InitReport> {
let checked = preflight::check(dir, home)?;
let dir = checked.as_path();
let config_path = user_config_path(home);
let config_raw = read_or_empty(&config_path)?;
let (config_doc, user_config) = config::parse_user_config(&config_raw, &config_path)?;
let project_path = dir.join(PROJECT_CONFIG_FILE);
let project_raw = read_or_empty(&project_path)?;
config::request_supervisor_profile(&project_raw, &project_path)?;
let current = match session_name::recorded(&config_doc, &config_path)? {
Some(name) => SessionNames::new(&name)?,
None => SessionNames::default_names(),
};
let names = session.cloned().unwrap_or_else(|| current.clone());
if names != current {
refuse_rename(¤t, &names, probe)?;
}
refuse_second_architect(&user_config, dir, probe, &names)?;
let mut report = InitReport::default();
report.steps.push(if dir.is_dir() {
Step::Unchanged(format!("project directory {}", dir.display()))
} else {
std::fs::create_dir_all(dir).with_context(|| format!("cannot create {}", dir.display()))?;
Step::Changed(format!("created project directory {}", dir.display()))
});
report.steps.push(git_init(dir)?);
let edit = config::request_supervisor_profile(&project_raw, &project_path)?;
report.steps.push(write_edit(
&project_path,
edit,
"`profile = \"supervisor\"` in",
)?);
let edit = config::add_allowlist_entry(&config_raw, dir, &config_path)?;
let session_edit = match (session, &edit) {
(None, _) => None,
(Some(_), Edit::Changed(text)) => {
Some(session_name::record_name(text, &names, &config_path)?)
}
(Some(_), Edit::Unchanged) => Some(session_name::record_name(
&config_raw,
&names,
&config_path,
)?),
};
let label = format!("`[supervisor] projects` entry {} in", dir.display());
report.steps.push(write_edit(&config_path, edit, &label)?);
if let Some(edit) = session_edit {
let label = format!("`[supervisor] session = \"{}\"` in", names.architect());
report.steps.push(write_edit(&config_path, edit, &label)?);
}
report.steps.extend(seed::deploy(dir)?);
report.steps.extend(register::prune_step(home, probe));
let (step, unbound) = launch_step(dir, home, launch, probe, &names)?;
if !unbound && !matches!(step, Step::Skipped(_)) {
report.registration = Some(register::registration(dir, &names));
}
report.steps.push(step);
report.unbound = unbound;
report.steps.push(poller::step(dir, launch, probe, &names));
Ok(report)
}
pub(crate) fn user_config_path(home: &Path) -> PathBuf {
home.join(".trusty-mpm").join("config.toml")
}
fn read_or_empty(path: &Path) -> anyhow::Result<String> {
match std::fs::read_to_string(path) {
Ok(raw) => Ok(raw),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
Err(err) => Err(err).with_context(|| format!("cannot read {}", path.display())),
}
}
fn refuse_rename(
current: &SessionNames,
wanted: &SessionNames,
probe: Probe,
) -> anyhow::Result<()> {
for session in [current.architect(), current.poll()] {
match (probe.pane)(session) {
launch::PaneState::Absent => {}
launch::PaneState::Unknown(err) => bail!(
"cannot rename the Architect session to {}: tmux session {session} cannot be \
read ({err})",
wanted.architect()
),
launch::PaneState::Live(_) | launch::PaneState::Dead(_) => bail!(
"cannot rename the Architect session to {}: the recorded session {session} \
still exists; stop it with `tmux kill-session -t ={session}` first, or run \
without --session",
wanted.architect()
),
}
}
Ok(())
}
fn refuse_second_architect(
user_config: &trusty_mpm::core::config::MpmConfig,
dir: &Path,
probe: Probe,
names: &SessionNames,
) -> anyhow::Result<()> {
if let Some(other) = config::other_architects(user_config, dir).first() {
bail!(
"an Architect is already set up at {}; there is one per user. Run `tm fleet status \
--dir {}`, or remove that entry from `[supervisor] projects` first",
other.display(),
other.display()
);
}
let wanted = std::fs::canonicalize(dir).ok();
let session = names.architect();
match (probe.pane)(session).dir() {
Some(running) if Some(&running) != wanted.as_ref() => bail!(
"tmux session {session} already runs in {}; there is one Architect per user",
running.display()
),
_ => Ok(()),
}
}
fn git_init(dir: &Path) -> anyhow::Result<Step> {
if dir.join(".git").exists() {
return Ok(Step::Unchanged(
"git repository (no remote added)".to_owned(),
));
}
let out = preflight::git()
.args(["init", "--quiet"])
.arg(dir)
.output()
.context("failed to run `git init`")?;
if !out.status.success() {
bail!(
"`git init` failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(Step::Changed(
"initialised a local git repository with no remote".to_owned(),
))
}
fn write_edit(path: &Path, edit: Edit, label: &str) -> anyhow::Result<Step> {
let what = format!("{label} {}", path.display());
match edit {
Edit::Unchanged => Ok(Step::Unchanged(what)),
Edit::Changed(text) => {
trusty_common::atomic_file::write_atomic(path, text.as_bytes())
.with_context(|| format!("cannot write {}", path.display()))?;
Ok(Step::Changed(format!("wrote {what}")))
}
}
}
fn launch_step(
dir: &Path,
home: &Path,
launch: bool,
probe: Probe,
names: &SessionNames,
) -> anyhow::Result<(Step, bool)> {
let session = names.architect();
if (probe.pane)(session).dir().is_some() {
return Ok(match status::bound(dir, home, probe, session) {
Ok(_) => (
Step::Unchanged(format!(
"tmux session {session} is running, bound as the Architect"
)),
false,
),
Err(why) => {
let text = format!(
"tmux session {session} is running but is not a bound Architect ({why}); \
exit claude and kill the session, then re-run `tm fleet init`"
);
eprintln!("warning: {text}");
(Step::Unchanged(text), true)
}
});
}
if !launch {
return Ok((
Step::Skipped(format!(
"session start (--no-launch); start it with `tm fleet init --dir {}`",
dir.display()
)),
false,
));
}
let (binding, conversation) = launch::start(dir, home, session)?;
use trusty_mpm::core::architect_conversation::ConversationStart;
if let ConversationStart::Fresh {
reason: Some(_), ..
} = &conversation
{
eprintln!("warning: {}", conversation.describe());
}
let (bound, unbound) = match binding {
Ok(record) => (format!("bound to claude pid {}", record.pid), false),
Err(err) => {
eprintln!("warning: the Architect process was NOT recorded: {err}");
let text = format!(
"NOT bound to its claude ({err}), so it cannot write the trust anchors; stop \
the session and re-run `tm fleet init` to bind it"
);
(text, true)
}
};
let step = Step::Changed(format!(
"started tmux session {session} on the `opus` alias, {bound}, {} with remote control \
on; attach with `tmux attach -t ={session}`",
conversation.describe()
));
Ok((step, unbound))
}
#[cfg(test)]
#[path = "tests.rs"]
mod tests;