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:
12 files changed
+832
-378
No files matched your search
Generated
+2
@@ -3655,6 +3655,8 @@ dependencies = [
|
||||
"chrono",
|
||||
"ledger-config",
|
||||
"ledger-core",
|
||||
"ledger-gui",
|
||||
"ledger-mcp",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"toml 1.1.8+spec-1.1.0",
|
||||
|
||||
@@ -10,6 +10,16 @@ description = "wanderbooks CLI: one command from nothing to a working books dire
|
||||
name = "wanderbooks"
|
||||
path = "src/main.rs"
|
||||
|
||||
[features]
|
||||
default = ["gui"]
|
||||
# The viewer. Off in the server-only build for another machine (plan
|
||||
# D-22, P9), so a headless machine carries no window libraries.
|
||||
gui = ["dep:ledger-gui"]
|
||||
|
||||
[dependencies]
|
||||
ledger-mcp = { path = "../ledger-mcp" }
|
||||
ledger-gui = { path = "../ledger-gui", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
chrono = "0.4"
|
||||
ledger-config = { path = "../ledger-config" }
|
||||
|
||||
+118
-18
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
//! One executable (plan D-22): `wanderbooks` runs the books server
|
||||
//! (`dev-stdio`) and the viewer (`gui`) as modes of itself, each with its
|
||||
//! own flags, and the server-only build has no viewer.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
fn wanderbooks() -> Command {
|
||||
Command::new(env!("CARGO_BIN_EXE_wanderbooks"))
|
||||
}
|
||||
|
||||
/// One JSON-RPC request over the child's stdio, its response parsed.
|
||||
fn ask(
|
||||
stdin: &mut std::process::ChildStdin,
|
||||
stdout: &mut BufReader<std::process::ChildStdout>,
|
||||
id: u64,
|
||||
method: &str,
|
||||
params: Value,
|
||||
) -> Value {
|
||||
let msg = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params});
|
||||
writeln!(stdin, "{msg}").unwrap();
|
||||
stdin.flush().unwrap();
|
||||
let mut line = String::new();
|
||||
stdout.read_line(&mut line).expect("read a response");
|
||||
let resp: Value = serde_json::from_str(&line).expect("parse the response");
|
||||
assert_eq!(resp["id"], id, "{resp}");
|
||||
resp
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dev_stdio_is_the_books_server() {
|
||||
let dir = tempfile::Builder::new()
|
||||
.prefix("wb-one-exe-")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let data = dir.path().join("books").join("books-data");
|
||||
// An import root may not be inside the books dir (the data dir's
|
||||
// parent), so it is a sibling of it.
|
||||
let inbox = dir.path().join("inbox");
|
||||
std::fs::create_dir(&inbox).unwrap();
|
||||
let mut child = wanderbooks()
|
||||
.arg("dev-stdio")
|
||||
.arg("--data-dir")
|
||||
.arg(&data)
|
||||
.arg("--import-root")
|
||||
.arg(&inbox)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("spawn wanderbooks dev-stdio");
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
let mut stdout = BufReader::new(child.stdout.take().unwrap());
|
||||
let init = ask(
|
||||
&mut stdin,
|
||||
&mut stdout,
|
||||
1,
|
||||
"initialize",
|
||||
json!({"protocolVersion": "2025-06-18", "capabilities": {},
|
||||
"clientInfo": {"name": "one-executable", "version": "0"}}),
|
||||
);
|
||||
assert!(init["result"]["serverInfo"].is_object(), "{init}");
|
||||
writeln!(
|
||||
stdin,
|
||||
"{}",
|
||||
json!({"jsonrpc": "2.0", "method": "notifications/initialized"})
|
||||
)
|
||||
.unwrap();
|
||||
let tools = ask(&mut stdin, &mut stdout, 2, "tools/list", json!({}));
|
||||
let names: Vec<&str> = tools["result"]["tools"]
|
||||
.as_array()
|
||||
.expect("a tool list")
|
||||
.iter()
|
||||
.filter_map(|t| t["name"].as_str())
|
||||
.collect();
|
||||
assert!(names.contains(&"books.start"), "{names:?}");
|
||||
// The books it serves are real books: a fresh store was created.
|
||||
let tb = ask(
|
||||
&mut stdin,
|
||||
&mut stdout,
|
||||
3,
|
||||
"tools/call",
|
||||
json!({"name": "books.trial_balance", "arguments": {}}),
|
||||
);
|
||||
assert!(tb.get("error").is_none(), "{tb}");
|
||||
assert!(data.join("journal.jsonl").is_file());
|
||||
drop(stdin);
|
||||
let status = child.wait().unwrap();
|
||||
assert!(status.success(), "{status}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_mode_names_itself_in_its_usage() {
|
||||
let out = wanderbooks()
|
||||
.args(["dev-stdio", "--help"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success());
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
err.contains("usage: wanderbooks dev-stdio [--data-dir PATH]"),
|
||||
"{err}"
|
||||
);
|
||||
|
||||
let out = wanderbooks().arg("--help").output().unwrap();
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
for line in [
|
||||
"wanderbooks gui [DIR",
|
||||
"wanderbooks dev-stdio",
|
||||
"dev-stdio run the books server",
|
||||
] {
|
||||
assert!(err.contains(line), "{line:?} missing from {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
#[test]
|
||||
fn the_viewer_names_itself_in_its_usage() {
|
||||
let out = wanderbooks().args(["gui", "--help"]).output().unwrap();
|
||||
assert!(out.status.success());
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(
|
||||
text.contains("usage: wanderbooks gui [--data-dir PATH]"),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mode_refuses_what_it_does_not_know() {
|
||||
let modes: &[&str] = if cfg!(feature = "gui") {
|
||||
&["dev-stdio", "gui"]
|
||||
} else {
|
||||
&["dev-stdio"]
|
||||
};
|
||||
for &mode in modes {
|
||||
let out = wanderbooks()
|
||||
.args([mode, "--no-such-flag"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(out.status.code(), Some(2), "{mode}: {out:?}");
|
||||
}
|
||||
// A mode's flags are its own: the CLI's `--force` means nothing there.
|
||||
let out = wanderbooks()
|
||||
.args(["dev-stdio", "--force"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(out.status.code(), Some(2), "{out:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mode_comes_first() {
|
||||
for mode in ["gui", "dev-stdio"] {
|
||||
let out = wanderbooks().args(["--codex", mode]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(2), "{mode}: {out:?}");
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(err.contains(&format!("`{mode}` must come first")), "{err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_that_cannot_start_says_why_and_exits_1() {
|
||||
// A flag missing its value is an error, not a usage slip (exit 2).
|
||||
let out = wanderbooks()
|
||||
.args(["dev-stdio", "--data-dir"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(out.status.code(), Some(1), "{out:?}");
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(err.contains("error: --data-dir needs a path"), "{err}");
|
||||
}
|
||||
|
||||
/// With no display to open a window on, the viewer fails and says so
|
||||
/// (Linux only: elsewhere there is always a display, and a window would
|
||||
/// open).
|
||||
#[cfg(all(feature = "gui", target_os = "linux"))]
|
||||
#[test]
|
||||
fn a_viewer_that_cannot_open_a_window_exits_1() {
|
||||
let dir = tempfile::Builder::new()
|
||||
.prefix("wb-one-exe-")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let mut child = wanderbooks()
|
||||
.args(["gui", "--data-dir"])
|
||||
.arg(dir.path().join("books-data"))
|
||||
.env_remove("DISPLAY")
|
||||
.env_remove("WAYLAND_DISPLAY")
|
||||
.env_remove("WAYLAND_SOCKET")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
|
||||
let status = loop {
|
||||
if let Some(status) = child.try_wait().unwrap() {
|
||||
break status;
|
||||
}
|
||||
if std::time::Instant::now() > deadline {
|
||||
let _ = child.kill();
|
||||
panic!("the viewer neither opened nor failed within a minute");
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
};
|
||||
assert_eq!(status.code(), Some(1), "{status}");
|
||||
let mut err = String::new();
|
||||
std::io::Read::read_to_string(&mut child.stderr.take().unwrap(), &mut err).unwrap();
|
||||
assert!(err.contains("error: "), "{err}");
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "gui"))]
|
||||
#[test]
|
||||
fn the_server_only_build_has_no_viewer() {
|
||||
let out = wanderbooks().args(["gui", "--help"]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(2), "{out:?}");
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
err.contains("the server-only build: it has no viewer"),
|
||||
"{err}"
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,8 @@ mod widgets;
|
||||
pub use app::App;
|
||||
pub use data::Source;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// The vault key file named on the command line (`--vault-key-file`),
|
||||
/// which wins over a settings file's `[vault] key_file`, as it does for
|
||||
/// the server: books that keep their own settings never name it.
|
||||
@@ -37,3 +39,112 @@ pub fn set_vault_key_file(path: std::path::PathBuf) {
|
||||
pub fn vault_key_file() -> Option<std::path::PathBuf> {
|
||||
VAULT_KEY_FILE.get().cloned()
|
||||
}
|
||||
|
||||
/// How [`run`] stops when it can't go on: the reason is already printed
|
||||
/// on stderr, and the number is the process's exit code (2: a bad argument; 1: the demo books couldn't be built). A
|
||||
/// library never ends the process itself, so the books it opened are
|
||||
/// closed properly on the way out; the binaries turn this into the exit.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Exit(pub i32);
|
||||
|
||||
impl std::fmt::Display for Exit {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "exit code {}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Exit {}
|
||||
|
||||
/// Run the viewer with `args` (the command line after the program name);
|
||||
/// `prog` names the program in the usage text (`ledger-gui`, or
|
||||
/// `wanderbooks gui`). It opens the books read-only and returns when the
|
||||
/// window closes. An unknown argument returns [`Exit`] (code 2).
|
||||
///
|
||||
/// `ledger-gui [--data-dir PATH] [--books-toml PATH] [--vault-key-file PATH] [--today YYYY-MM-DD] [--demo]`:
|
||||
/// with no flags it looks for `books-data/` and `wanderbooks.toml` in the
|
||||
/// current directory; `--demo` builds a demo company in a temp directory
|
||||
/// and opens that.
|
||||
pub fn run(
|
||||
prog: &str,
|
||||
args: impl IntoIterator<Item = String>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut data_dir: Option<PathBuf> = None;
|
||||
let mut books_toml: Option<PathBuf> = None;
|
||||
let mut today = None;
|
||||
let mut demo = false;
|
||||
let mut args = args.into_iter();
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
"--data-dir" => data_dir = args.next().map(PathBuf::from),
|
||||
"--books-toml" => books_toml = args.next().map(PathBuf::from),
|
||||
"--vault-key-file" => {
|
||||
if let Some(k) = args.next() {
|
||||
crate::set_vault_key_file(PathBuf::from(k));
|
||||
}
|
||||
}
|
||||
"--today" => today = args.next().and_then(|d| d.parse().ok()),
|
||||
"--demo" => demo = true,
|
||||
"-h" | "--help" => {
|
||||
println!(
|
||||
"usage: {prog} [--data-dir PATH] [--books-toml PATH] [--vault-key-file PATH] [--today YYYY-MM-DD] [--demo]"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
other => {
|
||||
eprintln!("unknown argument {other}");
|
||||
return Err(Box::new(Exit(2)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if demo {
|
||||
let d = today.unwrap_or_else(|| chrono::Local::now().date_naive());
|
||||
let dir = std::env::temp_dir().join(format!("wanderbooks-demo-{d}"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
if let Err(e) = crate::demo::build(&dir, d) {
|
||||
eprintln!("building the demo books failed: {e}");
|
||||
return Err(Box::new(Exit(1)));
|
||||
}
|
||||
eprintln!("demo books: {}", dir.display());
|
||||
data_dir = Some(dir.join("books-data"));
|
||||
books_toml = Some(dir.join("wanderbooks.toml"));
|
||||
}
|
||||
let data_dir = data_dir.unwrap_or_else(|| PathBuf::from("books-data"));
|
||||
let books_toml = books_toml.or_else(|| {
|
||||
let guess = data_dir
|
||||
.parent()
|
||||
.unwrap_or(&data_dir)
|
||||
.join("wanderbooks.toml");
|
||||
guess.exists().then_some(guess)
|
||||
});
|
||||
let source = Source {
|
||||
data_dir,
|
||||
books_toml,
|
||||
today,
|
||||
};
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("wanderbooks")
|
||||
.with_app_id("wanderbooks")
|
||||
.with_inner_size([1440.0, 920.0])
|
||||
// Small on purpose: tiling window managers hand out small slots
|
||||
// regardless, and every screen lays itself out to fit.
|
||||
.with_min_inner_size([360.0, 400.0]),
|
||||
..Default::default()
|
||||
};
|
||||
Ok(eframe::run_native(
|
||||
"wanderbooks",
|
||||
options,
|
||||
Box::new(move |cc| Ok(Box::new(App::new(cc, source)))),
|
||||
)?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod exit_tests {
|
||||
use super::Exit;
|
||||
|
||||
#[test]
|
||||
fn an_exit_says_its_code() {
|
||||
assert_eq!(Exit(2).to_string(), "exit code 2");
|
||||
assert_eq!(Exit(1).to_string(), "exit code 1");
|
||||
}
|
||||
}
|
||||
@@ -1,79 +1,12 @@
|
||||
//! `ledger-gui [--data-dir PATH] [--books-toml PATH] [--vault-key-file PATH] [--today YYYY-MM-DD] [--demo]`
|
||||
//!
|
||||
//! Opens the books read-only and follows them live. With no flags it looks
|
||||
//! for `books-data/` and `wanderbooks.toml` in the current directory.
|
||||
//! `--demo` builds a demo company in a temp directory and opens that.
|
||||
//! `ledger-gui`: the read-only, live viewer. The same viewer is
|
||||
//! `wanderbooks gui`; both call [`ledger_gui::run`].
|
||||
|
||||
use ledger_gui::{App, Source};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() -> eframe::Result<()> {
|
||||
let mut data_dir: Option<PathBuf> = None;
|
||||
let mut books_toml: Option<PathBuf> = None;
|
||||
let mut today = None;
|
||||
let mut demo = false;
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
"--data-dir" => data_dir = args.next().map(PathBuf::from),
|
||||
"--books-toml" => books_toml = args.next().map(PathBuf::from),
|
||||
"--vault-key-file" => {
|
||||
if let Some(k) = args.next() {
|
||||
ledger_gui::set_vault_key_file(PathBuf::from(k));
|
||||
}
|
||||
}
|
||||
"--today" => today = args.next().and_then(|d| d.parse().ok()),
|
||||
"--demo" => demo = true,
|
||||
"-h" | "--help" => {
|
||||
println!(
|
||||
"usage: ledger-gui [--data-dir PATH] [--books-toml PATH] [--vault-key-file PATH] [--today YYYY-MM-DD] [--demo]"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
other => {
|
||||
eprintln!("unknown argument {other}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
match ledger_gui::run("ledger-gui", std::env::args().skip(1)) {
|
||||
Err(e) => match e.downcast_ref::<ledger_gui::Exit>() {
|
||||
Some(exit) => std::process::exit(exit.0),
|
||||
None => Err(e),
|
||||
},
|
||||
done => done,
|
||||
}
|
||||
if demo {
|
||||
let d = today.unwrap_or_else(|| chrono::Local::now().date_naive());
|
||||
let dir = std::env::temp_dir().join(format!("wanderbooks-demo-{d}"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
if let Err(e) = ledger_gui::demo::build(&dir, d) {
|
||||
eprintln!("building the demo books failed: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
eprintln!("demo books: {}", dir.display());
|
||||
data_dir = Some(dir.join("books-data"));
|
||||
books_toml = Some(dir.join("wanderbooks.toml"));
|
||||
}
|
||||
let data_dir = data_dir.unwrap_or_else(|| PathBuf::from("books-data"));
|
||||
let books_toml = books_toml.or_else(|| {
|
||||
let guess = data_dir
|
||||
.parent()
|
||||
.unwrap_or(&data_dir)
|
||||
.join("wanderbooks.toml");
|
||||
guess.exists().then_some(guess)
|
||||
});
|
||||
let source = Source {
|
||||
data_dir,
|
||||
books_toml,
|
||||
today,
|
||||
};
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("wanderbooks")
|
||||
.with_app_id("wanderbooks")
|
||||
.with_inner_size([1440.0, 920.0])
|
||||
// Small on purpose: tiling window managers hand out small slots
|
||||
// regardless, and every screen lays itself out to fit.
|
||||
.with_min_inner_size([360.0, 400.0]),
|
||||
..Default::default()
|
||||
};
|
||||
eframe::run_native(
|
||||
"wanderbooks",
|
||||
options,
|
||||
Box::new(move |cc| Ok(Box::new(App::new(cc, source)))),
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,9 @@ license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "MCP server for wanderbooks: the AI-first bookkeeping interface over ledger-store."
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "ledger-mcp"
|
||||
path = "src/main.rs"
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
//! `ledger-mcp`: the AI-first bookkeeping interface, over stdio.
|
||||
//!
|
||||
//! [`run`] is the whole server; the `ledger-mcp` binary and `wanderbooks
|
||||
//! dev-stdio` (one executable, plan D-22) both call it.
|
||||
//!
|
||||
//! Usage: `ledger-mcp [--data-dir PATH] [--config PATH] [--books-toml PATH]
|
||||
//! [--import-root DIR]...`
|
||||
//!
|
||||
//! The data dir holds `journal.jsonl` (the source of truth), `wanderbooks.db`
|
||||
//! (the disposable SQLite projection), `blobs/` (content-addressed receipts),
|
||||
//! and optionally `config.json`. If no store exists, one is created with the
|
||||
//! configured base currency. Company settings (name, fiscal year start,
|
||||
//! receipt threshold) come from the books dir's `wanderbooks.toml`, by
|
||||
//! default the one next to the data dir, and reload whenever it changes
|
||||
//! (settings.rs). `books.import_local_file` and `books.set_w9`'s
|
||||
//! `w9_file_path` read only from the import roots: each `--import-root`,
|
||||
//! or by default the downloads folder (local_files.rs); a root that is
|
||||
//! `/`, the home folder, or holds the books, the vault or a key is refused
|
||||
//! at startup.
|
||||
|
||||
mod handbook;
|
||||
mod local_files;
|
||||
mod policy;
|
||||
mod server;
|
||||
mod settings;
|
||||
mod tin_files;
|
||||
mod types;
|
||||
mod wire;
|
||||
|
||||
use ledger_core::Currency;
|
||||
use ledger_store::Store;
|
||||
use local_files::ImportRoots;
|
||||
use policy::ReceiptPolicy;
|
||||
use rmcp::ServiceExt;
|
||||
use serde::Deserialize;
|
||||
use server::BooksServer;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Server configuration, JSON. All fields optional.
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct Config {
|
||||
/// ISO 4217 base currency for a new store. Default "USD".
|
||||
#[serde(default)]
|
||||
base_currency: Option<String>,
|
||||
// `receipt_policy` is read by ledger_config::effective_receipt_policy.
|
||||
}
|
||||
|
||||
fn default_data_dir() -> PathBuf {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
|
||||
PathBuf::from(home).join(".local/share/wanderbooks")
|
||||
}
|
||||
|
||||
fn print_usage(prog: &str) {
|
||||
eprintln!(
|
||||
"usage: {prog} [--data-dir PATH] [--config PATH] [--books-toml PATH] [--vault-key-file PATH] [--http ADDR] [--import-root DIR]..."
|
||||
);
|
||||
eprintln!(" --data-dir directory holding journal.jsonl (default ~/.local/share/wanderbooks)");
|
||||
eprintln!(" --config JSON config file (default <data-dir>/config.json, optional)");
|
||||
eprintln!(
|
||||
" --books-toml company settings, for books that don't keep their own yet (default <data-dir>/../wanderbooks.toml, optional)"
|
||||
);
|
||||
eprintln!(
|
||||
" --vault-key-file the TIN vault's key file (default ~/.config/wanderbooks/vault.key; wins over a settings file's [vault] key_file)"
|
||||
);
|
||||
eprintln!(
|
||||
" --http serve over Streamable HTTP at ADDR (e.g. 127.0.0.1:8080) instead of stdio"
|
||||
);
|
||||
eprintln!(
|
||||
" --import-root a folder books.import_local_file may read from (repeatable; default: the downloads folder)"
|
||||
);
|
||||
}
|
||||
|
||||
/// How [`run`] stops when it can't go on: the reason is already printed
|
||||
/// on stderr, and the number is the process's exit code (2: a bad argument or a refused import folder; 1: books from a newer wanderbooks). A
|
||||
/// library never ends the process itself, so the books it opened are
|
||||
/// closed properly on the way out; the binaries turn this into the exit.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Exit(pub i32);
|
||||
|
||||
impl std::fmt::Display for Exit {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "exit code {}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Exit {}
|
||||
|
||||
/// Run the books server with `args` (the command line after the program
|
||||
/// name). `prog` names the program in the usage text: `ledger-mcp`, or
|
||||
/// `wanderbooks dev-stdio`. It returns when the client disconnects; a bad
|
||||
/// argument, books from a newer wanderbooks, or a refused import root
|
||||
/// return [`Exit`] after a plain message.
|
||||
pub fn run(
|
||||
prog: &str,
|
||||
args: impl IntoIterator<Item = String>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut data_dir = default_data_dir();
|
||||
let mut config_path: Option<PathBuf> = None;
|
||||
let mut books_toml: Option<PathBuf> = None;
|
||||
let mut vault_key_file: Option<PathBuf> = None;
|
||||
let mut http_addr: Option<String> = None;
|
||||
let mut import_roots: Vec<PathBuf> = Vec::new();
|
||||
let mut args = args.into_iter();
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--data-dir" => {
|
||||
data_dir = PathBuf::from(args.next().ok_or("--data-dir needs a path")?);
|
||||
}
|
||||
"--config" => {
|
||||
config_path = Some(PathBuf::from(args.next().ok_or("--config needs a path")?));
|
||||
}
|
||||
"--books-toml" => {
|
||||
books_toml = Some(PathBuf::from(
|
||||
args.next().ok_or("--books-toml needs a path")?,
|
||||
));
|
||||
}
|
||||
"--vault-key-file" => {
|
||||
vault_key_file = Some(PathBuf::from(
|
||||
args.next().ok_or("--vault-key-file needs a path")?,
|
||||
));
|
||||
}
|
||||
"--http" => {
|
||||
http_addr = Some(args.next().ok_or("--http needs an address")?);
|
||||
}
|
||||
"--import-root" => {
|
||||
import_roots.push(PathBuf::from(
|
||||
args.next().ok_or("--import-root needs a folder")?,
|
||||
));
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
print_usage(prog);
|
||||
return Ok(());
|
||||
}
|
||||
other => {
|
||||
eprintln!("unknown argument: {other}");
|
||||
print_usage(prog);
|
||||
return Err(Box::new(Exit(2)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let import_roots = if import_roots.is_empty() {
|
||||
ImportRoots::defaults()
|
||||
} else {
|
||||
ImportRoots::new(import_roots)
|
||||
};
|
||||
let config_path = config_path.unwrap_or_else(|| data_dir.join("config.json"));
|
||||
// config.json's base currency matters only to books being created: it
|
||||
// is read then, and never otherwise (books that keep their own
|
||||
// settings read no file).
|
||||
let read_config = || -> Result<Config, Box<dyn std::error::Error>> {
|
||||
if config_path.exists() {
|
||||
let raw = std::fs::read_to_string(&config_path)?;
|
||||
Ok(serde_json::from_str(&raw)
|
||||
.map_err(|e| format!("bad config {}: {e}", config_path.display()))?)
|
||||
} else {
|
||||
Ok(Config::default())
|
||||
}
|
||||
};
|
||||
// Logs go to stderr: stdout is the MCP channel.
|
||||
eprintln!("ledger-mcp: data dir {}", data_dir.display());
|
||||
// An explicit --books-toml must exist; the default (beside the data
|
||||
// dir) is optional. Either is reloaded whenever it changes.
|
||||
let source = settings::Source {
|
||||
required: books_toml.is_some(),
|
||||
books_toml: books_toml.unwrap_or_else(|| {
|
||||
data_dir
|
||||
.parent()
|
||||
.map(|p| p.join("wanderbooks.toml"))
|
||||
.unwrap_or_else(|| PathBuf::from("wanderbooks.toml"))
|
||||
}),
|
||||
config_json: config_path.clone(),
|
||||
};
|
||||
|
||||
let mut store = if data_dir.join(ledger_store::JOURNAL_FILE).exists() {
|
||||
match open_waiting_for_lock(&data_dir) {
|
||||
Ok(store) => store,
|
||||
// Books from a newer wanderbooks are fine; this server is old.
|
||||
// Say so in plain words (the error's text), and leave them be.
|
||||
Err(e @ ledger_store::StoreError::NewerJournal { .. }) => {
|
||||
eprintln!("ledger-mcp: {e}");
|
||||
return Err(Box::new(Exit(1)));
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
} else {
|
||||
let config = read_config()?;
|
||||
let code = config.base_currency.as_deref().unwrap_or("USD");
|
||||
let currency = Currency::new(code).map_err(|_| format!("bad base currency {code:?}"))?;
|
||||
eprintln!("ledger-mcp: creating new store (base currency {code})");
|
||||
Store::create(&data_dir, currency)?
|
||||
};
|
||||
// The journal is the source of truth: verify the chain, the replay, and
|
||||
// the whole projection before serving a single tool call, rebuilding
|
||||
// the projection if it drifted.
|
||||
let report = store
|
||||
.verify_and_heal()
|
||||
.map_err(|e| format!("store verify failed: {e}"))?;
|
||||
if let Some(why) = &report.healed {
|
||||
eprintln!("ledger-mcp: rebuilt the database from the journal ({why})");
|
||||
}
|
||||
// Books that keep their own settings never read a settings file, not
|
||||
// even to start (settings INVARIANT 1); others read it as before.
|
||||
let mut live = if store.ledger().settings().is_journaled() {
|
||||
settings::Live::journaled(source)
|
||||
} else {
|
||||
settings::Live::watching(source)?
|
||||
}
|
||||
.with_vault_key_file(vault_key_file);
|
||||
live.sync(store.ledger().settings(), store.base_currency().code());
|
||||
eprintln!(
|
||||
"ledger-mcp: company {:?} (settings from {})",
|
||||
live.current().company.name,
|
||||
if live.is_journaled() {
|
||||
"the books"
|
||||
} else {
|
||||
"the settings file"
|
||||
}
|
||||
);
|
||||
let live = std::sync::Arc::new(std::sync::Mutex::new(live));
|
||||
// No import root may be `/`, the home folder, or hold the books, the
|
||||
// vault or a key (local_files.rs INVARIANT 0): a named one stops the
|
||||
// server here, a default one is dropped.
|
||||
let settings_now = live.lock().unwrap_or_else(|e| e.into_inner()).current();
|
||||
let import_roots = match server::guard_import_roots(&store, &settings_now, import_roots) {
|
||||
Ok(roots) => roots,
|
||||
Err(why) => {
|
||||
eprintln!("ledger-mcp: {why}");
|
||||
return Err(Box::new(Exit(2)));
|
||||
}
|
||||
};
|
||||
|
||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
if let Some(addr) = http_addr {
|
||||
eprintln!("ledger-mcp: store verified, serving Streamable HTTP on {addr}");
|
||||
rt.block_on(serve_http(addr, store, live, import_roots))?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
eprintln!("ledger-mcp: store verified, serving over stdio");
|
||||
let server = BooksServer::new(store, ReceiptPolicy::default())
|
||||
.with_live_settings(live)
|
||||
.with_import_roots(import_roots);
|
||||
rt.block_on(async {
|
||||
// The wire answers what JSON-RPC requires that rmcp drops (wire.rs).
|
||||
let (input, output, writer) = wire::spawn(tokio::io::stdin(), tokio::io::stdout());
|
||||
let running = server.serve((input, output)).await?;
|
||||
let _ = running.waiting().await;
|
||||
let _ = writer.await;
|
||||
Ok::<(), Box<dyn std::error::Error>>(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Serve MCP over Streamable HTTP (for network clients).
|
||||
async fn serve_http(
|
||||
addr: String,
|
||||
store: Store,
|
||||
live: std::sync::Arc<std::sync::Mutex<settings::Live>>,
|
||||
import_roots: ImportRoots,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
use rmcp::transport::streamable_http_server::{
|
||||
StreamableHttpService, session::local::LocalSessionManager,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
// One store for every connection: the async mutex keeps it
|
||||
// single-writer, and tool bodies run on blocking threads (server.rs).
|
||||
let store = server::SharedStore::new(store);
|
||||
let service = StreamableHttpService::new(
|
||||
move || {
|
||||
let server = server::BooksServer::new_shared(store.clone(), ReceiptPolicy::default())
|
||||
.with_live_settings(live.clone())
|
||||
.with_import_roots(import_roots.clone());
|
||||
Ok(server)
|
||||
},
|
||||
Arc::new(LocalSessionManager::default()),
|
||||
Default::default(),
|
||||
);
|
||||
|
||||
let router = axum::Router::new().nest_service("/mcp", service);
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||
eprintln!("ledger-mcp: listening on http://{addr}/mcp");
|
||||
axum::serve(listener, router).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Open the books, waiting up to ten seconds for another writer to let go.
|
||||
/// A restarted server (a client reconnecting) often starts while the old
|
||||
/// process is still exiting with the lock; waiting turns that race into a
|
||||
/// short delay. A writer that keeps the books longer is a real conflict,
|
||||
/// and the refusal says so.
|
||||
fn open_waiting_for_lock(data_dir: &std::path::Path) -> Result<Store, ledger_store::StoreError> {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
loop {
|
||||
match Store::open(data_dir) {
|
||||
Err(ledger_store::StoreError::Locked(_)) if std::time::Instant::now() < deadline => {
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
}
|
||||
other => return other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod exit_tests {
|
||||
use super::Exit;
|
||||
|
||||
#[test]
|
||||
fn an_exit_says_its_code() {
|
||||
assert_eq!(Exit(2).to_string(), "exit code 2");
|
||||
assert_eq!(Exit(1).to_string(), "exit code 1");
|
||||
}
|
||||
}
|
||||
@@ -1,277 +1,12 @@
|
||||
//! `ledger-mcp`: the AI-first bookkeeping interface, over stdio.
|
||||
//!
|
||||
//! Usage: `ledger-mcp [--data-dir PATH] [--config PATH] [--books-toml PATH]
|
||||
//! [--import-root DIR]...`
|
||||
//!
|
||||
//! The data dir holds `journal.jsonl` (the source of truth), `wanderbooks.db`
|
||||
//! (the disposable SQLite projection), `blobs/` (content-addressed receipts),
|
||||
//! and optionally `config.json`. If no store exists, one is created with the
|
||||
//! configured base currency. Company settings (name, fiscal year start,
|
||||
//! receipt threshold) come from the books dir's `wanderbooks.toml`, by
|
||||
//! default the one next to the data dir, and reload whenever it changes
|
||||
//! (settings.rs). `books.import_local_file` and `books.set_w9`'s
|
||||
//! `w9_file_path` read only from the import roots: each `--import-root`,
|
||||
//! or by default the downloads folder (local_files.rs); a root that is
|
||||
//! `/`, the home folder, or holds the books, the vault or a key is refused
|
||||
//! at startup.
|
||||
|
||||
mod handbook;
|
||||
mod local_files;
|
||||
mod policy;
|
||||
mod server;
|
||||
mod settings;
|
||||
mod tin_files;
|
||||
mod types;
|
||||
mod wire;
|
||||
|
||||
use ledger_core::Currency;
|
||||
use ledger_store::Store;
|
||||
use local_files::ImportRoots;
|
||||
use policy::ReceiptPolicy;
|
||||
use rmcp::ServiceExt;
|
||||
use serde::Deserialize;
|
||||
use server::BooksServer;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Server configuration, JSON. All fields optional.
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct Config {
|
||||
/// ISO 4217 base currency for a new store. Default "USD".
|
||||
#[serde(default)]
|
||||
base_currency: Option<String>,
|
||||
// `receipt_policy` is read by ledger_config::effective_receipt_policy.
|
||||
}
|
||||
|
||||
fn default_data_dir() -> PathBuf {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
|
||||
PathBuf::from(home).join(".local/share/wanderbooks")
|
||||
}
|
||||
|
||||
fn print_usage() {
|
||||
eprintln!(
|
||||
"usage: ledger-mcp [--data-dir PATH] [--config PATH] [--books-toml PATH] [--vault-key-file PATH] [--http ADDR] [--import-root DIR]..."
|
||||
);
|
||||
eprintln!(" --data-dir directory holding journal.jsonl (default ~/.local/share/wanderbooks)");
|
||||
eprintln!(" --config JSON config file (default <data-dir>/config.json, optional)");
|
||||
eprintln!(
|
||||
" --books-toml company settings, for books that don't keep their own yet (default <data-dir>/../wanderbooks.toml, optional)"
|
||||
);
|
||||
eprintln!(
|
||||
" --vault-key-file the TIN vault's key file (default ~/.config/wanderbooks/vault.key; wins over a settings file's [vault] key_file)"
|
||||
);
|
||||
eprintln!(
|
||||
" --http serve over Streamable HTTP at ADDR (e.g. 127.0.0.1:8080) instead of stdio"
|
||||
);
|
||||
eprintln!(
|
||||
" --import-root a folder books.import_local_file may read from (repeatable; default: the downloads folder)"
|
||||
);
|
||||
}
|
||||
//! `ledger-mcp`: the books server over stdio (or `--http ADDR`). The same
|
||||
//! server is `wanderbooks dev-stdio`; both call [`ledger_mcp::run`].
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut data_dir = default_data_dir();
|
||||
let mut config_path: Option<PathBuf> = None;
|
||||
let mut books_toml: Option<PathBuf> = None;
|
||||
let mut vault_key_file: Option<PathBuf> = None;
|
||||
let mut http_addr: Option<String> = None;
|
||||
let mut import_roots: Vec<PathBuf> = Vec::new();
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--data-dir" => {
|
||||
data_dir = PathBuf::from(args.next().ok_or("--data-dir needs a path")?);
|
||||
}
|
||||
"--config" => {
|
||||
config_path = Some(PathBuf::from(args.next().ok_or("--config needs a path")?));
|
||||
}
|
||||
"--books-toml" => {
|
||||
books_toml = Some(PathBuf::from(
|
||||
args.next().ok_or("--books-toml needs a path")?,
|
||||
));
|
||||
}
|
||||
"--vault-key-file" => {
|
||||
vault_key_file = Some(PathBuf::from(
|
||||
args.next().ok_or("--vault-key-file needs a path")?,
|
||||
));
|
||||
}
|
||||
"--http" => {
|
||||
http_addr = Some(args.next().ok_or("--http needs an address")?);
|
||||
}
|
||||
"--import-root" => {
|
||||
import_roots.push(PathBuf::from(
|
||||
args.next().ok_or("--import-root needs a folder")?,
|
||||
));
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
print_usage();
|
||||
return Ok(());
|
||||
}
|
||||
other => {
|
||||
eprintln!("unknown argument: {other}");
|
||||
print_usage();
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
let import_roots = if import_roots.is_empty() {
|
||||
ImportRoots::defaults()
|
||||
} else {
|
||||
ImportRoots::new(import_roots)
|
||||
};
|
||||
let config_path = config_path.unwrap_or_else(|| data_dir.join("config.json"));
|
||||
// config.json's base currency matters only to books being created: it
|
||||
// is read then, and never otherwise (books that keep their own
|
||||
// settings read no file).
|
||||
let read_config = || -> Result<Config, Box<dyn std::error::Error>> {
|
||||
if config_path.exists() {
|
||||
let raw = std::fs::read_to_string(&config_path)?;
|
||||
Ok(serde_json::from_str(&raw)
|
||||
.map_err(|e| format!("bad config {}: {e}", config_path.display()))?)
|
||||
} else {
|
||||
Ok(Config::default())
|
||||
}
|
||||
};
|
||||
// Logs go to stderr: stdout is the MCP channel.
|
||||
eprintln!("ledger-mcp: data dir {}", data_dir.display());
|
||||
// An explicit --books-toml must exist; the default (beside the data
|
||||
// dir) is optional. Either is reloaded whenever it changes.
|
||||
let source = settings::Source {
|
||||
required: books_toml.is_some(),
|
||||
books_toml: books_toml.unwrap_or_else(|| {
|
||||
data_dir
|
||||
.parent()
|
||||
.map(|p| p.join("wanderbooks.toml"))
|
||||
.unwrap_or_else(|| PathBuf::from("wanderbooks.toml"))
|
||||
}),
|
||||
config_json: config_path.clone(),
|
||||
};
|
||||
|
||||
let mut store = if data_dir.join(ledger_store::JOURNAL_FILE).exists() {
|
||||
match open_waiting_for_lock(&data_dir) {
|
||||
Ok(store) => store,
|
||||
// Books from a newer wanderbooks are fine; this server is old.
|
||||
// Say so in plain words (the error's text), and leave them be.
|
||||
Err(e @ ledger_store::StoreError::NewerJournal { .. }) => {
|
||||
eprintln!("ledger-mcp: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
} else {
|
||||
let config = read_config()?;
|
||||
let code = config.base_currency.as_deref().unwrap_or("USD");
|
||||
let currency = Currency::new(code).map_err(|_| format!("bad base currency {code:?}"))?;
|
||||
eprintln!("ledger-mcp: creating new store (base currency {code})");
|
||||
Store::create(&data_dir, currency)?
|
||||
};
|
||||
// The journal is the source of truth: verify the chain, the replay, and
|
||||
// the whole projection before serving a single tool call, rebuilding
|
||||
// the projection if it drifted.
|
||||
let report = store
|
||||
.verify_and_heal()
|
||||
.map_err(|e| format!("store verify failed: {e}"))?;
|
||||
if let Some(why) = &report.healed {
|
||||
eprintln!("ledger-mcp: rebuilt the database from the journal ({why})");
|
||||
}
|
||||
// Books that keep their own settings never read a settings file, not
|
||||
// even to start (settings INVARIANT 1); others read it as before.
|
||||
let mut live = if store.ledger().settings().is_journaled() {
|
||||
settings::Live::journaled(source)
|
||||
} else {
|
||||
settings::Live::watching(source)?
|
||||
}
|
||||
.with_vault_key_file(vault_key_file);
|
||||
live.sync(store.ledger().settings(), store.base_currency().code());
|
||||
eprintln!(
|
||||
"ledger-mcp: company {:?} (settings from {})",
|
||||
live.current().company.name,
|
||||
if live.is_journaled() {
|
||||
"the books"
|
||||
} else {
|
||||
"the settings file"
|
||||
}
|
||||
);
|
||||
let live = std::sync::Arc::new(std::sync::Mutex::new(live));
|
||||
// No import root may be `/`, the home folder, or hold the books, the
|
||||
// vault or a key (local_files.rs INVARIANT 0): a named one stops the
|
||||
// server here, a default one is dropped.
|
||||
let settings_now = live.lock().unwrap_or_else(|e| e.into_inner()).current();
|
||||
let import_roots = match server::guard_import_roots(&store, &settings_now, import_roots) {
|
||||
Ok(roots) => roots,
|
||||
Err(why) => {
|
||||
eprintln!("ledger-mcp: {why}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
|
||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
if let Some(addr) = http_addr {
|
||||
eprintln!("ledger-mcp: store verified, serving Streamable HTTP on {addr}");
|
||||
rt.block_on(serve_http(addr, store, live, import_roots))?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
eprintln!("ledger-mcp: store verified, serving over stdio");
|
||||
let server = BooksServer::new(store, ReceiptPolicy::default())
|
||||
.with_live_settings(live)
|
||||
.with_import_roots(import_roots);
|
||||
rt.block_on(async {
|
||||
// The wire answers what JSON-RPC requires that rmcp drops (wire.rs).
|
||||
let (input, output, writer) = wire::spawn(tokio::io::stdin(), tokio::io::stdout());
|
||||
let running = server.serve((input, output)).await?;
|
||||
let _ = running.waiting().await;
|
||||
let _ = writer.await;
|
||||
Ok::<(), Box<dyn std::error::Error>>(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Serve MCP over Streamable HTTP (for network clients).
|
||||
async fn serve_http(
|
||||
addr: String,
|
||||
store: Store,
|
||||
live: std::sync::Arc<std::sync::Mutex<settings::Live>>,
|
||||
import_roots: ImportRoots,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
use rmcp::transport::streamable_http_server::{
|
||||
StreamableHttpService, session::local::LocalSessionManager,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
// One store for every connection: the async mutex keeps it
|
||||
// single-writer, and tool bodies run on blocking threads (server.rs).
|
||||
let store = server::SharedStore::new(store);
|
||||
let service = StreamableHttpService::new(
|
||||
move || {
|
||||
let server = server::BooksServer::new_shared(store.clone(), ReceiptPolicy::default())
|
||||
.with_live_settings(live.clone())
|
||||
.with_import_roots(import_roots.clone());
|
||||
Ok(server)
|
||||
match ledger_mcp::run("ledger-mcp", std::env::args().skip(1)) {
|
||||
Err(e) => match e.downcast_ref::<ledger_mcp::Exit>() {
|
||||
Some(exit) => std::process::exit(exit.0),
|
||||
None => Err(e),
|
||||
},
|
||||
Arc::new(LocalSessionManager::default()),
|
||||
Default::default(),
|
||||
);
|
||||
|
||||
let router = axum::Router::new().nest_service("/mcp", service);
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||
eprintln!("ledger-mcp: listening on http://{addr}/mcp");
|
||||
axum::serve(listener, router).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Open the books, waiting up to ten seconds for another writer to let go.
|
||||
/// A restarted server (a client reconnecting) often starts while the old
|
||||
/// process is still exiting with the lock; waiting turns that race into a
|
||||
/// short delay. A writer that keeps the books longer is a real conflict,
|
||||
/// and the refusal says so.
|
||||
fn open_waiting_for_lock(data_dir: &std::path::Path) -> Result<Store, ledger_store::StoreError> {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
loop {
|
||||
match Store::open(data_dir) {
|
||||
Err(ledger_store::StoreError::Locked(_)) if std::time::Instant::now() < deadline => {
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
}
|
||||
other => return other,
|
||||
}
|
||||
done => done,
|
||||
}
|
||||
}
|
||||
+15
-1
@@ -34,7 +34,7 @@ promise.
|
||||
| Mutation (roles, reload) | can the tests fail? | control roles and settings reload (2026-10-15, targeted: the role paths in `ledger-core` 30 caught of 30 viable; `ledger-store`'s journaling and replay of `role.assigned` 20 of 20; `ledger-mcp` settings, reload, roles, digest 28 of 28) | `cargo mutants -F` on those functions (see the commit) | ~15 min | with the crate's weekly run |
|
||||
| Mutation (config) | can the tests fail? | `ledger-config` (2026-10-14: 55 caught of 55 viable) | `just mutants-config` | minutes | weekly |
|
||||
| Mutation (MCP) | can the tests fail? | `ledger-mcp` (2026-10-14: from 197 caught + 5 timeouts of 328 viable (62%) to 314 caught + 2 timeouts of 331; of its 15 survivors, five then killed by targeted tests (the settings file beside the books, year earnings with an open period and with last year's partial close, year 1, a failed flush), one dead guard removed (`profit_and_loss_by_class` skipping placeholders, which never take postings), nine equivalent: `money` rescaling an amount already at two places, the lock wait's deadline instant, `year_earnings` starting the day the year starts either way, the bank import's rehearsal sides (a line's side can't make a draft refuse) and its zero-amount side (zero rows are dropped by the parser), a disposal's zero gain adding zero, and the CPA package index summing debits instead of credits (entries balance)) | `just mutants-mcp` | ~1 h | weekly |
|
||||
| Mutation (GUI) | can the tests fail? | `ledger-gui` view model and formatting (2026-10-14: 140 caught of 176 viable on the full run, then 92 of 99 on a targeted rerun of the money paths after new tests; of its 7 survivors, one then killed by a test (burn over exactly one month, confirmed by a rerun of `compute_kpis`'s 48 mutants), two dead arms removed (`parties`' bill arms: the ledger tags bill entries with their vendor, which wins), four equivalent: an entry's debits vs its credits (they balance), the three-month burn boundary (both paths give 3), burn over zero covered months (only when the first entry is after the window, which then holds no loss), and `compact`'s sign test at zero (zero prints "$0" unsigned either way)) | `just mutants-gui` | ~2 h | weekly |
|
||||
| Mutation (GUI) | can the tests fail? | `ledger-gui` view model and formatting (2026-10-14: 140 caught of 176 viable on the full run, then 92 of 99 on a targeted rerun of the money paths after new tests; of its 7 survivors, one then killed by a test (burn over exactly one month, confirmed by a rerun of `compute_kpis`'s 48 mutants), two dead arms removed (`parties`' bill arms: the ledger tags bill entries with their vendor, which wins), four equivalent: an entry's debits vs its credits (they balance), the three-month burn boundary (both paths give 3), burn over zero covered months (only when the first entry is after the window, which then holds no loss), and `compact`'s sign test at zero (zero prints "$0" unsigned either way)) | retired 2026-10-18 (owner: it crushed the machine; for the viewer, test its two musts directly: it never writes the books, `tests/read_only.rs`, and every figure it shows is the engine's, `tests/view_model.rs`) | | never |
|
||||
|
||||
CI: `.github/workflows/check.yml` runs `just check` on every push;
|
||||
`scheduled.yml` runs proofs, the oracle, fuzzing, and mutation weekly.
|
||||
@@ -365,6 +365,20 @@ package test), and the filing scan passes the company's own EIN printed
|
||||
Equivalent: `share_text`'s `scale() < 2` made `<= 2` (padding a
|
||||
two-place share to two places changes nothing).
|
||||
|
||||
## P2 slice 1: one executable (2026-10-18)
|
||||
|
||||
`wanderbooks dev-stdio` and `wanderbooks gui` run the server and the
|
||||
viewer from the one executable (plan D-22); `tests/one_executable.rs`
|
||||
drives both, and `just server-only` builds, clippies and tests the build
|
||||
without the viewer and fails if it links a window library. Mutation on
|
||||
the diff, two jobs, the viewer left out (owner, 2026-10-18): the server's
|
||||
`run` against the server's tests, 14 mutants, 12 caught or unviable, the
|
||||
exit text's then caught by a new test, and one equivalent (`<` vs `<=`
|
||||
on the ten-second lock deadline); the CLI, 24 mutants against its tests
|
||||
in the default build and the `run_gui` arms again in the server-only
|
||||
build (`--no-default-features`), every one caught in the build where its
|
||||
code exists.
|
||||
|
||||
## Benchmarks (baseline 2026-10-13)
|
||||
|
||||
No budget, by decision (Stephen, 2026-10-13: the books do little work
|
||||
|
||||
@@ -15,8 +15,23 @@ check: sweep-test-tmp
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --quiet
|
||||
cargo test --workspace
|
||||
@just server-only
|
||||
@just sweep-test-tmp
|
||||
|
||||
# The server-only build (plan D-22, for another machine) builds, passes
|
||||
# clippy and its tests, and links no window libraries
|
||||
server-only:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cargo clippy -p ledger-cli --no-default-features --all-targets -- -D warnings
|
||||
cargo test -p ledger-cli --no-default-features
|
||||
tree=$(cargo tree -p ledger-cli --no-default-features -e normal)
|
||||
if grep -qiE '\b(eframe|egui|winit|wgpu|glow|ledger-gui)\b' <<<"$tree"; then
|
||||
echo "the server-only build links a window library" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "server-only build: no window libraries"
|
||||
|
||||
# Test books go in the system temp dir (often RAM). Every test's dir is
|
||||
# removed when the test ends, pass or panic (a guard dropped on unwind;
|
||||
# until 2026-10-17 many were removed only on pass, or never: 117,000 piled
|
||||
@@ -109,16 +124,12 @@ mutants-mcp: sweep-test-tmp
|
||||
MCP_CASES=16 PROPTEST_MAX_SHRINK_ITERS=0 TMPDIR={{mutants_tmp}} cargo mutants -p ledger-mcp --test-package ledger-mcp -j 4 --timeout 600 -o mutants-mcp
|
||||
@just sweep-test-tmp
|
||||
|
||||
# Mutation testing of the viewer where it matters: the numbers it shows
|
||||
# (the view model) and how it writes them (formatting), against the tests
|
||||
# that check them (the unit tests, spec_gui, view_model, copy). Its read-only promise has
|
||||
# its own test (tests/read_only.rs, which renders every page: too slow to
|
||||
# rerun per mutant); search, history, ticks, and the attention list are
|
||||
# spec-tested, not swept.
|
||||
mutants-gui: sweep-test-tmp
|
||||
mkdir -p {{mutants_tmp}}
|
||||
PROPTEST_MAX_SHRINK_ITERS=0 TMPDIR={{mutants_tmp}} cargo mutants -p ledger-gui --test-package ledger-gui -j 4 --timeout 300 -o mutants-gui -f crates/ledger-gui/src/books.rs -f crates/ledger-gui/src/fmt.rs -- --lib --test spec_gui --test view_model --test copy
|
||||
@just sweep-test-tmp
|
||||
# No mutation runs on the viewer (owner, 2026-10-18: they crushed the
|
||||
# machine, and for the GUI it's "not as critical"). Its two musts are
|
||||
# tested directly instead: it never writes the books (tests/read_only.rs)
|
||||
# and every figure it shows is the engine's (tests/view_model.rs, against
|
||||
# ledger-core on random books). Mutation recipes for other crates leave
|
||||
# crates/ledger-gui out.
|
||||
|
||||
# Benchmarks of the core's hot paths (no budget gates them yet)
|
||||
bench:
|
||||
|
||||
+2
-1
@@ -140,7 +140,7 @@ The data root is `$XDG_DATA_HOME/wanderbooks` on Linux (default `~/.local/share/
|
||||
| Viewer | `wanderbooks gui <book>`, the viewer mode (the `ledger-gui` crate; local, direct, the default; what opening the app shows); `wanderbooks gui --books <url>` (remote replica, P9) | the human | local: yes, read-only, never writes. Remote: none |
|
||||
| CLI | `wanderbooks` (ledger-cli) | the human | **by design never**. Talks to `run/admin.sock` (peer-credential check: same user ID as the server). The admin socket cannot read books data; it can create or adopt books and mint agent tokens, admin-scope ones included. The peer-credential check keeps other local accounts out; any process running as the human, agents included, can use it (advisory, like the rest of the desktop deployment). |
|
||||
| Agent bridge | `wanderbooks mcp --book <id>` (stdio to the agent; `--url` for a remote book, P9) | the agent's user | never; it has no data-dir flag. It may start the host (D-23), whose server then reads the disk as itself |
|
||||
| Test/demo | `ledger-mcp dev-stdio --data-dir` | any | refuses inside the data root; `wanderbooks connect` never configures it (D-18) |
|
||||
| Test/demo | `wanderbooks dev-stdio --data-dir` (also the `ledger-mcp` binary until the scaffold goes) | any | refuses inside the data root; `wanderbooks connect` never configures it (D-18) |
|
||||
|
||||
### 3.2 Transport (one code path, local and remote)
|
||||
|
||||
@@ -813,6 +813,7 @@ Wanderland keeps working between phases.
|
||||
- `setup_needs_no_root` (setup, serve, adopt, connect and gui as an unprivileged user in a clean container; nothing is left installed to run in the background).
|
||||
- `books_close_cleanly` (the last lease ending, "close the books", SIGTERM and SIGHUP of the host, all during a burst of posts: every request either completes or is refused, the head verifies at the next open, the lock is released); `openers_share_one_server` (the app, `serve`, a CLI command and two bridges started together: one host, one writer); `books_close_with_last_holder` (each kind of holder ending, `kill -9` included: closed 10 s after the last, not before); `bridge_opens_closed_books` and `bridge_survives_server_restart` (the agent's session carries on, no reconnect); `close_now_ends_every_lease`; `host_starts_clean` (started from a confined viewer or bridge with a hostile environment, `XDG_DATA_HOME` and `LD_PRELOAD` included, the host is unconfined, finds the real root, and holds no inherited descriptor); `host_respawns` (kill the host: a holder starts a new one); `host_survives_app_scope` (under a systemd app scope, as uwsm sets up, closing the app that started the host leaves it running for the other holders); `no_local_agent_tcp` (the bridge never connects to TCP; TCP is off by default; with it on, a squatter on 7411 makes `doctor` report a failed bind and sees no bridge traffic); `lease_socket_accepts_only_owner_intents` (exactly the five; consent and replace without the passphrase refused); `restart_is_not_close` (bridges carry on across a restart; openers wait on `update.lock`); `children_match_host_version` (replace the executable mid-run: no child of the new version joins the old host); `format_upgrade_waits_for_consent` (read-only until the owner's consent, a backup with the keys made first, no MCP tool can consent, declining leaves the book openable after `wanderbooks rollback`); `no_rollback_after_consent` ("Go back" refuses for an upgraded book; the pre-upgrade restore arrives beside it, states the entries it lacks, and needs the passphrase); `restore_reads_only_staged_backups` (a `restore` intent naming a path is refused); `bridge_initializes_during_cold_start`; `archive_catches_up_at_start` (a push that failed before a close is made at the next open).
|
||||
- `one_executable` (every mode from one binary; the viewer mode under read-only Landlock opens and follows a book); `server_build_has_no_gui` (the self-host build links no window libraries).
|
||||
- `viewer_shows_the_engines_numbers` (the viewer's two musts, owner 2026-10-18: it never writes the books, and every figure it shows is correct): on random books, every report page, account register, entry and Needs-attention count the viewer renders equals the engine's report for the same books and dates, extending `tests/view_model.rs` from the overview to every page. No mutation runs on the viewer.
|
||||
- `viewer_without_server` (with the server stopped or failing to start, the viewer still opens and follows the books from disk; "Restore…" and "New books…" say why they can't run).
|
||||
- `isolation_label_is_advisory`.
|
||||
- `no_path_params` schema lint; `no_result_names_a_path`.
|
||||
|
||||
Reference in new issue
Block a user