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
+507 -197

No files matched your search

Generated
+1
View File
@@ -3369,6 +3369,7 @@ checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
name = "ledger-cli" name = "ledger-cli"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"serde_json",
"tempfile", "tempfile",
"toml 1.1.8+spec-1.1.0", "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 ```bash
cargo test # whole suite 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-mcp -- --data-dir /tmp/books # MCP server over stdio
cargo run -p ledger-gui -- --demo # the desktop view, on a demo company cargo run -p ledger-gui -- --demo # the desktop view, on a demo company
``` ```
Then `cd ~/books/acme`: `mise trust && mise install`, `mise run build`, New books, one command (installs Rust and wanderbooks if needed, makes the
`mise run mcp-add`, and start `claude` — the agent onboards from AGENT.md. 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 ## 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-mcp/ # MCP server: 64 tools, resources, receipt policy
crates/ledger-import/ # QBO CSV import, IIF/XLSX export crates/ledger-import/ # QBO CSV import, IIF/XLSX export
crates/ledger-config/ # wanderbooks.toml company settings 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) crates/ledger-gui/ # read-only live desktop view (egui)
docs/ # requirements, architecture, oracle, parity, glossary docs/ # requirements, architecture, oracle, parity, glossary
plan/ # STATUS.md, roadmap.md plan/ # STATUS.md, roadmap.md
+2 -1
View File
@@ -4,13 +4,14 @@ version = "0.1.0"
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
repository.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]] [[bin]]
name = "wanderbooks" name = "wanderbooks"
path = "src/main.rs" path = "src/main.rs"
[dev-dependencies] [dev-dependencies]
serde_json = "1"
toml = "1" toml = "1"
tempfile = "3" tempfile = "3"
+334 -139
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), //! `new` makes a complete books directory and starts the agent in it:
//! `AGENT.md` (the agent operating contract), `wanderbooks.toml` (company //!
//! profile, filled in during onboarding), and an empty `books-data/` dir. //! 1. Finds `ledger-mcp` and `ledger-gui` next to this binary or on the
//! From there: `mise trust && mise install`, `mise run build`, //! PATH, and installs them with cargo if they're missing.
//! `mise run mcp-add`, then start `claude`/`codex` in the dir and let the //! 2. Writes `AGENTS.md` (the bookkeeper's operating contract, with a
//! agent take the onboarding from AGENT.md. //! `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::env;
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
/// Absolute path of the wanderbooks repo checkout this binary was built from. /// Where `wanderbooks update` installs from.
fn repo_root() -> PathBuf { 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("../.."); 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`. const JUSTFILE: &str = r#"# wanderbooks books dir. Run `just` to list recipes.
# Bootstrap: `mise trust && mise install` then `mise run build && mise run mcp-add`. # Day to day you talk to the agent (`just agent`); the recipes are the
# handful of things you might do yourself.
[tools] set shell := ["bash", "-cu"]
rust = "stable"
[tasks.build] # List recipes
description = "Build ledger-mcp from the wanderbooks repo checkout" default:
dir = "{{REPO}}" @just --list
run = "cargo build --release -p ledger-mcp"
[tasks.mcp-add] # Start a bookkeeping session with the agent
description = "Register wanderbooks with Claude Code (stdio transport; OS user boundary is the auth)" agent:
run = 'claude mcp add wanderbooks -- "{{REPO}}/target/release/ledger-mcp" --data-dir "{{BOOKS}}/books-data"' claude
[tasks.backup] # Open the live, read-only view of these books
description = "Commit the journal — the git history of this dir is the archive" gui:
run = 'git add -A && (git commit -m "books backup $(date -u +%Y-%m-%dT%H:%M:%SZ)" || true)' "@@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 This directory is a **wanderbooks** books dir: AI-native double-entry
bookkeeping. You (the AI agent) are the bookkeeper. The human talks to you; 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 you drive the books through the `wanderbooks` MCP server (registered for
bookkeeping UI; the numbers you show the human *are* the interface. 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
## Bootstrap (once, in order) a second. Everything else they see, you show them.
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.
## The MCP contract ## 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 - **Posted is forever.** Corrections are reversing entries linked to the
original, never edits, never deletes. original, never edits, never deletes.
- **Receipts:** attach with `attach_receipt` and link to the entry. Anything - **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. - **Show the numbers.** After every mutation, show the affected balances.
When the human questions a figure, trace it to journal entries. When the human questions a figure, trace it to journal entries.
## First run: onboarding ## First run: onboarding
If `books-data/journal.jsonl` is missing or empty, onboard before recording 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 1. Ask: company legal name, entity type (sole proprietorship or
currency, fiscal year start. single-member LLC, partnership or multi-member LLC, S corp, C corp),
2. Set up the chart of accounts: propose a sensible default for the entity owners and their shares, fiscal year start, and whether they want
type and let the human adjust, or import one they hand you. accrual or cash basis.
3. Record opening balances as one balanced opening entry. Verify the trial 2. Propose a chart of accounts with `books.chart_template` for that entity
balance balances before moving on. type and those owners. Walk the human through it, adjust, then import it
4. Write the answers into `wanderbooks.toml` under `[company]`. with `books.import_chart_csv`. Put the template's settings (members,
5. If they're migrating from another system, import from wherever the books tax form, close target) in `wanderbooks.toml`.
live now — QBO/CSV via `ledger-import`, bank or card CSVs, email receipts, 3. Fill in `wanderbooks.toml` `[company]`: name, entity type, tax
a spreadsheet, whatever they point you at. Diff trial balance, P&L, classification, formation state and date, EIN, address. Don't store
balance sheet, and cash flow against the old system **to the cent** SSNs or full account numbers anywhere in this directory.
before calling the migration done. 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 ## Ongoing
- Every transaction: draft, preview, confirm with the human, post, attach - 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 - `books.digest` is the review surface — "what did you do with my books
lately." Offer it; don't make the human ask. lately." Offer it; don't make the human ask.
- Reports are pure functions of the journal. If a number looks wrong, the - Reports are pure functions of the journal. If a number looks wrong, the
journal is the evidence — read it, don't guess. journal is the evidence — read it, don't guess.
- Back up: run `mise run backup` after every session that posted entries. - Back up: `just backup` after every session that posted entries. The git
The git history of this directory is the archive; the journal file is the history of this directory is the archive; `books-data/journal.jsonl` is
system of record and the SQLite database is disposable. 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 — 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. # the agent asks, the human answers, the agent writes it here.
[company] [company]
name = "" name = ""
entity_type = ""
owners = ""
base_currency = "USD" base_currency = "USD"
fiscal_year_start = "01-01" fiscal_year_start = "01-01"
[books] [books]
data_dir = "books-data" data_dir = "books-data"
accounting_basis = "accrual"
receipt_threshold = "25.00" receipt_threshold = "25.00"
"#; "#;
const GITIGNORE: &str = r#"# The SQLite projection is disposable — it rebuilds from journal.jsonl. const GITIGNORE: &str = r#"# The SQLite projection is disposable — it rebuilds from journal.jsonl.
books-data/wanderbooks.db books-data/wanderbooks.db
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() { fn print_usage() {
eprintln!("usage: wanderbooks init [DIR] [--force]"); eprintln!("usage: wanderbooks new [DIR] [--force] [--no-start]");
eprintln!(" DIR books directory to create (default: current directory)"); eprintln!(" wanderbooks gui [DIR]");
eprintln!(" --force overwrite an existing wanderbooks.toml"); 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 { /// A binary next to this one (cargo installs them side by side), else on
template /// the PATH.
.replace("{{REPO}}", repo) fn find_binary(name: &str) -> Option<PathBuf> {
.replace("{{BOOKS}}", books) 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. /// `git init` the dir unless already inside a repo; commit the scaffold.
/// Failures are warnings, not errors — the scaffold is what matters. /// Failures are warnings, not errors — the scaffold is what matters.
fn git_bootstrap(dir: &Path) { fn git_bootstrap(dir: &Path) {
let inside = Command::new("git") let git = |args: &[&str]| {
.args(["rev-parse", "--is-inside-work-tree"]) Command::new("git")
.current_dir(dir) .args(args)
.output()
.is_ok_and(|o| o.status.success());
if !inside {
let init_ok = Command::new("git")
.arg("init")
.current_dir(dir) .current_dir(dir)
.output() .output()
.is_ok_and(|o| o.status.success()); .is_ok_and(|o| o.status.success())
if !init_ok { };
eprintln!("warning: git init failed; skipping scaffold commit"); if !git(&["rev-parse", "--is-inside-work-tree"]) && !git(&["init", "-q"]) {
return; eprintln!("warning: git init failed; skipping the first commit");
}
}
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");
return; return;
} }
let _ = Command::new("git") if !git(&["add", "-A"]) {
.args([ 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", "-c",
"user.name=wanderbooks", "user.name=wanderbooks",
"-c", "-c",
"user.email=wanderbooks@localhost", "user.email=wanderbooks@localhost",
"commit", ]);
"-m", }
"init: wanderbooks books dir scaffold", args.extend(["commit", "-qm", "init: wanderbooks books dir"]);
]) git(&args);
.current_dir(dir)
.output();
} }
/// Scaffold a books directory. Returns the dir on success. /// Write the books directory. Returns it, canonical.
fn init(dir: &Path, force: bool) -> Result<PathBuf, String> { 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()))?; fs::create_dir_all(dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?;
let dir = dir let dir = dir
.canonicalize() .canonicalize()
.map_err(|e| format!("cannot resolve {}: {e}", dir.display()))?; .map_err(|e| format!("cannot resolve {}: {e}", dir.display()))?;
if dir.join("wanderbooks.toml").exists() && !force {
let marker = dir.join("wanderbooks.toml");
if marker.exists() && !force {
return Err(format!( 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() dir.display()
)); ));
} }
let justfile = JUSTFILE
let repo = repo_root(); .replace("@@LEDGER_GUI@@", &ledger_gui.to_string_lossy())
let repo_s = repo.to_string_lossy(); .replace("@@REPO_URL@@", REPO_URL);
let books_s = dir.to_string_lossy(); let mut files: Vec<(&str, String)> = vec![
("justfile", justfile),
let files: &[(&str, String)] = &[ ("AGENTS.md", AGENTS_MD.to_string()),
("mise.toml", render(MISE_TOML, &repo_s, &books_s)), ("CLAUDE.md", CLAUDE_MD.to_string()),
("AGENT.md", AGENT_MD.to_string()),
("wanderbooks.toml", WANDERBOOKS_TOML.to_string()),
(".gitignore", GITIGNORE.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 { for (name, content) in files {
fs::write(dir.join(name), content).map_err(|e| format!("cannot write {name}: {e}"))?; 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")) fs::create_dir_all(dir.join("books-data"))
.map_err(|e| format!("cannot create books-data: {e}"))?; .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}"))?; .map_err(|e| format!("cannot write .gitkeep: {e}"))?;
git_bootstrap(&dir); git_bootstrap(&dir);
Ok(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() { fn main() {
let args = env::args().skip(1);
let mut dir: Option<PathBuf> = None; let mut dir: Option<PathBuf> = None;
let mut force = false; let mut force = false;
let mut start = true;
let mut cmd: Option<String> = None; let mut cmd: Option<String> = None;
for arg in env::args().skip(1) {
for arg in args {
match arg.as_str() { match arg.as_str() {
"--help" | "-h" => { "--help" | "-h" => {
print_usage(); print_usage();
return; return;
} }
"--force" => force = true, "--force" => force = true,
"--no-start" => start = false,
s if s.starts_with('-') => { s if s.starts_with('-') => {
eprintln!("unknown flag: {s}"); eprintln!("unknown flag: {s}");
print_usage(); print_usage();
std::process::exit(2); 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 => { s => {
if cmd.is_none() { eprintln!("unexpected argument: {s}");
cmd = Some(s.to_string()); print_usage();
} else if dir.is_none() { std::process::exit(2);
dir = Some(PathBuf::from(s));
} else {
eprintln!("unexpected argument: {s}");
print_usage();
std::process::exit(2);
}
} }
} }
} }
let dir = dir.unwrap_or_else(|| PathBuf::from("."));
match cmd.as_deref() { let result = match cmd.as_deref() {
Some("init") => { Some("new") | Some("init") => new(&dir, force, start),
let dir = dir.unwrap_or_else(|| PathBuf::from(".")); Some("gui") => gui(&dir),
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);
}
}
}
_ => { _ => {
print_usage(); print_usage();
std::process::exit(2); 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, //! Integration tests for `wanderbooks new`: one command lands a complete
//! the TOML parses, re-init is refused without `--force`. //! 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::fs;
use std::path::PathBuf; use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
fn bin() -> PathBuf { fn bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_wanderbooks")) PathBuf::from(env!("CARGO_BIN_EXE_wanderbooks"))
} }
fn run_init(dir: &std::path::Path, extra: &[&str]) -> (bool, String) { /// Stand-ins for the installed binaries, so tests never run cargo install.
let mut cmd = Command::new(bin()); fn fake_bins(tmp: &Path) -> PathBuf {
cmd.arg("init").arg(dir); let dir = tmp.join("bin");
for e in extra { fs::create_dir_all(&dir).unwrap();
cmd.arg(e); 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(); let mut s = String::from_utf8_lossy(&out.stdout).into_owned();
s.push_str(&String::from_utf8_lossy(&out.stderr)); s.push_str(&String::from_utf8_lossy(&out.stderr));
(out.status.success(), s) (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] #[test]
fn scaffold_lands_complete() { fn scaffold_lands_complete() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let books = tmp.path().join("acme-books"); let books = tmp.path().join("acme-books");
let (ok, _) = run_init(&books, &[]); let (ok, out) = new(tmp.path(), &books, &[]);
assert!(ok, "init should succeed"); assert!(ok, "new should succeed: {out}");
for f in ["mise.toml", "AGENT.md", "wanderbooks.toml", ".gitignore"] { for f in [
"justfile",
"AGENTS.md",
"CLAUDE.md",
"wanderbooks.toml",
".gitignore",
".mcp.json",
".claude/settings.json",
] {
let p = books.join(f); let p = books.join(f);
assert!(p.exists(), "{f} exists"); assert!(p.exists(), "{f} exists");
assert!(!fs::read_to_string(&p).unwrap().is_empty(), "{f} non-empty"); 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("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!( assert!(
books.join(".git").is_dir(), String::from_utf8_lossy(&log.stdout).contains("init"),
"fresh dir gets git init for the journal archive" "scaffold committed"
); );
} }
#[test] #[test]
fn mise_toml_parses_and_names_repo_and_books() { fn the_server_is_registered_for_this_dir() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let books = tmp.path().join("b"); let books = tmp.path().join("b");
let (ok, _) = run_init(&books, &[]); assert!(new(tmp.path(), &books, &[]).0);
assert!(ok); let books = books.canonicalize().unwrap();
let raw = fs::read_to_string(books.join("mise.toml")).unwrap(); let mcp: serde_json::Value =
let doc: toml::Value = toml::from_str(&raw).expect("mise.toml parses"); serde_json::from_str(&fs::read_to_string(books.join(".mcp.json")).unwrap()).unwrap();
assert!(doc.get("tools").is_some(), "[tools] present"); let server = &mcp["mcpServers"]["wanderbooks"];
let tasks = doc.get("tasks").expect("[tasks] present"); assert!(server["command"].as_str().unwrap().ends_with("ledger-mcp"));
for t in ["build", "mcp-add", "backup"] { assert_eq!(
assert!(tasks.get(t).is_some(), "task {t} present"); 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. assert_eq!(
let build_run = tasks["build"]["run"].as_str().unwrap(); fs::read_to_string(books.join("CLAUDE.md")).unwrap().trim(),
assert!( "@AGENTS.md"
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"
); );
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] #[test]
fn agent_md_carries_the_contract() { fn agents_md_carries_the_contract() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let books = tmp.path().join("b"); let books = tmp.path().join("b");
let (ok, _) = run_init(&books, &[]); assert!(new(tmp.path(), &books, &[]).0);
assert!(ok); let md = fs::read_to_string(books.join("AGENTS.md")).unwrap();
let md = fs::read_to_string(books.join("AGENT.md")).unwrap();
for phrase in [ for phrase in [
"preview", "preview",
"Posted is forever", "Posted is forever",
@@ -84,26 +125,39 @@ fn agent_md_carries_the_contract() {
"receipt", "receipt",
"journal.jsonl", "journal.jsonl",
"onboard", "onboard",
"chart_template",
"to the cent", "to the cent",
"just gui",
] { ] {
assert!(md.contains(phrase), "AGENT.md mentions {phrase:?}"); assert!(md.contains(phrase), "AGENTS.md mentions {phrase:?}");
} }
} }
#[test] #[test]
fn reinit_refused_without_force() { fn rerun_refused_without_force_and_never_clobbers_the_profile() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let books = tmp.path().join("b"); let books = tmp.path().join("b");
assert!(run_init(&books, &[]).0); assert!(new(tmp.path(), &books, &[]).0);
let (ok, out) = run_init(&books, &[]); let (ok, out) = new(tmp.path(), &books, &[]);
assert!(!ok, "second init without --force fails"); assert!(!ok, "second new without --force fails");
assert!(out.contains("--force"), "error points at --force: {out}"); assert!(out.contains("--force"), "error points at --force: {out}");
let (ok, _) = run_init(&books, &["--force"]); fs::write(
assert!(ok, "second init with --force succeeds"); 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] #[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 tmp = tempfile::tempdir().unwrap();
let books = tmp.path().join("b"); let books = tmp.path().join("b");
fs::create_dir_all(&books).unwrap(); fs::create_dir_all(&books).unwrap();
@@ -115,8 +169,7 @@ fn init_inside_existing_repo_skips_git_init() {
.unwrap() .unwrap()
.success() .success()
); );
let (ok, _) = run_init(&books, &[]); let (ok, out) = run(tmp.path(), &["init", books.to_str().unwrap()]);
assert!(ok); assert!(ok, "{out}");
// Still a valid repo (we didn't nest another one) and scaffold landed.
assert!(books.join("wanderbooks.toml").exists()); 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, "Income", &ts.income);
sec(&mut t, "Cost of goods sold", &ts.cost_of_goods); sec(&mut t, "Cost of goods sold", &ts.cost_of_goods);
sec(&mut t, "Deductions", &ts.deductions); sec(&mut t, "Deductions", &ts.deductions);
let net = if ts.uncategorized.is_empty() {
"Net"
} else {
"Net of categorized accounts"
};
t.row( t.row(
Kind::Grand, Kind::Grand,
"Net", net,
vec![Cell::of(ts.book_net()), Cell::of(ts.tax_net())], vec![Cell::of(ts.book_net()), Cell::of(ts.tax_net())],
); );
if !ts.uncategorized.is_empty() { 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 balance series and history, P&L by interval, flows), ledger-config
(extracted from ledger-mcp; full profile, cash_accounts). Fonts: Inter (extracted from ledger-mcp; full profile, cash_accounts). Fonts: Inter
with tnum frozen in and private-use glyphs removed (Phosphor icons). 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.