use anyhow::{bail, Context, Result};
use arete_mcp::protocol_agent::{ProtocolAgent, SQUADS_PROPOSER_LOW_LAMPORTS};
use clap::{Args, Subcommand};
use colored::Colorize;
use serde_json::{json, Value};
#[derive(Subcommand, Debug)]
pub enum SquadsCommands {
Proposer(ProposerArgs),
Status(StatusArgs),
}
#[derive(Args, Debug, Clone, Default)]
pub struct SquadsTarget {
pub target: Option<String>,
#[arg(long)]
pub dev: bool,
#[arg(long, value_name = "DIR")]
pub project: Option<String>,
}
impl SquadsTarget {
fn is_dev(&self) -> bool {
self.dev || self.project.is_some() || arete_mcp::dev::environment_is_dev().unwrap_or(false)
}
fn tool_target(&self) -> Result<arete_mcp::protocol_agent::TargetArgs> {
super::protocol_target(self.target.as_deref(), self.dev, self.project.as_deref())
}
fn project_flag(&self) -> String {
self.project
.as_deref()
.map(|project| format!(" --project {project}"))
.unwrap_or_default()
}
}
#[derive(Args, Debug, Clone, Default)]
pub struct ProposerArgs {
#[command(flatten)]
pub target: SquadsTarget,
#[arg(long, value_name = "URL")]
pub rpc_url: Option<String>,
}
#[derive(Args, Debug, Clone, Default)]
pub struct StatusArgs {
#[command(flatten)]
pub target: SquadsTarget,
#[arg(long, value_name = "ADDRESS")]
pub multisig: String,
#[arg(long, value_name = "URL")]
pub rpc_url: Option<String>,
}
pub fn run(command: SquadsCommands, json: bool) -> Result<()> {
let agent = ProtocolAgent::new(None);
match command {
SquadsCommands::Proposer(args) => {
let result = block_on(fetch_proposer(&agent, &args))?;
output(json, &result, render_proposer)
}
SquadsCommands::Status(args) => {
let result = block_on(fetch_status(&agent, &args))?;
output(json, &result, render_status)
}
}
}
fn output(json: bool, result: &Value, render: fn(&Value) -> String) -> Result<()> {
if json {
println!("{result}");
} else {
print!("{}", render(result));
}
Ok(())
}
fn block_on<T>(future: impl std::future::Future<Output = Result<T>>) -> Result<T> {
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.context("Failed to start the async runtime")?
.block_on(future)
}
fn balance_rpc(target: &SquadsTarget, rpc_url: Option<&str>) -> Result<Option<String>> {
match rpc_url.map(str::trim).filter(|url| !url.is_empty()) {
Some(_) if target.is_dev() => {
bail!("--rpc-url cannot be combined with --dev: the dev environment's RPC is used")
}
Some(url) => Ok(Some(url.to_string())),
None => Ok(None),
}
}
pub async fn fetch_proposer(agent: &ProtocolAgent, args: &ProposerArgs) -> Result<Value> {
let rpc = balance_rpc(&args.target, args.rpc_url.as_deref())?;
let mut result = agent
.squads_proposer(&args.target.tool_target()?, rpc.as_deref())
.await?;
let proposer = result["pubkey"]
.as_str()
.unwrap_or("<proposer>")
.to_string();
result["next"] = json!(format!(
"Add {proposer} to your Squads v4 multisig as a member with only the Initiate permission (mask 1), and fund it with a little SOL: it pays the fee and rent of every proposal. {}",
add_proposer_hint(&args.target, None, &proposer)
));
Ok(result)
}
pub async fn fetch_status(agent: &ProtocolAgent, args: &StatusArgs) -> Result<Value> {
let rpc = balance_rpc(&args.target, args.rpc_url.as_deref())?;
let mut result = agent
.squads_status(&args.target.tool_target()?, &args.multisig, rpc.as_deref())
.await?;
result["next"] = json!(status_next(&args.target, &result));
Ok(result)
}
fn add_proposer_hint(target: &SquadsTarget, multisig: Option<&str>, proposer: &str) -> String {
if target.is_dev() {
format!(
"`a4 dev squads add-proposer{} --multisig {} --proposer {proposer} --wallet <a dev wallet that can initiate>` proposes adding it; then approve and execute with `a4 dev squads approve` / `execute`.",
target.project_flag(),
multisig.unwrap_or("<multisig>")
)
} else {
format!(
"Set it up at {} (any browser wallet, Ledger included), or in the Squads app: Members → Add member → paste the proposer → permission Initiate only → approve and execute.",
match multisig {
Some(multisig) => format!("https://app.arete.run/dashboard/squads/{multisig}"),
None => "https://app.arete.run/dashboard/squads".to_string(),
}
)
}
}
pub fn status_next(target: &SquadsTarget, status: &Value) -> String {
let multisig = status["multisig"].as_str();
let proposer = status["proposer"].as_str().unwrap_or("<proposer>");
if status["ready"] == true {
return "Ready: the proposer can propose to this multisig. Keep it funded; it pays the fee and rent of every proposal.".into();
}
match status["problem"].as_str() {
Some("proposer_unfunded") => {
let needs = status["proposerMinLamports"]
.as_u64()
.map(|minimum| format!(" (one proposal needs {})", sol(minimum)))
.unwrap_or_default();
if target.is_dev() {
format!(
"The proposer is a member but cannot pay for a proposal{needs}. Fund it with `a4 dev fund{} {proposer}`.",
target.project_flag()
)
} else {
format!(
"The proposer is a member but cannot pay for a proposal{needs}. Send SOL to {proposer}; it pays the fee and rent of every proposal."
)
}
}
Some("permissions_invalid") => format!(
"The proposer is a member with permission mask {} instead of exactly Initiate (1). Remove it with a config proposal in Squads, then add it again. {}",
status["mask"],
add_proposer_hint(target, multisig, proposer)
),
_ => format!(
"The proposer is not a member yet. {}",
add_proposer_hint(target, multisig, proposer)
),
}
}
fn sol(lamports: u64) -> String {
let text = format!("{:.9}", lamports as f64 / 1e9);
let text = text.trim_end_matches('0').trim_end_matches('.');
format!("{text} SOL")
}
fn render_balance(result: &Value) -> String {
let minimum = result["proposerMinLamports"].as_u64();
let needs = minimum
.map(|minimum| format!(" (one proposal needs {})", sol(minimum)))
.unwrap_or_default();
match result["proposerLamports"].as_u64() {
Some(lamports) if lamports < minimum.unwrap_or(SQUADS_PROPOSER_LOW_LAMPORTS) => format!(
"{}{} {}\n",
sol(lamports).yellow(),
needs.yellow(),
"low: fund it, or proposals fail".yellow()
),
Some(lamports) => format!("{}{needs}\n", sol(lamports)),
None => format!(
"unavailable ({})\n",
result["balanceNote"]
.as_str()
.unwrap_or("no RPC")
.trim_start_matches("The balance was not read: ")
),
}
}
pub fn render_proposer(result: &Value) -> String {
let mut out = format!(
"Squads proposer for deployment {}\n",
result["deploymentId"].as_str().unwrap_or("?")
);
out.push_str(&format!(
" pubkey {}\n",
result["pubkey"].as_str().unwrap_or("?").bold()
));
out.push_str(" permissions Initiate only (mask 1)\n");
out.push_str(&format!(" balance {}", render_balance(result)));
out.push_str(&format!(
"\n{}\n",
result["next"].as_str().unwrap_or_default()
));
out
}
pub fn render_status(result: &Value) -> String {
let yes_no = |value: &Value| {
if *value == true {
"yes".green().to_string()
} else {
"no".red().to_string()
}
};
let mut out = format!(
"Squads proposer {} on multisig {}\n",
result["proposer"].as_str().unwrap_or("?"),
result["multisig"].as_str().unwrap_or("?")
);
out.push_str(&format!(" ready {}\n", yes_no(&result["ready"])));
out.push_str(&format!(
" member {}{}\n",
yes_no(&result["member"]),
result["mask"]
.as_u64()
.map(|mask| format!(" (mask {mask})"))
.unwrap_or_default()
));
if let Some(problem) = result["problem"].as_str() {
out.push_str(&format!(" problem {problem}\n"));
}
out.push_str(&format!(" threshold {}\n", result["threshold"]));
out.push_str(&format!(" balance {}", render_balance(result)));
out.push_str(&format!(
"\n{}\n",
result["next"].as_str().unwrap_or_default()
));
out
}
#[cfg(test)]
mod tests;