P2 slice 1: one executable (D-22)

The server and the viewer are libraries with run(prog, args), returning
Exit(code) instead of ending the process; ledger-mcp and ledger-gui are
one-line shims (the live books keep using them until the cutover).
wanderbooks dispatches dev-stdio and gui to them in-process; a mode must
come first and owns its flags. The gui feature (default) leaves the
viewer out of the server-only build, which just server-only builds,
clippies, tests, and checks for window libraries. The viewer's mutation
recipe is retired (owner, 2026-10-18); its two musts are tested
directly. Two review rounds; mutation on the diff (docs/testing.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-18 12:27:48 -06:00
1 parent 18120727eb
commit cab6e8a334
12 files changed
+832 -378

No files matched your search

+118 -18
View File
@@ -3,9 +3,15 @@
//!
//! ```text
//! wanderbooks new [DIR] [--codex] [--force] [--no-start] (alias: init)
//! wanderbooks gui [DIR]
//! wanderbooks gui [DIR | viewer flags]
//! wanderbooks dev-stdio [server flags]
//! ```
//!
//! `wanderbooks` is the one executable (plan D-22): `gui` is the viewer and
//! `dev-stdio` the books server over stdio, run in this process. A build
//! without the `gui` feature (the server-only build for another machine)
//! has no viewer.
//!
//! `new` makes a complete books directory and starts the agent in it:
//!
//! 1. Finds `ledger-mcp` and `ledger-gui` next to this binary or on the
@@ -79,7 +85,7 @@ gui-fg:
# Is the viewer open for these books? Prints open/closed; exits 0 only if open
gui-status:
@pgrep -f -- "ledger-gui --data-dir {{justfile_directory()}}/books-data( |$)" >/dev/null && echo open || { echo closed; exit 1; }
@pgrep -f -- "(ledger-gui|wanderbooks gui) --data-dir {{justfile_directory()}}/books-data( |$)|wanderbooks gui {{justfile_directory()}}/?$" >/dev/null && echo open || { echo closed; exit 1; }
# The agents' session-start hook: the viewer's state, handled per [viewer] open_at_start
session-start:
@@ -384,10 +390,12 @@ const START_PROMPT: &str = "Hi! These are brand-new books. Please set them up wi
fn print_usage() {
eprintln!("usage: wanderbooks new [DIR] [--codex] [--force] [--no-start]");
eprintln!(" wanderbooks gui [DIR]");
eprintln!(" wanderbooks gui [DIR | --data-dir PATH ...]");
eprintln!(" wanderbooks dev-stdio [--data-dir PATH ...]");
eprintln!();
eprintln!(" new make a books directory and start the agent in it (alias: init)");
eprintln!(" gui open the live, read-only view of a books directory");
eprintln!(" dev-stdio run the books server over stdio (`dev-stdio --help` for its flags)");
eprintln!(" DIR the books directory (default: current directory)");
eprintln!(" --force rewrite the scaffold in an existing books directory");
eprintln!(" --no-start don't start the agent afterwards");
@@ -743,24 +751,72 @@ fn new(dir: &Path, force: bool, start: bool, agent: Agent) -> Result<(), String>
Ok(())
}
fn gui(dir: &Path) -> Result<(), String> {
let gui = find_binary("ledger-gui")
.ok_or("ledger-gui isn't installed; run `wanderbooks new` or `just update`")?;
let status = Command::new(gui)
.arg("--data-dir")
.arg(dir.join("books-data"))
.arg("--books-toml")
.arg(dir.join("wanderbooks.toml"))
.status()
.map_err(|e| e.to_string())?;
if status.success() {
Ok(())
} else {
Err("ledger-gui exited with an error".into())
/// The viewer's arguments for `wanderbooks gui ARGS`: a lone books
/// directory (none means this one) becomes its `books-data/` and
/// `wanderbooks.toml`, named explicitly as before the viewer moved into
/// this executable, so a settings file made later is still followed;
/// 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() {
[] => gui_args(vec![".".into()]),
[dir] if !dir.starts_with('-') => {
let dir = Path::new(dir);
vec![
"--data-dir".into(),
dir.join("books-data").display().to_string(),
"--books-toml".into(),
dir.join("wanderbooks.toml").display().to_string(),
]
}
_ => args,
}
}
/// `wanderbooks gui`: the viewer, in this process.
#[cfg(feature = "gui")]
fn run_gui(args: Vec<String>) -> i32 {
match ledger_gui::run("wanderbooks gui", gui_args(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 dev-stdio`: the books server over stdio, in this process.
fn run_dev_stdio(args: Vec<String>) -> i32 {
match ledger_mcp::run("wanderbooks dev-stdio", args) {
Ok(()) => 0,
Err(e) => match e.downcast_ref::<ledger_mcp::Exit>() {
Some(exit) => exit.0,
None => {
eprintln!("error: {e}");
1
}
},
}
}
fn main() {
// The modes run in this process and take their own flags.
let mut all = env::args().skip(1);
match all.next().as_deref() {
Some("gui") => std::process::exit(run_gui(all.collect())),
Some("dev-stdio") => std::process::exit(run_dev_stdio(all.collect())),
_ => {}
}
let mut dir: Option<PathBuf> = None;
let mut force = false;
let mut start = true;
@@ -789,10 +845,13 @@ fn main() {
}
}
}
if let Some(mode @ ("gui" | "dev-stdio")) = cmd.as_deref() {
eprintln!("`{mode}` must come first: wanderbooks {mode} [its own flags]");
std::process::exit(2);
}
let dir = dir.unwrap_or_else(|| PathBuf::from("."));
let result = match cmd.as_deref() {
Some("new") | Some("init") => new(&dir, force, start, agent),
Some("gui") => gui(&dir),
_ => {
print_usage();
std::process::exit(2);
@@ -803,3 +862,44 @@ fn main() {
std::process::exit(1);
}
}
#[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_books_dir_names_its_data_and_settings() {
let dir = std::path::Path::new("some/books");
assert_eq!(
gui_args(v(&["some/books"])),
v(&[
"--data-dir",
&dir.join("books-data").display().to_string(),
"--books-toml",
&dir.join("wanderbooks.toml").display().to_string(),
])
);
}
#[test]
fn no_dir_means_this_one_named_explicitly() {
assert_eq!(gui_args(v(&[])), gui_args(v(&["."])));
assert_eq!(gui_args(v(&[]))[0], "--data-dir");
}
#[test]
fn flags_and_anything_else_pass_as_they_are() {
for args in [
v(&["--demo"]),
v(&["--data-dir", "x"]),
v(&["a", "b"]),
v(&["--help"]),
] {
assert_eq!(gui_args(args.clone()), args);
}
}
}