feat: one-command onboarding — wanderbooks new, install.sh, justfile

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-12 16:29:29 -07:00
1 parent 105fd52694
commit 714867ed58
8 files changed
+501 -191

No files matched your search

Generated
+1
View File
@@ -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",
]
+10 -3
View File
@@ -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
+2 -1
View File
@@ -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"
+328 -133
View File
@@ -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<PathBuf> {
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<PathBuf> {
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"])
let git = |args: &[&str]| {
Command::new("git")
.args(args)
.current_dir(dir)
.output()
.is_ok_and(|o| o.status.success());
if !inside {
let init_ok = Command::new("git")
.arg("init")
.current_dir(dir)
.output()
.is_ok_and(|o| o.status.success());
if !init_ok {
eprintln!("warning: git init 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 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");
if !git(&["add", "-A"]) {
eprintln!("warning: git add failed; skipping the first commit");
return;
}
let _ = Command::new("git")
.args([
// 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<PathBuf, String> {
/// Write the books directory. Returns it, canonical.
fn scaffold(
dir: &Path,
force: bool,
ledger_mcp: &Path,
ledger_gui: &Path,
) -> Result<PathBuf, String> {
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<PathBuf> = None;
let mut force = false;
let mut start = true;
let mut cmd: Option<String> = 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);
}
}
}
}
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 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);
}
}
+106 -53
View File
@@ -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());
}
+6 -1
View File
@@ -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() {
Executable
+41
View File
@@ -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 "$@" </dev/tty
fi
exec "$HOME/.cargo/bin/wanderbooks" new "$@" --no-start
fi
say "Done. Make your books with: wanderbooks new ~/Books/YourCompany"
+7
View File
@@ -267,3 +267,10 @@ interface is MCP, not a cloned GUI. The first storage + MCP slice is done
balance series and history, P&L by interval, flows), ledger-config
(extracted from ledger-mcp; full profile, cash_accounts). Fonts: Inter
with tnum frozen in and private-use glyphs removed (Phosphor icons).
- 2026-10-12: One-command onboarding. `wanderbooks new DIR` installs the
server and GUI if missing, writes AGENTS.md (+ CLAUDE.md importing it),
wanderbooks.toml, a justfile (agent, gui, backup, update), .gitignore,
.mcp.json (the server registered for that dir only) and
.claude/settings.json (approved, no prompt), commits, and starts claude
with the onboarding prompt. `install.sh` does Rust + cargo install +
`wanderbooks new` in one line. mise is no longer part of the scaffold.