use adenosine::app_bsky;
use adenosine::auth::parse_did_from_jwt;
use adenosine::created_at_now;
use adenosine::identifiers::*;
use adenosine::xrpc::{XrpcClient, XrpcMethod};
use adenosine_cli::*;
use anyhow::anyhow;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::str::FromStr;
use colored_json::to_colored_json_auto;
use log::{self, debug};
use std::io::Write;
use structopt::StructOpt;
use termcolor::{Color, ColorChoice, ColorSpec, StandardStream, WriteColor};
#[derive(StructOpt)]
#[structopt(
rename_all = "kebab-case",
about = "command-line client for AT protocol (atproto.com)"
)]
struct Opt {
#[structopt(
global = true,
long = "--pds-host",
env = "ATP_PDS_HOST",
default_value = "http://localhost:2583"
)]
pds_host: String,
#[structopt(
global = true,
long = "--auth-token",
env = "ATP_AUTH_TOKEN",
hide_env_values = true
)]
auth_token: Option<String>,
#[structopt(
global = true,
long = "--auth-handle",
env = "ATP_AUTH_HANDLE",
hide_env_values = true
)]
auth_handle: Option<String>,
#[structopt(
global = true,
long = "--auth-password",
env = "ATP_AUTH_PASSWORD",
hide_env_values = true
)]
auth_password: Option<String>,
#[structopt(
global = true,
long = "--admin-password",
env = "ATP_AUTH_ADMIN_PASSWORD",
hide_env_values = true
)]
admin_password: Option<String>,
#[structopt(global = true, long, short = "v", parse(from_occurrences))]
verbose: i8,
#[structopt(long = "--shell-completions", hidden = true)]
shell_completions: Option<structopt::clap::Shell>,
#[structopt(subcommand)]
cmd: Command,
}
#[derive(StructOpt)]
enum AccountCommand {
Register {
#[structopt(long, short)]
email: String,
#[structopt(long = "--username", short = "-u")]
handle: String,
#[structopt(long, short)]
password: String,
#[structopt(long, short)]
recovery_key: Option<String>,
#[structopt(long, short)]
invite_code: Option<String>,
},
Delete,
Login {
#[structopt(long = "--username", short = "-u")]
handle: String,
#[structopt(long, short)]
password: String,
},
Refresh,
Logout,
CreateInvite {
#[structopt(short = "-u", default_value = "1")]
uses: u64,
},
}
#[derive(StructOpt)]
enum RepoCommand {
Head {
did: Option<DidOrHost>,
},
Export {
did: Option<DidOrHost>,
#[structopt(long)]
from: Option<String>,
},
Import {
#[structopt(long)]
did: Option<DidOrHost>,
},
}
#[derive(StructOpt)]
enum BskyCommand {
Feed { name: Option<DidOrHost> },
Timeline,
Thread {
uri: AtUri,
#[structopt(long)]
depth: Option<u64>,
},
Notifications,
Post { text: String },
Repost { uri: AtUri },
Like { uri: AtUri },
Follow { uri: DidOrHost },
Profile { name: Option<DidOrHost> },
SearchUsers { query: String },
}
#[derive(StructOpt)]
enum Command {
Status,
Ls { uri: AtUri },
Get {
uri: AtUri,
#[structopt(long)]
cid: Option<String>,
},
Create {
collection: Nsid,
fields: Vec<ArgField>,
},
Update {
uri: AtUri,
fields: Vec<ArgField>,
},
Delete { uri: AtUri },
Describe { name: Option<DidOrHost> },
Resolve { name: DidOrHost },
Xrpc {
method: XrpcMethod,
nsid: Nsid,
fields: Vec<ArgField>,
},
Account {
#[structopt(subcommand)]
cmd: AccountCommand,
},
Repo {
#[structopt(subcommand)]
cmd: RepoCommand,
},
Bsky {
#[structopt(subcommand)]
cmd: BskyCommand,
},
}
fn main() -> Result<()> {
dotenvy::dotenv().ok();
let opt = Opt::from_args();
let log_level = match opt.verbose {
std::i8::MIN..=-1 => "none",
0 => "error",
1 => "warn",
2 => "info",
3 => "debug",
4..=std::i8::MAX => "trace",
};
let log_filter = format!("{log_level},hyper=error");
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(log_filter))
.format_timestamp(None)
.init();
debug!("Args parsed, starting up");
#[cfg(windows)]
colored_json::enable_ansi_support();
if let Some(shell) = opt.shell_completions {
Opt::clap().gen_completions_to("adenosine", shell, &mut std::io::stdout());
std::process::exit(0);
}
if let Err(err) = run(opt) {
if let Some(io_err) = err.root_cause().downcast_ref::<std::io::Error>() {
if let std::io::ErrorKind::BrokenPipe = io_err.kind() {
debug!("got BrokenPipe error, assuming stdout closed as expected and exiting with success");
std::process::exit(0);
}
}
let mut color_stderr = StandardStream::stderr(if atty::is(atty::Stream::Stderr) {
ColorChoice::Auto
} else {
ColorChoice::Never
});
color_stderr.set_color(ColorSpec::new().set_fg(Some(Color::Red)).set_bold(true))?;
eprintln!("Error: {err:?}");
color_stderr.set_color(&ColorSpec::new())?;
std::process::exit(1);
}
Ok(())
}
fn print_result_json(result: Option<Value>) -> Result<()> {
if let Some(val) = result {
writeln!(&mut std::io::stdout(), "{}", to_colored_json_auto(&val)?)?
};
Ok(())
}
fn require_auth_did(opt: &Opt, xrpc_client: &mut XrpcClient) -> Result<Did> {
if opt.auth_token.is_some() {
} else if let (Some(handle), Some(passwd)) = (&opt.auth_handle, &opt.auth_password) {
xrpc_client.auth_login(handle, passwd)?;
} else {
return Err(anyhow!(
"command requires auth, but have neither token or handle/password"
));
}
xrpc_client.auth_did()
}
fn run(opt: Opt) -> Result<()> {
let mut xrpc_client = XrpcClient::new(
opt.pds_host.clone(),
opt.auth_token.clone(),
opt.admin_password.clone(),
)?;
let mut params: HashMap<String, String> = HashMap::new();
let jwt_did: Option<String> = if let Some(ref token) = opt.auth_token {
Some(parse_did_from_jwt(token)?)
} else {
None
};
let result = match opt.cmd {
Command::Status => {
println!("Configuration");
println!(" ATP_PDS_HOST: {}", opt.pds_host);
if opt.auth_token.is_some() {
println!(" ATP_AUTH_TOKEN: <configured>");
} else {
println!(" ATP_AUTH_TOKEN:");
}
None
}
Command::Describe { ref name } => {
let name = name
.as_ref()
.map(|v| v.to_string())
.unwrap_or(require_auth_did(&opt, &mut xrpc_client)?.to_string());
params.insert("repo".to_string(), name);
xrpc_client.get(
&Nsid::from_str("com.atproto.repo.describeRepo")?,
Some(params),
)?
}
Command::Resolve { name } => {
params.insert("handle".to_string(), name.to_string());
xrpc_client.get(
&Nsid::from_str("com.atproto.identity.resolveHandle")?,
Some(params),
)?
}
Command::Get { uri, cid } => {
params.insert("repo".to_string(), uri.repository.to_string());
params.insert(
"collection".to_string(),
uri.collection.ok_or(anyhow!("collection required"))?,
);
params.insert(
"rkey".to_string(),
uri.record.ok_or(anyhow!("record key required"))?,
);
if let Some(c) = cid {
params.insert("cid".to_string(), c);
}
xrpc_client.get(&Nsid::from_str("com.atproto.repo.getRecord")?, Some(params))?
}
Command::Ls { uri } => {
params.insert("repo".to_string(), uri.repository.to_string());
if uri.collection.is_none() {
let describe = xrpc_client
.get(
&Nsid::from_str("com.atproto.repo.describeRepo")?,
Some(params),
)?
.ok_or(anyhow!("expected a repo.describeRepo response"))?;
for c in describe["collections"]
.as_array()
.ok_or(anyhow!("expected collection list"))?
{
println!(
"at://{}/{}",
uri.repository,
c.as_str()
.ok_or(anyhow!("expected collection as a JSON string"))?
);
}
} else if uri.collection.is_some() && uri.record.is_none() {
params.insert("collection".to_string(), uri.collection.unwrap());
let records = xrpc_client
.get(
&Nsid::from_str("com.atproto.repo.listRecords")?,
Some(params),
)?
.ok_or(anyhow!("expected a repoListRecords response"))?;
for r in records["records"].as_array().unwrap_or(&vec![]).iter() {
println!("{}", r["uri"].as_str().unwrap());
}
} else {
return Err(anyhow!("got too much of a URI to 'ls'"));
}
None
}
Command::Create {
ref collection,
ref fields,
} => {
let did = require_auth_did(&opt, &mut xrpc_client)?;
let val = value_from_fields(fields.clone());
xrpc_client.post(
&Nsid::from_str("com.atproto.repo.createRecord")?,
None,
Some(json!({
"repo": did,
"collection": collection,
"record": val
})),
)?
}
Command::Update {
ref uri,
ref fields,
} => {
require_auth_did(&opt, &mut xrpc_client)?;
let did = uri.repository.to_string();
let collection = uri
.collection
.clone()
.ok_or(anyhow!("collection required"))?;
let rkey = uri.record.clone().ok_or(anyhow!("record key required"))?;
params.insert("repo".to_string(), did.clone());
params.insert("collection".to_string(), collection.clone());
params.insert("rkey".to_string(), rkey.clone());
let mut record = xrpc_client
.get(&Nsid::from_str("com.atproto.repo.getRecord")?, Some(params))?
.unwrap_or(json!({}));
update_value_from_fields(fields.clone(), &mut record);
xrpc_client.post(
&Nsid::from_str("com.atproto.repo.putRecord")?,
None,
Some(json!({
"repo": did,
"collection": collection,
"rkey": rkey,
"record": record,
})),
)?
}
Command::Delete { ref uri } => {
require_auth_did(&opt, &mut xrpc_client)?;
let did = uri.repository.to_string();
let collection = uri
.collection
.clone()
.ok_or(anyhow!("collection required"))?;
let rkey = uri.record.clone().ok_or(anyhow!("record key required"))?;
xrpc_client.post(
&Nsid::from_str("com.atproto.repo.deleteRecord")?,
None,
Some(json!({
"repo": did,
"collection": collection,
"rkey": rkey,
})),
)?
}
Command::Xrpc {
ref method,
ref nsid,
ref fields,
} => {
update_params_from_fields(fields, &mut params);
let body = value_from_fields(fields.clone());
match method {
XrpcMethod::Get => xrpc_client.get(nsid, Some(params))?,
XrpcMethod::Post => {
require_auth_did(&opt, &mut xrpc_client)?;
xrpc_client.post(nsid, Some(params), Some(body))?
}
}
}
Command::Account {
cmd:
AccountCommand::Register {
email,
handle,
password,
recovery_key,
invite_code,
},
} => {
let mut body = json!({
"email": email,
"handle": handle,
"password": password,
});
if let Some(key) = recovery_key {
body["recoveryKey"] = json!(key);
}
if let Some(code) = invite_code {
body["inviteCode"] = json!(code);
}
xrpc_client.post(
&Nsid::from_str("com.atproto.server.createAccount")?,
None,
Some(body),
)?
}
Command::Account {
cmd: AccountCommand::Login { handle, password },
} => xrpc_client.post(
&Nsid::from_str("com.atproto.server.createSession")?,
None,
Some(json!({
"handle": handle,
"password": password,
})),
)?,
Command::Account {
cmd: AccountCommand::Refresh,
} => xrpc_client.post(
&Nsid::from_str("com.atproto.server.refreshSession")?,
None,
None,
)?,
Command::Account {
cmd: AccountCommand::Logout,
} => xrpc_client.post(
&Nsid::from_str("com.atproto.server.deleteSession")?,
None,
None,
)?,
Command::Account {
cmd: AccountCommand::Delete,
} => xrpc_client.post(
&Nsid::from_str("com.atproto.server.deleteAccount")?,
None,
None,
)?,
Command::Account {
cmd: AccountCommand::CreateInvite { uses },
} => xrpc_client.post(
&Nsid::from_str("com.atproto.server.createInviteCode")?,
None,
Some(json!({ "useCount": uses })),
)?,
Command::Repo {
cmd: RepoCommand::Head { did },
} => {
let did = match did {
Some(DidOrHost::Host(_)) => return Err(anyhow!("expected a DID, not a hostname")),
Some(v) => v.to_string(),
None => jwt_did.ok_or(anyhow!("expected a DID"))?,
};
params.insert("did".to_string(), did);
xrpc_client.get(&Nsid::from_str("com.atproto.sync.getHead")?, Some(params))?
}
Command::Repo {
cmd: RepoCommand::Export { did, from },
} => {
let did = match did {
Some(DidOrHost::Host(_)) => return Err(anyhow!("expected a DID, not a hostname")),
Some(v) => v.to_string(),
None => jwt_did.ok_or(anyhow!("expected a DID"))?,
};
params.insert("did".to_string(), did);
if let Some(from) = from {
params.insert("from".to_string(), from);
};
xrpc_client.get_to_writer(
&Nsid::from_str("com.atproto.sync.getRepo")?,
Some(params),
&mut std::io::stdout(),
)?;
None
}
Command::Repo {
cmd: RepoCommand::Import { did },
} => {
let did = match did {
Some(DidOrHost::Host(_)) => return Err(anyhow!("expected a DID, not a hostname")),
Some(v) => v.to_string(),
None => jwt_did.ok_or(anyhow!("expected a DID"))?,
};
params.insert("did".to_string(), did);
xrpc_client.post_cbor_from_reader(
&Nsid::from_str("com.atproto.sync.updateRepo")?,
Some(params),
&mut std::io::stdin(),
)?
}
Command::Bsky {
cmd: BskyCommand::Feed { ref name },
} => {
let name = name
.as_ref()
.map(|v| v.to_string())
.unwrap_or(require_auth_did(&opt, &mut xrpc_client)?.to_string());
params.insert("actor".to_string(), name);
let resp = xrpc_client.get(
&Nsid::from_str("app.bsky.feed.getAuthorFeed")?,
Some(params),
)?;
let resp = resp.ok_or(anyhow!("expected resp from getAuthorFeed"))?;
if atty::is(atty::Stream::Stdout) {
for val in resp["feed"]
.as_array()
.ok_or(anyhow!("expected feed from getAuthorFeed"))?
.iter()
{
let val: serde_json::Value = val.clone();
let fi: app_bsky::FeedViewPost = serde_json::from_value(val)?;
pretty::pp_feed_post_view(&fi)?;
}
None
} else {
Some(resp)
}
}
Command::Bsky {
cmd: BskyCommand::Timeline,
} => {
require_auth_did(&opt, &mut xrpc_client)?;
let resp = xrpc_client.get(&Nsid::from_str("app.bsky.feed.getTimeline")?, None)?;
let resp = resp.ok_or(anyhow!("expected resp from getTimeline"))?;
if atty::is(atty::Stream::Stdout) {
for val in resp["feed"]
.as_array()
.ok_or(anyhow!("expected feed from getTimeline"))?
.iter()
{
let val: serde_json::Value = val.clone();
let fi: app_bsky::FeedViewPost = serde_json::from_value(val)?;
pretty::pp_feed_post_view(&fi)?;
}
None
} else {
Some(resp)
}
}
Command::Bsky {
cmd: BskyCommand::Thread { ref uri, depth },
} => {
require_auth_did(&opt, &mut xrpc_client)?;
params.insert("uri".to_string(), uri.to_string());
if let Some(d) = depth {
params.insert("depth".to_string(), d.to_string());
}
let resp = xrpc_client.get(
&Nsid::from_str("app.bsky.feed.getPostThread")?,
Some(params),
)?;
let resp = resp.ok_or(anyhow!("expected resp from getPostThread"))?;
if atty::is(atty::Stream::Stdout) {
resp["thread"]
.as_object()
.ok_or(anyhow!("expected thread from getPostThread"))?;
let tpv: app_bsky::ThreadViewPost = serde_json::from_value(resp["thread"].clone())?;
pretty::pp_thread_post_view(&tpv)?;
None
} else {
Some(resp)
}
}
Command::Bsky {
cmd: BskyCommand::Notifications,
} => {
require_auth_did(&opt, &mut xrpc_client)?;
xrpc_client.get(
&Nsid::from_str("app.bsky.notification.listNotifications")?,
None,
)?
}
Command::Bsky {
cmd: BskyCommand::Post { ref text },
} => {
let did = require_auth_did(&opt, &mut xrpc_client)?;
xrpc_client.post(
&Nsid::from_str("com.atproto.repo.createRecord")?,
None,
Some(json!({
"repo": did,
"collection": "app.bsky.feed.post",
"record": {
"text": text,
"createdAt": created_at_now(),
},
})),
)?
}
Command::Bsky {
cmd: BskyCommand::Repost { ref uri },
} => {
let did = require_auth_did(&opt, &mut xrpc_client)?;
params.insert("repo".to_string(), uri.repository.to_string());
params.insert(
"collection".to_string(),
uri.collection
.clone()
.ok_or(anyhow!("collection required"))?,
);
params.insert(
"rkey".to_string(),
uri.record.clone().ok_or(anyhow!("record key required"))?,
);
let existing =
xrpc_client.get(&Nsid::from_str("com.atproto.repo.getRecord")?, Some(params))?;
let existing = existing.ok_or(anyhow!("expected record in reponse"))?;
let cid = existing["cid"]
.as_str()
.ok_or(anyhow!("expected 'cid' in record response"))?;
xrpc_client.post(
&Nsid::from_str("com.atproto.repo.createRecord")?,
None,
Some(json!({
"repo": did,
"collection": "app.bsky.feed.repost",
"record": {
"subject": {
"uri": uri.to_string(),
"cid": cid,
},
"createdAt": created_at_now(),
}
})),
)?
}
Command::Bsky {
cmd: BskyCommand::Like { ref uri },
} => {
let did = require_auth_did(&opt, &mut xrpc_client)?;
params.insert("repo".to_string(), uri.repository.to_string());
params.insert(
"collection".to_string(),
uri.collection
.clone()
.ok_or(anyhow!("collection required"))?,
);
params.insert(
"rkey".to_string(),
uri.record.clone().ok_or(anyhow!("record key required"))?,
);
let existing =
xrpc_client.get(&Nsid::from_str("com.atproto.repo.getRecord")?, Some(params))?;
let existing = existing.ok_or(anyhow!("expected record in reponse"))?;
let cid = existing["cid"]
.as_str()
.ok_or(anyhow!("expected 'cid' in record response"))?;
xrpc_client.post(
&Nsid::from_str("com.atproto.repo.createRecord")?,
None,
Some(json!({
"repo": did,
"collection": "app.bsky.feed.like",
"record": {
"subject": { "uri": uri.to_string(), "cid": cid },
"createdAt": created_at_now(),
},
})),
)?
}
Command::Bsky {
cmd: BskyCommand::Follow { ref uri },
} => {
let did = require_auth_did(&opt, &mut xrpc_client)?;
xrpc_client.post(
&Nsid::from_str("com.atproto.repo.createRecord")?,
None,
Some(json!({
"repo": did,
"collection": "app.bsky.graph.follow",
"record": {
"subject": { "did": uri.to_string() },
"createdAt": created_at_now(),
}
})),
)?
}
Command::Bsky {
cmd: BskyCommand::Profile { ref name },
} => {
let name = name
.as_ref()
.map(|v| v.to_string())
.unwrap_or(require_auth_did(&opt, &mut xrpc_client)?.to_string());
params.insert("actor".to_string(), name);
xrpc_client.get(&Nsid::from_str("app.bsky.actor.getProfile")?, Some(params))?
}
Command::Bsky {
cmd: BskyCommand::SearchUsers { ref query },
} => {
require_auth_did(&opt, &mut xrpc_client)?;
params.insert("term".to_string(), query.to_string());
xrpc_client.get(
&Nsid::from_str("app.bsky.actor.searchActors")?,
Some(params),
)?
}
};
print_result_json(result)?;
Ok(())
}