//! `wanderbooks` CLI: one command from nothing to talking with your //! bookkeeper. //! //! ```text //! wanderbooks new [DIR] [--codex] [--force] [--no-start] (alias: init) //! wanderbooks gui [DIR] //! ``` //! //! `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 //! PATH, and installs them with cargo if they're missing. //! 2. Writes `AGENTS.md` (the bookkeeper's operating contract, with a //! `CLAUDE.md` that imports it), `wanderbooks.toml`, a `justfile`, //! `.gitignore`, and `books-data/`. //! 3. Registers the MCP server for this directory only: `.mcp.json`, plus //! `.claude/settings.json` approving it, so Claude Code loads the tools //! without `claude mcp add` or a prompt. Claude Code //! (`.claude/settings.json`) and Codex (`.codex/hooks.json`) both get a //! SessionStart hook that runs `just session-start`: the viewer check. //! 4. `git init`s the directory (its history is the archive) and commits. //! 5. Starts `claude` in the directory with the onboarding prompt, unless //! `--no-start` or there's no terminal. With `--codex`, registers the //! server with Codex (as `wanderbooks-`) and starts Codex. use std::env; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; /// Where `wanderbooks update` installs from. const REPO_URL: &str = "https://git.swaits.com/swaits/wanderbooks"; /// The checkout this binary was built from, if it still exists. fn repo_root() -> Option { let raw = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); let root = raw.canonicalize().ok()?; root.join("crates/ledger-mcp/Cargo.toml") .exists() .then_some(root) } const JUSTFILE: &str = r#"# wanderbooks books dir. Run `just` to list recipes. # Day to day you talk to the agent (`just agent`); the recipes are the # handful of things you might do yourself. set shell := ["bash", "-cu"] # List recipes default: @just --list # Start a bookkeeping session with the agent (Claude Code) agent: claude # Start a bookkeeping session in Codex (run `just codex-setup` once first) codex: codex # Register these books' MCP server with Codex (once per machine) codex-setup: codex mcp add "@@CODEX_NAME@@" -- "@@LEDGER_MCP@@" --data-dir "{{justfile_directory()}}/books-data" # Open the live, read-only view of these books (in the background; once per books dir) gui: #!/usr/bin/env bash set -u if just gui-status &>/dev/null; then echo "The viewer is already open."; exit 0; fi log="{{justfile_directory()}}/books-data/gui.log" nohup "@@LEDGER_GUI@@" --data-dir "{{justfile_directory()}}/books-data" \ --books-toml "{{justfile_directory()}}/wanderbooks.toml" >"$log" 2>&1 /dev/null; then echo "Viewer open (pid $!)."; else echo "The viewer didn't start:"; cat "$log"; exit 1; fi # Open the viewer in the foreground (logs in the terminal; Ctrl-C closes it) gui-fg: "@@LEDGER_GUI@@" --data-dir "{{justfile_directory()}}/books-data" --books-toml "{{justfile_directory()}}/wanderbooks.toml" # 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; } # The agents' session-start hook: the viewer's state, handled per [viewer] open_at_start session-start: #!/usr/bin/env bash set -u just gui-status &>/dev/null && { echo "wanderbooks viewer: open."; exit 0; } pref=$(sed -n 's/^open_at_start *= *"\([a-z]*\)".*/\1/p' "{{justfile_directory()}}/wanderbooks.toml") case "${pref:-ask}" in never) ;; always) echo "wanderbooks viewer: closed; open_at_start = \"always\", so it was opened: $(just gui 2>&1 | tail -1)" ;; *) echo "wanderbooks viewer: CLOSED. [viewer] open_at_start = \"ask\": at the top of your first reply, before anything else, offer to open it (\`just gui\`)." ;; esac # Commit the journal: the git history of this dir is the archive backup: git add -A && (git commit -qm "books backup $(date -u +%Y-%m-%dT%H:%M:%SZ)" && echo committed || echo "nothing to commit") # Update wanderbooks (server, GUI, CLI) to the latest version update: cargo install --locked --git @@REPO_URL@@ ledger-cli ledger-mcp ledger-gui "#; const AGENTS_MD: &str = r#"# AGENTS.md — wanderbooks books dir This directory is a **wanderbooks** books dir: AI-native double-entry bookkeeping. You (the AI agent) are the bookkeeper. The human talks to you; you drive the books through the `wanderbooks` MCP server (registered for this directory in `.mcp.json`). The humans read the books in the GUI (`just gui`): read-only and live, so whatever you post appears there within a second. Everything else they see, you show them. ## The MCP contract - **Amounts are decimal strings** (`"123.45"`). Never floats, never locale-formatted. Money is exact or it is wrong. - **Draft → add lines → preview → confirmed post.** `post_entry` without confirmation returns a preview and posts nothing: show the preview to the human before confirming. Same pattern for `reverse_entry`. - **Every entry balances.** Debits equal credits exactly. No epsilon. - **Posted is forever.** Corrections are reversing entries linked to the original, never edits, never deletes. - **Receipts:** attach with `attach_receipt` and link to the entry. Anything over the receipt threshold ($25 unless `wanderbooks.toml` says otherwise) requires a receipt — the server enforces this, don't argue with it. One file per receipt: PDF preferred, an image is fine. - **Show the numbers.** After every mutation, show the affected balances. When the human questions a figure, trace it to journal entries. ## First run: onboarding If `books-data/journal.jsonl` is missing or empty, onboard before recording anything. Keep it conversational; ask a few things at a time. 1. Ask: company legal name, entity type (sole proprietorship or single-member LLC, partnership or multi-member LLC, S corp, C corp), owners and their shares, fiscal year start, and whether they want accrual or cash basis. 2. Propose a chart of accounts with `books.chart_template` for that entity type and those owners. Walk the human through it, adjust, then import it with `books.import_chart_csv`. Put the template's settings (members, tax form, close target) in `wanderbooks.toml`. 3. Fill in `wanderbooks.toml` `[company]`: name, entity type, tax classification, formation state and date, EIN, address. Don't store SSNs or full account numbers anywhere in this directory. 4. Record opening balances as one balanced opening entry (or the owners' first contributions). Verify the trial balance before moving on. 5. File the formation documents they have (articles, EIN letter, operating agreement) with `books.file_document`. 6. If they're migrating from another system, import from wherever the books live now — QBO/CSV, bank or card CSVs, email receipts, a spreadsheet, whatever they point you at. Diff trial balance, P&L, balance sheet, and cash flow against the old system **to the cent** before calling the migration done. 7. Tell them about `just gui`, then commit with `just backup`. ## Receipts from email If the human has connected their email (a Gmail or Outlook connector, or a browser extension with their mail open), find receipts there yourself: search for the vendor and amount around the transaction date, then file one file per receipt — the PDF attachment if there is one; otherwise save the message and render it to PDF. Never file a raw `.eml`. Ask before downloading anything, and never type a password or account number into a form. ## Switching from QuickBooks, Xero, or a spreadsheet 1. Ask the human to export, from the old system, for every year they want to bring over: the chart of accounts, the general journal (or general ledger), and the trial balance, profit and loss, and balance sheet. CSV or XLSX. Receipts usually live as attachments; export those too. 2. Set up the chart (`books.import_chart_csv` or account by account), then rebuild the history as entries — or, if they only want a fresh start, one opening entry from the old trial balance at the cutover date. 3. Diff your trial balance, P&L, balance sheet, and cash flow against the old system's reports for the same dates, **to the cent**, and show the human the comparison. Not done until every number matches. 4. Keep the old system read-only until a full month has been kept in wanderbooks and closed cleanly. Then they can cancel it. Bank feeds aren't automatic here: import the bank's CSV export (`books.import_bank_csv`, drafts to review) or read their statements. ## Working with the CPA - At year end (or whenever the CPA asks), build the package with `books.export_cpa_package`: every statement as PDF and XLSX, the tax summary, the general ledger and journal, a QuickBooks Desktop IIF journal, the 1099 worksheet, and every receipt with an index. Save it where the human can send it. - Before that: every expense and revenue account has a tax category (`books.tax_categories`), the period's entries all have receipts, and `books.verify` is clean. - A CPA who wants to look around can get a read-only copy of this directory (it's a git repository: push it to a private remote and give them read access) and open it with `wanderbooks gui`, or `ledger-gui --data-dir books-data`. Nothing they do there can change the books. ## Session start A SessionStart hook runs `just session-start` and hands you the result; if it didn't, run `just gui-status` yourself. If the viewer isn't open, follow `[viewer] open_at_start` in `wanderbooks.toml`: `"ask"` (the default) offers to open it, `"always"` opens it with `just gui`, `"never"` leaves it. `just gui` starts it in the background and does nothing if it's already open. If the human tells you their preference, write it there. ## Ongoing - Every transaction: draft, preview, confirm with the human, post, attach the receipt. Tag payments to contractors with their vendor (1099s). - The human usually has the viewer open beside you (`just gui`). When they ask to see something, and after you post, put it on their screen with `books.show` (an entry, an account register, a report, a document, or a search). - `books.digest` is the review surface — "what did you do with my books lately." Offer it; don't make the human ask. - Reports are pure functions of the journal. If a number looks wrong, the journal is the evidence — read it, don't guess. - Integrity: `books.verify` checks the journal's hash chain, the database against the journal (rebuilding it if it drifted), and every stored file. Run it when a number looks wrong or the human asks. - Back up: `just backup` after every session that posted entries. The git history of this directory is the archive; `books-data/journal.jsonl` is the system of record and the SQLite database is disposable. "#; const CLAUDE_MD: &str = "@AGENTS.md\n"; const WANDERBOOKS_TOML: &str = r#"# wanderbooks books dir config. [company] is filled in during onboarding — # the agent asks, the human answers, the agent writes it here. [company] name = "" base_currency = "USD" fiscal_year_start = "01-01" [books] data_dir = "books-data" accounting_basis = "accrual" receipt_threshold = "25.00" [viewer] # When a session starts and the viewer isn't open (`just gui-status`), the # agent: "ask" offers to open it, "always" opens it (`just gui`), "never" # leaves it closed. open_at_start = "ask" "#; const GITIGNORE: &str = r#"# The SQLite projection is disposable — it rebuilds from journal.jsonl. books-data/wanderbooks.db books-data/wanderbooks.db-* # Generated exports can always be regenerated from the journal. exports/ # The background viewer's log (`just gui`). books-data/gui.log "#; const START_PROMPT: &str = "Hi! These are brand-new books. Please set them up with me."; fn print_usage() { eprintln!("usage: wanderbooks new [DIR] [--codex] [--force] [--no-start]"); eprintln!(" wanderbooks gui [DIR]"); 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!(" 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"); eprintln!(" --codex keep the books with Codex instead of Claude Code"); } /// A binary next to this one (cargo installs them side by side), else on /// the PATH. fn find_binary(name: &str) -> Option { let name = format!("{name}{}", env::consts::EXE_SUFFIX); let name = name.as_str(); if let Some(dir) = env::current_exe() .ok() .and_then(|e| e.parent().map(Path::to_path_buf)) { let p = dir.join(name); if p.is_file() { return Some(p); } } env::var_os("PATH").and_then(|paths| { env::split_paths(&paths) .map(|d| d.join(name)) .find(|p| p.is_file()) }) } /// Install the server and GUI with cargo: from this checkout when there is /// one, else from the repo. fn install_binaries() -> Result<(), String> { let mut cmd = Command::new("cargo"); cmd.args(["install", "--locked"]); match repo_root() { Some(root) => { eprintln!( "Installing ledger-mcp and ledger-gui from {} (a few minutes, once)…", root.display() ); // `--path` takes one crate at a time. for c in ["ledger-mcp", "ledger-gui"] { let ok = Command::new("cargo") .args(["install", "--locked", "--path"]) .arg(root.join("crates").join(c)) .status() .map_err(|e| { format!("can't run cargo: {e} (install Rust from https://rustup.rs)") })? .success(); if !ok { return Err(format!("cargo install of {c} failed")); } } return Ok(()); } None => { eprintln!( "Installing ledger-mcp and ledger-gui from {REPO_URL} (a few minutes, once)…" ); cmd.args(["--git", REPO_URL, "ledger-mcp", "ledger-gui"]); } } let ok = cmd .status() .map_err(|e| format!("can't run cargo: {e} (install Rust from https://rustup.rs)"))? .success(); if ok { Ok(()) } else { Err("cargo install failed".into()) } } fn binaries() -> Result<(PathBuf, PathBuf), String> { if let (Some(m), Some(g)) = (find_binary("ledger-mcp"), find_binary("ledger-gui")) { return Ok((m, g)); } install_binaries()?; match (find_binary("ledger-mcp"), find_binary("ledger-gui")) { (Some(m), Some(g)) => Ok((m, g)), _ => Err( "installed, but ledger-mcp/ledger-gui aren't on the PATH; add ~/.cargo/bin to it" .into(), ), } } fn json_string(s: &str) -> String { let mut out = String::from("\""); for c in s.chars() { match c { '"' => out.push_str("\\\""), '\\' => out.push_str("\\\\"), '\n' => out.push_str("\\n"), c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), c => out.push(c), } } out.push('"'); out } /// `.mcp.json`: the wanderbooks server for this directory only. fn mcp_json(ledger_mcp: &Path, books: &Path) -> String { format!( "{{\n \"mcpServers\": {{\n \"wanderbooks\": {{\n \"command\": {},\n \"args\": [\"--data-dir\", {}]\n }}\n }}\n}}\n", json_string(&ledger_mcp.to_string_lossy()), json_string(&books.join("books-data").to_string_lossy()) ) } /// `.claude/settings.json`: approve this directory's server, and its tools, /// so the first session starts working instead of asking. The server still /// previews every posting for the human to confirm. A SessionStart hook /// runs `just session-start`, so the viewer check doesn't hang on the agent /// remembering AGENTS.md. const CLAUDE_SETTINGS: &str = r#"{ "enabledMcpjsonServers": ["wanderbooks"], "permissions": { "allow": ["mcp__wanderbooks"] }, "hooks": { "SessionStart": [ { "matcher": "startup|resume|clear", "hooks": [ { "type": "command", "command": "cd \"$CLAUDE_PROJECT_DIR\" && just session-start" } ] } ] } } "#; /// `.codex/hooks.json`: the same session-start check for Codex. Codex runs a /// project hook only once the project is trusted and the hook approved /// (`/hooks`); until then AGENTS.md's "Session start" is the fallback. const CODEX_HOOKS: &str = r#"{ "hooks": { "SessionStart": [ { "matcher": "startup|resume|clear", "hooks": [ { "type": "command", "command": "cd \"$(git rev-parse --show-toplevel)\" && just session-start", "timeout": 10 } ] } ] } } "#; /// `git init` the dir unless already inside a repo; commit the scaffold. /// Failures are warnings, not errors — the scaffold is what matters. fn git_bootstrap(dir: &Path) { let git = |args: &[&str]| { Command::new("git") .args(args) .current_dir(dir) .output() .is_ok_and(|o| o.status.success()) }; if !git(&["rev-parse", "--is-inside-work-tree"]) && !git(&["init", "-q"]) { eprintln!("warning: git init failed; skipping the first commit"); return; } if !git(&["add", "-A"]) { eprintln!("warning: git add failed; skipping the first commit"); return; } // Use the person's identity when they have one; fall back so a fresh // machine still gets a first commit. let has_identity = git(&["config", "user.email"]); let mut args = Vec::new(); if !has_identity { args.extend([ "-c", "user.name=wanderbooks", "-c", "user.email=wanderbooks@localhost", ]); } args.extend(["commit", "-qm", "init: wanderbooks books dir"]); git(&args); } /// Write the books directory. Returns it, canonical. fn scaffold( dir: &Path, force: bool, ledger_mcp: &Path, ledger_gui: &Path, ) -> Result { fs::create_dir_all(dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?; let dir = dir .canonicalize() .map_err(|e| format!("cannot resolve {}: {e}", dir.display()))?; if dir.join("wanderbooks.toml").exists() && !force { return Err(format!( "{} is already a books dir (wanderbooks.toml exists); use --force to rewrite the scaffold", dir.display() )); } let justfile = JUSTFILE .replace("@@LEDGER_GUI@@", &ledger_gui.to_string_lossy()) .replace("@@LEDGER_MCP@@", &ledger_mcp.to_string_lossy()) .replace("@@CODEX_NAME@@", &codex_name(&dir)) .replace("@@REPO_URL@@", REPO_URL); let mut files: Vec<(&str, String)> = vec![ ("justfile", justfile), ("AGENTS.md", AGENTS_MD.to_string()), ("CLAUDE.md", CLAUDE_MD.to_string()), (".gitignore", GITIGNORE.to_string()), (".mcp.json", mcp_json(ledger_mcp, &dir)), ]; // The company profile is the person's data: never overwrite it. if !dir.join("wanderbooks.toml").exists() { files.push(("wanderbooks.toml", WANDERBOOKS_TOML.to_string())); } for (name, content) in files { fs::write(dir.join(name), content).map_err(|e| format!("cannot write {name}: {e}"))?; } fs::create_dir_all(dir.join(".claude")).map_err(|e| format!("cannot create .claude: {e}"))?; fs::write(dir.join(".claude/settings.json"), CLAUDE_SETTINGS) .map_err(|e| format!("cannot write .claude/settings.json: {e}"))?; fs::create_dir_all(dir.join(".codex")).map_err(|e| format!("cannot create .codex: {e}"))?; fs::write(dir.join(".codex/hooks.json"), CODEX_HOOKS) .map_err(|e| format!("cannot write .codex/hooks.json: {e}"))?; fs::create_dir_all(dir.join("books-data")) .map_err(|e| format!("cannot create books-data: {e}"))?; fs::write(dir.join("books-data/.gitkeep"), "") .map_err(|e| format!("cannot write .gitkeep: {e}"))?; git_bootstrap(&dir); Ok(dir) } fn is_terminal() -> bool { use std::io::IsTerminal; std::io::stdin().is_terminal() && std::io::stdout().is_terminal() } /// Which agent keeps the books. #[derive(Clone, Copy, PartialEq, Eq)] enum Agent { Claude, Codex, } impl Agent { fn binary(self) -> &'static str { match self { Agent::Claude => "claude", Agent::Codex => "codex", } } } /// Codex keeps MCP servers in one global config, so each books dir gets /// its own server name: `wanderbooks-`. fn codex_name(dir: &Path) -> String { let base: String = dir .file_name() .map(|n| n.to_string_lossy().to_lowercase()) .unwrap_or_default() .chars() .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) .collect(); let base = base.trim_matches('-'); if base.is_empty() { "wanderbooks".to_string() } else { format!("wanderbooks-{base}") } } /// Register this books dir's MCP server with Codex. fn codex_register(dir: &Path, ledger_mcp: &Path) -> Result<(), String> { let Some(codex) = find_binary("codex") else { return Err("Codex isn't installed: https://developers.openai.com/codex".into()); }; let name = codex_name(dir); let ok = Command::new(codex) .args(["mcp", "add", &name, "--"]) .arg(ledger_mcp) .arg("--data-dir") .arg(dir.join("books-data")) .status() .map_err(|e| format!("can't run codex: {e}"))? .success(); if ok { println!("Registered the books with Codex as MCP server {name:?}."); Ok(()) } else { Err(format!("codex mcp add {name} failed")) } } /// Replace this process with the agent in the books dir, onboarding. fn start_agent(dir: &Path, agent: Agent) -> Result<(), String> { let Some(bin) = find_binary(agent.binary()) else { println!( "{} isn't installed. Install it, then run `{}` in {}.", if agent == Agent::Claude { "Claude Code" } else { "Codex" }, agent.binary(), dir.display() ); return Ok(()); }; println!("Starting your bookkeeper…"); let mut cmd = Command::new(bin); cmd.arg(START_PROMPT).current_dir(dir); #[cfg(unix)] { use std::os::unix::process::CommandExt; let err = cmd.exec(); Err(format!("can't start {}: {err}", agent.binary())) } #[cfg(not(unix))] { cmd.status() .map_err(|e| format!("can't start {}: {e}", agent.binary()))?; Ok(()) } } fn new(dir: &Path, force: bool, start: bool, agent: Agent) -> Result<(), String> { let (ledger_mcp, ledger_gui) = binaries()?; let dir = scaffold(dir, force, &ledger_mcp, &ledger_gui)?; if agent == Agent::Codex { codex_register(&dir, &ledger_mcp)?; } println!("Books ready in {}", dir.display()); println!(" just gui the live, read-only view of the books"); println!( " just {} a session with your bookkeeper", if agent == Agent::Codex { "codex" } else { "agent" } ); println!(" just backup commit the journal"); if agent == Agent::Codex { println!("In Codex, trust this directory and approve its session-start hook (/hooks)."); } if start && is_terminal() { start_agent(&dir, agent)?; } else { println!("Next: cd {} && {}", dir.display(), agent.binary()); } 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()) } } fn main() { let mut dir: Option = None; let mut force = false; let mut start = true; let mut agent = Agent::Claude; let mut cmd: Option = None; for arg in env::args().skip(1) { match arg.as_str() { "--help" | "-h" => { print_usage(); return; } "--force" => force = true, "--no-start" => start = false, "--codex" => agent = Agent::Codex, s if s.starts_with('-') => { eprintln!("unknown flag: {s}"); print_usage(); std::process::exit(2); } s if cmd.is_none() => cmd = Some(s.to_string()), s if dir.is_none() => dir = Some(PathBuf::from(s)), s => { eprintln!("unexpected argument: {s}"); print_usage(); 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); } }; if let Err(e) = result { eprintln!("error: {e}"); std::process::exit(1); } }