Files
wanderbooks/crates/ledger-cli/src/main.rs
T
swaitsandClaude Fable 5.1 4b5f44689d feat(cli): a launch with nothing to say and no terminal is the viewer (P11a step 3)
An AppImage's launcher runs the desktop file's Exec line and appends the
arguments it was given, so with `Exec=wanderbooks gui` the AppImage's
`update` ran `wanderbooks gui update`: the program's own commands were
unreachable through it. The desktop files now say plain `Exec=wanderbooks`
and a bare launch with stdin not a terminal (a menu entry, a double-click)
is the viewer; at a terminal it is the usage, as before. The AppImage is
thereby also the command line (`Wanderbooks.AppImage update`).

Test: bare_launch_tests::only_nothing_at_all_with_no_terminal_is_the_viewer
(the launch itself cannot run under test: the viewer finds the account's
real data root from the password database, not from the environment).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 15:12:18 -06:00

510 lines
19 KiB
Rust

//! `wanderbooks`: the one executable (plan D-22). Its modes are the
//! viewer (`gui`), the books server (`server`: the one way books are
//! served, D-44), the host and the agents' bridge (`host`, `mcp`), and the
//! owner's commands (`setup`, `books`, `connect`, `token`, `passphrase`,
//! `serve`, `close`, `doctor`, `upload`, `download`). A build without the
//! `gui` feature (the server-only build for another machine) has no
//! viewer.
//!
//! From nothing to the first post: `wanderbooks setup`, `wanderbooks books
//! create ID`, `wanderbooks connect claude --book ID` (then talk to the
//! agent).
use std::env;
use std::path::PathBuf;
fn print_usage() {
eprintln!("usage: wanderbooks setup [--root PATH] [--home DIR]");
eprintln!(" wanderbooks gui [BOOK | --root PATH --book ID]");
eprintln!(
" wanderbooks books list | create ID | adopt DATA-DIR --id ID | restore BACKUP | back-up ID | upgrade ID | put-aside ID DATA-DIR [PATH...]"
);
eprintln!(" wanderbooks connect claude|codex --book ID");
eprintln!(
" wanderbooks token create --book ID | list | revoke --book ID | check --book ID | print --book ID"
);
eprintln!(" wanderbooks mcp --book ID [--token NAME]");
eprintln!(" wanderbooks passphrase");
eprintln!(" wanderbooks upload --book ID [--token NAME] UPLOAD [--] FILE");
eprintln!(" wanderbooks download --book ID [--token NAME] ATTACHMENT --to FOLDER");
eprintln!(" wanderbooks serve | close | doctor");
eprintln!(" wanderbooks update [--check] | rollback");
eprintln!();
eprintln!(" setup make the books' private folder (only your account can open it)");
eprintln!(" gui open the live, read-only view of the books (a book of the data root)");
eprintln!(
" books list, start, adopt, restore, back up or upgrade books (`books --help`)"
);
eprintln!(" connect let Claude Code or Codex reach the books");
eprintln!(" token make, list or revoke an agent's token for books (`token --help`)");
eprintln!(" mcp an agent's way to the books, over stdio (what `connect` registers)");
eprintln!(" passphrase set or change the owner passphrase (typed at a terminal)");
eprintln!(" upload send a file for an upload an agent began (books.begin_upload)");
eprintln!(" download save a copy of a stored file or of one the books handed over");
eprintln!(" serve hold the books open until Ctrl-C");
eprintln!(" close close the books now: everything holding them lets go");
eprintln!(" doctor whether the books are open, what holds them, what went wrong");
eprintln!(" update bring this program to the newest release (`rollback` goes back)");
}
/// The viewer's arguments for `wanderbooks gui ARGS` (plan 3.6): a lone
/// word names the book of the data root to show (`--book`), never a
/// folder; nothing, the root's book; anything else is the viewer's own
/// flags, passed as they are.
#[cfg(feature = "gui")]
fn gui_args(args: Vec<String>) -> Vec<String> {
match args.as_slice() {
[book] if !book.starts_with('-') => vec!["--book".into(), book.clone()],
_ => args,
}
}
/// The viewer's owner (ledger-gui owner.rs): its Books menu, done as the
/// command line does it (the books server, the agents' own commands).
#[cfg(all(feature = "gui", unix))]
struct ViewerOwner(ledger_host::commands::Common);
#[cfg(all(feature = "gui", unix))]
impl ledger_gui::owner::Owner for ViewerOwner {
fn close_books(&self) -> Result<String, String> {
ledger_host::commands::close_books(&self.0)
}
fn new_books(&self, id: &str, currency: &str) -> Result<String, String> {
ledger_host::commands::new_books(&self.0, id, currency)
}
fn back_up_now(&self, book: &str) -> Result<Vec<String>, String> {
ledger_host::commands::back_up_now(&self.0, book, false)
}
fn plan_restore(
&self,
source: &str,
id: Option<String>,
replace: bool,
) -> Result<ledger_gui::owner::RestorePlan, String> {
let p = ledger_host::commands::plan_restore(&self.0, source, id, replace, false)?;
Ok(ledger_gui::owner::RestorePlan {
source: p.source,
id: p.id,
replace: p.replace,
head: p.head,
lines: p.lines,
})
}
fn restore(&self, plan: &ledger_gui::owner::RestorePlan) -> Result<Vec<String>, String> {
ledger_host::commands::carry_out_restore(
&self.0,
&ledger_host::commands::RestorePlan {
source: plan.source.clone(),
id: plan.id.clone(),
replace: plan.replace,
head: plan.head.clone(),
origin: None,
lines: plan.lines.clone(),
},
)
}
fn connect(&self, agent: ledger_gui::owner::Agent, book: &str) -> Result<Vec<String>, String> {
let agent = match agent {
ledger_gui::owner::Agent::Claude => "claude",
ledger_gui::owner::Agent::Codex => "codex",
};
ledger_host::commands::connect_agent(&self.0, agent, book)
}
fn upgrade_format(&self, book: &str, passphrase: &str) -> Result<Vec<String>, String> {
ledger_host::commands::upgrade_format(&self.0, book, passphrase)
}
}
/// `wanderbooks gui`: the viewer, in this process. For books of the data
/// root it holds them open while it runs, and its Books menu acts on them.
#[cfg(feature = "gui")]
fn run_gui(args: Vec<String>) -> i32 {
let args = gui_args(args);
#[cfg(unix)]
{
let value = |flag: &str| {
// The last given, as the viewer reads it.
args.iter()
.rposition(|a| a == flag)
.and_then(|i| args.get(i + 1))
.cloned()
};
// Only the viewer's own flags for books of the data root, each with
// its value: anything else (help, a mistake, the demo) holds
// nothing.
let mut known = true;
let mut rest = args.iter();
while let Some(a) = rest.next() {
if !matches!(a.as_str(), "--root" | "--book" | "--today") || rest.next().is_none() {
known = false;
break;
}
}
let root = value("--root").map(PathBuf::from);
let book = value("--book");
if known && ledger_gui::book_in_root(root.as_deref(), book.as_deref()).is_ok() {
// The viewer asks about a format upgrade in its own window.
let common = ledger_host::commands::Common {
root,
..Default::default()
};
ledger_gui::owner::set_owner(std::sync::Arc::new(ViewerOwner(common.clone())));
ledger_host::commands::hold_for_viewer(&common, book);
}
}
match ledger_gui::run("wanderbooks gui", args) {
Ok(()) => 0,
Err(e) => match e.downcast_ref::<ledger_gui::Exit>() {
Some(exit) => exit.0,
None => {
eprintln!("error: {e}");
1
}
},
}
}
/// `wanderbooks gui` in a build without the viewer.
#[cfg(not(feature = "gui"))]
fn run_gui(_args: Vec<String>) -> i32 {
eprintln!("error: this wanderbooks is the server-only build: it has no viewer");
2
}
/// `wanderbooks setup [--root PATH] [--home DIR]`: make the books'
/// private folder (plan 3.4): `--root` if given, else
/// `$XDG_DATA_HOME/wanderbooks`, else the platform's default under the
/// account's home (`--home`, else the system's user records). The folder's
/// location is never printed: nothing an agent is shown names it.
fn run_setup(args: Vec<String>) -> i32 {
let mut root: Option<PathBuf> = None;
let mut home: Option<PathBuf> = None;
let mut args = args.into_iter();
while let Some(arg) = args.next() {
let slot = match arg.as_str() {
"--root" => &mut root,
"--home" => &mut home,
"--help" | "-h" => {
eprintln!("usage: wanderbooks setup [--root PATH] [--home DIR]");
eprintln!(
" --root where the books' private folder goes (default: your data folder)"
);
eprintln!(" --home the account's home (default: from the system's user records)");
return 0;
}
other => {
eprintln!("unknown argument: {other}");
eprintln!("usage: wanderbooks setup [--root PATH] [--home DIR]");
return 2;
}
};
match args.next() {
Some(value) => *slot = Some(PathBuf::from(value)),
None => {
eprintln!("error: {arg} needs a path");
return 1;
}
}
}
let account = ledger_store::root::account_home().ok();
let home = match home.or_else(|| account.clone()) {
Some(home) => home,
None => {
eprintln!("error: {}", ledger_store::root::RootError::NoHome);
return 1;
}
};
if account.as_ref() != Some(&home) {
eprintln!(
"note: --home isn't this account's home, so this account's books server won't \
look there (tests and unusual setups only)"
);
}
// XDG_DATA_HOME is this account's, for this account's home: with
// another home given (tests), it is never read, so it can't aim at
// this account's real books.
let xdg = if account.as_ref() == Some(&home) {
env::var_os("XDG_DATA_HOME").map(PathBuf::from)
} else {
None
};
match ledger_store::root::Root::setup(&home, root.as_deref(), xdg.as_deref()) {
Ok((found, report)) => {
let what = if report.created {
"made"
} else if report.tightened {
"already there; made private again (other users could open it)"
} else {
"already there"
};
println!("The books' private folder: {what}. Only your account can open it.");
if report.recorded {
println!("It isn't in the default place, so where it is was noted for next time.");
}
owner_passphrase(&found)
}
Err(e) => {
eprintln!("error: {e}");
1
}
}
}
/// After setup: the owner passphrase, asked for at a terminal if none is
/// set yet; elsewhere (a script, an agent) only said how to set it.
#[cfg(unix)]
fn owner_passphrase(root: &ledger_store::root::Root) -> i32 {
let auth = match root.auth_dir() {
Ok(dir) => dir,
Err(e) => {
eprintln!("error: {e}");
return 1;
}
};
match ledger_store::passphrase::is_set(&auth) {
Ok(true) => return 0,
Ok(false) => {}
Err(e) => {
eprintln!("error: {e}");
return 1;
}
}
if !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
println!("No owner passphrase yet: set one in a terminal with `wanderbooks passphrase`.");
return 0;
}
println!(
"Now the owner passphrase: what the books ask you for before anything only you may do."
);
match ledger_host::commands::set_passphrase_at_terminal(&auth) {
Ok(()) => {
println!("The owner passphrase is set.");
0
}
Err(why) => {
eprintln!(
"error: {why}. The folder is made; set the passphrase with `wanderbooks passphrase`."
);
1
}
}
}
#[cfg(not(unix))]
fn owner_passphrase(_root: &ledger_store::root::Root) -> i32 {
0
}
/// `wanderbooks server`: the books server for the data root, on its
/// local sockets (plan 3.9). The host starts it.
#[cfg(unix)]
fn run_server(args: Vec<String>) -> i32 {
match ledger_mcp::local::run("wanderbooks server", args) {
Ok(()) => 0,
Err(e) => match e.downcast_ref::<ledger_mcp::Exit>() {
Some(exit) => exit.0,
None => {
eprintln!("error: {e}");
1
}
},
}
}
/// The modes that open and hold the books (plan 3.9): the host, the
/// agents' bridge, and the owner's commands.
#[cfg(unix)]
fn run_local(mode: &str, args: Vec<String>) -> i32 {
let prog = format!("wanderbooks {mode}");
let result = match mode {
"host" => ledger_host::host::run(&prog, args),
"mcp" => ledger_host::bridge::run(&prog, args),
"serve" => ledger_host::commands::serve(&prog, args),
"close" => ledger_host::commands::close(&prog, args),
"doctor" => ledger_host::commands::doctor(&prog, args),
"token" => ledger_host::commands::token(&prog, args),
"connect" => ledger_host::commands::connect(&prog, args),
"passphrase" => ledger_host::commands::passphrase(&prog, args),
"update" => ledger_host::update::update(&prog, env!("CARGO_PKG_VERSION"), args),
"rollback" => ledger_host::update::rollback(&prog, args),
_ => ledger_host::commands::books(&prog, args),
};
match result {
Ok(()) => 0,
Err(e) => match e.downcast_ref::<ledger_host::Exit>() {
Some(exit) => exit.0,
None => {
eprintln!("error: {e}");
1
}
},
}
}
/// `upload` and `download`: the agent's file commands, whose words may
/// be paths that aren't text.
#[cfg(unix)]
fn run_files(mode: &str, args: Vec<std::ffi::OsString>) -> i32 {
let prog = format!("wanderbooks {mode}");
let result = match mode {
"upload" => ledger_host::commands::upload(&prog, args),
_ => ledger_host::commands::download(&prog, args),
};
match result {
Ok(()) => 0,
Err(e) => match e.downcast_ref::<ledger_host::Exit>() {
Some(exit) => exit.0,
None => {
eprintln!("error: {e}");
1
}
},
}
}
/// The file commands, where local sockets aren't built yet.
#[cfg(not(unix))]
fn run_files(_mode: &str, _args: Vec<std::ffi::OsString>) -> i32 {
eprintln!("error: opening the books this way comes to this system with P11");
2
}
/// The modes that hold the books, where local sockets aren't built yet.
#[cfg(not(unix))]
fn run_local(_mode: &str, _args: Vec<String>) -> i32 {
eprintln!("error: opening the books this way comes to this system with P11");
2
}
/// `wanderbooks server` where local sockets aren't built yet.
#[cfg(not(unix))]
fn run_server(_args: Vec<String>) -> i32 {
eprintln!("error: the books server's local sockets come to this system with P11");
2
}
/// Whether the program was launched with nothing to say and no terminal
/// to say it to: a menu entry, a double-click, an AppImage (whose launcher
/// runs the desktop file's command and appends what it was given, so the
/// desktop file says plain `wanderbooks`). At a terminal it is the usage,
/// as ever.
fn is_bare_launch(words: &[String], stdin_is_terminal: bool) -> bool {
words.is_empty() && !stdin_is_terminal
}
fn main() {
// A file's path may be any name the system takes: the file commands
// get the words as given. Everything else is text.
let given: Vec<std::ffi::OsString> = env::args_os().skip(1).collect();
if let Some(mode @ ("upload" | "download")) = given.first().and_then(|m| m.to_str()) {
let mode = mode.to_string();
std::process::exit(run_files(&mode, given[1..].to_vec()));
}
let Ok(words) = given
.into_iter()
.map(std::ffi::OsString::into_string)
.collect::<Result<Vec<String>, _>>()
else {
eprintln!("error: an argument isn't UTF-8 text");
std::process::exit(2);
};
// A bare launch is the viewer.
if is_bare_launch(&words, std::io::IsTerminal::is_terminal(&std::io::stdin())) {
std::process::exit(run_gui(Vec::new()));
}
// The modes run in this process and take their own flags.
let mut all = words.clone().into_iter();
match all.next().as_deref() {
Some("gui") => std::process::exit(run_gui(all.collect())),
Some("setup") => std::process::exit(run_setup(all.collect())),
Some("server") => std::process::exit(run_server(all.collect())),
Some(
mode @ ("host" | "mcp" | "serve" | "close" | "books" | "doctor" | "token" | "connect"
| "passphrase" | "update" | "rollback"),
) => {
let mode = mode.to_string();
std::process::exit(run_local(&mode, all.collect()))
}
_ => {}
}
if words.first().is_some_and(|w| w == "--help" || w == "-h") {
print_usage();
return;
}
// The books-folder scaffold is gone (P2 slice 8): said plainly.
if let Some(gone @ ("new" | "init")) = words.first().map(String::as_str) {
eprintln!(
"`wanderbooks {gone}` is gone: books now live in a private folder. Start them with `wanderbooks setup`, `wanderbooks books create ID` and `wanderbooks connect claude --book ID`."
);
std::process::exit(2);
}
// A mode named after a flag: it must come first.
match words
.iter()
.map(String::as_str)
.find(|w| !w.starts_with('-'))
{
Some(
mode @ ("gui" | "setup" | "server" | "host" | "mcp" | "serve" | "connect" | "close"
| "books" | "doctor" | "token" | "passphrase" | "update" | "rollback"
| "upload" | "download"),
) => {
eprintln!("`{mode}` must come first: wanderbooks {mode} [its own flags]");
std::process::exit(2);
}
_ => {
print_usage();
std::process::exit(2);
}
}
}
#[cfg(all(test, feature = "gui"))]
mod gui_args_tests {
use super::gui_args;
fn v(args: &[&str]) -> Vec<String> {
args.iter().map(|a| a.to_string()).collect()
}
#[test]
fn a_lone_word_names_a_book_never_a_folder() {
assert_eq!(gui_args(v(&["acme"])), v(&["--book", "acme"]));
assert_eq!(gui_args(v(&["."])), v(&["--book", "."]));
assert_eq!(gui_args(v(&[])), v(&[]));
}
#[test]
fn flags_and_anything_else_pass_as_they_are() {
for args in [
v(&["--demo"]),
v(&["--root", "x"]),
v(&["a", "b"]),
v(&["--help"]),
] {
assert_eq!(gui_args(args.clone()), args);
}
}
}
#[cfg(test)]
mod bare_launch_tests {
use super::is_bare_launch;
#[test]
fn only_nothing_at_all_with_no_terminal_is_the_viewer() {
assert!(is_bare_launch(&[], false));
// At a terminal it is the usage, as ever.
assert!(!is_bare_launch(&[], true));
// Anything said is a mode or a mistake, never the viewer.
assert!(!is_bare_launch(&["--help".to_string()], false));
assert!(!is_bare_launch(&["update".to_string()], false));
}
}