//! `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) -> Vec { 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 { ledger_host::commands::close_books(&self.0) } fn new_books(&self, id: &str, currency: &str) -> Result { ledger_host::commands::new_books(&self.0, id, currency) } fn back_up_now(&self, book: &str) -> Result, String> { ledger_host::commands::back_up_now(&self.0, book, false) } fn plan_restore( &self, source: &str, id: Option, replace: bool, ) -> Result { 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, 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, 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, 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) -> 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::() { 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) -> 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) -> i32 { let mut root: Option = None; let mut home: Option = 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) -> i32 { match ledger_mcp::local::run("wanderbooks server", args) { Ok(()) => 0, Err(e) => match e.downcast_ref::() { 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) -> 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::() { 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) -> 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::() { 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) -> 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) -> 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) -> 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 = 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::, _>>() 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 { 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)); } }