use anda_core::{AgentInput, AgentOutput, BoxError, HttpFeatures, ToolInput, ToolOutput};
use anda_web3_client::client::{Client as Web3Client, load_identity};
use base64::{Engine, prelude::BASE64_URL_SAFE};
use cbor2::Value;
use clap::{Parser, Subcommand};
use ic_cose_types::cose::ed25519::{SigningKey, VerifyingKey};
use rand::Rng;
use std::sync::Arc;
#[derive(Parser)]
#[command(author, version, about, long_about = None)]
struct Cli {
#[clap(long, default_value = "https://icp-api.io")]
host: String,
#[arg(long, env = "ID_SECRET", default_value = "Anonymous")]
id: String,
#[arg(long, global = true)]
allow_http: bool,
#[command(subcommand)]
command: Option<Commands>,
}
fn allow_http_for(endpoint: &str, forced: bool) -> bool {
if forced {
return true;
}
let Some(rest) = endpoint.strip_prefix("http://") else {
return false;
};
let authority = rest
.split(['/', '?', '#'])
.next()
.unwrap_or_default()
.rsplit('@')
.next()
.unwrap_or_default();
let host = match authority.strip_prefix('[') {
Some(v6) => v6.split(']').next().unwrap_or_default(),
None => authority.split(':').next().unwrap_or_default(),
};
host.eq_ignore_ascii_case("localhost")
|| host == "::1"
|| host
.parse::<std::net::IpAddr>()
.is_ok_and(|ip| ip.is_loopback())
}
#[derive(Subcommand)]
pub enum Commands {
RandBytes {
#[arg(short, long, default_value = "32")]
len: usize,
#[arg(short, long, default_value = "hex")]
format: String,
#[arg(long)]
ed25519: bool,
},
Rpc {
#[arg(short, long, default_value = "http://127.0.0.1:8042/default")]
endpoint: String,
#[arg(short, long)]
method: String,
#[arg(short, long, default_value = "[]")]
data: String,
},
AgentRun {
#[arg(short, long, default_value = "http://127.0.0.1:8042/default")]
endpoint: String,
#[arg(short, long)]
prompt: String,
#[arg(short, long)]
name: Option<String>,
},
ToolCall {
#[arg(short, long, default_value = "http://127.0.0.1:8042/default")]
endpoint: String,
#[arg(short, long)]
name: String,
#[arg(short, long)]
args: String,
},
}
fn normalize_rpc_data(data: &str) -> Result<serde_json::Value, serde_json::Error> {
let args: serde_json::Value = serde_json::from_str(data)?;
Ok(if args.is_array() {
args
} else {
serde_json::json!(vec![args])
})
}
fn agent_input(name: &Option<String>, prompt: &str) -> AgentInput {
AgentInput {
name: name.clone().unwrap_or_default(),
prompt: prompt.to_string(),
..Default::default()
}
}
fn tool_input(name: &str, args: &str) -> Result<ToolInput<serde_json::Value>, serde_json::Error> {
Ok(ToolInput {
name: name.to_string(),
args: serde_json::from_str(args)?,
..Default::default()
})
}
fn bounded_rand_len(len: usize) -> usize {
len.min(1024)
}
fn format_bytes(bytes: &[u8], format: &str) -> String {
match format {
"hex" => hex::encode(bytes),
"base64" => BASE64_URL_SAFE.encode(bytes),
_ => format!("{bytes:?}"),
}
}
fn format_ed25519_key_pair(bytes: [u8; 32], format: &str) -> (String, String) {
let signing_key = SigningKey::from_bytes(&bytes);
let verifying_key = VerifyingKey::from(&signing_key);
match format {
"hex" => (hex::encode(bytes), hex::encode(verifying_key.to_bytes())),
_ => (
BASE64_URL_SAFE.encode(bytes),
BASE64_URL_SAFE.encode(verifying_key.to_bytes()),
),
}
}
#[tokio::main]
async fn main() -> Result<(), BoxError> {
dotenv::dotenv().ok();
let cli = Cli::parse();
let identity = load_identity(&cli.id)?;
println!("principal: {}", identity.sender()?);
match &cli.command {
Some(Commands::RandBytes {
len,
format,
ed25519,
}) => {
let mut rng = rand::rng();
if *ed25519 {
let mut bytes = [0u8; 32];
rng.fill_bytes(&mut bytes);
let (secret_key, public_key) = format_ed25519_key_pair(bytes, format);
println!("Secret Key: {secret_key}");
println!("Public Key: {public_key}");
} else {
let mut bytes = vec![0u8; bounded_rand_len(*len)];
rng.fill_bytes(&mut bytes);
println!("{}", format_bytes(&bytes, format));
}
}
Some(Commands::Rpc {
endpoint,
method,
data,
}) => {
let web3 = Web3Client::builder()
.with_ic_host(&cli.host)
.with_identity(Arc::new(identity))
.with_allow_http(allow_http_for(endpoint, cli.allow_http))
.build()
.await?;
println!("principal: {}", web3.get_principal());
let args = normalize_rpc_data(data)?;
let res: Value = web3.https_signed_rpc(endpoint, method, &args).await?;
println!("{:?}", res);
}
Some(Commands::AgentRun {
endpoint,
name,
prompt,
}) => {
let web3 = Web3Client::builder()
.with_ic_host(&cli.host)
.with_identity(Arc::new(identity))
.with_allow_http(allow_http_for(endpoint, cli.allow_http))
.build()
.await?;
println!("principal: {}", web3.get_principal());
let res: AgentOutput = web3
.https_signed_rpc(endpoint, "agent_run", &(&agent_input(name, prompt),))
.await?;
println!("{:?}", res);
}
Some(Commands::ToolCall {
endpoint,
name,
args,
}) => {
let web3 = Web3Client::builder()
.with_ic_host(&cli.host)
.with_identity(Arc::new(identity))
.with_allow_http(allow_http_for(endpoint, cli.allow_http))
.build()
.await?;
println!("principal: {}", web3.get_principal());
let input = tool_input(name, args)?;
let res: ToolOutput<serde_json::Value> = web3
.https_signed_rpc(endpoint, "tool_call", &(&input,))
.await?;
println!("{}", serde_json::to_string_pretty(&res)?);
}
None => {
println!("no command");
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
#[test]
fn cli_parses_defaults_and_all_subcommands() {
Cli::command().debug_assert();
let cli = Cli::parse_from(["anda"]);
assert_eq!(cli.host, "https://icp-api.io");
assert_eq!(cli.id, "Anonymous");
assert!(cli.command.is_none());
let cli = Cli::parse_from(["anda", "rand-bytes", "--len", "8", "--format", "base64"]);
match cli.command.unwrap() {
Commands::RandBytes {
len,
format,
ed25519,
} => {
assert_eq!(len, 8);
assert_eq!(format, "base64");
assert!(!ed25519);
}
_ => panic!("expected rand-bytes"),
}
let cli = Cli::parse_from([
"anda",
"--host",
"http://localhost",
"--id",
"Anonymous",
"rpc",
"--endpoint",
"http://127.0.0.1:8042/default",
"--method",
"status",
"--data",
"{\"ok\":true}",
]);
assert_eq!(cli.host, "http://localhost");
match cli.command.unwrap() {
Commands::Rpc {
endpoint,
method,
data,
} => {
assert!(endpoint.ends_with("/default"));
assert_eq!(method, "status");
assert_eq!(data, "{\"ok\":true}");
}
_ => panic!("expected rpc"),
}
let cli = Cli::parse_from(["anda", "agent-run", "-p", "hello", "-n", "writer"]);
match cli.command.unwrap() {
Commands::AgentRun {
endpoint,
prompt,
name,
} => {
assert!(endpoint.contains("127.0.0.1"));
assert_eq!(prompt, "hello");
assert_eq!(name.as_deref(), Some("writer"));
}
_ => panic!("expected agent-run"),
}
let cli = Cli::parse_from([
"anda",
"tool-call",
"-n",
"lookup",
"-a",
"{\"q\":\"anda\"}",
]);
match cli.command.unwrap() {
Commands::ToolCall {
endpoint,
name,
args,
} => {
assert!(endpoint.contains("127.0.0.1"));
assert_eq!(name, "lookup");
assert_eq!(args, "{\"q\":\"anda\"}");
}
_ => panic!("expected tool-call"),
}
}
#[test]
fn pure_command_helpers_prepare_outputs_and_inputs() {
assert_eq!(bounded_rand_len(8), 8);
assert_eq!(bounded_rand_len(2048), 1024);
assert_eq!(format_bytes(&[0, 15, 255], "hex"), "000fff");
assert_eq!(format_bytes(&[1, 2, 3], "base64"), "AQID");
assert_eq!(format_bytes(&[1, 2], "debug"), "[1, 2]");
let (secret_hex, public_hex) = format_ed25519_key_pair([7_u8; 32], "hex");
assert_eq!(secret_hex.len(), 64);
assert_eq!(public_hex.len(), 64);
let (secret_b64, public_b64) = format_ed25519_key_pair([7_u8; 32], "base64");
assert!(!secret_b64.is_empty());
assert!(!public_b64.is_empty());
assert_ne!(secret_hex, secret_b64);
assert_eq!(
normalize_rpc_data("[1,2]").unwrap(),
serde_json::json!([1, 2])
);
assert_eq!(
normalize_rpc_data("{\"ok\":true}").unwrap(),
serde_json::json!([{"ok": true}])
);
assert!(normalize_rpc_data("not json").is_err());
let input = agent_input(&Some("writer".to_string()), "draft");
assert_eq!(input.name, "writer");
assert_eq!(input.prompt, "draft");
let input = agent_input(&None, "draft");
assert_eq!(input.name, "");
let input = tool_input("lookup", "{\"q\":\"anda\"}").unwrap();
assert_eq!(input.name, "lookup");
assert_eq!(input.args["q"], "anda");
assert!(tool_input("lookup", "bad json").is_err());
}
#[test]
fn plain_http_is_allowed_only_for_loopback_or_an_explicit_opt_in() {
for endpoint in [
"http://127.0.0.1:8042/default",
"http://localhost:8042/default",
"http://LOCALHOST:8042/default",
"http://[::1]:8042/default",
"http://127.0.0.1",
] {
assert!(
allow_http_for(endpoint, false),
"{endpoint} is loopback and must be allowed"
);
}
for endpoint in [
"http://engine.example/default",
"http://169.254.169.254/latest",
"http://127.0.0.1@evil.example/default",
"not-a-url",
] {
assert!(
!allow_http_for(endpoint, false),
"{endpoint} is not loopback and must require --allow-http"
);
assert!(
allow_http_for(endpoint, true),
"{endpoint} must be allowed once --allow-http is passed"
);
}
assert!(!allow_http_for("https://engine.example/default", false));
}
}