From 714867ed58a2ae3170fb57e304ad73bf9013d4ef Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Mon, 12 Oct 2026 16:29:28 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20one-command=20onboarding=20=E2=80=94=20?= =?UTF-8?q?wanderbooks=20new,=20install.sh,=20justfile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wanderbooks new DIR installs the server and GUI if missing, writes the books dir (AGENTS.md + CLAUDE.md, wanderbooks.toml, justfile, .mcp.json, .claude/settings.json approving the server), commits, and starts claude onboarding. install.sh: Rust + cargo install + wanderbooks new in one line. The tax summary labels its net when accounts are uncategorized. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu --- Cargo.lock | 1 + README.md | 13 +- crates/ledger-cli/Cargo.toml | 3 +- crates/ledger-cli/src/main.rs | 473 +++++++++++++++++-------- crates/ledger-cli/tests/init.rs | 159 ++++++--- crates/ledger-gui/src/pages/reports.rs | 7 +- install.sh | 41 +++ plan/STATUS.md | 7 + 8 files changed, 507 insertions(+), 197 deletions(-) create mode 100755 install.sh diff --git a/Cargo.lock b/Cargo.lock index cb8a20d..39bbe5c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3369,6 +3369,7 @@ checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" name = "ledger-cli" version = "0.1.0" dependencies = [ + "serde_json", "tempfile", "toml 1.1.8+spec-1.1.0", ] diff --git a/README.md b/README.md index c4db022..a7ee190 100644 --- a/README.md +++ b/README.md @@ -57,12 +57,18 @@ See [`plan/STATUS.md`](plan/STATUS.md) for where things stand and ```bash cargo test # whole suite -cargo run -p ledger-cli -- init ~/books/acme # scaffold a books dir: mise.toml + AGENT.md cargo run -p ledger-mcp -- --data-dir /tmp/books # MCP server over stdio cargo run -p ledger-gui -- --demo # the desktop view, on a demo company ``` -Then `cd ~/books/acme`: `mise trust && mise install`, `mise run build`, -`mise run mcp-add`, and start `claude` — the agent onboards from AGENT.md. +New books, one command (installs Rust and wanderbooks if needed, makes the +books dir with the MCP server registered for it, and starts the agent): + +```bash +curl -fsSL https://git.swaits.com/swaits/wanderbooks/raw/branch/main/install.sh | sh -s -- ~/Books/Acme +``` + +Already installed: `wanderbooks new ~/Books/Acme`. In a books dir, `just` +lists the shortcuts (`just gui`, `just agent`, `just backup`). ## Layout @@ -72,6 +78,7 @@ crates/ledger-store/ # journal + SQLite projection + blob store crates/ledger-mcp/ # MCP server: 64 tools, resources, receipt policy crates/ledger-import/ # QBO CSV import, IIF/XLSX export crates/ledger-config/ # wanderbooks.toml company settings +crates/ledger-cli/ # `wanderbooks new`: one-command books dir + agent crates/ledger-gui/ # read-only live desktop view (egui) docs/ # requirements, architecture, oracle, parity, glossary plan/ # STATUS.md, roadmap.md diff --git a/crates/ledger-cli/Cargo.toml b/crates/ledger-cli/Cargo.toml index cdc3db0..7a0fcd5 100644 --- a/crates/ledger-cli/Cargo.toml +++ b/crates/ledger-cli/Cargo.toml @@ -4,13 +4,14 @@ version = "0.1.0" edition.workspace = true license.workspace = true repository.workspace = true -description = "wanderbooks CLI: scaffold new books directories (mise.toml + AGENT.md)." +description = "wanderbooks CLI: one command from nothing to a working books directory." [[bin]] name = "wanderbooks" path = "src/main.rs" [dev-dependencies] +serde_json = "1" toml = "1" tempfile = "3" diff --git a/crates/ledger-cli/src/main.rs b/crates/ledger-cli/src/main.rs index fc8aa0d..6be3bf5 100644 --- a/crates/ledger-cli/src/main.rs +++ b/crates/ledger-cli/src/main.rs @@ -1,59 +1,77 @@ -//! `wanderbooks` CLI — the bootstrap for AI-native bookkeeping. +//! `wanderbooks` CLI: one command from nothing to talking with your +//! bookkeeper. //! -//! Usage: `wanderbooks init [DIR] [--force]` +//! ```text +//! wanderbooks new [DIR] [--force] [--no-start] (alias: init) +//! wanderbooks gui [DIR] +//! ``` //! -//! Scaffolds a new books directory: `mise.toml` (pinned toolchain + tasks), -//! `AGENT.md` (the agent operating contract), `wanderbooks.toml` (company -//! profile, filled in during onboarding), and an empty `books-data/` dir. -//! From there: `mise trust && mise install`, `mise run build`, -//! `mise run mcp-add`, then start `claude`/`codex` in the dir and let the -//! agent take the onboarding from AGENT.md. +//! `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. +//! 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. use std::env; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; -/// Absolute path of the wanderbooks repo checkout this binary was built from. -fn repo_root() -> PathBuf { +/// 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("../.."); - raw.canonicalize().unwrap_or(raw) + let root = raw.canonicalize().ok()?; + root.join("crates/ledger-mcp/Cargo.toml") + .exists() + .then_some(root) } -const MISE_TOML: &str = r#"# wanderbooks books dir — provisioned by `mise`. -# Bootstrap: `mise trust && mise install` then `mise run build && mise run mcp-add`. +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. -[tools] -rust = "stable" +set shell := ["bash", "-cu"] -[tasks.build] -description = "Build ledger-mcp from the wanderbooks repo checkout" -dir = "{{REPO}}" -run = "cargo build --release -p ledger-mcp" +# List recipes +default: + @just --list -[tasks.mcp-add] -description = "Register wanderbooks with Claude Code (stdio transport; OS user boundary is the auth)" -run = 'claude mcp add wanderbooks -- "{{REPO}}/target/release/ledger-mcp" --data-dir "{{BOOKS}}/books-data"' +# Start a bookkeeping session with the agent +agent: + claude -[tasks.backup] -description = "Commit the journal — the git history of this dir is the archive" -run = 'git add -A && (git commit -m "books backup $(date -u +%Y-%m-%dT%H:%M:%SZ)" || true)' +# Open the live, read-only view of these books +gui: + "@@LEDGER_GUI@@" --data-dir books-data --books-toml wanderbooks.toml + +# 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 AGENT_MD: &str = r#"# AGENT.md — wanderbooks books dir +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. There is no -bookkeeping UI; the numbers you show the human *are* the interface. - -## Bootstrap (once, in order) - -1. `mise trust && mise install` — installs the pinned Rust toolchain. -2. `mise run build` — builds `ledger-mcp` from the wanderbooks repo. -3. `mise run mcp-add` — registers the server with Claude Code over stdio. - (Codex: add the same command + `--data-dir` to your MCP config instead.) -4. Restart the session so the MCP tools load, then continue below. +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 @@ -66,204 +84,381 @@ bookkeeping UI; the numbers you show the human *are* the interface. - **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 $25 requires a receipt — the server enforces this, don't argue with it. + 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: +anything. Keep it conversational; ask a few things at a time. -1. Ask: company legal name, entity type, owners and equity split, base - currency, fiscal year start. -2. Set up the chart of accounts: propose a sensible default for the entity - type and let the human adjust, or import one they hand you. -3. Record opening balances as one balanced opening entry. Verify the trial - balance balances before moving on. -4. Write the answers into `wanderbooks.toml` under `[company]`. -5. If they're migrating from another system, import from wherever the books - live now — QBO/CSV via `ledger-import`, 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. +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`. ## Ongoing - Every transaction: draft, preview, confirm with the human, post, attach - the receipt. + the receipt. Tag payments to contractors with their vendor (1099s). - `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. -- Back up: run `mise run backup` after every session that posted entries. - The git history of this directory is the archive; the journal file is the - system of record and the SQLite database is disposable. +- 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 = "" -entity_type = "" -owners = "" base_currency = "USD" fiscal_year_start = "01-01" [books] data_dir = "books-data" +accounting_basis = "accrual" receipt_threshold = "25.00" "#; 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/ "#; +const START_PROMPT: &str = "Hi! These are brand-new books. Please set them up with me."; + fn print_usage() { - eprintln!("usage: wanderbooks init [DIR] [--force]"); - eprintln!(" DIR books directory to create (default: current directory)"); - eprintln!(" --force overwrite an existing wanderbooks.toml"); + eprintln!("usage: wanderbooks new [DIR] [--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"); } -fn render(template: &str, repo: &str, books: &str) -> String { - template - .replace("{{REPO}}", repo) - .replace("{{BOOKS}}", books) +/// A binary next to this one (cargo installs them side by side), else on +/// the PATH. +fn find_binary(name: &str) -> Option { + 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. +const CLAUDE_SETTINGS: &str = r#"{ + "enabledMcpjsonServers": ["wanderbooks"], + "permissions": { + "allow": ["mcp__wanderbooks"] + } +} +"#; + /// `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 inside = Command::new("git") - .args(["rev-parse", "--is-inside-work-tree"]) - .current_dir(dir) - .output() - .is_ok_and(|o| o.status.success()); - if !inside { - let init_ok = Command::new("git") - .arg("init") + let git = |args: &[&str]| { + Command::new("git") + .args(args) .current_dir(dir) .output() - .is_ok_and(|o| o.status.success()); - if !init_ok { - eprintln!("warning: git init failed; skipping scaffold commit"); - return; - } - } - let add = Command::new("git") - .args(["add", "-A"]) - .current_dir(dir) - .output(); - if !add.is_ok_and(|o| o.status.success()) { - eprintln!("warning: git add failed; skipping scaffold commit"); + .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; } - let _ = Command::new("git") - .args([ + 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", - "commit", - "-m", - "init: wanderbooks books dir scaffold", - ]) - .current_dir(dir) - .output(); + ]); + } + args.extend(["commit", "-qm", "init: wanderbooks books dir"]); + git(&args); } -/// Scaffold a books directory. Returns the dir on success. -fn init(dir: &Path, force: bool) -> Result { +/// 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()))?; - - let marker = dir.join("wanderbooks.toml"); - if marker.exists() && !force { + if dir.join("wanderbooks.toml").exists() && !force { return Err(format!( - "{} is already a books dir (wanderbooks.toml exists); use --force to overwrite", + "{} is already a books dir (wanderbooks.toml exists); use --force to rewrite the scaffold", dir.display() )); } - - let repo = repo_root(); - let repo_s = repo.to_string_lossy(); - let books_s = dir.to_string_lossy(); - - let files: &[(&str, String)] = &[ - ("mise.toml", render(MISE_TOML, &repo_s, &books_s)), - ("AGENT.md", AGENT_MD.to_string()), - ("wanderbooks.toml", WANDERBOOKS_TOML.to_string()), + let justfile = JUSTFILE + .replace("@@LEDGER_GUI@@", &ledger_gui.to_string_lossy()) + .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("books-data")) .map_err(|e| format!("cannot create books-data: {e}"))?; - fs::write(dir.join("books-data").join(".gitkeep"), "") + 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() +} + +/// Replace this process with `claude` in the books dir, onboarding. +fn start_agent(dir: &Path) -> Result<(), String> { + let Some(claude) = find_binary("claude") else { + println!( + "Claude Code isn't installed. Install it, then run `claude` in {}.", + dir.display() + ); + return Ok(()); + }; + println!("Starting your bookkeeper…"); + let mut cmd = Command::new(claude); + cmd.arg(START_PROMPT).current_dir(dir); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + let err = cmd.exec(); + Err(format!("can't start claude: {err}")) + } + #[cfg(not(unix))] + { + cmd.status() + .map_err(|e| format!("can't start claude: {e}"))?; + Ok(()) + } +} + +fn new(dir: &Path, force: bool, start: bool) -> Result<(), String> { + let (ledger_mcp, ledger_gui) = binaries()?; + let dir = scaffold(dir, force, &ledger_mcp, &ledger_gui)?; + println!("Books ready in {}", dir.display()); + println!(" just gui the live, read-only view of the books"); + println!(" just agent a session with your bookkeeper"); + println!(" just backup commit the journal"); + if start && is_terminal() { + start_agent(&dir)?; + } else { + println!("Next: cd {} && claude", dir.display()); + } + 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 args = env::args().skip(1); let mut dir: Option = None; let mut force = false; + let mut start = true; let mut cmd: Option = None; - - for arg in args { + for arg in env::args().skip(1) { match arg.as_str() { "--help" | "-h" => { print_usage(); return; } "--force" => force = true, + "--no-start" => start = false, 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 => { - if cmd.is_none() { - cmd = Some(s.to_string()); - } else if dir.is_none() { - dir = Some(PathBuf::from(s)); - } else { - eprintln!("unexpected argument: {s}"); - print_usage(); - std::process::exit(2); - } + eprintln!("unexpected argument: {s}"); + print_usage(); + std::process::exit(2); } } } - - match cmd.as_deref() { - Some("init") => { - let dir = dir.unwrap_or_else(|| PathBuf::from(".")); - match init(&dir, force) { - Ok(d) => { - println!("initialized books dir: {}", d.display()); - println!("next:"); - println!(" cd {}", d.display()); - println!(" mise trust && mise install"); - println!(" mise run build && mise run mcp-add"); - println!("then start claude (or codex) here and follow AGENT.md"); - } - Err(e) => { - eprintln!("error: {e}"); - std::process::exit(1); - } - } - } + let dir = dir.unwrap_or_else(|| PathBuf::from(".")); + let result = match cmd.as_deref() { + Some("new") | Some("init") => new(&dir, force, start), + Some("gui") => gui(&dir), _ => { print_usage(); std::process::exit(2); } + }; + if let Err(e) = result { + eprintln!("error: {e}"); + std::process::exit(1); } } diff --git a/crates/ledger-cli/tests/init.rs b/crates/ledger-cli/tests/init.rs index 615e18b..33ed2fa 100644 --- a/crates/ledger-cli/tests/init.rs +++ b/crates/ledger-cli/tests/init.rs @@ -1,82 +1,123 @@ -//! Integration tests for `wanderbooks init`: the scaffold lands complete, -//! the TOML parses, re-init is refused without `--force`. +//! Integration tests for `wanderbooks new`: one command lands a complete +//! books dir with the MCP server registered for it, the TOML and JSON +//! parse, and re-running is refused without `--force` (which never +//! touches the company profile). use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::Command; fn bin() -> PathBuf { PathBuf::from(env!("CARGO_BIN_EXE_wanderbooks")) } -fn run_init(dir: &std::path::Path, extra: &[&str]) -> (bool, String) { - let mut cmd = Command::new(bin()); - cmd.arg("init").arg(dir); - for e in extra { - cmd.arg(e); +/// Stand-ins for the installed binaries, so tests never run cargo install. +fn fake_bins(tmp: &Path) -> PathBuf { + let dir = tmp.join("bin"); + fs::create_dir_all(&dir).unwrap(); + for b in ["ledger-mcp", "ledger-gui"] { + fs::write(dir.join(b), "#!/bin/sh\n").unwrap(); } - let out = cmd.output().expect("run wanderbooks"); + dir +} + +fn run(tmp: &Path, args: &[&str]) -> (bool, String) { + let path = format!( + "{}:{}", + fake_bins(tmp).display(), + std::env::var("PATH").unwrap_or_default() + ); + let out = Command::new(bin()) + .args(args) + .arg("--no-start") + .env("PATH", path) + .output() + .expect("run wanderbooks"); let mut s = String::from_utf8_lossy(&out.stdout).into_owned(); s.push_str(&String::from_utf8_lossy(&out.stderr)); (out.status.success(), s) } +fn new(tmp: &Path, books: &Path, extra: &[&str]) -> (bool, String) { + let mut args = vec!["new", books.to_str().unwrap()]; + args.extend(extra); + run(tmp, &args) +} + #[test] fn scaffold_lands_complete() { let tmp = tempfile::tempdir().unwrap(); let books = tmp.path().join("acme-books"); - let (ok, _) = run_init(&books, &[]); - assert!(ok, "init should succeed"); - for f in ["mise.toml", "AGENT.md", "wanderbooks.toml", ".gitignore"] { + let (ok, out) = new(tmp.path(), &books, &[]); + assert!(ok, "new should succeed: {out}"); + for f in [ + "justfile", + "AGENTS.md", + "CLAUDE.md", + "wanderbooks.toml", + ".gitignore", + ".mcp.json", + ".claude/settings.json", + ] { let p = books.join(f); assert!(p.exists(), "{f} exists"); assert!(!fs::read_to_string(&p).unwrap().is_empty(), "{f} non-empty"); } assert!(books.join("books-data").is_dir(), "books-data dir exists"); + assert!(books.join(".git").is_dir(), "fresh dir gets git init"); + let log = Command::new("git") + .args(["log", "--oneline"]) + .current_dir(&books) + .output() + .unwrap(); assert!( - books.join(".git").is_dir(), - "fresh dir gets git init for the journal archive" + String::from_utf8_lossy(&log.stdout).contains("init"), + "scaffold committed" ); } #[test] -fn mise_toml_parses_and_names_repo_and_books() { +fn the_server_is_registered_for_this_dir() { let tmp = tempfile::tempdir().unwrap(); let books = tmp.path().join("b"); - let (ok, _) = run_init(&books, &[]); - assert!(ok); - let raw = fs::read_to_string(books.join("mise.toml")).unwrap(); - let doc: toml::Value = toml::from_str(&raw).expect("mise.toml parses"); - assert!(doc.get("tools").is_some(), "[tools] present"); - let tasks = doc.get("tasks").expect("[tasks] present"); - for t in ["build", "mcp-add", "backup"] { - assert!(tasks.get(t).is_some(), "task {t} present"); + assert!(new(tmp.path(), &books, &[]).0); + let books = books.canonicalize().unwrap(); + let mcp: serde_json::Value = + serde_json::from_str(&fs::read_to_string(books.join(".mcp.json")).unwrap()).unwrap(); + let server = &mcp["mcpServers"]["wanderbooks"]; + assert!(server["command"].as_str().unwrap().ends_with("ledger-mcp")); + assert_eq!( + server["args"], + serde_json::json!(["--data-dir", books.join("books-data").to_string_lossy()]) + ); + let settings: serde_json::Value = + serde_json::from_str(&fs::read_to_string(books.join(".claude/settings.json")).unwrap()) + .unwrap(); + assert_eq!( + settings["enabledMcpjsonServers"], + serde_json::json!(["wanderbooks"]) + ); + // The justfile opens this dir's books in the GUI. + let just = fs::read_to_string(books.join("justfile")).unwrap(); + assert!(just.contains("ledger-gui\" --data-dir books-data")); + for recipe in ["agent:", "gui:", "backup:", "update:"] { + assert!(just.contains(recipe), "justfile has {recipe}"); } - // The build task must point at the repo checkout; mcp-add at the books dir. - let build_run = tasks["build"]["run"].as_str().unwrap(); - assert!( - build_run.contains("cargo build"), - "build runs cargo: {build_run}" - ); - let mcp_run = tasks["mcp-add"]["run"].as_str().unwrap(); - assert!( - mcp_run.contains("ledger-mcp") && mcp_run.contains("books-data"), - "mcp-add wires the server to this books dir: {mcp_run}" - ); - let books_s = books.to_string_lossy(); - assert!( - mcp_run.contains(books_s.as_ref()), - "mcp-add references the books dir path" + assert_eq!( + fs::read_to_string(books.join("CLAUDE.md")).unwrap().trim(), + "@AGENTS.md" ); + let toml: toml::Value = + toml::from_str(&fs::read_to_string(books.join("wanderbooks.toml")).unwrap()).unwrap(); + assert_eq!(toml["books"]["data_dir"].as_str(), Some("books-data")); } #[test] -fn agent_md_carries_the_contract() { +fn agents_md_carries_the_contract() { let tmp = tempfile::tempdir().unwrap(); let books = tmp.path().join("b"); - let (ok, _) = run_init(&books, &[]); - assert!(ok); - let md = fs::read_to_string(books.join("AGENT.md")).unwrap(); + assert!(new(tmp.path(), &books, &[]).0); + let md = fs::read_to_string(books.join("AGENTS.md")).unwrap(); for phrase in [ "preview", "Posted is forever", @@ -84,26 +125,39 @@ fn agent_md_carries_the_contract() { "receipt", "journal.jsonl", "onboard", + "chart_template", "to the cent", + "just gui", ] { - assert!(md.contains(phrase), "AGENT.md mentions {phrase:?}"); + assert!(md.contains(phrase), "AGENTS.md mentions {phrase:?}"); } } #[test] -fn reinit_refused_without_force() { +fn rerun_refused_without_force_and_never_clobbers_the_profile() { let tmp = tempfile::tempdir().unwrap(); let books = tmp.path().join("b"); - assert!(run_init(&books, &[]).0); - let (ok, out) = run_init(&books, &[]); - assert!(!ok, "second init without --force fails"); + assert!(new(tmp.path(), &books, &[]).0); + let (ok, out) = new(tmp.path(), &books, &[]); + assert!(!ok, "second new without --force fails"); assert!(out.contains("--force"), "error points at --force: {out}"); - let (ok, _) = run_init(&books, &["--force"]); - assert!(ok, "second init with --force succeeds"); + fs::write( + books.join("wanderbooks.toml"), + "[company]\nname = \"Acme LLC\"\n", + ) + .unwrap(); + let (ok, _) = new(tmp.path(), &books, &["--force"]); + assert!(ok, "second new with --force succeeds"); + assert!( + fs::read_to_string(books.join("wanderbooks.toml")) + .unwrap() + .contains("Acme LLC"), + "--force rewrites the scaffold, never the company profile" + ); } #[test] -fn init_inside_existing_repo_skips_git_init() { +fn init_is_an_alias_and_respects_an_existing_repo() { let tmp = tempfile::tempdir().unwrap(); let books = tmp.path().join("b"); fs::create_dir_all(&books).unwrap(); @@ -115,8 +169,7 @@ fn init_inside_existing_repo_skips_git_init() { .unwrap() .success() ); - let (ok, _) = run_init(&books, &[]); - assert!(ok); - // Still a valid repo (we didn't nest another one) and scaffold landed. + let (ok, out) = run(tmp.path(), &["init", books.to_str().unwrap()]); + assert!(ok, "{out}"); assert!(books.join("wanderbooks.toml").exists()); } diff --git a/crates/ledger-gui/src/pages/reports.rs b/crates/ledger-gui/src/pages/reports.rs index 252e7f0..6e88512 100644 --- a/crates/ledger-gui/src/pages/reports.rs +++ b/crates/ledger-gui/src/pages/reports.rs @@ -773,9 +773,14 @@ fn tax(b: &Books, s: NaiveDate, e: NaiveDate) -> Table { sec(&mut t, "Income", &ts.income); sec(&mut t, "Cost of goods sold", &ts.cost_of_goods); sec(&mut t, "Deductions", &ts.deductions); + let net = if ts.uncategorized.is_empty() { + "Net" + } else { + "Net of categorized accounts" + }; t.row( Kind::Grand, - "Net", + net, vec![Cell::of(ts.book_net()), Cell::of(ts.tax_net())], ); if !ts.uncategorized.is_empty() { diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..87e4bb7 --- /dev/null +++ b/install.sh @@ -0,0 +1,41 @@ +#!/bin/sh +# wanderbooks installer. One line on a new machine: +# +# curl -fsSL https://git.swaits.com/swaits/wanderbooks/raw/branch/main/install.sh | sh -s -- ~/Books/Acme +# +# Installs Rust if needed, then the wanderbooks CLI, MCP server, and GUI +# (into ~/.cargo/bin), then — given a directory — makes your books there and +# starts the agent. Without a directory it just installs. +set -eu + +REPO="https://git.swaits.com/swaits/wanderbooks" + +say() { printf '\033[1m%s\033[0m\n' "$*"; } + +if ! command -v cargo >/dev/null 2>&1; then + say "Installing Rust (rustup)…" + curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal + # shellcheck disable=SC1091 + . "$HOME/.cargo/env" +fi + +say "Installing wanderbooks (a few minutes, once)…" +cargo install --locked --git "$REPO" ledger-cli ledger-mcp ledger-gui + +if ! command -v just >/dev/null 2>&1; then + say "Installing just (for the books dir's shortcuts)…" + cargo install --locked just +fi + +if ! command -v claude >/dev/null 2>&1; then + say "Claude Code isn't installed: see https://claude.com/claude-code" +fi + +if [ $# -gt 0 ]; then + # Hand the terminal to the agent when there is one. + if [ -t 1 ] && [ -e /dev/tty ]; then + exec "$HOME/.cargo/bin/wanderbooks" new "$@"