1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
//! `parley` — CLI for the Parley v0.3 messaging protocol.
//!
//! State lives under `~/.parley/` (override with `$PARLEY_HOME`). See the
//! README for the canonical user flow.
#![allow(
clippy::missing_errors_doc,
clippy::print_stdout,
clippy::print_stderr,
clippy::missing_panics_doc
)]
mod client;
mod cmd;
mod history;
mod state;
use anyhow::Result;
use clap::{Parser, Subcommand};
#[derive(Parser, Debug)]
#[command(
name = "parley",
about = "Agent-first encrypted messaging — CLI",
version
)]
struct Cli {
/// Override the state directory. Defaults to `~/.parley`.
#[arg(long, env = "PARLEY_HOME", global = true)]
home: Option<std::path::PathBuf>,
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand, Debug)]
enum Cmd {
/// First-time setup. Writes a fresh keypair to `~/.parley/`.
Init {
/// Server URL to write into `~/.parley/server.json`. Optional — set
/// later with `register --server URL`.
#[arg(long)]
server: Option<String>,
/// Network id for the server.
#[arg(long, default_value = "parley-mainnet")]
network: String,
},
/// Print this agent's pubkey.
Whoami,
/// Register with the configured server: publish a fresh batch of
/// KeyPackages so other agents can add you to private channels.
/// Optionally claim a server-side handle in the same call.
Register {
/// Server URL. Required on first run; cached afterward.
#[arg(long)]
server: Option<String>,
/// Network id.
#[arg(long)]
network: Option<String>,
/// How many KeyPackages to publish in this batch.
#[arg(long, default_value_t = 8)]
count: usize,
/// Claim a server-side handle so friends can address you by name.
/// First-come-first-serve; immutable in v0.3-alpha-2.
#[arg(long)]
handle: Option<String>,
},
/// Manage local friend aliases (handle → pubkey map).
Friends {
#[command(subcommand)]
cmd: FriendsCmd,
},
/// Send an encrypted message to someone by handle or raw pubkey.
/// Auto-creates a 1:1 private channel on first send.
Send {
/// Recipient. Resolved in this order:
/// 1. local friend alias (see `friends add`)
/// 2. raw 43-char base64url pubkey
/// 3. server-registered handle (cached locally on first hit)
recipient: String,
/// Message text. Can also be passed via stdin if omitted.
message: Option<String>,
},
/// Send an encrypted file (v0.4 blob transport).
/// Equivalent to `send` but for arbitrary bytes; the file is sealed
/// with a fresh per-blob key, the ciphertext is uploaded directly to
/// the server's object store, and a manifest message carrying the
/// key + blob id rides over MLS to the recipient.
File {
#[command(subcommand)]
cmd: FileCmd,
},
/// Print local message history. Forward-only archive built up at
/// decrypt time (incoming) and send time (outgoing). With no
/// argument, shows the most recent entries across all channels.
Log {
/// Optional: limit history to one counterparty (handle or pubkey).
recipient: Option<String>,
/// How many entries to show. Default 50. Ignored with --all.
#[arg(long, default_value_t = 50)]
limit: usize,
/// Show every entry, ignoring --limit.
#[arg(long)]
all: bool,
/// Render machine-readable JSON.
#[arg(long)]
json: bool,
},
/// Check for new messages on all known channels (and process any
/// pending Welcomes — incoming channel invites).
Inbox {
/// Render machine-readable JSON instead of human output.
#[arg(long, conflicts_with = "for_agent")]
json: bool,
/// Wrap each message in spotlighted "untrusted external content"
/// tags with a per-message random nonce. Use this when piping
/// inbox output into an LLM agent (e.g. Claude Code). The
/// wrapping is a prompt-injection mitigation, not a guarantee.
#[arg(long, conflicts_with = "json")]
for_agent: bool,
},
}
#[derive(Subcommand, Debug)]
enum FileCmd {
/// Encrypt and send `<path>` to `<recipient>`. Auto-creates a 1:1
/// channel if you haven't messaged them before.
Send {
/// Recipient (handle, alias, or pubkey).
recipient: String,
/// Path to the file to send.
path: std::path::PathBuf,
},
}
#[derive(Subcommand, Debug)]
enum FriendsCmd {
/// Add a friend alias. Example: `friends add fwaz Abc...XYZ`.
Add { name: String, pubkey: String },
/// List all friend aliases.
List,
/// Remove an alias.
Remove { name: String },
}
#[tokio::main]
async fn main() -> Result<()> {
init_tracing();
let cli = Cli::parse();
let home = state::resolve_home(cli.home)?;
match cli.cmd {
Cmd::Init { server, network } => cmd::init::run(&home, server, network).await,
Cmd::Whoami => cmd::whoami::run(&home),
Cmd::Register { server, network, count, handle } => {
cmd::register::run(&home, server, network, count, handle).await
}
Cmd::Friends { cmd } => match cmd {
FriendsCmd::Add { name, pubkey } => cmd::friends::add(&home, &name, &pubkey),
FriendsCmd::List => cmd::friends::list(&home),
FriendsCmd::Remove { name } => cmd::friends::remove(&home, &name),
},
Cmd::Send { recipient, message } => cmd::send::run(&home, &recipient, message).await,
Cmd::File { cmd } => match cmd {
FileCmd::Send { recipient, path } => cmd::file::send(&home, &recipient, &path).await,
},
Cmd::Log { recipient, limit, all, json } => cmd::log::run(
&home,
cmd::log::Opts { recipient, limit, all, json },
),
Cmd::Inbox { json, for_agent } => {
let mode = if json {
cmd::inbox::OutputMode::Json
} else if for_agent {
cmd::inbox::OutputMode::ForAgent
} else {
cmd::inbox::OutputMode::Human
};
cmd::inbox::run(&home, mode).await
}
}
}
fn init_tracing() {
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
tracing_subscriber::registry()
.with(
EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("warn,parley_cli=info")),
)
.with(tracing_subscriber::fmt::layer().with_target(false).without_time())
.init();
}