Files
wanderbooks/crates/ledger-cli/src/main.rs
T
swaitsandClaude Fable 5.1 8f2e3378e8 feat(viewer): the agent dock and the first-run page (P11b)
D-27: a terminal panel along the bottom of the viewer (alacritty_terminal over
a PTY) runs Claude Code (default) or Codex in the book's books home, resizable,
collapsible, the choice remembered. The agent is an ordinary child process the
owner describes (agent_start); the viewer stays read-only: the agent writes
through the server like any other. A missing agent is offered through its
vendor's installer (a command to copy), never bundled; no folder is marked
trusted (Q-7, recommended answer: let Codex ask). Windows is not offered until
its ConPTY checks are recorded.

P11a found that onboarding with no terminal was false: the viewer opened only an
existing book. With no books and an owner, wanderbooks gui now shows a first-run
page: the private folder, the owner passphrase (typed in the viewer, optional),
the first books, an agent, then the books open. connect and New books... make
the books home. Tests: dock.rs, welcome.rs, books_home.rs (host and cli).
Docs: testing.md record and For P12, architecture, parity, gui.md, README.

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

570 lines
21 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_and_home(&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_and_home(&self.0, agent, book)
}
fn first_run(&self) -> ledger_gui::owner::FirstRun {
let s = ledger_host::commands::first_run_state(&self.0);
ledger_gui::owner::FirstRun {
root: s.root,
books: s.books,
passphrase_set: s.passphrase_set,
}
}
fn set_up(&self) -> Result<Vec<String>, String> {
ledger_host::commands::set_up_root(&self.0)
}
fn set_passphrase(&self, new: &str) -> Result<(), String> {
ledger_host::commands::set_first_passphrase(&self.0, new)
}
fn hold(&self, book: &str) {
ledger_host::commands::hold_for_viewer(&self.0, Some(book.to_string()));
}
fn agent_start(
&self,
agent: ledger_gui::owner::Agent,
book: &str,
) -> Result<ledger_gui::owner::AgentStart, String> {
use ledger_gui::owner::{AgentStart, Installer};
let agent = match agent {
ledger_gui::owner::Agent::Claude => "claude",
ledger_gui::owner::Agent::Codex => "codex",
};
Ok(match ledger_host::commands::start_agent(agent, book)? {
ledger_host::commands::AgentStart::Ready {
label,
program,
args,
dir,
env,
} => AgentStart::Ready {
label: label.to_string(),
program,
args,
dir,
env,
},
ledger_host::commands::AgentStart::Missing(i) => AgentStart::Missing(Installer {
label: i.label.to_string(),
command: i.command.to_string(),
page: i.page.to_string(),
}),
})
}
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 {
// The viewer asks about a format upgrade in its own window, makes
// the first books when there are none, and starts the agent in
// its dock.
let common = ledger_host::commands::Common {
root: root.clone(),
..Default::default()
};
ledger_gui::owner::set_owner(std::sync::Arc::new(ViewerOwner(common.clone())));
if ledger_gui::book_in_root(root.as_deref(), book.as_deref()).is_ok() {
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, ledger_host::update::program_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() {
ledger_host::update::set_program_version(env!("CARGO_PKG_VERSION"));
// 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));
}
}