mod audit;
mod backfill;
mod connect;
mod devices;
mod doctor;
mod edit;
mod eval;
mod hook;
mod init;
mod project;
mod promote;
mod review;
mod status;
mod ui;
use std::fmt::Write as _;
use std::path::PathBuf;
use clap::builder::styling::{AnsiColor, Style, Styles};
use clap::builder::StyledStr;
use clap::{Command, CommandFactory, FromArgMatches, Parser, Subcommand};
use recall_hooks::exit;
const VERSION: &str = env!("CARGO_PKG_VERSION");
const COMMIT: Option<&str> = option_env!("RECALL_GIT_COMMIT");
#[derive(Parser)]
#[command(
name = "recall",
about = "Sync Claude Code's auto memory across machines and cloud sessions",
// clap's own `--version` is replaced rather than merely enabled. Its
// default prints `recall <version>` and nothing else, while `recall
// version` prints the commit too — two ways of asking the same question
// giving different answers is the failure this project keeps finding in
// itself. `version_line` below is the single source both go through.
disable_version_flag = true,
// `recall` alone still prints help and exits 2, which it did before this
// flag existed. Without this, an optional subcommand would make a bare
// `recall` a silent success.
arg_required_else_help = true
)]
struct Cli {
#[arg(short = 'V', long = "version", action = clap::ArgAction::SetTrue)]
version: bool,
#[command(subcommand)]
command: Option<Cmd>,
}
fn version_line() -> String {
let commit = COMMIT.unwrap_or("unknown");
match recall_wire::discovery::channel() {
recall_wire::discovery::CHANNEL_RELEASE => format!("recall {VERSION} ({commit})"),
_ => format!(
"recall {VERSION} ({commit}, dev build {})",
recall_wire::discovery::version()
),
}
}
#[derive(Subcommand)]
enum Cmd {
#[command(verbatim_doc_comment)]
Init {
#[arg(long)]
path: Option<PathBuf>,
},
#[command(verbatim_doc_comment)]
Backfill,
Promote {
file: PathBuf,
#[arg(long, value_enum, default_value_t = promote::Target::Global)]
to: promote::Target,
},
#[command(verbatim_doc_comment)]
Connect {
url: Option<String>,
#[arg(long)]
name: Option<String>,
#[arg(long, short)]
yes: bool,
},
#[command(subcommand)]
Devices(devices::Cmd),
#[command(subcommand, verbatim_doc_comment)]
Authkey(devices::KeyCmd),
#[command(subcommand, verbatim_doc_comment)]
Audit(audit::Cmd),
#[command(subcommand, verbatim_doc_comment)]
Eval(eval::Cmd),
#[command(subcommand, verbatim_doc_comment)]
Review(review::Cmd),
Disconnect {
url: Option<String>,
},
#[command(verbatim_doc_comment)]
Sync,
#[command(verbatim_doc_comment)]
Status {
#[arg(long)]
json: bool,
},
#[command(verbatim_doc_comment)]
Doctor {
#[arg(long)]
json: bool,
},
#[command(hide = true)]
Serve,
#[command(verbatim_doc_comment)]
Push,
#[command(verbatim_doc_comment)]
Pull,
Version,
}
const GROUPS: &[(&str, &[&str])] = &[
(
"Get started",
&["connect", "init", "backfill", "disconnect"],
),
("Every day", &["status", "doctor", "sync", "promote"]),
("Memory quality", &["review", "eval"]),
("Your server", &["devices", "authkey", "audit"]),
("Run by Claude Code (hooks)", &["push", "pull"]),
];
const STYLES: Styles = Styles::styled()
.header(Style::new().bold())
.usage(Style::new().bold())
.literal(AnsiColor::Cyan.on_default());
fn cli() -> Command {
let mut cmd = Cli::command().styles(STYLES);
cmd.build();
let bold = Style::new().bold();
let version = StyledStr::from(format!("{bold}{}{bold:#}", version_line()));
let cmd = with_version(cmd, &version);
let template = help_template(&cmd);
cmd.help_template(template).after_help(footer())
}
fn with_version(cmd: Command, line: &StyledStr) -> Command {
cmd.before_help(line.clone())
.mut_subcommands(|sub| with_version(sub, line))
}
fn help_template(cmd: &Command) -> StyledStr {
let styles = cmd.get_styles();
let (header, literal) = (styles.get_header(), styles.get_literal());
let visible: Vec<&Command> = cmd.get_subcommands().filter(|c| !c.is_hide_set()).collect();
let width = visible
.iter()
.map(|c| c.get_name().len())
.max()
.unwrap_or(0);
let grouped = |name: &str| GROUPS.iter().any(|(_, names)| names.contains(&name));
let mut groups: Vec<(&str, Vec<&Command>)> = GROUPS
.iter()
.map(|(heading, names)| {
let members = names
.iter()
.filter_map(|n| visible.iter().copied().find(|c| c.get_name() == *n))
.collect();
(*heading, members)
})
.collect();
let other = visible
.iter()
.copied()
.filter(|c| !grouped(c.get_name()))
.collect();
groups.push(("Other", other));
let mut out = StyledStr::new();
out.push_str("{before-help}{about-with-newline}\n{usage-heading} {usage}\n");
for (heading, commands) in groups.iter().filter(|(_, c)| !c.is_empty()) {
let _ = write!(out, "\n{header}{heading}:{header:#}\n");
for c in commands {
let name = c.get_name();
let about = c.get_about().map(ToString::to_string).unwrap_or_default();
let pad = width - name.len();
let _ = writeln!(out, " {literal}{name}{literal:#}{:pad$} {about}", "");
}
}
let _ = write!(
out,
"\n{header}Options:{header:#}\n{{options}}{{after-help}}"
);
out
}
fn footer() -> StyledStr {
let literal = STYLES.get_literal();
StyledStr::from(format!(
"New here? Run '{literal}recall connect https://your-recall-host{literal:#}', \
then '{literal}recall doctor{literal:#}'.\n\
See '{literal}recall help <command>{literal:#}' for more on a command."
))
}
fn main() {
let mut matches = cli().get_matches();
let args =
Cli::from_arg_matches_mut(&mut matches).unwrap_or_else(|e| e.format(&mut cli()).exit());
if args.version {
println!("{}", version_line());
std::process::exit(exit::OK);
}
let Some(command) = args.command else {
cli().print_help().ok();
std::process::exit(exit::CONFIG);
};
let result = match command {
Cmd::Version => {
println!("{}", version_line());
Ok(exit::OK)
}
Cmd::Init { path } => init::run(path.as_deref()),
Cmd::Backfill => block_on_current(backfill::run()),
Cmd::Serve => {
eprintln!(
"recall: the server is its own binary since 0.4.0. Run recall-server \
instead, with the same environment. See \
https://github.com/pimlabs/recall/blob/main/docs/reference/install.md"
);
Ok(exit::CONFIG)
}
Cmd::Promote { file, to } => block_on_current(promote::run(&file, to)),
Cmd::Connect { url, name, yes } => {
block_on_current(connect::connect(connect::Args { url, name, yes }))
}
Cmd::Disconnect { url } => connect::disconnect(url.as_deref()),
Cmd::Devices(cmd) => block_on_current(devices::run(cmd)),
Cmd::Authkey(cmd) => block_on_current(devices::run_authkey(cmd)),
Cmd::Audit(cmd) => block_on_current(audit::run(cmd)),
Cmd::Eval(cmd) => block_on_current(eval::run(cmd)),
Cmd::Review(cmd) => block_on_current(review::run(cmd)),
Cmd::Status { json } => block_on_current(status::run(json)),
Cmd::Doctor { json } => block_on_current(doctor::run(json)),
Cmd::Push => block_on_current(hook::push()),
Cmd::Pull => block_on_current(hook::pull()),
Cmd::Sync => block_on_current(hook::sync()),
};
match result {
Ok(code) => std::process::exit(code),
Err(err) => {
eprintln!("recall: {err:#}");
std::process::exit(exit::CONFIG)
}
}
}
fn block_on_current<F: std::future::Future<Output = anyhow::Result<i32>>>(
fut: F,
) -> anyhow::Result<i32> {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?
.block_on(fut)
}
#[cfg(test)]
mod tests {
use super::*;
fn paths() -> Vec<Vec<String>> {
fn walk(cmd: &Command, path: Vec<String>, out: &mut Vec<Vec<String>>) {
out.push(path.clone());
for sub in cmd.get_subcommands().filter(|s| s.get_name() != "help") {
let mut next = path.clone();
next.push(sub.get_name().to_string());
walk(sub, next, out);
}
}
let mut out = Vec::new();
walk(&cli(), Vec::new(), &mut out);
out
}
fn render(args: &[&str]) -> (clap::error::ErrorKind, String) {
let argv = std::iter::once("recall").chain(args.iter().copied());
match cli().try_get_matches_from(argv) {
Ok(_) => panic!("recall {args:?} parsed instead of printing help"),
Err(e) => (e.kind(), e.render().to_string()),
}
}
fn every_help() -> Vec<(Vec<String>, String)> {
let mut out = Vec::new();
for path in paths() {
let words: Vec<&str> = path.iter().map(String::as_str).collect();
let then = |flag| words.iter().copied().chain([flag]).collect::<Vec<_>>();
let mut forms = vec![
then("-h"),
then("--help"),
["help"].into_iter().chain(words.iter().copied()).collect(),
];
if cli_at(&words).has_subcommands() {
forms.push(words.clone());
}
for form in forms {
let (_, text) = render(&form);
out.push((form.iter().map(|s| s.to_string()).collect(), text));
}
}
out
}
fn cli_at(words: &[&str]) -> Command {
let mut cmd = cli();
for word in words {
cmd = cmd.find_subcommand(word).expect("a real command").clone();
}
cmd
}
#[test]
fn every_help_starts_with_the_version_line() {
for (form, text) in every_help() {
assert_eq!(
text.lines().next(),
Some(version_line().as_str()),
"recall {form:?}:\n{text}"
);
}
}
#[test]
fn no_help_line_is_wider_than_100_columns() {
for (form, text) in every_help() {
for line in text.lines() {
assert!(
line.chars().count() <= 100,
"recall {form:?} has a line of {} columns:\n{line}",
line.chars().count()
);
}
}
}
#[test]
fn every_summary_is_one_short_line() {
fn walk(cmd: &Command, name: String) {
for sub in cmd.get_subcommands() {
let name = format!("{name} {}", sub.get_name());
let about = sub.get_about().map(ToString::to_string).unwrap_or_default();
assert!(!about.is_empty(), "{name} has no summary");
assert!(!about.contains('\n'), "{name}'s summary is two lines");
assert!(
!about.ends_with('.'),
"{name}'s summary ends in a full stop"
);
assert!(
about.chars().count() <= 60,
"{name}'s summary is {} characters: {about}",
about.chars().count()
);
walk(sub, name);
}
}
walk(&cli(), "recall".to_string());
}
#[test]
fn every_grouped_name_is_a_visible_command() {
let cmd = cli();
for (heading, names) in GROUPS {
for name in *names {
let sub = cmd.find_subcommand(name);
assert!(
sub.is_some_and(|s| !s.is_hide_set()),
"{heading} names {name}, which is not a visible command"
);
}
}
}
#[test]
fn the_top_level_help_lists_every_command_once() {
let (_, text) = render(&["--help"]);
let cmd = cli();
for sub in cmd.get_subcommands() {
let name = sub.get_name();
let listed = text
.lines()
.filter(|l| l.split_whitespace().next() == Some(name) && l.starts_with(" "))
.count();
let want = usize::from(!sub.is_hide_set());
assert_eq!(listed, want, "{name} is listed {listed} times:\n{text}");
}
for (heading, _) in GROUPS {
assert!(
text.contains(&format!("\n{heading}:\n")),
"no {heading}:\n{text}"
);
}
}
#[test]
fn a_command_in_no_group_is_listed_under_other() {
let cmd = Command::new("recall")
.subcommand(Command::new("status").about("Show whether sync is set up"))
.subcommand(Command::new("brand-new").about("Do something new"));
let template = help_template(&cmd).to_string();
let other = template.split("Other:").nth(1).expect("an Other heading");
assert!(
other.contains(" brand-new Do something new"),
"{template}"
);
assert!(!other.contains("status"), "{template}");
}
#[test]
fn a_bare_recall_prints_the_help_as_an_error() {
let (kind, text) = render(&[]);
assert_eq!(
kind,
clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
);
assert_eq!(text, render(&["-h"]).1);
}
}