mod guide;
mod hooks;
pub mod hosts;
mod mcp;
mod skill;
mod status;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use serde::Serialize;
pub use crate::setup_hooks::Mode;
use crate::setup_hooks::home_dir;
pub use status::run_agent_status;
pub const MARKER_VERSION: &str = "v1";
pub const MARKER_PREFIX: &str = "fallow:agent-install";
pub const SCHEMA_VERSION: u32 = 2;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, clap::ValueEnum)]
#[serde(rename_all = "snake_case")]
pub enum Harness {
Claude,
Codex,
Cursor,
}
impl Harness {
pub const ALL: [Self; 3] = [Self::Claude, Self::Codex, Self::Cursor];
pub const fn as_str(self) -> &'static str {
match self {
Self::Claude => "claude",
Self::Codex => "codex",
Self::Cursor => "cursor",
}
}
pub const fn display_name(self) -> &'static str {
match self {
Self::Claude => "Claude Code",
Self::Codex => "Codex",
Self::Cursor => "Cursor",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
pub enum HarnessArg {
Auto,
Claude,
Codex,
Cursor,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, clap::ValueEnum)]
#[serde(rename_all = "snake_case")]
pub enum Step {
Guide,
Skill,
Mcp,
Hooks,
}
impl Step {
pub const fn as_str(self) -> &'static str {
match self {
Self::Guide => "guide",
Self::Skill => "skill",
Self::Mcp => "mcp",
Self::Hooks => "hooks",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Reason {
SkillNameTaken,
SkillNotEmbedded,
McpEntryUnavailable,
McpEntryForeign,
MachineLocalLauncher,
ApprovalNotRequested,
SettingsLocalTracked,
ManualCommand,
UnsupportedHarness,
UserScopeUnsupported,
UserEdited,
InvalidJson,
InvalidToml,
NotAnObject,
ManagedBlockMalformed,
}
impl Reason {
#[cfg_attr(
not(test),
allow(dead_code, reason = "read by the documentation drift test")
)]
pub const ALL: [Self; 15] = [
Self::SkillNameTaken,
Self::SkillNotEmbedded,
Self::McpEntryUnavailable,
Self::McpEntryForeign,
Self::MachineLocalLauncher,
Self::ApprovalNotRequested,
Self::SettingsLocalTracked,
Self::ManualCommand,
Self::UnsupportedHarness,
Self::UserScopeUnsupported,
Self::UserEdited,
Self::InvalidJson,
Self::InvalidToml,
Self::NotAnObject,
Self::ManagedBlockMalformed,
];
pub const fn as_str(self) -> &'static str {
match self {
Self::SkillNameTaken => "skill_name_taken",
Self::SkillNotEmbedded => "skill_not_embedded",
Self::McpEntryUnavailable => "mcp_entry_unavailable",
Self::McpEntryForeign => "mcp_entry_foreign",
Self::MachineLocalLauncher => "machine_local_launcher",
Self::ApprovalNotRequested => "approval_not_requested",
Self::SettingsLocalTracked => "settings_local_tracked",
Self::ManualCommand => "manual_command",
Self::UnsupportedHarness => "unsupported_harness",
Self::UserScopeUnsupported => "user_scope_unsupported",
Self::UserEdited => "user_edited",
Self::InvalidJson => "invalid_json",
Self::InvalidToml => "invalid_toml",
Self::NotAnObject => "not_an_object",
Self::ManagedBlockMalformed => "managed_block_malformed",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum StepStatus {
Written,
Removed,
Unchanged,
Skipped,
Refused,
Failed,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Scope {
Shared,
Local,
}
#[derive(Clone, Debug, Serialize)]
pub struct StepReport {
pub harness: Option<Harness>,
pub step: Step,
pub status: StepStatus,
pub scope: Scope,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<Reason>,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
}
impl StepReport {
pub fn new(harness: Option<Harness>, step: Step, status: StepStatus, scope: Scope) -> Self {
Self {
harness,
step,
status,
scope,
path: None,
reason: None,
detail: None,
}
}
pub fn path(mut self, ctx: &Ctx, path: &Path) -> Self {
self.path = Some(display_path(&ctx.root, ctx.home.as_deref(), path));
self
}
pub fn reason(mut self, reason: Reason) -> Self {
self.reason = Some(reason);
self
}
pub fn detail(mut self, detail: impl Into<String>) -> Self {
self.detail = Some(detail.into());
self
}
pub fn failed(
harness: Option<Harness>,
step: Step,
scope: Scope,
message: impl Into<String>,
) -> Self {
Self::new(harness, step, StepStatus::Failed, scope).detail(message)
}
}
#[derive(Clone, Debug, Serialize)]
pub struct NextAction {
pub id: &'static str,
pub command: String,
pub reason: String,
pub mutating: bool,
}
pub fn display_path(root: &Path, home: Option<&Path>, path: &Path) -> String {
let root_rel = path.strip_prefix(root).ok();
let home_rel = home.and_then(|home| path.strip_prefix(home).ok());
let root_len = root.as_os_str().len();
let home_len = home.map_or(0, |home| home.as_os_str().len());
match (root_rel, home_rel) {
(Some(rel), Some(_)) if root_len >= home_len => slashes(rel),
(Some(rel), None) => slashes(rel),
(_, Some(rel)) => format!("~/{}", slashes(rel)),
(None, None) => slashes(path),
}
}
fn slashes(path: &Path) -> String {
path.display().to_string().replace('\\', "/")
}
pub struct AgentInstallOptions<'a> {
pub root: &'a Path,
pub root_explicit: bool,
pub harnesses: &'a [HarnessArg],
pub user: bool,
pub without: &'a [Step],
pub dry_run: bool,
pub force: bool,
pub approve: bool,
pub gitignore_claude: bool,
}
pub struct AgentUninstallOptions<'a> {
pub root: &'a Path,
pub root_explicit: bool,
pub harnesses: &'a [HarnessArg],
pub user: bool,
pub dry_run: bool,
pub force: bool,
}
pub struct Ctx {
pub root: PathBuf,
pub home: Option<PathBuf>,
pub user: bool,
pub dry_run: bool,
pub force: bool,
pub approve: bool,
pub gitignore_claude: bool,
pub mode: Mode,
}
impl Ctx {
pub fn scope_base(&self) -> Result<&Path, String> {
if self.user {
self.home.as_deref().ok_or_else(|| {
"Cannot resolve the user home directory; unset --user or set $HOME.".to_string()
})
} else {
Ok(&self.root)
}
}
pub const fn scope(&self) -> Scope {
if self.user {
Scope::Local
} else {
Scope::Shared
}
}
}
#[derive(Serialize)]
struct Report {
kind: &'static str,
schema_version: u32,
fallow_version: &'static str,
root: String,
mode: Mode,
dry_run: bool,
harnesses: Vec<Harness>,
detected: bool,
evidence: Vec<hosts::Detection>,
steps: Vec<StepReport>,
next_actions: Vec<NextAction>,
}
pub fn resolve_root(root: &Path, root_explicit: bool) -> PathBuf {
if root_explicit {
return dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
}
crate::base_worktree::git_toplevel(root)
.unwrap_or_else(|| dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()))
}
fn select_harnesses(
requested: &[HarnessArg],
root: &Path,
home: Option<&Path>,
) -> (Vec<Harness>, bool, Vec<hosts::Detection>) {
let auto = requested.is_empty() || requested.contains(&HarnessArg::Auto);
let evidence = hosts::detect(root, home);
if auto {
let harnesses = evidence.iter().map(|d| d.harness).collect();
return (harnesses, true, evidence);
}
let mut harnesses: Vec<Harness> = Harness::ALL
.into_iter()
.filter(|harness| {
requested.iter().any(|arg| match arg {
HarnessArg::Claude => *harness == Harness::Claude,
HarnessArg::Codex => *harness == Harness::Codex,
HarnessArg::Cursor => *harness == Harness::Cursor,
HarnessArg::Auto => false,
})
})
.collect();
harnesses.dedup();
(harnesses, false, evidence)
}
pub fn run_agent_install(
opts: &AgentInstallOptions<'_>,
output: fallow_config::OutputFormat,
json_style: crate::json_style::JsonStyle,
) -> ExitCode {
let root = resolve_root(opts.root, opts.root_explicit);
let home = home_dir();
let (harnesses, detected, evidence) = select_harnesses(opts.harnesses, &root, home.as_deref());
let ctx = Ctx {
root: root.clone(),
home,
user: opts.user,
dry_run: opts.dry_run,
force: opts.force,
approve: opts.approve,
gitignore_claude: opts.gitignore_claude,
mode: Mode::Install,
};
let skip = |step: Step| opts.without.contains(&step);
let mut steps: Vec<StepReport> = Vec::new();
if !skip(Step::Guide) {
steps.extend(guide::install(&ctx, &harnesses));
}
if !skip(Step::Skill) {
steps.extend(skill::install(&ctx, &harnesses));
}
let mcp_command = if skip(Step::Mcp) {
None
} else {
let command = mcp::resolve_command(&root);
steps.extend(mcp::install(&ctx, &harnesses, command.as_ref()));
command
};
if !skip(Step::Hooks) {
steps.extend(hooks::install(&ctx, &harnesses));
}
let next_actions = next_actions(&ctx, &harnesses, detected, mcp_command.as_ref(), &steps);
let report = Report {
kind: "agent-install",
schema_version: SCHEMA_VERSION,
fallow_version: env!("CARGO_PKG_VERSION"),
root: slashes(&root),
mode: Mode::Install,
dry_run: opts.dry_run,
harnesses,
detected,
evidence,
steps,
next_actions,
};
render(&report, output, json_style)
}
pub fn run_agent_uninstall(
opts: &AgentUninstallOptions<'_>,
output: fallow_config::OutputFormat,
json_style: crate::json_style::JsonStyle,
) -> ExitCode {
let root = resolve_root(opts.root, opts.root_explicit);
let home = home_dir();
let (harnesses, detected, evidence) = select_harnesses(opts.harnesses, &root, home.as_deref());
let ctx = Ctx {
root: root.clone(),
home,
user: opts.user,
dry_run: opts.dry_run,
force: opts.force,
approve: false,
gitignore_claude: false,
mode: Mode::Uninstall,
};
let mut steps: Vec<StepReport> = Vec::new();
steps.extend(hooks::uninstall(&ctx, &harnesses));
steps.extend(mcp::uninstall(&ctx, &harnesses));
steps.extend(skill::uninstall(&ctx, &harnesses));
steps.extend(guide::uninstall(&ctx, &harnesses));
let report = Report {
kind: "agent-uninstall",
schema_version: SCHEMA_VERSION,
fallow_version: env!("CARGO_PKG_VERSION"),
root: slashes(&root),
mode: Mode::Uninstall,
dry_run: opts.dry_run,
harnesses,
detected,
evidence,
steps,
next_actions: Vec::new(),
};
render(&report, output, json_style)
}
fn next_actions(
ctx: &Ctx,
harnesses: &[Harness],
detected: bool,
mcp_command: Option<&mcp::McpCommand>,
steps: &[StepReport],
) -> Vec<NextAction> {
let mut next: Vec<NextAction> = Vec::new();
if detected && harnesses.is_empty() {
next.push(NextAction {
id: "choose-harness",
command: "fallow agent install --harness claude".to_string(),
reason: "No harness was detected; pass --harness claude, codex, or cursor to wire one explicitly."
.to_string(),
mutating: true,
});
}
if let Some(command) = mcp_command {
if harnesses.contains(&Harness::Codex) && !ctx.user {
next.push(NextAction {
id: "codex-mcp-add",
command: format!("codex mcp add fallow -- {}", command.shell_words()),
reason: "A project-level .codex/config.toml only applies once Codex trusts the project; the user-level entry works immediately."
.to_string(),
mutating: true,
});
}
if harnesses.contains(&Harness::Claude) && ctx.user {
next.push(NextAction {
id: "claude-mcp-add-user",
command: format!("claude mcp add --scope user fallow -- {}", command.shell_words()),
reason: "fallow does not edit ~/.claude.json; register the user-scope server through the Claude CLI."
.to_string(),
mutating: true,
});
}
let approval_skipped = steps.iter().any(|step| {
step.harness == Some(Harness::Claude)
&& step.step == Step::Mcp
&& step.reason == Some(Reason::ApprovalNotRequested)
});
if approval_skipped {
next.push(NextAction {
id: "claude-approve-mcp",
command: "fallow agent install --harness claude --approve".to_string(),
reason: "Claude Code asks before starting a project-scoped MCP server; --approve records that approval for you in .claude/settings.local.json."
.to_string(),
mutating: true,
});
}
}
if !has_config_file(&ctx.root) {
next.push(NextAction {
id: "recommend-config",
command: "fallow recommend --format json".to_string(),
reason: "No fallow config was found; recommend proposes one from the detected stack without writing anything."
.to_string(),
mutating: false,
});
}
next
}
fn has_config_file(root: &Path) -> bool {
[
".fallowrc.json",
".fallowrc.jsonc",
"fallow.toml",
".fallow.toml",
]
.iter()
.any(|name| root.join(name).is_file())
}
fn render(
report: &Report,
output: fallow_config::OutputFormat,
json_style: crate::json_style::JsonStyle,
) -> ExitCode {
let blocked = report
.steps
.iter()
.any(|step| matches!(step.status, StepStatus::Refused | StepStatus::Failed));
let exit = if blocked {
ExitCode::from(2)
} else {
ExitCode::SUCCESS
};
match output {
fallow_config::OutputFormat::Json => match json_style.serialize(report) {
Ok(json) => {
crate::report::sink::outln!("{json}");
exit
}
Err(error) => crate::error::emit_error_with_style(
&format!("failed to serialize agent report: {error}"),
2,
output,
json_style,
),
},
fallow_config::OutputFormat::Human => {
print_human(report);
exit
}
_ => crate::error::emit_error("agent commands support human and json output", 2, output),
}
}
fn print_human(report: &Report) {
eprint!("{}", render_human(report));
}
fn render_human(report: &Report) -> String {
use std::fmt::Write as _;
let mut out = String::new();
let suffix = if report.dry_run { " (dry run)" } else { "" };
let _ = writeln!(out, "fallow agent {}{suffix}", report.mode.as_str());
let _ = writeln!(out, " root: {}", report.root);
let names: Vec<&str> = report.harnesses.iter().map(|h| h.as_str()).collect();
let origin = if report.detected {
"detected"
} else {
"from --harness"
};
if names.is_empty() {
let _ = writeln!(
out,
" harnesses: none {origin}; harness-neutral files only"
);
} else {
let _ = writeln!(out, " harnesses: {} ({origin})", names.join(", "));
}
let shared_header = if report.mode == Mode::Install {
"Shared with your team (commit these):"
} else {
"Shared with your team:"
};
for (header, scope) in [
(shared_header, Scope::Shared),
("Local to you:", Scope::Local),
] {
let rows: Vec<&StepReport> = report
.steps
.iter()
.filter(|step| step.scope == scope)
.collect();
if rows.is_empty() {
continue;
}
out.push('\n');
let _ = writeln!(out, "{header}");
for step in rows {
out.push_str(&render_step(step, report.dry_run));
}
}
let refused = report
.steps
.iter()
.filter(|step| step.status == StepStatus::Refused)
.count();
let failed = report
.steps
.iter()
.filter(|step| step.status == StepStatus::Failed)
.count();
if refused + failed > 0 {
out.push('\n');
let mut parts: Vec<String> = Vec::new();
if refused > 0 {
parts.push(format!(
"{refused} step{} refused (existing content is not fallow-managed; pass --force to replace it)",
if refused == 1 { "" } else { "s" }
));
}
if failed > 0 {
parts.push(format!(
"{failed} step{} failed",
if failed == 1 { "" } else { "s" }
));
}
let _ = writeln!(
out,
"{}; every other step still ran. Exit code 2.",
parts.join(", ")
);
} else if report.mode == Mode::Uninstall
&& report
.steps
.iter()
.all(|step| matches!(step.status, StepStatus::Unchanged | StepStatus::Skipped))
{
out.push('\n');
let _ = writeln!(out, "Nothing to remove.");
}
if !report.next_actions.is_empty() {
out.push('\n');
let _ = writeln!(out, "Next:");
for next in &report.next_actions {
let _ = writeln!(out, " {}", next.command);
let _ = writeln!(out, " {}", next.reason);
}
}
out
}
fn render_step(step: &StepReport, dry_run: bool) -> String {
let status = match (step.status, dry_run) {
(StepStatus::Written, true) => "would write",
(StepStatus::Written, false) => "written",
(StepStatus::Removed, true) => "would remove",
(StepStatus::Removed, false) => "removed",
(StepStatus::Unchanged, _) => "unchanged",
(StepStatus::Skipped, _) => "skipped",
(StepStatus::Refused, _) => "refused",
(StepStatus::Failed, _) => "failed",
};
let label = match step.harness {
Some(harness) => format!("{} ({})", step.step.as_str(), harness.as_str()),
None => step.step.as_str().to_string(),
};
let path = step.path.as_deref().unwrap_or("-");
let mut note = String::new();
if let Some(reason) = step.reason {
note.push_str(reason.as_str());
}
if let Some(detail) = &step.detail {
if !note.is_empty() {
note.push_str(": ");
}
note.push_str(detail);
}
if note.is_empty() {
format!(" {path:<40} {status:<13} {label}\n")
} else {
format!(" {path:<40} {status:<13} {label:<16} {note}\n")
}
}
#[cfg(test)]
mod tests;